diff --git a/Scripts/build_libSession_util.sh b/Scripts/build_libSession_util.sh index df2d64b325..beea36f663 100755 --- a/Scripts/build_libSession_util.sh +++ b/Scripts/build_libSession_util.sh @@ -12,7 +12,6 @@ COMPILE_DIR="${TARGET_BUILD_DIR}/LibSessionUtil" INDEX_DIR="${DERIVED_DATA_PATH}/Index.noindex/Build/Products/Debug-${PLATFORM_NAME}" LAST_SUCCESSFUL_HASH_FILE="${TARGET_BUILD_DIR}/last_successful_source_tree.hash.log" LAST_BUILT_FRAMEWORK_SLICE_DIR_FILE="${TARGET_BUILD_DIR}/last_built_framework_slice_dir.log" -BUILT_LIB_FINAL_TIMESTAMP_FILE="${TARGET_BUILD_DIR}/libsession_util_built.timestamp" # Save original stdout and set trap for cleanup exec 3>&1 @@ -22,6 +21,48 @@ function finish { } trap finish EXIT ERR SIGINT SIGTERM +# Robustly removes a directory, first clearing any immutable flags (work around Xcode's indexer file locking) +remove_locked_dir() { + local dir_to_remove="$1" + if [ -d "${dir_to_remove}" ]; then + echo "- Unlocking and removing ${dir_to_remove}" + chflags -R nouchg "${dir_to_remove}" &>/dev/null || true + rm -rf "${dir_to_remove}" + fi +} + +sync_headers() { + local source_dir="$1" + echo "- Syncing headers from ${source_dir}" + + local destinations=( + "${TARGET_BUILD_DIR}/include" + "${INDEX_DIR}/include" + "${BUILT_PRODUCTS_DIR}/include" + "${CONFIGURATION_BUILD_DIR}/include" + ) + + for dest in "${destinations[@]}"; do + if [ -n "$dest" ]; then + remove_locked_dir "$dest" + mkdir -p "$dest" + rsync -rtc --delete --exclude='.DS_Store' "${source_dir}/" "$dest/" + echo " Synced to: $dest" + fi + done +} + +# Modify the platform detection to handle archive builds +if [ "${ACTION}" = "install" ] || [ "${CONFIGURATION}" = "Release" ]; then + # Archive builds typically use 'install' action + if [ -z "$PLATFORM_NAME" ]; then + # During archive, PLATFORM_NAME might not be set correctly + # Default to device build for archives + PLATFORM_NAME="iphoneos" + echo "Missing 'PLATFORM_NAME' value, manually set to ${PLATFORM_NAME}" + fi +fi + # Determine whether we want to build from source TARGET_ARCH_DIR="" @@ -35,11 +76,14 @@ else fi if [ "${COMPILE_LIB_SESSION}" != "YES" ]; then - echo "Restoring original headers to Xcode Indexer cache from backup..." - rm -rf "${INDEX_DIR}/include" - rsync -rt --exclude='.DS_Store' "${PRE_BUILT_FRAMEWORK_DIR}/${FRAMEWORK_DIR}/${TARGET_ARCH_DIR}/Headers/" "${INDEX_DIR}/include" - echo "Using pre-packaged SessionUtil" + sync_headers "${PRE_BUILT_FRAMEWORK_DIR}/${FRAMEWORK_DIR}/${TARGET_ARCH_DIR}/Headers/" + + # Create the placeholder in the FINAL products directory to satisfy dependency. + touch "${BUILT_PRODUCTS_DIR}/libsession-util.a" + + echo "- Revert to SPM complete." + exit 0 fi @@ -83,20 +127,22 @@ fi echo "- Checking if libSession changed..." REQUIRES_BUILD=0 -# Generate a hash to determine whether any source files have changed -SOURCE_HASH=$(find "${LIB_SESSION_SOURCE_DIR}/src" -type f -not -name '.DS_Store' -exec md5 {} + | awk '{print $NF}' | sort | md5 | awk '{print $NF}') -HEADER_HASH=$(find "${LIB_SESSION_SOURCE_DIR}/include" -type f -not -name '.DS_Store' -exec md5 {} + | awk '{print $NF}' | sort | md5 | awk '{print $NF}') -EXTERNAL_HASH=$(find "${LIB_SESSION_SOURCE_DIR}/external" -type f -not -name '.DS_Store' -exec md5 {} + | awk '{print $NF}' | sort | md5 | awk '{print $NF}') -MAKE_LISTS_HASH=$(md5 -q "${LIB_SESSION_SOURCE_DIR}/CMakeLists.txt") -STATIC_BUNDLE_HASH=$(md5 -q "${LIB_SESSION_SOURCE_DIR}/utils/static-bundle.sh") - -CURRENT_SOURCE_TREE_HASH=$( ( - echo "${SOURCE_HASH}" - echo "${HEADER_HASH}" - echo "${EXTERNAL_HASH}" - echo "${MAKE_LISTS_HASH}" - echo "${STATIC_BUNDLE_HASH}" -) | sort | md5 -q) +# Generate a hash to determine whether any source files have changed (by using git we automatically +# respect .gitignore) +CURRENT_SOURCE_TREE_HASH=$( \ + ( \ + cd "${LIB_SESSION_SOURCE_DIR}" && git ls-files --recurse-submodules \ + ) \ + | grep -vE '/(tests?|docs?|examples?)/|\.md$|/(\.DS_Store|\.gitignore)$' \ + | sort \ + | tr '\n' '\0' \ + | ( \ + cd "${LIB_SESSION_SOURCE_DIR}" && xargs -0 md5 -r \ + ) \ + | awk '{print $1}' \ + | sort \ + | md5 -q \ +) PREVIOUS_BUILT_FRAMEWORK_SLICE_DIR="" if [ -f "$LAST_BUILT_FRAMEWORK_SLICE_DIR_FILE" ]; then @@ -125,6 +171,16 @@ else fi if [ "${REQUIRES_BUILD}" == 1 ]; then + +# # Hide the SPM framework to prevent module conflicts +# if [ -d "${PRE_BUILT_FRAMEWORK_DIR}" ]; then +# # Temporarily rename the SPM framework to prevent it from being found +# mv "${PRE_BUILT_FRAMEWORK_DIR}" "${PRE_BUILT_FRAMEWORK_DIR}.disabled" 2>/dev/null || true +# +# # Store that we disabled it so we can restore if build fails +# echo "DISABLED" > "${TARGET_BUILD_DIR}/.spm_framework_disabled" +# fi + # Import settings from XCode (defaulting values if not present) VALID_SIM_ARCHS=(arm64 x86_64) VALID_DEVICE_ARCHS=(arm64) @@ -217,6 +273,8 @@ if [ "${REQUIRES_BUILD}" == 1 ]; then -DBUILD_TESTS=OFF \ -DBUILD_STATIC_DEPS=ON \ -DENABLE_VISIBILITY=ON \ + -DLOKINET_FULL=OFF \ + -DLOKINET_DAEMON=OFF \ -DSUBMODULE_CHECK=$submodule_check \ -DCMAKE_BUILD_TYPE=$build_type \ -DLOCAL_MIRROR=https://oxen.rocks/deps @@ -308,25 +366,24 @@ if [ "${REQUIRES_BUILD}" == 1 ]; then echo "- Saving successful build cache files" echo "${TARGET_ARCH_DIR}" > "${LAST_BUILT_FRAMEWORK_SLICE_DIR_FILE}" echo "${CURRENT_SOURCE_TREE_HASH}" > "${LAST_SUCCESSFUL_HASH_FILE}" - - echo "- Touching timestamp file to signal update to Xcode" - touch "${BUILT_LIB_FINAL_TIMESTAMP_FILE}" - cp "${BUILT_LIB_FINAL_TIMESTAMP_FILE}" "${SPM_TIMESTAMP_FILE}" echo "- Build complete" fi echo "- Replacing build dir files" -# Remove the current files (might be "newer") -rm -rf "${TARGET_BUILD_DIR}/libsession-util.a" -rm -rf "${TARGET_BUILD_DIR}/include" -rm -rf "${INDEX_DIR}/include" - # Rsync the compiled ones (maintaining timestamps) +rm -rf "${TARGET_BUILD_DIR}/libsession-util.a" rsync -rt "${COMPILE_DIR}/libsession-util.a" "${TARGET_BUILD_DIR}/libsession-util.a" -rsync -rt --exclude='.DS_Store' "${COMPILE_DIR}/Headers/" "${TARGET_BUILD_DIR}/include" -rsync -rt --exclude='.DS_Store' "${COMPILE_DIR}/Headers/" "${INDEX_DIR}/include" + +if [ "${TARGET_BUILD_DIR}" != "${BUILT_PRODUCTS_DIR}" ]; then + echo "- TARGET_BUILD_DIR and BUILT_PRODUCTS_DIR are different. Copying library." + rm -f "${BUILT_PRODUCTS_DIR}/libsession-util.a" + rsync -rt "${COMPILE_DIR}/libsession-util.a" "${BUILT_PRODUCTS_DIR}/libsession-util.a" +fi + +sync_headers "${COMPILE_DIR}/Headers/" +echo "- Sync complete." # Output to XCode just so the output is good echo "LibSession is Ready" diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index e16be1e309..60797d5c0f 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -396,10 +396,6 @@ FCB11D8C1A129A76002F93FB /* CoreMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCB11D8B1A129A76002F93FB /* CoreMedia.framework */; }; FD00CDCB2D5317A7006B96D3 /* Scheduler+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD00CDCA2D5317A3006B96D3 /* Scheduler+Utilities.swift */; }; FD0150262CA23D5B005B08A1 /* SessionUtilitiesKit.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = FD0150252CA23D5B005B08A1 /* SessionUtilitiesKit.xctestplan */; }; - FD0150282CA23DB7005B08A1 /* GRDBExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0150272CA23DB7005B08A1 /* GRDBExtensions.swift */; }; - FD0150292CA23DB7005B08A1 /* GRDBExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0150272CA23DB7005B08A1 /* GRDBExtensions.swift */; }; - FD01502A2CA23DB7005B08A1 /* GRDBExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0150272CA23DB7005B08A1 /* GRDBExtensions.swift */; }; - FD01502C2CA23DB7005B08A1 /* GRDBExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0150272CA23DB7005B08A1 /* GRDBExtensions.swift */; }; FD0150382CA24328005B08A1 /* MockJobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0150372CA24328005B08A1 /* MockJobRunner.swift */; }; FD0150392CA24328005B08A1 /* MockJobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0150372CA24328005B08A1 /* MockJobRunner.swift */; }; FD01503A2CA24328005B08A1 /* MockJobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0150372CA24328005B08A1 /* MockJobRunner.swift */; }; @@ -409,24 +405,16 @@ FD0150422CA2433D005B08A1 /* VersionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD01503F2CA2433D005B08A1 /* VersionSpec.swift */; }; FD0150452CA243BB005B08A1 /* LibSessionUtilSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0150442CA243BB005B08A1 /* LibSessionUtilSpec.swift */; }; FD0150462CA243BB005B08A1 /* LibSessionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0150432CA243BB005B08A1 /* LibSessionSpec.swift */; }; - FD0150482CA243CB005B08A1 /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0150472CA243CB005B08A1 /* Mock.swift */; }; - FD0150492CA243CB005B08A1 /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0150472CA243CB005B08A1 /* Mock.swift */; }; - FD01504B2CA243CB005B08A1 /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0150472CA243CB005B08A1 /* Mock.swift */; }; - FD01504C2CA243CB005B08A1 /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0150472CA243CB005B08A1 /* Mock.swift */; }; FD01504E2CA243E7005B08A1 /* TypeConversionUtilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD01504D2CA243E7005B08A1 /* TypeConversionUtilitiesSpec.swift */; }; FD0150502CA24468005B08A1 /* SessionNetworkingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionNetworkingKit.framework */; platformFilter = ios; }; FD0150522CA2446D005B08A1 /* Quick in Frameworks */ = {isa = PBXBuildFile; productRef = FD0150512CA2446D005B08A1 /* Quick */; }; FD0150542CA24471005B08A1 /* Nimble in Frameworks */ = {isa = PBXBuildFile; productRef = FD0150532CA24471005B08A1 /* Nimble */; }; FD0150582CA27DF3005B08A1 /* ScrollableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0150572CA27DEE005B08A1 /* ScrollableLabel.swift */; }; - FD02CC162C3681EF009AB976 /* RevokeSubaccountResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD02CC152C3681EF009AB976 /* RevokeSubaccountResponse.swift */; }; FD05593D2DFA3A2800DC48CE /* VoipPayloadKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD05593C2DFA3A2200DC48CE /* VoipPayloadKey.swift */; }; FD05594E2E012D2700DC48CE /* _043_RenameAttachments.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD05594D2E012D1A00DC48CE /* _043_RenameAttachments.swift */; }; FD0559562E026E1B00DC48CE /* ObservingDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0559542E026CC900DC48CE /* ObservingDatabase.swift */; }; FD0606C32BCE13ED00C3816E /* MessageRequestFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0606C22BCE13ED00C3816E /* MessageRequestFooterView.swift */; }; FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.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 */; }; @@ -452,7 +440,6 @@ FD0B77B229B82B7A009169BA /* ArrayUtilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0B77B129B82B7A009169BA /* ArrayUtilitiesSpec.swift */; }; FD0E353C2AB9880B006A81F7 /* AppVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0E353A2AB98773006A81F7 /* AppVersion.swift */; }; FD10AF0C2AF32B9A007709E5 /* SessionListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD10AF0B2AF32B9A007709E5 /* SessionListViewModel.swift */; }; - FD10AF122AF85D11007709E5 /* Feature+ServiceNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD10AF112AF85D11007709E5 /* Feature+ServiceNetwork.swift */; }; FD11E22D2CA4D12C001BAF58 /* DifferenceKit in Frameworks */ = {isa = PBXBuildFile; productRef = FD2286782C38D4FF00BC06F7 /* DifferenceKit */; }; FD11E22E2CA4D12C001BAF58 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = FDEF57292C3CF50B00131302 /* WebRTC */; }; FD12A83F2AD63BDF00EEBA0D /* Navigatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD12A83E2AD63BDF00EEBA0D /* Navigatable.swift */; }; @@ -467,15 +454,29 @@ FD17D79927F40AB800122BE0 /* _009_SMK_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _009_SMK_YDBToGRDBMigration.swift */; }; FD17D7A127F40D2500122BE0 /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F527EAD44C00FF65E7 /* Storage.swift */; }; FD17D7A227F40F0500122BE0 /* _006_SMK_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79527F3E04600122BE0 /* _006_SMK_InitialSetupMigration.swift */; }; - FD17D7AE27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7AD27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift */; }; FD17D7B827F51ECA00122BE0 /* Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7B727F51ECA00122BE0 /* Migration.swift */; }; FD17D7E527F6A09900122BE0 /* Identity.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E427F6A09900122BE0 /* Identity.swift */; }; - FD19363C2ACA3134004BCF0F /* ResponseInfo+SnodeAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD19363B2ACA3134004BCF0F /* ResponseInfo+SnodeAPI.swift */; }; FD19363F2ACA66DE004BCF0F /* DatabaseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD19363E2ACA66DE004BCF0F /* DatabaseSpec.swift */; }; FD1A553E2E14BE11003761E4 /* PagedData.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1A553D2E14BE0E003761E4 /* PagedData.swift */; }; FD1A55412E161AF6003761E4 /* Combine+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1A55402E161AF3003761E4 /* Combine+Utilities.swift */; }; FD1A55432E179AED003761E4 /* ObservableKeyEvent+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1A55422E179AE6003761E4 /* ObservableKeyEvent+Utilities.swift */; }; FD1A94FB2900D1C2000D73D3 /* PersistableRecord+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1A94FA2900D1C2000D73D3 /* PersistableRecord+Utilities.swift */; }; + FD1BDBD02E653625008EF998 /* Mockable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1BDBAC2E653200008EF998 /* Mockable.swift */; }; + FD1BDBD12E653625008EF998 /* MockHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1BDBB12E6532DB008EF998 /* MockHandler.swift */; }; + FD1BDBD32E653660008EF998 /* MockFunction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1BDBD22E65365E008EF998 /* MockFunction.swift */; }; + FD1BDBD72E653852008EF998 /* RecordedCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1BDBD62E65384F008EF998 /* RecordedCall.swift */; }; + FD1BDBD92E653868008EF998 /* MockError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1BDBD82E653866008EF998 /* MockError.swift */; }; + FD1BDBDB2E6538B4008EF998 /* MockFunctionBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1BDBDA2E6538B0008EF998 /* MockFunctionBuilder.swift */; }; + FD1BDBDF2E655735008EF998 /* MockFunctionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1BDBDE2E655734008EF998 /* MockFunctionHandler.swift */; }; + FD1BDBE12E655B2A008EF998 /* Nimble in Frameworks */ = {isa = PBXBuildFile; productRef = FD1BDBE02E655B2A008EF998 /* Nimble */; }; + FD1BDBE32E655BB6008EF998 /* Mocked.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1BDBE22E655BB4008EF998 /* Mocked.swift */; }; + FD1BDBE52E655DDF008EF998 /* NimbleVerification.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1BDBE42E655DDE008EF998 /* NimbleVerification.swift */; }; + FD1BDBE62E655EA8008EF998 /* TestUtilities.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD1BDBC32E6535EE008EF998 /* TestUtilities.framework */; }; + FD1BDBEB2E655EAC008EF998 /* TestUtilities.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD1BDBC32E6535EE008EF998 /* TestUtilities.framework */; }; + FD1BDBF02E655EB1008EF998 /* TestUtilities.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD1BDBC32E6535EE008EF998 /* TestUtilities.framework */; }; + FD1BDBF52E655EB5008EF998 /* TestUtilities.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD1BDBC32E6535EE008EF998 /* TestUtilities.framework */; }; + FD1BDBFB2E656539008EF998 /* TestFailureReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1BDBFA2E656538008EF998 /* TestFailureReporter.swift */; }; + FD1BDBFE2E656562008EF998 /* NimbleFailureReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1BDBFD2E656561008EF998 /* NimbleFailureReporter.swift */; }; FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */; }; FD1D732E2A86114600E3F410 /* _029_BlockCommunityMessageRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1D732D2A86114600E3F410 /* _029_BlockCommunityMessageRequests.swift */; }; FD22726B2C32911C004D8A6C /* SendReadReceiptsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272532C32911A004D8A6C /* SendReadReceiptsJob.swift */; }; @@ -501,7 +502,6 @@ FD2272812C32911C004D8A6C /* UpdateProfilePictureJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD22726A2C32911C004D8A6C /* UpdateProfilePictureJob.swift */; }; FD2272832C337830004D8A6C /* GroupPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272822C337830004D8A6C /* GroupPoller.swift */; }; FD2272A92C33E337004D8A6C /* ContentProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272952C33E335004D8A6C /* ContentProxy.swift */; }; - FD2272AA2C33E337004D8A6C /* UpdatableTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272962C33E335004D8A6C /* UpdatableTimestamp.swift */; }; FD2272AB2C33E337004D8A6C /* ValidatableResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272972C33E335004D8A6C /* ValidatableResponse.swift */; }; FD2272AC2C33E337004D8A6C /* IPv4.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272982C33E336004D8A6C /* IPv4.swift */; }; FD2272AD2C33E337004D8A6C /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272992C33E336004D8A6C /* Network.swift */; }; @@ -511,7 +511,6 @@ FD2272B12C33E337004D8A6C /* ProxiedContentDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD22729D2C33E336004D8A6C /* ProxiedContentDownloader.swift */; }; FD2272B22C33E337004D8A6C /* PreparedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD22729E2C33E336004D8A6C /* PreparedRequest.swift */; }; FD2272B32C33E337004D8A6C /* BatchRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD22729F2C33E336004D8A6C /* BatchRequest.swift */; }; - FD2272B42C33E337004D8A6C /* SwarmDrainBehaviour.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272A02C33E336004D8A6C /* SwarmDrainBehaviour.swift */; }; FD2272B52C33E337004D8A6C /* BatchResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272A12C33E336004D8A6C /* BatchResponse.swift */; }; FD2272B72C33E337004D8A6C /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272A32C33E337004D8A6C /* Request.swift */; }; FD2272B92C33E337004D8A6C /* ResponseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272A52C33E337004D8A6C /* ResponseInfo.swift */; }; @@ -526,8 +525,7 @@ FD2272D12C34EBD6004D8A6C /* JSONDecoder+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD47E0AE2AA692F400A55E41 /* JSONDecoder+Utilities.swift */; }; FD2272D42C34ECE1004D8A6C /* BencodeEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272D32C34ECE1004D8A6C /* BencodeEncoder.swift */; }; FD2272D62C34ED6A004D8A6C /* RetryWithDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83DCDC2A739D350065FFAE /* RetryWithDependencies.swift */; }; - FD2272D82C34EDE7004D8A6C /* SnodeAPIEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272D72C34EDE6004D8A6C /* SnodeAPIEndpoint.swift */; }; - FD2272DD2C34EFFA004D8A6C /* AppSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272DC2C34EFFA004D8A6C /* AppSetup.swift */; }; + FD2272D82C34EDE7004D8A6C /* StorageServerEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272D72C34EDE6004D8A6C /* StorageServerEndpoint.swift */; }; FD2272E02C3502BE004D8A6C /* Setting+Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272DF2C3502BE004D8A6C /* Setting+Theme.swift */; }; FD2272E62C351378004D8A6C /* SUIKImageFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272E52C351378004D8A6C /* SUIKImageFormat.swift */; }; FD2272EA2C351CA7004D8A6C /* Threading.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272E92C351CA7004D8A6C /* Threading.swift */; }; @@ -556,10 +554,6 @@ FD23CE332A67C4D90000B97C /* MockNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE312A67C38D0000B97C /* MockNetwork.swift */; }; FD23CE342A67C4D90000B97C /* MockNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE312A67C38D0000B97C /* MockNetwork.swift */; }; FD23EA5D28ED00FA0058676E /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BD27CF2243005E1583 /* TestConstants.swift */; }; - FD23EA5E28ED00FD0058676E /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; - FD23EA6128ED0B260058676E /* CombineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23EA6028ED0B260058676E /* CombineExtensions.swift */; }; - FD23EA6228ED0B260058676E /* CombineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23EA6028ED0B260058676E /* CombineExtensions.swift */; }; - FD23EA6328ED0B260058676E /* CombineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23EA6028ED0B260058676E /* CombineExtensions.swift */; }; FD245C50285065C700B966DD /* VisibleMessage+Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7552553A3AB00C340D1 /* VisibleMessage+Quote.swift */; }; FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5FB2554B0A000555489 /* MessageReceiver.swift */; }; FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF224255B6D5D007E1867 /* SignalAttachment.swift */; }; @@ -583,18 +577,12 @@ FD2AAAF228ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; }; FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */; }; FD2DD5902C6DD13C0073D9BE /* DifferenceKit in Frameworks */ = {isa = PBXBuildFile; productRef = FD2DD58F2C6DD13C0073D9BE /* DifferenceKit */; }; - FD336F602CAA28CF00C0B51B /* CommonSMKMockExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F562CAA28CF00C0B51B /* CommonSMKMockExtensions.swift */; }; FD336F612CAA28CF00C0B51B /* MockNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5C2CAA28CF00C0B51B /* MockNotificationsManager.swift */; }; - FD336F622CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F572CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift */; }; - FD336F632CAA28CF00C0B51B /* MockOGMCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5D2CAA28CF00C0B51B /* MockOGMCache.swift */; }; + FD336F632CAA28CF00C0B51B /* MockOpenGroupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5D2CAA28CF00C0B51B /* MockOpenGroupManager.swift */; }; FD336F642CAA28CF00C0B51B /* MockCommunityPollerCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F582CAA28CF00C0B51B /* MockCommunityPollerCache.swift */; }; - FD336F652CAA28CF00C0B51B /* MockDisplayPictureCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F592CAA28CF00C0B51B /* MockDisplayPictureCache.swift */; }; - FD336F662CAA28CF00C0B51B /* MockSwarmPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5F2CAA28CF00C0B51B /* MockSwarmPoller.swift */; }; FD336F672CAA28CF00C0B51B /* MockGroupPollerCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5A2CAA28CF00C0B51B /* MockGroupPollerCache.swift */; }; FD336F682CAA28CF00C0B51B /* MockPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5E2CAA28CF00C0B51B /* MockPoller.swift */; }; FD336F692CAA28CF00C0B51B /* MockLibSessionCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5B2CAA28CF00C0B51B /* MockLibSessionCache.swift */; }; - FD336F6C2CAA29C600C0B51B /* CommunityPollerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F6B2CAA29C200C0B51B /* CommunityPollerSpec.swift */; }; - FD336F6F2CAA37CB00C0B51B /* MockCommunityPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F6E2CAA37CB00C0B51B /* MockCommunityPoller.swift */; }; FD336F702CABB96C00C0B51B /* BatchRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD01502D2CA24310005B08A1 /* BatchRequestSpec.swift */; }; FD336F712CABB97800C0B51B /* DestinationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD11E22F2CA4F498001BAF58 /* DestinationSpec.swift */; }; FD336F722CABB97800C0B51B /* BatchResponseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD01502E2CA24310005B08A1 /* BatchResponseSpec.swift */; }; @@ -605,12 +593,8 @@ FD3559462CC1FF200088F2A9 /* _034_AddMissingWhisperFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3559452CC1FF140088F2A9 /* _034_AddMissingWhisperFlag.swift */; }; FD368A6829DE8F9C000DBF1E /* _026_AddFTSIfNeeded.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6729DE8F9B000DBF1E /* _026_AddFTSIfNeeded.swift */; }; FD368A6A29DE9E30000DBF1E /* UIContextualAction+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */; }; - FD3765DF2AD8F03100DC1489 /* MockSnodeAPICache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3765DE2AD8F03100DC1489 /* MockSnodeAPICache.swift */; }; - FD3765E02AD8F05100DC1489 /* MockSnodeAPICache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3765DE2AD8F03100DC1489 /* MockSnodeAPICache.swift */; }; - FD3765E22AD8F53B00DC1489 /* CommonSSKMockExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3765E12AD8F53B00DC1489 /* CommonSSKMockExtensions.swift */; }; - FD3765E32AD8F56200DC1489 /* CommonSSKMockExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3765E12AD8F53B00DC1489 /* CommonSSKMockExtensions.swift */; }; - FD3765E72ADE1AA300DC1489 /* UnrevokeSubaccountRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3765E62ADE1AA300DC1489 /* UnrevokeSubaccountRequest.swift */; }; - FD3765E92ADE1AAE00DC1489 /* UnrevokeSubaccountResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3765E82ADE1AAE00DC1489 /* UnrevokeSubaccountResponse.swift */; }; + FD3765E22AD8F53B00DC1489 /* Mocked+SNK.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3765E12AD8F53B00DC1489 /* Mocked+SNK.swift */; }; + FD3765E32AD8F56200DC1489 /* Mocked+SNK.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3765E12AD8F53B00DC1489 /* Mocked+SNK.swift */; }; FD3765EA2ADE37B400DC1489 /* Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD47E0AA2AA68EEA00A55E41 /* Authentication.swift */; }; FD37E9C328A1C6F3003AE748 /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */; }; FD37E9C628A1D4EC003AE748 /* Theme+ClassicDark.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C528A1D4EC003AE748 /* Theme+ClassicDark.swift */; }; @@ -636,6 +620,7 @@ FD37EA1928AC5CCA003AE748 /* NotificationSoundViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37EA1828AC5CCA003AE748 /* NotificationSoundViewModel.swift */; }; FD39352C28F382920084DADA /* VersionFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD39352B28F382920084DADA /* VersionFooterView.swift */; }; FD3937082E4AD3FE00571F17 /* NoopDependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3937072E4AD3F800571F17 /* NoopDependency.swift */; }; + FD39370A2E4B04E000571F17 /* SwarmDrainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3937092E4B04DB00571F17 /* SwarmDrainer.swift */; }; FD39370C2E4D7BCA00571F17 /* DocumentPickerHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD39370B2E4D7BBE00571F17 /* DocumentPickerHandler.swift */; }; FD3AABE928306BBD00E5099A /* ThreadPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */; }; FD3C906727E416AF00CD579F /* BlindedIdLookupSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906627E416AF00CD579F /* BlindedIdLookupSpec.swift */; }; @@ -661,7 +646,7 @@ FD42ECD62E3308B5002D03EA /* ObservableKey+SessionUtilitiesKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD42ECD52E3308AC002D03EA /* ObservableKey+SessionUtilitiesKit.swift */; }; FD432434299C6985008A0213 /* PendingReadReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD432433299C6985008A0213 /* PendingReadReceipt.swift */; }; FD47E0B12AA6A05800A55E41 /* Authentication+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD47E0B02AA6A05800A55E41 /* Authentication+SessionMessagingKit.swift */; }; - FD47E0B52AA6D7AA00A55E41 /* Request+SnodeAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD47E0B42AA6D7AA00A55E41 /* Request+SnodeAPI.swift */; }; + FD47E0B52AA6D7AA00A55E41 /* Request+StorageServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD47E0B42AA6D7AA00A55E41 /* Request+StorageServer.swift */; }; FD481A902CAD16F100ECC4CF /* LibSessionGroupInfoSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD481A8F2CAD16EA00ECC4CF /* LibSessionGroupInfoSpec.swift */; }; FD481A922CAD17DE00ECC4CF /* LibSessionGroupMembersSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD481A912CAD17D900ECC4CF /* LibSessionGroupMembersSpec.swift */; }; FD481A942CAE0AE000ECC4CF /* MockAppContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD481A932CAE0ADD00ECC4CF /* MockAppContext.swift */; }; @@ -669,9 +654,6 @@ FD481A962CAE0AE000ECC4CF /* MockAppContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD481A932CAE0ADD00ECC4CF /* MockAppContext.swift */; }; FD481A972CAE0AE000ECC4CF /* MockAppContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD481A932CAE0ADD00ECC4CF /* MockAppContext.swift */; }; FD481A992CB4CAAA00ECC4CF /* MockLibSessionCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5B2CAA28CF00C0B51B /* MockLibSessionCache.swift */; }; - FD481A9A2CB4CAE500ECC4CF /* CommonSMKMockExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F562CAA28CF00C0B51B /* CommonSMKMockExtensions.swift */; }; - FD481A9B2CB4CAF100ECC4CF /* CustomArgSummaryDescribable+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F572CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift */; }; - FD481A9C2CB4D58300ECC4CF /* MockSnodeAPICache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3765DE2AD8F03100DC1489 /* MockSnodeAPICache.swift */; }; FD481AA32CB889AE00ECC4CF /* RetrieveDefaultOpenGroupRoomsJobSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD481AA22CB889A400ECC4CF /* RetrieveDefaultOpenGroupRoomsJobSpec.swift */; }; FD49E2462B05C1D500FFBBB5 /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD49E2452B05C1D500FFBBB5 /* MockKeychain.swift */; }; FD49E2472B05C1D500FFBBB5 /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD49E2452B05C1D500FFBBB5 /* MockKeychain.swift */; }; @@ -684,7 +666,7 @@ FD52090328B4680F006098F6 /* RadioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52090228B4680F006098F6 /* RadioButton.swift */; }; FD52090528B4915F006098F6 /* PrivacySettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52090428B4915F006098F6 /* PrivacySettingsViewModel.swift */; }; FD52CB5A2E12166F00A4DA70 /* OnboardingSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52CB592E12166D00A4DA70 /* OnboardingSpec.swift */; }; - FD52CB5B2E123FBC00A4DA70 /* CommonSSKMockExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3765E12AD8F53B00DC1489 /* CommonSSKMockExtensions.swift */; }; + FD52CB5B2E123FBC00A4DA70 /* Mocked+SNK.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3765E12AD8F53B00DC1489 /* Mocked+SNK.swift */; }; FD52CB5C2E12536400A4DA70 /* MockExtensionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BCC2DC81ABB00564172 /* MockExtensionHelper.swift */; }; FD52CB632E13B61700A4DA70 /* ObservableKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52CB622E13B61700A4DA70 /* ObservableKey.swift */; }; FD52CB652E13B6E900A4DA70 /* ObservationBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52CB642E13B6E600A4DA70 /* ObservationBuilder.swift */; }; @@ -708,9 +690,6 @@ FD65318B2AA025C500DFEEAA /* TestDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6531892AA025C500DFEEAA /* TestDependencies.swift */; }; FD65318C2AA025C500DFEEAA /* TestDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6531892AA025C500DFEEAA /* TestDependencies.swift */; }; FD65318D2AA025C500DFEEAA /* TestDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6531892AA025C500DFEEAA /* TestDependencies.swift */; }; - FD6673F62D7021E700041530 /* SessionUtil in Frameworks */ = {isa = PBXBuildFile; productRef = FD6673F52D7021E700041530 /* SessionUtil */; }; - FD6673F82D7021F200041530 /* SessionUtil in Frameworks */ = {isa = PBXBuildFile; productRef = FD6673F72D7021F200041530 /* SessionUtil */; }; - FD6673FA2D7021F800041530 /* SessionUtil in Frameworks */ = {isa = PBXBuildFile; productRef = FD6673F92D7021F800041530 /* SessionUtil */; }; FD6673FD2D77F54600041530 /* ScreenLockViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6673FC2D77F54400041530 /* ScreenLockViewController.swift */; }; FD6673FF2D77F9C100041530 /* ScreenLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6673FE2D77F9BE00041530 /* ScreenLock.swift */; }; FD6674002D77F9FD00041530 /* ScreenLockWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52090828B59411006098F6 /* ScreenLockWindow.swift */; }; @@ -727,6 +706,26 @@ FD6A393B2C2AD3A300762359 /* Nimble in Frameworks */ = {isa = PBXBuildFile; productRef = FD6A393A2C2AD3A300762359 /* Nimble */; }; FD6A393D2C2AD3AC00762359 /* Nimble in Frameworks */ = {isa = PBXBuildFile; productRef = FD6A393C2C2AD3AC00762359 /* Nimble */; }; FD6A39412C2AD3B600762359 /* Nimble in Frameworks */ = {isa = PBXBuildFile; productRef = FD6A39402C2AD3B600762359 /* Nimble */; }; + FD6B925A2E66C997004463B5 /* ArgumentDescribing.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B92592E66C994004463B5 /* ArgumentDescribing.swift */; }; + FD6B925E2E695ACE004463B5 /* FixtureBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B925D2E695ACD004463B5 /* FixtureBase.swift */; }; + FD6B925F2E695ACE004463B5 /* FixtureBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B925D2E695ACD004463B5 /* FixtureBase.swift */; }; + FD6B92602E695ACE004463B5 /* FixtureBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B925D2E695ACD004463B5 /* FixtureBase.swift */; }; + FD6B92612E695ACE004463B5 /* FixtureBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B925D2E695ACD004463B5 /* FixtureBase.swift */; }; + FD6B92642E696EE0004463B5 /* Mocked+SUK.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B92632E696EDC004463B5 /* Mocked+SUK.swift */; }; + FD6B92652E697012004463B5 /* Mocked+SUK.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B92632E696EDC004463B5 /* Mocked+SUK.swift */; }; + FD6B92662E697012004463B5 /* Mocked+SUK.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B92632E696EDC004463B5 /* Mocked+SUK.swift */; }; + FD6B92672E697012004463B5 /* Mocked+SUK.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B92632E696EDC004463B5 /* Mocked+SUK.swift */; }; + FD6B926C2E6A7644004463B5 /* Async+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B926B2E6A763D004463B5 /* Async+Utilities.swift */; }; + FD6B92722E6AB045004463B5 /* Quick in Frameworks */ = {isa = PBXBuildFile; productRef = FD6B92712E6AB045004463B5 /* Quick */; }; + FD6B927A2E6F8B90004463B5 /* ServiceNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD10AF112AF85D11007709E5 /* ServiceNetwork.swift */; }; + FD6B927C2E6F8BB2004463B5 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B927B2E6F8BAC004463B5 /* Router.swift */; }; + FD6B927E2E6FEDFF004463B5 /* MockFallbackRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B927D2E6FEDFE004463B5 /* MockFallbackRegistry.swift */; }; + FD6B92822E77819F004463B5 /* Combine+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B92812E77819B004463B5 /* Combine+Utilities.swift */; }; + FD6B92842E7781BC004463B5 /* Collection+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B92832E7781B7004463B5 /* Collection+Utilities.swift */; }; + FD6B92852E77821E004463B5 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B927F2E778150004463B5 /* NimbleExtensions.swift */; }; + FD6B92862E77821E004463B5 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B927F2E778150004463B5 /* NimbleExtensions.swift */; }; + FD6B92872E77821E004463B5 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B927F2E778150004463B5 /* NimbleExtensions.swift */; }; + FD6B92882E77821E004463B5 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B927F2E778150004463B5 /* NimbleExtensions.swift */; }; FD6B928C2E779DCC004463B5 /* FileServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B928B2E779DC8004463B5 /* FileServer.swift */; }; FD6B928E2E779E99004463B5 /* FileServerEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B928D2E779E95004463B5 /* FileServerEndpoint.swift */; }; FD6B92902E779EDD004463B5 /* FileServerAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B928F2E779EDA004463B5 /* FileServerAPI.swift */; }; @@ -736,7 +735,7 @@ FD6B92992E77A06E004463B5 /* Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B92982E77A06C004463B5 /* Token.swift */; }; FD6B929B2E77A084004463B5 /* NetworkInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B929A2E77A083004463B5 /* NetworkInfo.swift */; }; FD6B929D2E77A096004463B5 /* Info.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B929C2E77A095004463B5 /* Info.swift */; }; - FD6B92A32E77A18B004463B5 /* SnodeAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B92A22E77A189004463B5 /* SnodeAPI.swift */; }; + FD6B92A32E77A18B004463B5 /* StorageServerAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B92A22E77A189004463B5 /* StorageServerAPI.swift */; }; FD6B92AB2E77A920004463B5 /* SOGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6B92A92E77A8F8004463B5 /* SOGS.swift */; }; FD6B92AC2E77A993004463B5 /* SOGSEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */; }; FD6B92AD2E77A9F1004463B5 /* SOGSError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380827B31D4E00C60D73 /* SOGSError.swift */; }; @@ -869,6 +868,7 @@ FD78EA0D2DDFEDE200D55B50 /* LibSession+Local.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78EA0C2DDFEDDF00D55B50 /* LibSession+Local.swift */; }; FD7F745B2BAAA35E006DDFD8 /* LibSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7F745A2BAAA35E006DDFD8 /* LibSession.swift */; }; FD7F745F2BAAA3B4006DDFD8 /* TypeConversion+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7F745E2BAAA3B4006DDFD8 /* TypeConversion+Utilities.swift */; }; + FD7FAAFA2E823166008D9BDA /* Mocked+SMK.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B652E821E230023F5F9 /* Mocked+SMK.swift */; }; FD83B9B327CF200A005E1583 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; platformFilter = ios; }; FD83B9BB27CF20AF005E1583 /* SessionIdSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BA27CF20AF005E1583 /* SessionIdSpec.swift */; }; FD83B9BF27CF2294005E1583 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BD27CF2243005E1583 /* TestConstants.swift */; }; @@ -932,12 +932,8 @@ FD99D0922D10F5EE005D2E15 /* ThreadSafeSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99D0912D10F5EB005D2E15 /* ThreadSafeSpec.swift */; }; FD9AECA52AAA9609009B3406 /* NotificationResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9AECA42AAA9609009B3406 /* NotificationResolution.swift */; }; 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 */; }; FDA335F52D91157A007E0EB6 /* SessionImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA335F42D911576007E0EB6 /* SessionImageView.swift */; }; FDAA16762AC28A3B00DDBF77 /* UserDefaultsType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA16752AC28A3B00DDBF77 /* UserDefaultsType.swift */; }; - FDAA167B2AC28E2F00DDBF77 /* SnodeRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA167A2AC28E2F00DDBF77 /* SnodeRequestSpec.swift */; }; FDAA167D2AC528A200DDBF77 /* Preferences+Sound.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA167C2AC528A200DDBF77 /* Preferences+Sound.swift */; }; FDAA167F2AC5290000DDBF77 /* Preferences+NotificationPreviewType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA167E2AC5290000DDBF77 /* Preferences+NotificationPreviewType.swift */; }; FDB11A4C2DCC527D00BEF49F /* NotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A4B2DCC527900BEF49F /* NotificationContent.swift */; }; @@ -974,14 +970,10 @@ FDB5DAE82A95D96C002C8721 /* MessageReceiver+Groups.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB5DAE72A95D96C002C8721 /* MessageReceiver+Groups.swift */; }; FDB5DAF32A96DD4F002C8721 /* PreparedRequest+Sending.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB5DAF22A96DD4F002C8721 /* PreparedRequest+Sending.swift */; }; FDB5DB062A981C67002C8721 /* PreparedRequestSendingSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB5DB052A981C67002C8721 /* PreparedRequestSendingSpec.swift */; }; - FDB5DB082A981F8B002C8721 /* Mocked.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0969F82A69FFE700C5C365 /* Mocked.swift */; }; FDB5DB092A981F8D002C8721 /* MockCrypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE272A67755C0000B97C /* MockCrypto.swift */; }; FDB5DB0B2A981F92002C8721 /* MockGeneralCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */; }; FDB5DB0C2A981F96002C8721 /* MockNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE312A67C38D0000B97C /* MockNetwork.swift */; }; FDB5DB102A981FA3002C8721 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BD27CF2243005E1583 /* TestConstants.swift */; }; - FDB5DB112A981FA6002C8721 /* TestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9DD2702A72516D00ECB68E /* TestExtensions.swift */; }; - FDB5DB122A981FA8002C8721 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; - FDB5DB142A981FAE002C8721 /* CombineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23EA6028ED0B260058676E /* CombineExtensions.swift */; }; FDB5DB152A981FB0002C8721 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; }; FDB6A87C2AD75B7F002D4F96 /* PhotosUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FDB6A87B2AD75B7F002D4F96 /* PhotosUI.framework */; }; FDB7400B28EB99A70094D718 /* TimeInterval+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB7400A28EB99A70094D718 /* TimeInterval+Utilities.swift */; }; @@ -993,17 +985,22 @@ FDC1BD662CFD6C4F002CDC71 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC1BD652CFD6C4E002CDC71 /* Config.swift */; }; FDC1BD682CFE6EEB002CDC71 /* DeveloperSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC1BD672CFE6EEA002CDC71 /* DeveloperSettingsViewModel.swift */; }; FDC1BD6A2CFE7B6B002CDC71 /* DirectoryArchiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC1BD692CFE7B67002CDC71 /* DirectoryArchiver.swift */; }; - FDC289472C881A3800020BC2 /* MutableIdentifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC289462C881A3800020BC2 /* MutableIdentifiable.swift */; }; - FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; - FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; + FDC2908927D70656005DAE71 /* RoomPollInfoSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2908827D70656005DAE71 /* RoomPollInfoSpec.swift */; }; + FDC2908B27D707F3005DAE71 /* SendSOGSMessageRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2908A27D707F3005DAE71 /* SendSOGSMessageRequestSpec.swift */; }; + FDC2908D27D70905005DAE71 /* UpdateMessageRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2908C27D70905005DAE71 /* UpdateMessageRequestSpec.swift */; }; + FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909727D7129B005DAE71 /* PersonalizationSpec.swift */; }; + FDC4380927B31D4E00C60D73 /* SOGSError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380827B31D4E00C60D73 /* SOGSError.swift */; }; FDC4386C27B4E90300C60D73 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; }; FDC4389227B9FFC700C60D73 /* SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; platformFilter = ios; }; FDC438CD27BC641200C60D73 /* Set+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438CC27BC641200C60D73 /* Set+Utilities.swift */; }; FDC498B92AC15FE300EDD897 /* AppNotificationAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC498B82AC15FE300EDD897 /* AppNotificationAction.swift */; }; FDC6D7602862B3F600B04575 /* Dependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC6D75F2862B3F600B04575 /* Dependencies.swift */; }; + FDCC22D02E52E46000C77B1A /* GroupAuthData.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCC22CF2E52E45A00C77B1A /* GroupAuthData.swift */; }; + FDCC22D42E5C0D9900C77B1A /* AppSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272DC2C34EFFA004D8A6C /* AppSetup.swift */; }; + FDCC22D82E5D3C1400C77B1A /* AsyncStream+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCC22D72E5D3C1000C77B1A /* AsyncStream+Utilities.swift */; }; + FDCC22DB2E5E897800C77B1A /* DeveloperSettingsNetworkViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCC22DA2E5E897200C77B1A /* DeveloperSettingsNetworkViewModel.swift */; }; FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; }; - FDD20C162A09E64A003898FB /* GetExpiriesRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD20C152A09E64A003898FB /* GetExpiriesRequest.swift */; }; - FDD20C182A09E7D3003898FB /* GetExpiriesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD20C172A09E7D3003898FB /* GetExpiriesResponse.swift */; }; + FDD23ADE2E44501E0057E853 /* RequestCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD23ADD2E44501B0057E853 /* RequestCategory.swift */; }; FDD23ADF2E457CAA0057E853 /* _016_ThemePreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9F828A5F14A003AE748 /* _016_ThemePreferences.swift */; }; FDD23AE02E457CD40057E853 /* _004_SNK_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _004_SNK_InitialSetupMigration.swift */; }; FDD23AE12E457CDE0057E853 /* _005_SNK_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A6C2818C61500035AC1 /* _005_SNK_SetupStandardJobs.swift */; }; @@ -1041,10 +1038,42 @@ FDE521A62E0E6C8C00061B8E /* MockNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5C2CAA28CF00C0B51B /* MockNotificationsManager.swift */; }; FDE6E99829F8E63A00F93C5D /* Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE6E99729F8E63A00F93C5D /* Accessibility.swift */; }; FDE71B052E77E1AA0023F5F9 /* ObservableKey+SessionNetworkingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B042E77E1A00023F5F9 /* ObservableKey+SessionNetworkingKit.swift */; }; + FDE71B072E7903480023F5F9 /* Dependencies+Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B062E7903400023F5F9 /* Dependencies+Network.swift */; }; FDE71B0B2E79352D0023F5F9 /* DeveloperSettingsGroupsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B0A2E7935250023F5F9 /* DeveloperSettingsGroupsViewModel.swift */; }; FDE71B0D2E793B250023F5F9 /* DeveloperSettingsProViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B0C2E793B1F0023F5F9 /* DeveloperSettingsProViewModel.swift */; }; FDE71B0F2E7A195B0023F5F9 /* Session - Anonymous Messenger.storekit in Resources */ = {isa = PBXBuildFile; fileRef = FDE71B0E2E7A195B0023F5F9 /* Session - Anonymous Messenger.storekit */; }; + FDE71B112E7A1D1C0023F5F9 /* CommunityPollerManagerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B102E7A1D1C0023F5F9 /* CommunityPollerManagerSpec.swift */; }; + FDE71B182E7A1F660023F5F9 /* SnodeReceivedMessageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B162E7A1F660023F5F9 /* SnodeReceivedMessageInfo.swift */; }; + FDE71B3A2E7A1F6F0023F5F9 /* SendMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B2B2E7A1F6F0023F5F9 /* SendMessageRequest.swift */; }; + FDE71B3B2E7A1F6F0023F5F9 /* UpdateExpiryAllResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B362E7A1F6F0023F5F9 /* UpdateExpiryAllResponse.swift */; }; + FDE71B3C2E7A1F6F0023F5F9 /* RevokeSubaccountRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B292E7A1F6F0023F5F9 /* RevokeSubaccountRequest.swift */; }; + FDE71B3D2E7A1F6F0023F5F9 /* SendMessageResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B2C2E7A1F6F0023F5F9 /* SendMessageResponse.swift */; }; + FDE71B3E2E7A1F6F0023F5F9 /* GetNetworkTimestampResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B232E7A1F6F0023F5F9 /* GetNetworkTimestampResponse.swift */; }; + FDE71B3F2E7A1F6F0023F5F9 /* OxenDaemonRPCRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B282E7A1F6F0023F5F9 /* OxenDaemonRPCRequest.swift */; }; + FDE71B402E7A1F6F0023F5F9 /* BaseSwarmItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B322E7A1F6F0023F5F9 /* BaseSwarmItem.swift */; }; + FDE71B412E7A1F6F0023F5F9 /* GetMessagesRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B212E7A1F6F0023F5F9 /* GetMessagesRequest.swift */; }; + FDE71B432E7A1F6F0023F5F9 /* UpdateExpiryAllRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B352E7A1F6F0023F5F9 /* UpdateExpiryAllRequest.swift */; }; + FDE71B462E7A1F6F0023F5F9 /* GetMessagesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B222E7A1F6F0023F5F9 /* GetMessagesResponse.swift */; }; + FDE71B472E7A1F6F0023F5F9 /* ONSResolveRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B262E7A1F6F0023F5F9 /* ONSResolveRequest.swift */; }; + FDE71B482E7A1F6F0023F5F9 /* ONSResolveResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B272E7A1F6F0023F5F9 /* ONSResolveResponse.swift */; }; + FDE71B492E7A1F6F0023F5F9 /* BaseAuthenticatedRequestBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B2D2E7A1F6F0023F5F9 /* BaseAuthenticatedRequestBody.swift */; }; + FDE71B4A2E7A1F6F0023F5F9 /* DeleteMessagesRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B1D2E7A1F6F0023F5F9 /* DeleteMessagesRequest.swift */; }; + FDE71B4B2E7A1F6F0023F5F9 /* BaseResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B312E7A1F6F0023F5F9 /* BaseResponse.swift */; }; + FDE71B4C2E7A1F6F0023F5F9 /* UnrevokeSubaccountRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B332E7A1F6F0023F5F9 /* UnrevokeSubaccountRequest.swift */; }; + FDE71B4D2E7A1F6F0023F5F9 /* DeleteAllMessagesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B1C2E7A1F6F0023F5F9 /* DeleteAllMessagesResponse.swift */; }; + FDE71B4F2E7A1F6F0023F5F9 /* DeleteAllMessagesRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B1B2E7A1F6F0023F5F9 /* DeleteAllMessagesRequest.swift */; }; + FDE71B502E7A1F6F0023F5F9 /* GetExpiriesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B202E7A1F6F0023F5F9 /* GetExpiriesResponse.swift */; }; + FDE71B512E7A1F6F0023F5F9 /* BaseRecursiveResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B2F2E7A1F6F0023F5F9 /* BaseRecursiveResponse.swift */; }; + FDE71B532E7A1F6F0023F5F9 /* RevokeSubaccountResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B2A2E7A1F6F0023F5F9 /* RevokeSubaccountResponse.swift */; }; + FDE71B552E7A1F6F0023F5F9 /* UpdateExpiryResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B382E7A1F6F0023F5F9 /* UpdateExpiryResponse.swift */; }; + FDE71B562E7A1F6F0023F5F9 /* DeleteMessagesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B1E2E7A1F6F0023F5F9 /* DeleteMessagesResponse.swift */; }; + FDE71B572E7A1F6F0023F5F9 /* GetExpiriesRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B1F2E7A1F6F0023F5F9 /* GetExpiriesRequest.swift */; }; + FDE71B592E7A1F6F0023F5F9 /* UnrevokeSubaccountResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B342E7A1F6F0023F5F9 /* UnrevokeSubaccountResponse.swift */; }; + FDE71B5B2E7A2CFB0023F5F9 /* UpdateExpiryRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B5A2E7A2CF70023F5F9 /* UpdateExpiryRequest.swift */; }; + FDE71B5D2E7A6BA90023F5F9 /* StorageServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B5C2E7A6BA70023F5F9 /* StorageServer.swift */; }; FDE71B5F2E7A73570023F5F9 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FDE71B5E2E7A73560023F5F9 /* StoreKit.framework */; }; + FDE71B642E821E1D0023F5F9 /* ArgumentDescribing+SMK.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B632E821E1D0023F5F9 /* ArgumentDescribing+SMK.swift */; }; + FDE71B662E821E230023F5F9 /* Mocked+SMK.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B652E821E230023F5F9 /* Mocked+SMK.swift */; }; FDE7549B2C940108002A2623 /* MessageViewModel+DeletionActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE7549A2C940108002A2623 /* MessageViewModel+DeletionActions.swift */; }; FDE7549D2C9961A4002A2623 /* CommunityPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE7549C2C9961A4002A2623 /* CommunityPoller.swift */; }; FDE754A12C9A60A6002A2623 /* Crypto+OpenGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754A02C9A60A6002A2623 /* Crypto+OpenGroup.swift */; }; @@ -1098,6 +1127,7 @@ FDE755242C9BC1D1002A2623 /* Publisher+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE755232C9BC1D1002A2623 /* Publisher+Utilities.swift */; }; FDEF573E2C40F2A100131302 /* GroupUpdateMemberLeftNotificationMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDEF573D2C40F2A100131302 /* GroupUpdateMemberLeftNotificationMessage.swift */; }; FDEF57712C44D2D300131302 /* GeoLite2-Country-Blocks-IPv4 in Resources */ = {isa = PBXBuildFile; fileRef = FDEF57702C44D2D300131302 /* GeoLite2-Country-Blocks-IPv4 */; }; + FDEFDC702E84A13100EBCD81 /* FlatMapLatestActor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDEFDC6F2E84A12A00EBCD81 /* FlatMapLatestActor.swift */; }; FDF01FAD2A9ECC4200CAF969 /* SingletonConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF01FAC2A9ECC4200CAF969 /* SingletonConfig.swift */; }; FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */; }; FDF0B7422804EA4F004C14C5 /* _007_SMK_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7412804EA4F004C14C5 /* _007_SMK_SetupStandardJobs.swift */; }; @@ -1118,37 +1148,9 @@ FDF71EA32B072C2800A8D6B5 /* LibSessionMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF71EA22B072C2800A8D6B5 /* LibSessionMessage.swift */; }; FDF71EA52B07363500A8D6B5 /* MessageReceiver+LibSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF71EA42B07363500A8D6B5 /* MessageReceiver+LibSession.swift */; }; FDF8488929405B27007DCAE5 /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */; }; - FDF8489129405C13007DCAE5 /* SnodeAPINamespace.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF8489029405C13007DCAE5 /* SnodeAPINamespace.swift */; }; - FDF848BC29405C5A007DCAE5 /* SnodeRecursiveResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF8489A29405C5A007DCAE5 /* SnodeRecursiveResponse.swift */; }; - FDF848BD29405C5A007DCAE5 /* GetMessagesRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF8489B29405C5A007DCAE5 /* GetMessagesRequest.swift */; }; - FDF848BF29405C5A007DCAE5 /* SnodeResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF8489D29405C5A007DCAE5 /* SnodeResponse.swift */; }; - FDF848C029405C5A007DCAE5 /* ONSResolveResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF8489E29405C5A007DCAE5 /* ONSResolveResponse.swift */; }; - FDF848C129405C5A007DCAE5 /* UpdateExpiryRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF8489F29405C5A007DCAE5 /* UpdateExpiryRequest.swift */; }; - FDF848C229405C5A007DCAE5 /* OxenDaemonRPCRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848A029405C5A007DCAE5 /* OxenDaemonRPCRequest.swift */; }; - FDF848C329405C5A007DCAE5 /* DeleteMessagesRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848A129405C5A007DCAE5 /* DeleteMessagesRequest.swift */; }; - FDF848C629405C5B007DCAE5 /* DeleteAllMessagesRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848A429405C5A007DCAE5 /* DeleteAllMessagesRequest.swift */; }; - FDF848C729405C5B007DCAE5 /* SendMessageResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848A529405C5A007DCAE5 /* SendMessageResponse.swift */; }; - FDF848C829405C5B007DCAE5 /* ONSResolveRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848A629405C5A007DCAE5 /* ONSResolveRequest.swift */; }; - FDF848C929405C5B007DCAE5 /* SnodeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848A729405C5A007DCAE5 /* SnodeRequest.swift */; }; - FDF848CA29405C5B007DCAE5 /* DeleteAllBeforeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848A829405C5A007DCAE5 /* DeleteAllBeforeRequest.swift */; }; - FDF848CC29405C5B007DCAE5 /* SnodeReceivedMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848AA29405C5A007DCAE5 /* SnodeReceivedMessage.swift */; }; - FDF848CD29405C5B007DCAE5 /* GetNetworkTimestampResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848AB29405C5A007DCAE5 /* GetNetworkTimestampResponse.swift */; }; - FDF848CE29405C5B007DCAE5 /* UpdateExpiryAllRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848AC29405C5A007DCAE5 /* UpdateExpiryAllRequest.swift */; }; - FDF848CF29405C5B007DCAE5 /* SendMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848AD29405C5A007DCAE5 /* SendMessageRequest.swift */; }; - FDF848D029405C5B007DCAE5 /* UpdateExpiryResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848AE29405C5A007DCAE5 /* UpdateExpiryResponse.swift */; }; - FDF848D129405C5B007DCAE5 /* SnodeSwarmItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848AF29405C5A007DCAE5 /* SnodeSwarmItem.swift */; }; - FDF848D229405C5B007DCAE5 /* LegacyGetMessagesRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848B029405C5A007DCAE5 /* LegacyGetMessagesRequest.swift */; }; - FDF848D329405C5B007DCAE5 /* UpdateExpiryAllResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848B129405C5A007DCAE5 /* UpdateExpiryAllResponse.swift */; }; - FDF848D429405C5B007DCAE5 /* DeleteAllBeforeResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848B229405C5A007DCAE5 /* DeleteAllBeforeResponse.swift */; }; - FDF848D529405C5B007DCAE5 /* DeleteAllMessagesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848B329405C5A007DCAE5 /* DeleteAllMessagesResponse.swift */; }; - FDF848D629405C5B007DCAE5 /* SnodeMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848B429405C5A007DCAE5 /* SnodeMessage.swift */; }; - FDF848D729405C5B007DCAE5 /* SnodeBatchRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848B529405C5A007DCAE5 /* SnodeBatchRequest.swift */; }; - FDF848D929405C5B007DCAE5 /* SnodeAuthenticatedRequestBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848B729405C5A007DCAE5 /* SnodeAuthenticatedRequestBody.swift */; }; - FDF848DA29405C5B007DCAE5 /* GetMessagesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848B829405C5A007DCAE5 /* GetMessagesResponse.swift */; }; - FDF848DB29405C5B007DCAE5 /* DeleteMessagesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848B929405C5A007DCAE5 /* DeleteMessagesResponse.swift */; }; - FDF848DC29405C5B007DCAE5 /* RevokeSubaccountRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848BA29405C5A007DCAE5 /* RevokeSubaccountRequest.swift */; }; - FDF848DD29405C5B007DCAE5 /* LegacySendMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848BB29405C5A007DCAE5 /* LegacySendMessageRequest.swift */; }; - FDF848E529405D6E007DCAE5 /* SnodeAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848E029405D6E007DCAE5 /* SnodeAPIError.swift */; }; + FDF8489129405C13007DCAE5 /* StorageServerNamespace.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF8489029405C13007DCAE5 /* StorageServerNamespace.swift */; }; + FDF848CC29405C5B007DCAE5 /* StorageServerMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848AA29405C5A007DCAE5 /* StorageServerMessage.swift */; }; + FDF848E529405D6E007DCAE5 /* StorageServerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848E029405D6E007DCAE5 /* StorageServerError.swift */; }; FDF848E629405D6E007DCAE5 /* Destination.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848E129405D6E007DCAE5 /* Destination.swift */; }; FDF848F129406A30007DCAE5 /* Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848F029406A30007DCAE5 /* Format.swift */; }; FDF848F329413DB0007DCAE5 /* ImagePickerHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848F229413DB0007DCAE5 /* ImagePickerHandler.swift */; }; @@ -1281,6 +1283,34 @@ remoteGlobalIDString = C33FD9AA255A548A00E217F9; remoteInfo = SignalUtilitiesKit; }; + FD1BDBE82E655EA8008EF998 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D221A080169C9E5E00537ABF /* Project object */; + proxyType = 1; + remoteGlobalIDString = FD1BDBC22E6535EE008EF998; + remoteInfo = TestUtilities; + }; + FD1BDBED2E655EAC008EF998 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D221A080169C9E5E00537ABF /* Project object */; + proxyType = 1; + remoteGlobalIDString = FD1BDBC22E6535EE008EF998; + remoteInfo = TestUtilities; + }; + FD1BDBF22E655EB1008EF998 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D221A080169C9E5E00537ABF /* Project object */; + proxyType = 1; + remoteGlobalIDString = FD1BDBC22E6535EE008EF998; + remoteInfo = TestUtilities; + }; + FD1BDBF72E655EB5008EF998 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D221A080169C9E5E00537ABF /* Project object */; + proxyType = 1; + remoteGlobalIDString = FD1BDBC22E6535EE008EF998; + remoteInfo = TestUtilities; + }; FD71160D28D00BAE00B47552 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = D221A080169C9E5E00537ABF /* Project object */; @@ -1791,7 +1821,6 @@ FCB11D8B1A129A76002F93FB /* CoreMedia.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreMedia.framework; path = System/Library/Frameworks/CoreMedia.framework; sourceTree = SDKROOT; }; FD00CDCA2D5317A3006B96D3 /* Scheduler+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Scheduler+Utilities.swift"; sourceTree = ""; }; FD0150252CA23D5B005B08A1 /* SessionUtilitiesKit.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = SessionUtilitiesKit.xctestplan; sourceTree = ""; }; - FD0150272CA23DB7005B08A1 /* GRDBExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRDBExtensions.swift; sourceTree = ""; }; FD01502D2CA24310005B08A1 /* BatchRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchRequestSpec.swift; sourceTree = ""; }; FD01502E2CA24310005B08A1 /* BatchResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchResponseSpec.swift; sourceTree = ""; }; FD01502F2CA24310005B08A1 /* HeaderSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderSpec.swift; sourceTree = ""; }; @@ -1803,17 +1832,14 @@ FD01503F2CA2433D005B08A1 /* VersionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionSpec.swift; sourceTree = ""; }; FD0150432CA243BB005B08A1 /* LibSessionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibSessionSpec.swift; sourceTree = ""; }; FD0150442CA243BB005B08A1 /* LibSessionUtilSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibSessionUtilSpec.swift; sourceTree = ""; }; - FD0150472CA243CB005B08A1 /* Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mock.swift; sourceTree = ""; }; FD01504D2CA243E7005B08A1 /* TypeConversionUtilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypeConversionUtilitiesSpec.swift; sourceTree = ""; }; FD0150572CA27DEE005B08A1 /* ScrollableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollableLabel.swift; sourceTree = ""; }; FD02CC112C367761009AB976 /* Request+PushNotificationAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Request+PushNotificationAPI.swift"; sourceTree = ""; }; FD02CC132C3677E6009AB976 /* Request+SOGS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Request+SOGS.swift"; sourceTree = ""; }; - FD02CC152C3681EF009AB976 /* RevokeSubaccountResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RevokeSubaccountResponse.swift; sourceTree = ""; }; FD05593C2DFA3A2200DC48CE /* VoipPayloadKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoipPayloadKey.swift; sourceTree = ""; }; FD05594D2E012D1A00DC48CE /* _043_RenameAttachments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _043_RenameAttachments.swift; sourceTree = ""; }; FD0559542E026CC900DC48CE /* ObservingDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservingDatabase.swift; sourceTree = ""; }; FD0606C22BCE13ED00C3816E /* MessageRequestFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestFooterView.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 = ""; }; @@ -1839,7 +1865,7 @@ FD0B77B129B82B7A009169BA /* ArrayUtilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUtilitiesSpec.swift; sourceTree = ""; }; FD0E353A2AB98773006A81F7 /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = ""; }; FD10AF0B2AF32B9A007709E5 /* SessionListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionListViewModel.swift; sourceTree = ""; }; - FD10AF112AF85D11007709E5 /* Feature+ServiceNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Feature+ServiceNetwork.swift"; sourceTree = ""; }; + FD10AF112AF85D11007709E5 /* ServiceNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceNetwork.swift; sourceTree = ""; }; FD11E22F2CA4F498001BAF58 /* DestinationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationSpec.swift; sourceTree = ""; }; FD12A83E2AD63BDF00EEBA0D /* Navigatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Navigatable.swift; sourceTree = ""; }; FD12A8402AD63BEA00EEBA0D /* NavigatableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigatableState.swift; sourceTree = ""; }; @@ -1852,19 +1878,29 @@ FD17D79827F40AB800122BE0 /* _009_SMK_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _009_SMK_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D79F27F40CC800122BE0 /* _004_SNK_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _004_SNK_InitialSetupMigration.swift; sourceTree = ""; }; FD17D7A327F40F8100122BE0 /* _008_SNK_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _008_SNK_YDBToGRDBMigration.swift; sourceTree = ""; }; - FD17D7AD27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeReceivedMessageInfo.swift; sourceTree = ""; }; FD17D7AF27F4225C00122BE0 /* Set+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Set+Utilities.swift"; sourceTree = ""; }; FD17D7B727F51ECA00122BE0 /* Migration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Migration.swift; sourceTree = ""; }; FD17D7BE27F51F8200122BE0 /* ColumnExpressible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColumnExpressible.swift; sourceTree = ""; }; FD17D7C927F546D900122BE0 /* _001_SUK_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_SUK_InitialSetupMigration.swift; sourceTree = ""; }; FD17D7E427F6A09900122BE0 /* Identity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Identity.swift; sourceTree = ""; }; FD17D7E627F6A16700122BE0 /* _003_SUK_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_SUK_YDBToGRDBMigration.swift; sourceTree = ""; }; - FD19363B2ACA3134004BCF0F /* ResponseInfo+SnodeAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ResponseInfo+SnodeAPI.swift"; sourceTree = ""; }; FD19363E2ACA66DE004BCF0F /* DatabaseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseSpec.swift; sourceTree = ""; }; FD1A553D2E14BE0E003761E4 /* PagedData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedData.swift; sourceTree = ""; }; FD1A55402E161AF3003761E4 /* Combine+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Combine+Utilities.swift"; sourceTree = ""; }; FD1A55422E179AE6003761E4 /* ObservableKeyEvent+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ObservableKeyEvent+Utilities.swift"; sourceTree = ""; }; FD1A94FA2900D1C2000D73D3 /* PersistableRecord+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistableRecord+Utilities.swift"; sourceTree = ""; }; + FD1BDBAC2E653200008EF998 /* Mockable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mockable.swift; sourceTree = ""; }; + FD1BDBB12E6532DB008EF998 /* MockHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockHandler.swift; sourceTree = ""; }; + FD1BDBC32E6535EE008EF998 /* TestUtilities.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TestUtilities.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + FD1BDBD22E65365E008EF998 /* MockFunction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockFunction.swift; sourceTree = ""; }; + FD1BDBD62E65384F008EF998 /* RecordedCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordedCall.swift; sourceTree = ""; }; + FD1BDBD82E653866008EF998 /* MockError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockError.swift; sourceTree = ""; }; + FD1BDBDA2E6538B0008EF998 /* MockFunctionBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockFunctionBuilder.swift; sourceTree = ""; }; + FD1BDBDE2E655734008EF998 /* MockFunctionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockFunctionHandler.swift; sourceTree = ""; }; + FD1BDBE22E655BB4008EF998 /* Mocked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mocked.swift; sourceTree = ""; }; + FD1BDBE42E655DDE008EF998 /* NimbleVerification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NimbleVerification.swift; sourceTree = ""; }; + FD1BDBFA2E656538008EF998 /* TestFailureReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestFailureReporter.swift; sourceTree = ""; }; + FD1BDBFD2E656561008EF998 /* NimbleFailureReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NimbleFailureReporter.swift; sourceTree = ""; }; FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = ""; }; FD1D732D2A86114600E3F410 /* _029_BlockCommunityMessageRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _029_BlockCommunityMessageRequests.swift; sourceTree = ""; }; FD2272532C32911A004D8A6C /* SendReadReceiptsJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SendReadReceiptsJob.swift; sourceTree = ""; }; @@ -1890,7 +1926,6 @@ FD22726A2C32911C004D8A6C /* UpdateProfilePictureJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UpdateProfilePictureJob.swift; sourceTree = ""; }; FD2272822C337830004D8A6C /* GroupPoller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPoller.swift; sourceTree = ""; }; FD2272952C33E335004D8A6C /* ContentProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentProxy.swift; sourceTree = ""; }; - FD2272962C33E335004D8A6C /* UpdatableTimestamp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UpdatableTimestamp.swift; sourceTree = ""; }; FD2272972C33E335004D8A6C /* ValidatableResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ValidatableResponse.swift; sourceTree = ""; }; FD2272982C33E336004D8A6C /* IPv4.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IPv4.swift; sourceTree = ""; }; FD2272992C33E336004D8A6C /* Network.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = ""; }; @@ -1900,7 +1935,6 @@ FD22729D2C33E336004D8A6C /* ProxiedContentDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProxiedContentDownloader.swift; sourceTree = ""; }; FD22729E2C33E336004D8A6C /* PreparedRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreparedRequest.swift; sourceTree = ""; }; FD22729F2C33E336004D8A6C /* BatchRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BatchRequest.swift; sourceTree = ""; }; - FD2272A02C33E336004D8A6C /* SwarmDrainBehaviour.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwarmDrainBehaviour.swift; sourceTree = ""; }; FD2272A12C33E336004D8A6C /* BatchResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BatchResponse.swift; sourceTree = ""; }; FD2272A32C33E337004D8A6C /* Request.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = ""; }; FD2272A52C33E337004D8A6C /* ResponseInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ResponseInfo.swift; sourceTree = ""; }; @@ -1909,7 +1943,7 @@ FD2272A82C33E337004D8A6C /* URLResponse+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URLResponse+Utilities.swift"; sourceTree = ""; }; FD2272BD2C34B710004D8A6C /* Publisher+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+Utilities.swift"; sourceTree = ""; }; FD2272D32C34ECE1004D8A6C /* BencodeEncoder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BencodeEncoder.swift; sourceTree = ""; }; - FD2272D72C34EDE6004D8A6C /* SnodeAPIEndpoint.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnodeAPIEndpoint.swift; sourceTree = ""; }; + FD2272D72C34EDE6004D8A6C /* StorageServerEndpoint.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StorageServerEndpoint.swift; sourceTree = ""; }; FD2272DC2C34EFFA004D8A6C /* AppSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSetup.swift; sourceTree = ""; }; FD2272DF2C3502BE004D8A6C /* Setting+Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Setting+Theme.swift"; sourceTree = ""; }; FD2272E52C351378004D8A6C /* SUIKImageFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SUIKImageFormat.swift; sourceTree = ""; }; @@ -1930,31 +1964,21 @@ FD23CE252A676B5B0000B97C /* DependenciesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependenciesSpec.swift; sourceTree = ""; }; FD23CE272A67755C0000B97C /* MockCrypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCrypto.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 = ""; }; FD29598C2A43BC0B00888A17 /* Version.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Version.swift; sourceTree = ""; }; FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronousStorage.swift; sourceTree = ""; }; FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QueryInterfaceRequest+Utilities.swift"; sourceTree = ""; }; - FD336F562CAA28CF00C0B51B /* CommonSMKMockExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonSMKMockExtensions.swift; sourceTree = ""; }; - FD336F572CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CustomArgSummaryDescribable+SessionMessagingKit.swift"; sourceTree = ""; }; FD336F582CAA28CF00C0B51B /* MockCommunityPollerCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCommunityPollerCache.swift; sourceTree = ""; }; - FD336F592CAA28CF00C0B51B /* MockDisplayPictureCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDisplayPictureCache.swift; sourceTree = ""; }; FD336F5A2CAA28CF00C0B51B /* MockGroupPollerCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGroupPollerCache.swift; sourceTree = ""; }; FD336F5B2CAA28CF00C0B51B /* MockLibSessionCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockLibSessionCache.swift; sourceTree = ""; }; FD336F5C2CAA28CF00C0B51B /* MockNotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNotificationsManager.swift; sourceTree = ""; }; - FD336F5D2CAA28CF00C0B51B /* MockOGMCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockOGMCache.swift; sourceTree = ""; }; + FD336F5D2CAA28CF00C0B51B /* MockOpenGroupManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockOpenGroupManager.swift; sourceTree = ""; }; FD336F5E2CAA28CF00C0B51B /* MockPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPoller.swift; sourceTree = ""; }; - FD336F5F2CAA28CF00C0B51B /* MockSwarmPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSwarmPoller.swift; sourceTree = ""; }; - FD336F6B2CAA29C200C0B51B /* CommunityPollerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityPollerSpec.swift; sourceTree = ""; }; - FD336F6E2CAA37CB00C0B51B /* MockCommunityPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCommunityPoller.swift; sourceTree = ""; }; FD3559452CC1FF140088F2A9 /* _034_AddMissingWhisperFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _034_AddMissingWhisperFlag.swift; sourceTree = ""; }; FD368A6729DE8F9B000DBF1E /* _026_AddFTSIfNeeded.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = _026_AddFTSIfNeeded.swift; sourceTree = ""; }; FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIContextualAction+Utilities.swift"; sourceTree = ""; }; - FD3765DE2AD8F03100DC1489 /* MockSnodeAPICache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSnodeAPICache.swift; sourceTree = ""; }; - FD3765E12AD8F53B00DC1489 /* CommonSSKMockExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonSSKMockExtensions.swift; sourceTree = ""; }; - FD3765E62ADE1AA300DC1489 /* UnrevokeSubaccountRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnrevokeSubaccountRequest.swift; sourceTree = ""; }; - FD3765E82ADE1AAE00DC1489 /* UnrevokeSubaccountResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnrevokeSubaccountResponse.swift; sourceTree = ""; }; + FD3765E12AD8F53B00DC1489 /* Mocked+SNK.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mocked+SNK.swift"; sourceTree = ""; }; FD3765F32ADE5A0800DC1489 /* AuthenticatedRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedRequest.swift; sourceTree = ""; }; FD3765F52ADE5BA500DC1489 /* ServiceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceInfo.swift; sourceTree = ""; }; FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = ""; }; @@ -1986,6 +2010,7 @@ FD39352B28F382920084DADA /* VersionFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionFooterView.swift; sourceTree = ""; }; FD39353528F7C3390084DADA /* _010_FlagMessageHashAsDeletedOrInvalid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _010_FlagMessageHashAsDeletedOrInvalid.swift; sourceTree = ""; }; FD3937072E4AD3F800571F17 /* NoopDependency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoopDependency.swift; sourceTree = ""; }; + FD3937092E4B04DB00571F17 /* SwarmDrainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwarmDrainer.swift; sourceTree = ""; }; FD39370B2E4D7BBE00571F17 /* DocumentPickerHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentPickerHandler.swift; sourceTree = ""; }; FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = ""; }; FD3C906627E416AF00CD579F /* BlindedIdLookupSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdLookupSpec.swift; sourceTree = ""; }; @@ -2012,7 +2037,7 @@ FD47E0AA2AA68EEA00A55E41 /* Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Authentication.swift; sourceTree = ""; }; FD47E0AE2AA692F400A55E41 /* JSONDecoder+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSONDecoder+Utilities.swift"; sourceTree = ""; }; FD47E0B02AA6A05800A55E41 /* Authentication+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Authentication+SessionMessagingKit.swift"; sourceTree = ""; }; - FD47E0B42AA6D7AA00A55E41 /* Request+SnodeAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Request+SnodeAPI.swift"; sourceTree = ""; }; + FD47E0B42AA6D7AA00A55E41 /* Request+StorageServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Request+StorageServer.swift"; sourceTree = ""; }; FD481A8F2CAD16EA00ECC4CF /* LibSessionGroupInfoSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibSessionGroupInfoSpec.swift; sourceTree = ""; }; FD481A912CAD17D900ECC4CF /* LibSessionGroupMembersSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibSessionGroupMembersSpec.swift; sourceTree = ""; }; FD481A932CAE0ADD00ECC4CF /* MockAppContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAppContext.swift; sourceTree = ""; }; @@ -2052,6 +2077,15 @@ FD66CB2B2BF344C600268FAB /* SessionMessageKit.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = SessionMessageKit.xctestplan; sourceTree = ""; }; FD6A38F02C2A66B100762359 /* KeychainStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStorage.swift; sourceTree = ""; }; FD6A7A6C2818C61500035AC1 /* _005_SNK_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _005_SNK_SetupStandardJobs.swift; sourceTree = ""; }; + FD6B92592E66C994004463B5 /* ArgumentDescribing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArgumentDescribing.swift; sourceTree = ""; }; + FD6B925D2E695ACD004463B5 /* FixtureBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FixtureBase.swift; sourceTree = ""; }; + FD6B92632E696EDC004463B5 /* Mocked+SUK.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mocked+SUK.swift"; sourceTree = ""; }; + FD6B926B2E6A763D004463B5 /* Async+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Async+Utilities.swift"; sourceTree = ""; }; + FD6B927B2E6F8BAC004463B5 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; + FD6B927D2E6FEDFE004463B5 /* MockFallbackRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockFallbackRegistry.swift; sourceTree = ""; }; + FD6B927F2E778150004463B5 /* NimbleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NimbleExtensions.swift; sourceTree = ""; }; + FD6B92812E77819B004463B5 /* Combine+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Combine+Utilities.swift"; sourceTree = ""; }; + FD6B92832E7781B7004463B5 /* Collection+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Utilities.swift"; sourceTree = ""; }; FD6B928B2E779DC8004463B5 /* FileServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileServer.swift; sourceTree = ""; }; FD6B928D2E779E95004463B5 /* FileServerEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileServerEndpoint.swift; sourceTree = ""; }; FD6B928F2E779EDA004463B5 /* FileServerAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileServerAPI.swift; sourceTree = ""; }; @@ -2061,7 +2095,7 @@ FD6B92982E77A06C004463B5 /* Token.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Token.swift; sourceTree = ""; }; FD6B929A2E77A083004463B5 /* NetworkInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkInfo.swift; sourceTree = ""; }; FD6B929C2E77A095004463B5 /* Info.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Info.swift; sourceTree = ""; }; - FD6B92A22E77A189004463B5 /* SnodeAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeAPI.swift; sourceTree = ""; }; + FD6B92A22E77A189004463B5 /* StorageServerAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageServerAPI.swift; sourceTree = ""; }; FD6B92A92E77A8F8004463B5 /* SOGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGS.swift; sourceTree = ""; }; FD6B92C52E77AD0B004463B5 /* Crypto+FileServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Crypto+FileServer.swift"; sourceTree = ""; }; FD6B92C72E77AD35004463B5 /* Crypto+SOGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Crypto+SOGS.swift"; sourceTree = ""; }; @@ -2194,10 +2228,8 @@ FD99D0862D0FA72E005D2E15 /* ThreadSafe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafe.swift; sourceTree = ""; }; FD99D0912D10F5EB005D2E15 /* ThreadSafeSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafeSpec.swift; sourceTree = ""; }; FD9AECA42AAA9609009B3406 /* NotificationResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationResolution.swift; sourceTree = ""; }; - FD9DD2702A72516D00ECB68E /* TestExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestExtensions.swift; sourceTree = ""; }; FDA335F42D911576007E0EB6 /* SessionImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionImageView.swift; sourceTree = ""; }; FDAA16752AC28A3B00DDBF77 /* UserDefaultsType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsType.swift; sourceTree = ""; }; - FDAA167A2AC28E2F00DDBF77 /* SnodeRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeRequestSpec.swift; sourceTree = ""; }; FDAA167C2AC528A200DDBF77 /* Preferences+Sound.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Sound.swift"; sourceTree = ""; }; FDAA167E2AC5290000DDBF77 /* Preferences+NotificationPreviewType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+NotificationPreviewType.swift"; sourceTree = ""; }; FDB11A4B2DCC527900BEF49F /* NotificationContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContent.swift; sourceTree = ""; }; @@ -2251,7 +2283,6 @@ FDC1BD652CFD6C4E002CDC71 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; FDC1BD672CFE6EEA002CDC71 /* DeveloperSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsViewModel.swift; sourceTree = ""; }; FDC1BD692CFE7B67002CDC71 /* DirectoryArchiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectoryArchiver.swift; sourceTree = ""; }; - FDC289462C881A3800020BC2 /* MutableIdentifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutableIdentifiable.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 /* SendSOGSMessageRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendSOGSMessageRequestSpec.swift; sourceTree = ""; }; @@ -2262,7 +2293,6 @@ FDC2909527D71252005DAE71 /* SOGSErrorSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSErrorSpec.swift; sourceTree = ""; }; FDC2909727D7129B005DAE71 /* PersonalizationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalizationSpec.swift; sourceTree = ""; }; FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupManagerSpec.swift; sourceTree = ""; }; - FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NimbleExtensions.swift; sourceTree = ""; }; FDC4380827B31D4E00C60D73 /* SOGSError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSError.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 = ""; }; @@ -2285,11 +2315,13 @@ FDC438CC27BC641200C60D73 /* Set+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Set+Utilities.swift"; sourceTree = ""; }; FDC498B82AC15FE300EDD897 /* AppNotificationAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNotificationAction.swift; sourceTree = ""; }; FDC6D75F2862B3F600B04575 /* Dependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dependencies.swift; sourceTree = ""; }; + FDCC22CF2E52E45A00C77B1A /* GroupAuthData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupAuthData.swift; sourceTree = ""; }; + FDCC22D72E5D3C1000C77B1A /* AsyncStream+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncStream+Utilities.swift"; sourceTree = ""; }; + FDCC22DA2E5E897200C77B1A /* DeveloperSettingsNetworkViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsNetworkViewModel.swift; sourceTree = ""; }; FDCCC6E82ABA7402002BBEF5 /* EmojiGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiGenerator.swift; sourceTree = ""; }; FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; - FDD20C152A09E64A003898FB /* GetExpiriesRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetExpiriesRequest.swift; sourceTree = ""; }; - FDD20C172A09E7D3003898FB /* GetExpiriesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetExpiriesResponse.swift; sourceTree = ""; }; FDD20C192A0A03AC003898FB /* DeleteInboxResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteInboxResponse.swift; sourceTree = ""; }; + FDD23ADD2E44501B0057E853 /* RequestCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestCategory.swift; sourceTree = ""; }; FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DifferenceKit+Utilities.swift"; sourceTree = ""; }; FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryNavigationController.swift; sourceTree = ""; }; FDD383702AFDD0E1001367F2 /* BencodeResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BencodeResponse.swift; sourceTree = ""; }; @@ -2311,10 +2343,42 @@ FDE521A12E0D23A200061B8E /* ObservableKey+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ObservableKey+SessionMessagingKit.swift"; sourceTree = ""; }; FDE6E99729F8E63A00F93C5D /* Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Accessibility.swift; sourceTree = ""; }; FDE71B042E77E1A00023F5F9 /* ObservableKey+SessionNetworkingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ObservableKey+SessionNetworkingKit.swift"; sourceTree = ""; }; + FDE71B062E7903400023F5F9 /* Dependencies+Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dependencies+Network.swift"; sourceTree = ""; }; FDE71B0A2E7935250023F5F9 /* DeveloperSettingsGroupsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsGroupsViewModel.swift; sourceTree = ""; }; FDE71B0C2E793B1F0023F5F9 /* DeveloperSettingsProViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsProViewModel.swift; sourceTree = ""; }; FDE71B0E2E7A195B0023F5F9 /* Session - Anonymous Messenger.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Session - Anonymous Messenger.storekit"; sourceTree = ""; }; + FDE71B102E7A1D1C0023F5F9 /* CommunityPollerManagerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityPollerManagerSpec.swift; sourceTree = ""; }; + FDE71B162E7A1F660023F5F9 /* SnodeReceivedMessageInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeReceivedMessageInfo.swift; sourceTree = ""; }; + FDE71B1B2E7A1F6F0023F5F9 /* DeleteAllMessagesRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAllMessagesRequest.swift; sourceTree = ""; }; + FDE71B1C2E7A1F6F0023F5F9 /* DeleteAllMessagesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAllMessagesResponse.swift; sourceTree = ""; }; + FDE71B1D2E7A1F6F0023F5F9 /* DeleteMessagesRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteMessagesRequest.swift; sourceTree = ""; }; + FDE71B1E2E7A1F6F0023F5F9 /* DeleteMessagesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteMessagesResponse.swift; sourceTree = ""; }; + FDE71B1F2E7A1F6F0023F5F9 /* GetExpiriesRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetExpiriesRequest.swift; sourceTree = ""; }; + FDE71B202E7A1F6F0023F5F9 /* GetExpiriesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetExpiriesResponse.swift; sourceTree = ""; }; + FDE71B212E7A1F6F0023F5F9 /* GetMessagesRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetMessagesRequest.swift; sourceTree = ""; }; + FDE71B222E7A1F6F0023F5F9 /* GetMessagesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetMessagesResponse.swift; sourceTree = ""; }; + FDE71B232E7A1F6F0023F5F9 /* GetNetworkTimestampResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetNetworkTimestampResponse.swift; sourceTree = ""; }; + FDE71B262E7A1F6F0023F5F9 /* ONSResolveRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ONSResolveRequest.swift; sourceTree = ""; }; + FDE71B272E7A1F6F0023F5F9 /* ONSResolveResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ONSResolveResponse.swift; sourceTree = ""; }; + FDE71B282E7A1F6F0023F5F9 /* OxenDaemonRPCRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OxenDaemonRPCRequest.swift; sourceTree = ""; }; + FDE71B292E7A1F6F0023F5F9 /* RevokeSubaccountRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevokeSubaccountRequest.swift; sourceTree = ""; }; + FDE71B2A2E7A1F6F0023F5F9 /* RevokeSubaccountResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevokeSubaccountResponse.swift; sourceTree = ""; }; + FDE71B2B2E7A1F6F0023F5F9 /* SendMessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageRequest.swift; sourceTree = ""; }; + FDE71B2C2E7A1F6F0023F5F9 /* SendMessageResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageResponse.swift; sourceTree = ""; }; + FDE71B2D2E7A1F6F0023F5F9 /* BaseAuthenticatedRequestBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseAuthenticatedRequestBody.swift; sourceTree = ""; }; + FDE71B2F2E7A1F6F0023F5F9 /* BaseRecursiveResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseRecursiveResponse.swift; sourceTree = ""; }; + FDE71B312E7A1F6F0023F5F9 /* BaseResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseResponse.swift; sourceTree = ""; }; + FDE71B322E7A1F6F0023F5F9 /* BaseSwarmItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseSwarmItem.swift; sourceTree = ""; }; + FDE71B332E7A1F6F0023F5F9 /* UnrevokeSubaccountRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnrevokeSubaccountRequest.swift; sourceTree = ""; }; + FDE71B342E7A1F6F0023F5F9 /* UnrevokeSubaccountResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnrevokeSubaccountResponse.swift; sourceTree = ""; }; + FDE71B352E7A1F6F0023F5F9 /* UpdateExpiryAllRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateExpiryAllRequest.swift; sourceTree = ""; }; + FDE71B362E7A1F6F0023F5F9 /* UpdateExpiryAllResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateExpiryAllResponse.swift; sourceTree = ""; }; + FDE71B382E7A1F6F0023F5F9 /* UpdateExpiryResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateExpiryResponse.swift; sourceTree = ""; }; + FDE71B5A2E7A2CF70023F5F9 /* UpdateExpiryRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateExpiryRequest.swift; sourceTree = ""; }; + FDE71B5C2E7A6BA70023F5F9 /* StorageServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageServer.swift; sourceTree = ""; }; FDE71B5E2E7A73560023F5F9 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; + FDE71B632E821E1D0023F5F9 /* ArgumentDescribing+SMK.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ArgumentDescribing+SMK.swift"; sourceTree = ""; }; + FDE71B652E821E230023F5F9 /* Mocked+SMK.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mocked+SMK.swift"; sourceTree = ""; }; FDE7214F287E50D50093DF33 /* ProtoWrappers.py */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.python; path = ProtoWrappers.py; sourceTree = ""; }; FDE72150287E50D50093DF33 /* LintLocalizableStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LintLocalizableStrings.swift; sourceTree = ""; }; FDE7549A2C940108002A2623 /* MessageViewModel+DeletionActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageViewModel+DeletionActions.swift"; sourceTree = ""; }; @@ -2382,6 +2446,7 @@ FDEF576D2C44C1DF00131302 /* GeoLite2-Country-Locations-ru.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = "GeoLite2-Country-Locations-ru.csv"; sourceTree = ""; }; FDEF576E2C44C1DF00131302 /* GeoLite2-Country-Locations-zh-CN.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = "GeoLite2-Country-Locations-zh-CN.csv"; sourceTree = ""; }; FDEF57702C44D2D300131302 /* GeoLite2-Country-Blocks-IPv4 */ = {isa = PBXFileReference; lastKnownFileType = file; name = "GeoLite2-Country-Blocks-IPv4"; path = "Countries/GeoLite2-Country-Blocks-IPv4"; sourceTree = ""; }; + FDEFDC6F2E84A12A00EBCD81 /* FlatMapLatestActor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlatMapLatestActor.swift; sourceTree = ""; }; FDF01FAC2A9ECC4200CAF969 /* SingletonConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingletonConfig.swift; sourceTree = ""; }; FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreview.swift; sourceTree = ""; }; FDF0B73F280402C4004C14C5 /* Job.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Job.swift; sourceTree = ""; }; @@ -2405,37 +2470,9 @@ FDF71EA42B07363500A8D6B5 /* MessageReceiver+LibSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+LibSession.swift"; sourceTree = ""; }; FDF8487D29405993007DCAE5 /* HTTPHeader+SOGS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "HTTPHeader+SOGS.swift"; sourceTree = ""; }; FDF8487E29405994007DCAE5 /* HTTPQueryParam+SOGS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "HTTPQueryParam+SOGS.swift"; sourceTree = ""; }; - FDF8489029405C13007DCAE5 /* SnodeAPINamespace.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnodeAPINamespace.swift; sourceTree = ""; }; - FDF8489A29405C5A007DCAE5 /* SnodeRecursiveResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnodeRecursiveResponse.swift; sourceTree = ""; }; - FDF8489B29405C5A007DCAE5 /* GetMessagesRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GetMessagesRequest.swift; sourceTree = ""; }; - FDF8489D29405C5A007DCAE5 /* SnodeResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnodeResponse.swift; sourceTree = ""; }; - FDF8489E29405C5A007DCAE5 /* ONSResolveResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ONSResolveResponse.swift; sourceTree = ""; }; - FDF8489F29405C5A007DCAE5 /* UpdateExpiryRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UpdateExpiryRequest.swift; sourceTree = ""; }; - FDF848A029405C5A007DCAE5 /* OxenDaemonRPCRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OxenDaemonRPCRequest.swift; sourceTree = ""; }; - FDF848A129405C5A007DCAE5 /* DeleteMessagesRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteMessagesRequest.swift; sourceTree = ""; }; - FDF848A429405C5A007DCAE5 /* DeleteAllMessagesRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteAllMessagesRequest.swift; sourceTree = ""; }; - FDF848A529405C5A007DCAE5 /* SendMessageResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SendMessageResponse.swift; sourceTree = ""; }; - FDF848A629405C5A007DCAE5 /* ONSResolveRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ONSResolveRequest.swift; sourceTree = ""; }; - FDF848A729405C5A007DCAE5 /* SnodeRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnodeRequest.swift; sourceTree = ""; }; - FDF848A829405C5A007DCAE5 /* DeleteAllBeforeRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteAllBeforeRequest.swift; sourceTree = ""; }; - FDF848AA29405C5A007DCAE5 /* SnodeReceivedMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnodeReceivedMessage.swift; sourceTree = ""; }; - FDF848AB29405C5A007DCAE5 /* GetNetworkTimestampResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GetNetworkTimestampResponse.swift; sourceTree = ""; }; - FDF848AC29405C5A007DCAE5 /* UpdateExpiryAllRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UpdateExpiryAllRequest.swift; sourceTree = ""; }; - FDF848AD29405C5A007DCAE5 /* SendMessageRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SendMessageRequest.swift; sourceTree = ""; }; - FDF848AE29405C5A007DCAE5 /* UpdateExpiryResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UpdateExpiryResponse.swift; sourceTree = ""; }; - FDF848AF29405C5A007DCAE5 /* SnodeSwarmItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnodeSwarmItem.swift; sourceTree = ""; }; - FDF848B029405C5A007DCAE5 /* LegacyGetMessagesRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyGetMessagesRequest.swift; sourceTree = ""; }; - FDF848B129405C5A007DCAE5 /* UpdateExpiryAllResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UpdateExpiryAllResponse.swift; sourceTree = ""; }; - FDF848B229405C5A007DCAE5 /* DeleteAllBeforeResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteAllBeforeResponse.swift; sourceTree = ""; }; - FDF848B329405C5A007DCAE5 /* DeleteAllMessagesResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteAllMessagesResponse.swift; sourceTree = ""; }; - FDF848B429405C5A007DCAE5 /* SnodeMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnodeMessage.swift; sourceTree = ""; }; - FDF848B529405C5A007DCAE5 /* SnodeBatchRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnodeBatchRequest.swift; sourceTree = ""; }; - FDF848B729405C5A007DCAE5 /* SnodeAuthenticatedRequestBody.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnodeAuthenticatedRequestBody.swift; sourceTree = ""; }; - FDF848B829405C5A007DCAE5 /* GetMessagesResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GetMessagesResponse.swift; sourceTree = ""; }; - FDF848B929405C5A007DCAE5 /* DeleteMessagesResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteMessagesResponse.swift; sourceTree = ""; }; - FDF848BA29405C5A007DCAE5 /* RevokeSubaccountRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RevokeSubaccountRequest.swift; sourceTree = ""; }; - FDF848BB29405C5A007DCAE5 /* LegacySendMessageRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacySendMessageRequest.swift; sourceTree = ""; }; - FDF848E029405D6E007DCAE5 /* SnodeAPIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnodeAPIError.swift; sourceTree = ""; }; + FDF8489029405C13007DCAE5 /* StorageServerNamespace.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StorageServerNamespace.swift; sourceTree = ""; }; + FDF848AA29405C5A007DCAE5 /* StorageServerMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StorageServerMessage.swift; sourceTree = ""; }; + FDF848E029405D6E007DCAE5 /* StorageServerError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StorageServerError.swift; sourceTree = ""; }; FDF848E129405D6E007DCAE5 /* Destination.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Destination.swift; sourceTree = ""; }; FDF848F029406A30007DCAE5 /* Format.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Format.swift; path = "SessionUIKit/Style Guide/Format.swift"; sourceTree = SOURCE_ROOT; }; FDF848F229413DB0007DCAE5 /* ImagePickerHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePickerHandler.swift; sourceTree = ""; }; @@ -2508,7 +2545,6 @@ files = ( C3C2A6C62553896A00C340D1 /* SessionUtilitiesKit.framework in Frameworks */, 946F5A732D5DA3AC00A5ADCE /* Punycode in Frameworks */, - FD6673F82D7021F200041530 /* SessionUtil in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2516,7 +2552,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - FD6673F62D7021E700041530 /* SessionUtil in Frameworks */, FD6A38EC2C2A63B500762359 /* KeychainSwift in Frameworks */, FD6A38EF2C2A641200762359 /* DifferenceKit in Frameworks */, FD756BEB2D0181D700BD7199 /* GRDB in Frameworks */, @@ -2529,7 +2564,6 @@ buildActionMask = 2147483647; files = ( FD9BDE012A5D24EA005F1EBC /* SessionUIKit.framework in Frameworks */, - FD6673FA2D7021F800041530 /* SessionUtil in Frameworks */, FD2286732C38D43900BC06F7 /* DifferenceKit in Frameworks */, FDC4386C27B4E90300C60D73 /* SessionUtilitiesKit.framework in Frameworks */, C3C2A70B25539E1E00C340D1 /* SessionNetworkingKit.framework in Frameworks */, @@ -2587,6 +2621,16 @@ FD0150542CA24471005B08A1 /* Nimble in Frameworks */, FD0150522CA2446D005B08A1 /* Quick in Frameworks */, FD0150502CA24468005B08A1 /* SessionNetworkingKit.framework in Frameworks */, + FD1BDBF02E655EB1008EF998 /* TestUtilities.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FD1BDBC02E6535EE008EF998 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FD6B92722E6AB045004463B5 /* Quick in Frameworks */, + FD1BDBE12E655B2A008EF998 /* Nimble in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2597,6 +2641,7 @@ FD2DD5902C6DD13C0073D9BE /* DifferenceKit in Frameworks */, FD6A39322C2AD33E00762359 /* Quick in Frameworks */, FD6A393B2C2AD3A300762359 /* Nimble in Frameworks */, + FD1BDBE62E655EA8008EF998 /* TestUtilities.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2607,6 +2652,7 @@ FD6A39412C2AD3B600762359 /* Nimble in Frameworks */, FD6A39382C2AD36900762359 /* Quick in Frameworks */, FD83B9B327CF200A005E1583 /* SessionUtilitiesKit.framework in Frameworks */, + FD1BDBF52E655EB5008EF998 /* TestUtilities.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2617,6 +2663,7 @@ FD6A393D2C2AD3AC00762359 /* Nimble in Frameworks */, FD6A39342C2AD35F00762359 /* Quick in Frameworks */, FDC4389227B9FFC700C60D73 /* SessionMessagingKit.framework in Frameworks */, + FD1BDBEB2E655EAC008EF998 /* TestUtilities.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3053,7 +3100,6 @@ FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */, C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */, FD2272EB2C352155004D8A6C /* Feature.swift */, - FD10AF112AF85D11007709E5 /* Feature+ServiceNetwork.swift */, FD2272EF2C352200004D8A6C /* General.swift */, C3C2A5CE2553860700C340D1 /* Logging.swift */, C33FDAFD255A580600E217F9 /* LRUCache.swift */, @@ -3654,6 +3700,7 @@ C3BBE0B32554F0D30050F1E3 /* Utilities */ = { isa = PBXGroup; children = ( + FD2272DC2C34EFFA004D8A6C /* AppSetup.swift */, 94B6BAF92E38454F00E718BB /* SessionProState.swift */, FD428B1E2B4B758B006D0888 /* AppReadiness.swift */, FDE5218D2E03A06700061B8E /* AttachmentManager.swift */, @@ -3692,6 +3739,7 @@ isa = PBXGroup; children = ( C3C2A5B0255385C700C340D1 /* Meta */, + FD6B92792E6F8B7E004463B5 /* Configuration */, FDE754E22C9BAFF4002A2623 /* Crypto */, FD6B928A2E779DB6004463B5 /* FileServer */, FD7F74682BAB8A5D006DDFD8 /* LibSession */, @@ -3719,6 +3767,7 @@ isa = PBXGroup; children = ( C3C2A5D82553860B00C340D1 /* Data+Utilities.swift */, + FDE71B062E7903400023F5F9 /* Dependencies+Network.swift */, FDE71B042E77E1A00023F5F9 /* ObservableKey+SessionNetworkingKit.swift */, FD2272BD2C34B710004D8A6C /* Publisher+Utilities.swift */, FD83DCDC2A739D350065FFAE /* RetryWithDependencies.swift */, @@ -3821,7 +3870,6 @@ C3CA3B11255CF17200F4C6D4 /* Utilities */ = { isa = PBXGroup; children = ( - FD2272DC2C34EFFA004D8A6C /* AppSetup.swift */, C38EF3DC255B6DF1007E1867 /* DirectionalPanGestureRecognizer.swift */, C38EF240255B6D67007E1867 /* UIView+OWS.swift */, FD71161D28D9772700B47552 /* UIViewController+OWS.swift */, @@ -3883,6 +3931,7 @@ C3C2A5A0255385C100C340D1 /* SessionNetworkingKit */, C3C2A67A255388CC00C340D1 /* SessionUtilitiesKit */, C33FD9AC255A548A00E217F9 /* SignalUtilitiesKit */, + FD1BDBCD2E653614008EF998 /* TestUtilities */, FD83B9BC27CF2215005E1583 /* _SharedTestUtilities */, FD71160A28D00BAE00B47552 /* SessionTests */, FDC4388F27B9FFC700C60D73 /* SessionMessagingKitTests */, @@ -3909,6 +3958,7 @@ FD83B9AF27CF200A005E1583 /* SessionUtilitiesKitTests.xctest */, FD71160928D00BAE00B47552 /* SessionTests.xctest */, FDB5DAFA2A981C42002C8721 /* SessionNetworkingKitTests.xctest */, + FD1BDBC32E6535EE008EF998 /* TestUtilities.framework */, ); name = Products; sourceTree = ""; @@ -3979,6 +4029,7 @@ 94C58AC82D2E036E00609195 /* Permissions.swift */, FD97B23F2A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift */, FD78EA052DDEC8F100D55B50 /* AsyncSequence+Utilities.swift */, + FDCC22D72E5D3C1000C77B1A /* AsyncStream+Utilities.swift */, FD7443452D07CA9F00862443 /* CGFloat+Utilities.swift */, FD7443462D07CA9F00862443 /* CGPoint+Utilities.swift */, FD7443472D07CA9F00862443 /* CGRect+Utilities.swift */, @@ -3991,7 +4042,6 @@ FD09796A27F6C67500936362 /* Failable.swift */, FD47E0AE2AA692F400A55E41 /* JSONDecoder+Utilities.swift */, FDFF9FDE2A787F57005E0628 /* JSONEncoder+Utilities.swift */, - FDC289462C881A3800020BC2 /* MutableIdentifiable.swift */, FD09797C27FBDB2000936362 /* Notification+Utilities.swift */, FDF222082818D2B0000A4995 /* NSAttributedString+Utilities.swift */, FD09797127FAA2F500936362 /* Optional+Utilities.swift */, @@ -4087,14 +4137,6 @@ path = Migrations; sourceTree = ""; }; - FD17D79D27F40CAA00122BE0 /* Database */ = { - isa = PBXGroup; - children = ( - FD17D7AD27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift */, - ); - path = Database; - sourceTree = ""; - }; FD17D7B427F51E6700122BE0 /* Types */ = { isa = PBXGroup; children = ( @@ -4140,16 +4182,46 @@ path = Database; sourceTree = ""; }; + FD1BDBCD2E653614008EF998 /* TestUtilities */ = { + isa = PBXGroup; + children = ( + FD1BDBFC2E656559008EF998 /* Nimble */, + FD6B926A2E6A7635004463B5 /* Utilities */, + FD6B92592E66C994004463B5 /* ArgumentDescribing.swift */, + FD1BDBAC2E653200008EF998 /* Mockable.swift */, + FD1BDBE22E655BB4008EF998 /* Mocked.swift */, + FD1BDBD82E653866008EF998 /* MockError.swift */, + FD6B927D2E6FEDFE004463B5 /* MockFallbackRegistry.swift */, + FD1BDBD22E65365E008EF998 /* MockFunction.swift */, + FD1BDBDA2E6538B0008EF998 /* MockFunctionBuilder.swift */, + FD1BDBDE2E655734008EF998 /* MockFunctionHandler.swift */, + FD1BDBB12E6532DB008EF998 /* MockHandler.swift */, + FD1BDBD62E65384F008EF998 /* RecordedCall.swift */, + FD1BDBFA2E656538008EF998 /* TestFailureReporter.swift */, + ); + path = TestUtilities; + sourceTree = ""; + }; + FD1BDBFC2E656559008EF998 /* Nimble */ = { + isa = PBXGroup; + children = ( + FD1BDBFD2E656561008EF998 /* NimbleFailureReporter.swift */, + FD1BDBE42E655DDE008EF998 /* NimbleVerification.swift */, + ); + path = Nimble; + sourceTree = ""; + }; FD2272842C33E28D004D8A6C /* StorageServer */ = { isa = PBXGroup; children = ( - FD17D79D27F40CAA00122BE0 /* Database */, - FDF8489929405C5A007DCAE5 /* Models */, + FDE71B172E7A1F660023F5F9 /* Database */, + FDE71B392E7A1F6F0023F5F9 /* Models */, FD6B92A12E77A153004463B5 /* Types */, - FD6B92A22E77A189004463B5 /* SnodeAPI.swift */, - FD2272D72C34EDE6004D8A6C /* SnodeAPIEndpoint.swift */, - FDF848E029405D6E007DCAE5 /* SnodeAPIError.swift */, - FDF8489029405C13007DCAE5 /* SnodeAPINamespace.swift */, + FDE71B5C2E7A6BA70023F5F9 /* StorageServer.swift */, + FD6B92A22E77A189004463B5 /* StorageServerAPI.swift */, + FD2272D72C34EDE6004D8A6C /* StorageServerEndpoint.swift */, + FDF848E029405D6E007DCAE5 /* StorageServerError.swift */, + FDF8489029405C13007DCAE5 /* StorageServerNamespace.swift */, ); path = StorageServer; sourceTree = ""; @@ -4172,6 +4244,7 @@ FD2272D22C34ECBB004D8A6C /* Types */ = { isa = PBXGroup; children = ( + FDEFDC6F2E84A12A00EBCD81 /* FlatMapLatestActor.swift */, FD39370B2E4D7BBE00571F17 /* DocumentPickerHandler.swift */, FD0E353A2AB98773006A81F7 /* AppVersion.swift */, FDB3486D2BE8457F00B716C2 /* BackgroundTaskManager.swift */, @@ -4209,7 +4282,7 @@ FD336F6A2CAA29BC00C0B51B /* Pollers */ = { isa = PBXGroup; children = ( - FD336F6B2CAA29C200C0B51B /* CommunityPollerSpec.swift */, + FDE71B102E7A1D1C0023F5F9 /* CommunityPollerManagerSpec.swift */, ); path = Pollers; sourceTree = ""; @@ -4218,8 +4291,7 @@ isa = PBXGroup; children = ( FD23CE312A67C38D0000B97C /* MockNetwork.swift */, - FD3765DE2AD8F03100DC1489 /* MockSnodeAPICache.swift */, - FD3765E12AD8F53B00DC1489 /* CommonSSKMockExtensions.swift */, + FD3765E12AD8F53B00DC1489 /* Mocked+SNK.swift */, ); path = _TestUtilities; sourceTree = ""; @@ -4258,18 +4330,10 @@ isa = PBXGroup; children = ( FD78EA082DDFE45000D55B50 /* Convenience */, - FD37E9F728A5F143003AE748 /* Migrations */, ); path = Database; sourceTree = ""; }; - FD37E9F728A5F143003AE748 /* Migrations */ = { - isa = PBXGroup; - children = ( - ); - path = Migrations; - sourceTree = ""; - }; FD37EA1228AB3F60003AE748 /* Database */ = { isa = PBXGroup; children = ( @@ -4353,6 +4417,41 @@ path = Models; sourceTree = ""; }; + FD6B92622E696ED3004463B5 /* _TestUtilities */ = { + isa = PBXGroup; + children = ( + FD6B92632E696EDC004463B5 /* Mocked+SUK.swift */, + FD481A932CAE0ADD00ECC4CF /* MockAppContext.swift */, + FD23CE272A67755C0000B97C /* MockCrypto.swift */, + FD3FAB6A2AF1B27800DC5421 /* MockFileManager.swift */, + FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */, + FD0150372CA24328005B08A1 /* MockJobRunner.swift */, + FD49E2452B05C1D500FFBBB5 /* MockKeychain.swift */, + FDB11A552DD17C3000BEF49F /* MockLogger.swift */, + FD83B9D127D59495005E1583 /* MockUserDefaults.swift */, + ); + path = _TestUtilities; + sourceTree = ""; + }; + FD6B926A2E6A7635004463B5 /* Utilities */ = { + isa = PBXGroup; + children = ( + FD6B926B2E6A763D004463B5 /* Async+Utilities.swift */, + FD6B92832E7781B7004463B5 /* Collection+Utilities.swift */, + FD6B92812E77819B004463B5 /* Combine+Utilities.swift */, + ); + path = Utilities; + sourceTree = ""; + }; + FD6B92792E6F8B7E004463B5 /* Configuration */ = { + isa = PBXGroup; + children = ( + FD6B927B2E6F8BAC004463B5 /* Router.swift */, + FD10AF112AF85D11007709E5 /* ServiceNetwork.swift */, + ); + path = Configuration; + sourceTree = ""; + }; FD6B92892E779D8D004463B5 /* SOGS */ = { isa = PBXGroup; children = ( @@ -4403,10 +4502,8 @@ FD6B92A12E77A153004463B5 /* Types */ = { isa = PBXGroup; children = ( - FD19363B2ACA3134004BCF0F /* ResponseInfo+SnodeAPI.swift */, - FD47E0B42AA6D7AA00A55E41 /* Request+SnodeAPI.swift */, - FDF848B429405C5A007DCAE5 /* SnodeMessage.swift */, - FDF848AA29405C5A007DCAE5 /* SnodeReceivedMessage.swift */, + FD47E0B42AA6D7AA00A55E41 /* Request+StorageServer.swift */, + FDF848AA29405C5A007DCAE5 /* StorageServerMessage.swift */, ); path = Types; sourceTree = ""; @@ -4597,8 +4694,8 @@ FD71161328D00D5D00B47552 /* Settings */ = { isa = PBXGroup; children = ( - 9499E68A2DF92F3B00091434 /* ThreadNotificationSettingsViewModelSpec.swift */, FD71161428D00D6700B47552 /* ThreadDisappearingMessagesViewModelSpec.swift */, + 9499E68A2DF92F3B00091434 /* ThreadNotificationSettingsViewModelSpec.swift */, FD71161628D00DA400B47552 /* ThreadSettingsViewModelSpec.swift */, ); path = Settings; @@ -4756,6 +4853,7 @@ isa = PBXGroup; children = ( FD0150252CA23D5B005B08A1 /* SessionUtilitiesKit.xctestplan */, + FD6B92622E696ED3004463B5 /* _TestUtilities */, FD37EA1228AB3F60003AE748 /* Database */, FD83B9B927CF20A5005E1583 /* General */, FDDF074829DAB35200E5E8B5 /* JobRunner */, @@ -4779,22 +4877,10 @@ FD83B9BC27CF2215005E1583 /* _SharedTestUtilities */ = { isa = PBXGroup; children = ( - FD0150472CA243CB005B08A1 /* Mock.swift */, - FD0969F82A69FFE700C5C365 /* Mocked.swift */, - FD481A932CAE0ADD00ECC4CF /* MockAppContext.swift */, - FD23CE272A67755C0000B97C /* MockCrypto.swift */, - FD3FAB6A2AF1B27800DC5421 /* MockFileManager.swift */, - FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */, - FD83B9D127D59495005E1583 /* MockUserDefaults.swift */, - FD49E2452B05C1D500FFBBB5 /* MockKeychain.swift */, - FD0150372CA24328005B08A1 /* MockJobRunner.swift */, - FDB11A552DD17C3000BEF49F /* MockLogger.swift */, + FD6B925D2E695ACD004463B5 /* FixtureBase.swift */, FD83B9BD27CF2243005E1583 /* TestConstants.swift */, FD6531892AA025C500DFEEAA /* TestDependencies.swift */, - FD9DD2702A72516D00ECB68E /* TestExtensions.swift */, - FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */, - FD23EA6028ED0B260058676E /* CombineExtensions.swift */, - FD0150272CA23DB7005B08A1 /* GRDBExtensions.swift */, + FD6B927F2E778150004463B5 /* NimbleExtensions.swift */, FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */, ); path = _SharedTestUtilities; @@ -4914,7 +5000,6 @@ isa = PBXGroup; children = ( FDE754AB2C9B967D002A2623 /* FileUploadResponseSpec.swift */, - FDAA167A2AC28E2F00DDBF77 /* SnodeRequestSpec.swift */, ); path = Models; sourceTree = ""; @@ -4960,6 +5045,7 @@ FDC1BD642CFD6C44002CDC71 /* Types */ = { isa = PBXGroup; children = ( + FDCC22CF2E52E45A00C77B1A /* GroupAuthData.swift */, FDC1BD652CFD6C4E002CDC71 /* Config.swift */, FD78E9F52DDD43AB00D55B50 /* Mutation.swift */, FDB11A512DCC6AFF00BEF49F /* OpenGroupUrlInfo.swift */, @@ -5028,19 +5114,16 @@ FDC4389B27BA01E300C60D73 /* _TestUtilities */ = { isa = PBXGroup; children = ( - FD336F562CAA28CF00C0B51B /* CommonSMKMockExtensions.swift */, - FD336F572CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift */, - FD336F6E2CAA37CB00C0B51B /* MockCommunityPoller.swift */, + FDE71B632E821E1D0023F5F9 /* ArgumentDescribing+SMK.swift */, FD336F582CAA28CF00C0B51B /* MockCommunityPollerCache.swift */, - FD336F592CAA28CF00C0B51B /* MockDisplayPictureCache.swift */, + FDE71B652E821E230023F5F9 /* Mocked+SMK.swift */, FD981BCC2DC81ABB00564172 /* MockExtensionHelper.swift */, FD336F5A2CAA28CF00C0B51B /* MockGroupPollerCache.swift */, FD78E9F12DDA9E9B00D55B50 /* MockImageDataManager.swift */, FD336F5B2CAA28CF00C0B51B /* MockLibSessionCache.swift */, FD336F5C2CAA28CF00C0B51B /* MockNotificationsManager.swift */, - FD336F5D2CAA28CF00C0B51B /* MockOGMCache.swift */, + FD336F5D2CAA28CF00C0B51B /* MockOpenGroupManager.swift */, FD336F5E2CAA28CF00C0B51B /* MockPoller.swift */, - FD336F5F2CAA28CF00C0B51B /* MockSwarmPoller.swift */, ); path = _TestUtilities; sourceTree = ""; @@ -5075,11 +5158,53 @@ FDC1BD672CFE6EEA002CDC71 /* DeveloperSettingsViewModel.swift */, FD860CBD2D6E7DA000BBE29C /* DeveloperSettingsViewModel+Testing.swift */, FDE71B0A2E7935250023F5F9 /* DeveloperSettingsGroupsViewModel.swift */, + FDCC22DA2E5E897200C77B1A /* DeveloperSettingsNetworkViewModel.swift */, FDE71B0C2E793B1F0023F5F9 /* DeveloperSettingsProViewModel.swift */, ); path = DeveloperSettings; sourceTree = ""; }; + FDE71B172E7A1F660023F5F9 /* Database */ = { + isa = PBXGroup; + children = ( + FDE71B162E7A1F660023F5F9 /* SnodeReceivedMessageInfo.swift */, + ); + path = Database; + sourceTree = ""; + }; + FDE71B392E7A1F6F0023F5F9 /* Models */ = { + isa = PBXGroup; + children = ( + FDE71B2D2E7A1F6F0023F5F9 /* BaseAuthenticatedRequestBody.swift */, + FDE71B312E7A1F6F0023F5F9 /* BaseResponse.swift */, + FDE71B2F2E7A1F6F0023F5F9 /* BaseRecursiveResponse.swift */, + FDE71B322E7A1F6F0023F5F9 /* BaseSwarmItem.swift */, + FDE71B1B2E7A1F6F0023F5F9 /* DeleteAllMessagesRequest.swift */, + FDE71B1C2E7A1F6F0023F5F9 /* DeleteAllMessagesResponse.swift */, + FDE71B1D2E7A1F6F0023F5F9 /* DeleteMessagesRequest.swift */, + FDE71B1E2E7A1F6F0023F5F9 /* DeleteMessagesResponse.swift */, + FDE71B1F2E7A1F6F0023F5F9 /* GetExpiriesRequest.swift */, + FDE71B202E7A1F6F0023F5F9 /* GetExpiriesResponse.swift */, + FDE71B212E7A1F6F0023F5F9 /* GetMessagesRequest.swift */, + FDE71B222E7A1F6F0023F5F9 /* GetMessagesResponse.swift */, + FDE71B232E7A1F6F0023F5F9 /* GetNetworkTimestampResponse.swift */, + FDE71B262E7A1F6F0023F5F9 /* ONSResolveRequest.swift */, + FDE71B272E7A1F6F0023F5F9 /* ONSResolveResponse.swift */, + FDE71B282E7A1F6F0023F5F9 /* OxenDaemonRPCRequest.swift */, + FDE71B292E7A1F6F0023F5F9 /* RevokeSubaccountRequest.swift */, + FDE71B2A2E7A1F6F0023F5F9 /* RevokeSubaccountResponse.swift */, + FDE71B2B2E7A1F6F0023F5F9 /* SendMessageRequest.swift */, + FDE71B2C2E7A1F6F0023F5F9 /* SendMessageResponse.swift */, + FDE71B332E7A1F6F0023F5F9 /* UnrevokeSubaccountRequest.swift */, + FDE71B342E7A1F6F0023F5F9 /* UnrevokeSubaccountResponse.swift */, + FDE71B352E7A1F6F0023F5F9 /* UpdateExpiryAllRequest.swift */, + FDE71B362E7A1F6F0023F5F9 /* UpdateExpiryAllResponse.swift */, + FDE71B5A2E7A2CF70023F5F9 /* UpdateExpiryRequest.swift */, + FDE71B382E7A1F6F0023F5F9 /* UpdateExpiryResponse.swift */, + ); + path = Models; + sourceTree = ""; + }; FDE7214E287E50D50093DF33 /* Scripts */ = { isa = PBXGroup; children = ( @@ -5197,53 +5322,14 @@ FDB5DAF22A96DD4F002C8721 /* PreparedRequest+Sending.swift */, FD22729D2C33E336004D8A6C /* ProxiedContentDownloader.swift */, FD2272A32C33E337004D8A6C /* Request.swift */, + FDD23ADD2E44501B0057E853 /* RequestCategory.swift */, FD2272A52C33E337004D8A6C /* ResponseInfo.swift */, - FD2272A02C33E336004D8A6C /* SwarmDrainBehaviour.swift */, - FD2272962C33E335004D8A6C /* UpdatableTimestamp.swift */, + FD3937092E4B04DB00571F17 /* SwarmDrainer.swift */, FD2272972C33E335004D8A6C /* ValidatableResponse.swift */, ); path = Types; sourceTree = ""; }; - FDF8489929405C5A007DCAE5 /* Models */ = { - isa = PBXGroup; - children = ( - FDF848A729405C5A007DCAE5 /* SnodeRequest.swift */, - FDF8489D29405C5A007DCAE5 /* SnodeResponse.swift */, - FDF8489A29405C5A007DCAE5 /* SnodeRecursiveResponse.swift */, - FDF848AF29405C5A007DCAE5 /* SnodeSwarmItem.swift */, - FDF848B729405C5A007DCAE5 /* SnodeAuthenticatedRequestBody.swift */, - FDF848B529405C5A007DCAE5 /* SnodeBatchRequest.swift */, - FDF8489B29405C5A007DCAE5 /* GetMessagesRequest.swift */, - FDF848B029405C5A007DCAE5 /* LegacyGetMessagesRequest.swift */, - FDF848B829405C5A007DCAE5 /* GetMessagesResponse.swift */, - FDF848AD29405C5A007DCAE5 /* SendMessageRequest.swift */, - FDF848BB29405C5A007DCAE5 /* LegacySendMessageRequest.swift */, - FDF848A529405C5A007DCAE5 /* SendMessageResponse.swift */, - FDF848A129405C5A007DCAE5 /* DeleteMessagesRequest.swift */, - FDF848B929405C5A007DCAE5 /* DeleteMessagesResponse.swift */, - FDF848A429405C5A007DCAE5 /* DeleteAllMessagesRequest.swift */, - FDF848B329405C5A007DCAE5 /* DeleteAllMessagesResponse.swift */, - FDF848A829405C5A007DCAE5 /* DeleteAllBeforeRequest.swift */, - FDF848B229405C5A007DCAE5 /* DeleteAllBeforeResponse.swift */, - FDF8489F29405C5A007DCAE5 /* UpdateExpiryRequest.swift */, - FDF848AE29405C5A007DCAE5 /* UpdateExpiryResponse.swift */, - FDF848AC29405C5A007DCAE5 /* UpdateExpiryAllRequest.swift */, - FDF848B129405C5A007DCAE5 /* UpdateExpiryAllResponse.swift */, - FDD20C152A09E64A003898FB /* GetExpiriesRequest.swift */, - FDD20C172A09E7D3003898FB /* GetExpiriesResponse.swift */, - FD3765E62ADE1AA300DC1489 /* UnrevokeSubaccountRequest.swift */, - FD3765E82ADE1AAE00DC1489 /* UnrevokeSubaccountResponse.swift */, - FDF848BA29405C5A007DCAE5 /* RevokeSubaccountRequest.swift */, - FD02CC152C3681EF009AB976 /* RevokeSubaccountResponse.swift */, - FDF848AB29405C5A007DCAE5 /* GetNetworkTimestampResponse.swift */, - FDF848A029405C5A007DCAE5 /* OxenDaemonRPCRequest.swift */, - FDF848A629405C5A007DCAE5 /* ONSResolveRequest.swift */, - FDF8489E29405C5A007DCAE5 /* ONSResolveResponse.swift */, - ); - path = Models; - sourceTree = ""; - }; FDFBB7522A2023DE00CA7350 /* Utilities */ = { isa = PBXGroup; children = ( @@ -5328,6 +5414,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FD1BDBBE2E6535EE008EF998 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ @@ -5444,7 +5537,6 @@ ); name = SessionNetworkingKit; packageProductDependencies = ( - FD6673F72D7021F200041530 /* SessionUtil */, ); productName = SessionSnodeKit; productReference = C3C2A59F255385C100C340D1 /* SessionNetworkingKit.framework */; @@ -5472,7 +5564,6 @@ FD6A38EB2C2A63B500762359 /* KeychainSwift */, FD6A38EE2C2A641200762359 /* DifferenceKit */, FD756BEA2D0181D700BD7199 /* GRDB */, - FD6673F52D7021E700041530 /* SessionUtil */, ); productName = SessionUtilities; productReference = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; @@ -5495,7 +5586,6 @@ packageProductDependencies = ( FD6A39122C2A946A00762359 /* SwiftProtobuf */, FD2286722C38D43900BC06F7 /* DifferenceKit */, - FD6673F92D7021F800041530 /* SessionUtil */, ); productName = SessionMessagingKit; productReference = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; @@ -5540,6 +5630,28 @@ productReference = D221A089169C9E5E00537ABF /* Session.app */; productType = "com.apple.product-type.application"; }; + FD1BDBC22E6535EE008EF998 /* TestUtilities */ = { + isa = PBXNativeTarget; + buildConfigurationList = FD1BDBC72E6535EE008EF998 /* Build configuration list for PBXNativeTarget "TestUtilities" */; + buildPhases = ( + FD1BDBBE2E6535EE008EF998 /* Headers */, + FD1BDBBF2E6535EE008EF998 /* Sources */, + FD1BDBC02E6535EE008EF998 /* Frameworks */, + FD1BDBC12E6535EE008EF998 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = TestUtilities; + packageProductDependencies = ( + FD1BDBE02E655B2A008EF998 /* Nimble */, + FD6B92712E6AB045004463B5 /* Quick */, + ); + productName = TestUtilities; + productReference = FD1BDBC32E6535EE008EF998 /* TestUtilities.framework */; + productType = "com.apple.product-type.framework"; + }; FD71160828D00BAE00B47552 /* SessionTests */ = { isa = PBXNativeTarget; buildConfigurationList = FD71160F28D00BAE00B47552 /* Build configuration list for PBXNativeTarget "SessionTests" */; @@ -5552,6 +5664,7 @@ ); dependencies = ( FD71160E28D00BAE00B47552 /* PBXTargetDependency */, + FD1BDBE92E655EA8008EF998 /* PBXTargetDependency */, ); name = SessionTests; packageProductDependencies = ( @@ -5575,6 +5688,7 @@ ); dependencies = ( FD83B9B527CF200A005E1583 /* PBXTargetDependency */, + FD1BDBF82E655EB5008EF998 /* PBXTargetDependency */, ); name = SessionUtilitiesKitTests; packageProductDependencies = ( @@ -5597,6 +5711,7 @@ ); dependencies = ( FDB5DB002A981C43002C8721 /* PBXTargetDependency */, + FD1BDBF32E655EB1008EF998 /* PBXTargetDependency */, ); name = SessionNetworkingKitTests; productName = SessionSnodeKitTests; @@ -5615,6 +5730,7 @@ ); dependencies = ( FDC4389427B9FFC700C60D73 /* PBXTargetDependency */, + FD1BDBEE2E655EAC008EF998 /* PBXTargetDependency */, ); name = SessionMessagingKitTests; packageProductDependencies = ( @@ -5633,7 +5749,7 @@ attributes = { BuildIndependentTargetsInParallel = YES; DefaultBuildSystemTypeForWorkspace = Original; - LastSwiftUpdateCheck = 1520; + LastSwiftUpdateCheck = 1630; LastTestingUpgradeCheck = 0600; LastUpgradeCheck = 1600; ORGANIZATIONNAME = "Rangeproof Pty Ltd"; @@ -5723,6 +5839,9 @@ }; }; }; + FD1BDBC22E6535EE008EF998 = { + CreatedOnToolsVersion = 16.3; + }; FD71160828D00BAE00B47552 = { CreatedOnToolsVersion = 13.4.1; TestTargetID = D221A088169C9E5E00537ABF; @@ -5777,6 +5896,7 @@ FDC4388D27B9FFC700C60D73 /* SessionMessagingKitTests */, FDB5DAF92A981C42002C8721 /* SessionNetworkingKitTests */, FD83B9AE27CF200A005E1583 /* SessionUtilitiesKitTests */, + FD1BDBC22E6535EE008EF998 /* TestUtilities */, ); }; /* End PBXProject section */ @@ -5902,6 +6022,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FD1BDBC12E6535EE008EF998 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; FD71160728D00BAE00B47552 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -6133,7 +6260,7 @@ outputFileListPaths = ( ); outputPaths = ( - "$(TARGET_BUILD_DIR)/LibSessionUtil_BuildCache/libsession_util_built.timestamp", + "$(BUILT_PRODUCTS_DIR)/libsession-util.a", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -6326,7 +6453,6 @@ C38EF32E255B6DBF007E1867 /* ImageCache.swift in Sources */, C38EF3BA255B6DE7007E1867 /* ImageEditorItem.swift in Sources */, C38EF24E255B6D67007E1867 /* Collection+OWS.swift in Sources */, - FD2272DD2C34EFFA004D8A6C /* AppSetup.swift in Sources */, C38EF24D255B6D67007E1867 /* UIView+OWS.swift in Sources */, FDE754E72C9BB051002A2623 /* OWSViewController.swift in Sources */, C38EF38B255B6DD2007E1867 /* AttachmentPrepViewController.swift in Sources */, @@ -6358,33 +6484,48 @@ files = ( FD6B92E12E77C1E1004463B5 /* PushNotification.swift in Sources */, FD2272B12C33E337004D8A6C /* ProxiedContentDownloader.swift in Sources */, - FDF848C329405C5A007DCAE5 /* DeleteMessagesRequest.swift in Sources */, + FDD23ADE2E44501E0057E853 /* RequestCategory.swift in Sources */, FD6B928E2E779E99004463B5 /* FileServerEndpoint.swift in Sources */, - FDF8489129405C13007DCAE5 /* SnodeAPINamespace.swift in Sources */, + FDF8489129405C13007DCAE5 /* StorageServerNamespace.swift in Sources */, FD2272AB2C33E337004D8A6C /* ValidatableResponse.swift in Sources */, FD2272B72C33E337004D8A6C /* Request.swift in Sources */, FD2272B92C33E337004D8A6C /* ResponseInfo.swift in Sources */, FD6B92F82E77C725004463B5 /* ProcessResult.swift in Sources */, FD6B92902E779EDD004463B5 /* FileServerAPI.swift in Sources */, - FDF848D729405C5B007DCAE5 /* SnodeBatchRequest.swift in Sources */, - FDF848CE29405C5B007DCAE5 /* UpdateExpiryAllRequest.swift in Sources */, - FDF848C229405C5A007DCAE5 /* OxenDaemonRPCRequest.swift in Sources */, - FDF848DC29405C5B007DCAE5 /* RevokeSubaccountRequest.swift in Sources */, - FDF848D029405C5B007DCAE5 /* UpdateExpiryResponse.swift in Sources */, FD6B92E82E77C5B7004463B5 /* PushNotificationEndpoint.swift in Sources */, FD6B92AC2E77A993004463B5 /* SOGSEndpoint.swift in Sources */, FD6B92922E779FC8004463B5 /* SessionNetwork.swift in Sources */, - FDF848D329405C5B007DCAE5 /* UpdateExpiryAllResponse.swift in Sources */, - FDD20C162A09E64A003898FB /* GetExpiriesRequest.swift in Sources */, - FDF848BC29405C5A007DCAE5 /* SnodeRecursiveResponse.swift in Sources */, - FDF848C029405C5A007DCAE5 /* ONSResolveResponse.swift in Sources */, FD2272AC2C33E337004D8A6C /* IPv4.swift in Sources */, - FD2272D82C34EDE7004D8A6C /* SnodeAPIEndpoint.swift in Sources */, + FD2272D82C34EDE7004D8A6C /* StorageServerEndpoint.swift in Sources */, + FD39370A2E4B04E000571F17 /* SwarmDrainer.swift in Sources */, FDFC4D9A29F0C51500992FB6 /* String+Trimming.swift in Sources */, FD6B92992E77A06E004463B5 /* Token.swift in Sources */, FDB5DAF32A96DD4F002C8721 /* PreparedRequest+Sending.swift in Sources */, - FDF848C629405C5B007DCAE5 /* DeleteAllMessagesRequest.swift in Sources */, - FDF848D429405C5B007DCAE5 /* DeleteAllBeforeResponse.swift in Sources */, + FDE71B3A2E7A1F6F0023F5F9 /* SendMessageRequest.swift in Sources */, + FDE71B3B2E7A1F6F0023F5F9 /* UpdateExpiryAllResponse.swift in Sources */, + FDE71B3C2E7A1F6F0023F5F9 /* RevokeSubaccountRequest.swift in Sources */, + FDE71B3D2E7A1F6F0023F5F9 /* SendMessageResponse.swift in Sources */, + FDE71B3E2E7A1F6F0023F5F9 /* GetNetworkTimestampResponse.swift in Sources */, + FDE71B3F2E7A1F6F0023F5F9 /* OxenDaemonRPCRequest.swift in Sources */, + FDE71B402E7A1F6F0023F5F9 /* BaseSwarmItem.swift in Sources */, + FDE71B412E7A1F6F0023F5F9 /* GetMessagesRequest.swift in Sources */, + FDE71B432E7A1F6F0023F5F9 /* UpdateExpiryAllRequest.swift in Sources */, + FDE71B462E7A1F6F0023F5F9 /* GetMessagesResponse.swift in Sources */, + FDE71B472E7A1F6F0023F5F9 /* ONSResolveRequest.swift in Sources */, + FDE71B482E7A1F6F0023F5F9 /* ONSResolveResponse.swift in Sources */, + FDE71B492E7A1F6F0023F5F9 /* BaseAuthenticatedRequestBody.swift in Sources */, + FDE71B4A2E7A1F6F0023F5F9 /* DeleteMessagesRequest.swift in Sources */, + FDE71B4B2E7A1F6F0023F5F9 /* BaseResponse.swift in Sources */, + FDE71B4C2E7A1F6F0023F5F9 /* UnrevokeSubaccountRequest.swift in Sources */, + FDE71B4D2E7A1F6F0023F5F9 /* DeleteAllMessagesResponse.swift in Sources */, + FDE71B4F2E7A1F6F0023F5F9 /* DeleteAllMessagesRequest.swift in Sources */, + FDE71B502E7A1F6F0023F5F9 /* GetExpiriesResponse.swift in Sources */, + FDE71B512E7A1F6F0023F5F9 /* BaseRecursiveResponse.swift in Sources */, + FDE71B532E7A1F6F0023F5F9 /* RevokeSubaccountResponse.swift in Sources */, + FDE71B552E7A1F6F0023F5F9 /* UpdateExpiryResponse.swift in Sources */, + FDE71B562E7A1F6F0023F5F9 /* DeleteMessagesResponse.swift in Sources */, + FDE71B572E7A1F6F0023F5F9 /* GetExpiriesRequest.swift in Sources */, + FDE71B592E7A1F6F0023F5F9 /* UnrevokeSubaccountResponse.swift in Sources */, FD6B92AF2E77AA03004463B5 /* HTTPQueryParam+SOGS.swift in Sources */, FD6B92B02E77AA03004463B5 /* Request+SOGS.swift in Sources */, FD6B92B12E77AA03004463B5 /* HTTPHeader+SOGS.swift in Sources */, @@ -6392,20 +6533,15 @@ FD6B92B22E77AA03004463B5 /* UpdateTypes.swift in Sources */, FD6B92B32E77AA03004463B5 /* Personalization.swift in Sources */, FD6B929B2E77A084004463B5 /* NetworkInfo.swift in Sources */, - FD47E0B52AA6D7AA00A55E41 /* Request+SnodeAPI.swift in Sources */, + FD47E0B52AA6D7AA00A55E41 /* Request+StorageServer.swift in Sources */, + FD47E0B52AA6D7AA00A55E41 /* Request+StorageServer.swift in Sources */, FDE71B052E77E1AA0023F5F9 /* ObservableKey+SessionNetworkingKit.swift in Sources */, FD5E93D12C100FD70038C25A /* FileUploadResponse.swift in Sources */, - FDF848D629405C5B007DCAE5 /* SnodeMessage.swift in Sources */, - FD02CC162C3681EF009AB976 /* RevokeSubaccountResponse.swift in Sources */, FDE754E32C9BAFF4002A2623 /* Crypto+SessionNetworkingKit.swift in Sources */, FD2272AD2C33E337004D8A6C /* Network.swift in Sources */, FD2272B32C33E337004D8A6C /* BatchRequest.swift in Sources */, - FDF848D129405C5B007DCAE5 /* SnodeSwarmItem.swift in Sources */, - FDF848DD29405C5B007DCAE5 /* LegacySendMessageRequest.swift in Sources */, - FDF848BD29405C5A007DCAE5 /* GetMessagesRequest.swift in Sources */, FD2272B02C33E337004D8A6C /* NetworkError.swift in Sources */, FD6B92AB2E77A920004463B5 /* SOGS.swift in Sources */, - FD19363C2ACA3134004BCF0F /* ResponseInfo+SnodeAPI.swift in Sources */, FD6B92E92E77C5D1004463B5 /* SubscribeResponse.swift in Sources */, FD6B92EA2E77C5D1004463B5 /* NotificationMetadata.swift in Sources */, FD6B92EB2E77C5D1004463B5 /* AuthenticatedRequest.swift in Sources */, @@ -6417,10 +6553,13 @@ FD6B92B42E77AA11004463B5 /* PinnedMessage.swift in Sources */, FD6B92B52E77AA11004463B5 /* SendDirectMessageResponse.swift in Sources */, FD6B92B62E77AA11004463B5 /* UserUnbanRequest.swift in Sources */, + FDE71B5D2E7A6BA90023F5F9 /* StorageServer.swift in Sources */, + FDE71B5B2E7A2CFB0023F5F9 /* UpdateExpiryRequest.swift in Sources */, FD6B92B72E77AA11004463B5 /* UserModeratorRequest.swift in Sources */, FD6B92B82E77AA11004463B5 /* SendSOGSMessageRequest.swift in Sources */, FD6B92B92E77AA11004463B5 /* Room.swift in Sources */, FD6B92BA2E77AA11004463B5 /* RoomPollInfo.swift in Sources */, + FDE71B182E7A1F660023F5F9 /* SnodeReceivedMessageInfo.swift in Sources */, FD6B92BB2E77AA11004463B5 /* UpdateMessageRequest.swift in Sources */, FD6B92BC2E77AA11004463B5 /* ReactionResponse.swift in Sources */, FD6B92BD2E77AA11004463B5 /* UserBanRequest.swift in Sources */, @@ -6430,56 +6569,41 @@ FD6B92C12E77AA11004463B5 /* CapabilitiesResponse.swift in Sources */, FD6B92C22E77AA11004463B5 /* SOGSMessage.swift in Sources */, 947D7FD82D509FC900E8E413 /* KeyValueStore+SessionNetwork.swift in Sources */, - FDF848DB29405C5B007DCAE5 /* DeleteMessagesResponse.swift in Sources */, FD6B92F42E77C61A004463B5 /* ServiceInfo.swift in Sources */, FDF848E629405D6E007DCAE5 /* Destination.swift in Sources */, - FD6B92A32E77A18B004463B5 /* SnodeAPI.swift in Sources */, - FDF848CC29405C5B007DCAE5 /* SnodeReceivedMessage.swift in Sources */, - FDF848C129405C5A007DCAE5 /* UpdateExpiryRequest.swift in Sources */, + FD6B92A32E77A18B004463B5 /* StorageServerAPI.swift in Sources */, + FDF848CC29405C5B007DCAE5 /* StorageServerMessage.swift in Sources */, FD6B92E22E77C21D004463B5 /* PushNotificationAPI.swift in Sources */, - FDF848C729405C5B007DCAE5 /* SendMessageResponse.swift in Sources */, - FDF848CA29405C5B007DCAE5 /* DeleteAllBeforeRequest.swift in Sources */, FD2272AF2C33E337004D8A6C /* JSON.swift in Sources */, FD2272D62C34ED6A004D8A6C /* RetryWithDependencies.swift in Sources */, - FDF848D229405C5B007DCAE5 /* LegacyGetMessagesRequest.swift in Sources */, - FDF848E529405D6E007DCAE5 /* SnodeAPIError.swift in Sources */, + FDF848E529405D6E007DCAE5 /* StorageServerError.swift in Sources */, + FD2272B22C33E337004D8A6C /* PreparedRequest.swift in Sources */, + FDE71B072E7903480023F5F9 /* Dependencies+Network.swift in Sources */, FD6B928C2E779DCC004463B5 /* FileServer.swift in Sources */, - FDF848D529405C5B007DCAE5 /* DeleteAllMessagesResponse.swift in Sources */, FD2272B22C33E337004D8A6C /* PreparedRequest.swift in Sources */, - FDF848BF29405C5A007DCAE5 /* SnodeResponse.swift in Sources */, FD6B92C82E77AD39004463B5 /* Crypto+SOGS.swift in Sources */, FD6B92942E77A003004463B5 /* SessionNetworkEndpoint.swift in Sources */, - FDD20C182A09E7D3003898FB /* GetExpiriesResponse.swift in Sources */, FD2272BB2C33E337004D8A6C /* HTTPMethod.swift in Sources */, FD6B92AE2E77A9F7004463B5 /* SOGSAPI.swift in Sources */, FD6B92972E77A047004463B5 /* Price.swift in Sources */, C3C2A5E42553860B00C340D1 /* Data+Utilities.swift in Sources */, - FDF848D929405C5B007DCAE5 /* SnodeAuthenticatedRequestBody.swift in Sources */, FD6B92AD2E77A9F1004463B5 /* SOGSError.swift in Sources */, FD2272BA2C33E337004D8A6C /* HTTPHeader.swift in Sources */, - FDF848CD29405C5B007DCAE5 /* GetNetworkTimestampResponse.swift in Sources */, - FDF848DA29405C5B007DCAE5 /* GetMessagesResponse.swift in Sources */, FD6B92C62E77AD0F004463B5 /* Crypto+FileServer.swift in Sources */, FD2286682C37DA3B00BC06F7 /* LibSession+Networking.swift in Sources */, + FD6B927A2E6F8B90004463B5 /* ServiceNetwork.swift in Sources */, FD2272A92C33E337004D8A6C /* ContentProxy.swift in Sources */, FD6B92E62E77C5A2004463B5 /* Service.swift in Sources */, FD6B92E72E77C5A2004463B5 /* Request+PushNotificationAPI.swift in Sources */, FD6B92DE2E77BDE2004463B5 /* Authentication+SOGS.swift in Sources */, - FDF848C829405C5B007DCAE5 /* ONSResolveRequest.swift in Sources */, FD6B929D2E77A096004463B5 /* Info.swift in Sources */, - FDF848C929405C5B007DCAE5 /* SnodeRequest.swift in Sources */, - FDF848CF29405C5B007DCAE5 /* SendMessageRequest.swift in Sources */, FD2272BE2C34B710004D8A6C /* Publisher+Utilities.swift in Sources */, - FD3765E72ADE1AA300DC1489 /* UnrevokeSubaccountRequest.swift in Sources */, FD2272BC2C33E337004D8A6C /* URLResponse+Utilities.swift in Sources */, FD2272B52C33E337004D8A6C /* BatchResponse.swift in Sources */, - FD2272AA2C33E337004D8A6C /* UpdatableTimestamp.swift in Sources */, - FD3765E92ADE1AAE00DC1489 /* UnrevokeSubaccountResponse.swift in Sources */, FD5E93D22C12B0580038C25A /* AppVersionResponse.swift in Sources */, - FD2272B42C33E337004D8A6C /* SwarmDrainBehaviour.swift in Sources */, + FD6B927C2E6F8BB2004463B5 /* Router.swift in Sources */, 941375BB2D5184C20058F244 /* HTTPHeader+SessionNetwork.swift in Sources */, FD2272AE2C33E337004D8A6C /* HTTPQueryParam.swift in Sources */, - FD17D7AE27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift in Sources */, FD2272C42C34E9AA004D8A6C /* BencodeResponse.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -6556,6 +6680,7 @@ FDE754DD2C9BAF8A002A2623 /* Mnemonic.swift in Sources */, FD52CB652E13B6E900A4DA70 /* ObservationBuilder.swift in Sources */, FDBEE52E2B6A18B900C143A0 /* UserDefaultsConfig.swift in Sources */, + FDCC22D82E5D3C1400C77B1A /* AsyncStream+Utilities.swift in Sources */, FD78EA042DDEC3C500D55B50 /* MultiTaskManager.swift in Sources */, FD78EA062DDEC8F600D55B50 /* AsyncSequence+Utilities.swift in Sources */, FDC438CD27BC641200C60D73 /* Set+Utilities.swift in Sources */, @@ -6574,7 +6699,6 @@ 7B0EFDEE274F598600FFAAE7 /* TimestampUtils.swift in Sources */, FD2272D02C34EBD0004D8A6C /* FileManager.swift in Sources */, FDE754CF2C9BAF37002A2623 /* DataSource.swift in Sources */, - FD10AF122AF85D11007709E5 /* Feature+ServiceNetwork.swift in Sources */, B8F5F58325EC94A6003BF8D4 /* Collection+Utilities.swift in Sources */, FD17D7A127F40D2500122BE0 /* Storage.swift in Sources */, FDE754DB2C9BAF8A002A2623 /* Crypto.swift in Sources */, @@ -6605,11 +6729,11 @@ FD74434C2D07CA9F00862443 /* CGSize+Utilities.swift in Sources */, FD74434D2D07CA9F00862443 /* CGPoint+Utilities.swift in Sources */, FD74434E2D07CA9F00862443 /* CGRect+Utilities.swift in Sources */, - FDC289472C881A3800020BC2 /* MutableIdentifiable.swift in Sources */, FD6F5B5E2E657A24009A8D01 /* CancellationAwareAsyncStream.swift in Sources */, C300A60D2554B31900555489 /* Logging.swift in Sources */, FD9004162818B46700ABAAF6 /* JobRunnerError.swift in Sources */, FDE7551C2C9BC169002A2623 /* UIBezierPath+Utilities.swift in Sources */, + FDEFDC702E84A13100EBCD81 /* FlatMapLatestActor.swift in Sources */, FDF8488929405B27007DCAE5 /* Data+Utilities.swift in Sources */, FD09797227FAA2F500936362 /* Optional+Utilities.swift in Sources */, FD7162DB281B6C440060647B /* TypedTableAlias.swift in Sources */, @@ -6703,6 +6827,9 @@ FDE754A12C9A60A6002A2623 /* Crypto+OpenGroup.swift in Sources */, FDF0B7512807BA56004C14C5 /* NotificationsManagerType.swift in Sources */, FD2272722C32911C004D8A6C /* FailedAttachmentDownloadsJob.swift in Sources */, + FDE754A12C9A60A6002A2623 /* Crypto+OpenGroup.swift in Sources */, + FDF0B7512807BA56004C14C5 /* NotificationsManagerType.swift in Sources */, + FD2272722C32911C004D8A6C /* FailedAttachmentDownloadsJob.swift in Sources */, FDD23AEA2E458EB00057E853 /* _012_AddJobPriority.swift in Sources */, FD245C59285065FC00B966DD /* ControlMessage.swift in Sources */, B8DE1FB626C22FCB0079C9CE /* CallMessage.swift in Sources */, @@ -6726,6 +6853,7 @@ 7B93D07127CF194000811CB6 /* MessageRequestResponse.swift in Sources */, FDB5DADE2A95D847002C8721 /* GroupUpdatePromoteMessage.swift in Sources */, FD245C5B2850660500B966DD /* ReadReceipt.swift in Sources */, + FDCC22D42E5C0D9900C77B1A /* AppSetup.swift in Sources */, FD428B1F2B4B758B006D0888 /* AppReadiness.swift in Sources */, FD22726B2C32911C004D8A6C /* SendReadReceiptsJob.swift in Sources */, B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */, @@ -6769,6 +6897,7 @@ FD37EA0D28AB2A45003AE748 /* _013_FixDeletedMessageReadState.swift in Sources */, FDD23AE32E457CFE0057E853 /* _010_FlagMessageHashAsDeletedOrInvalid.swift in Sources */, 7BAA7B6628D2DE4700AE1489 /* _018_OpenGroupPermission.swift in Sources */, + FDC4380927B31D4E00C60D73 /* SOGSError.swift in Sources */, FD2286692C37DA5500BC06F7 /* PollerType.swift in Sources */, FD860CBC2D6E7A9F00BBE29C /* _038_FixBustedInteractionVariant.swift in Sources */, FDD23AE72E458DBC0057E853 /* _001_SUK_InitialSetupMigration.swift in Sources */, @@ -6776,6 +6905,7 @@ FD22727B2C32911C004D8A6C /* MessageSendJob.swift in Sources */, FD78E9EE2DD6D32500D55B50 /* ImageDataManager+Singleton.swift in Sources */, C3A71D1F25589AC30043A11F /* WebSocketResources.pb.swift in Sources */, + FDCC22D02E52E46000C77B1A /* GroupAuthData.swift in Sources */, FD981BC42DC304E600564172 /* MessageDeduplication.swift in Sources */, FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */, FD09796E27FA6D0000936362 /* Contact.swift in Sources */, @@ -6785,6 +6915,9 @@ FD1A55432E179AED003761E4 /* ObservableKeyEvent+Utilities.swift in Sources */, C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, FDD23AE92E458E020057E853 /* _003_SUK_YDBToGRDBMigration.swift in Sources */, + FD1A55432E179AED003761E4 /* ObservableKeyEvent+Utilities.swift in Sources */, + C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, + FDD23AE92E458E020057E853 /* _003_SUK_YDBToGRDBMigration.swift in Sources */, FD8ECF7F2934298100C0D1BB /* ConfigDump.swift in Sources */, FD22727F2C32911C004D8A6C /* GetExpirationJob.swift in Sources */, FD2272792C32911C004D8A6C /* DisplayPictureDownloadJob.swift in Sources */, @@ -6993,6 +7126,7 @@ 7B3A3934298882D6002FE4AC /* SessionCarouselViewDelegate.swift in Sources */, 45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */, 942256802C23F8BB00C0FDBF /* StartConversationScreen.swift in Sources */, + FDCC22DB2E5E897800C77B1A /* DeveloperSettingsNetworkViewModel.swift in Sources */, 7B9F71C928470667006DFE7B /* ReactionListSheet.swift in Sources */, FD12A8412AD63BEA00EEBA0D /* NavigatableState.swift in Sources */, 7B7037452834BCC0000DCF35 /* ReactionView.swift in Sources */, @@ -7083,41 +7217,59 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FD1BDBBF2E6535EE008EF998 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FD1BDBE52E655DDF008EF998 /* NimbleVerification.swift in Sources */, + FD1BDBE32E655BB6008EF998 /* Mocked.swift in Sources */, + FD6B927E2E6FEDFF004463B5 /* MockFallbackRegistry.swift in Sources */, + FD1BDBDF2E655735008EF998 /* MockFunctionHandler.swift in Sources */, + FD1BDBD92E653868008EF998 /* MockError.swift in Sources */, + FD6B92822E77819F004463B5 /* Combine+Utilities.swift in Sources */, + FD1BDBDB2E6538B4008EF998 /* MockFunctionBuilder.swift in Sources */, + FD1BDBD32E653660008EF998 /* MockFunction.swift in Sources */, + FD1BDBD72E653852008EF998 /* RecordedCall.swift in Sources */, + FD6B92842E7781BC004463B5 /* Collection+Utilities.swift in Sources */, + FD1BDBD02E653625008EF998 /* Mockable.swift in Sources */, + FD1BDBFE2E656562008EF998 /* NimbleFailureReporter.swift in Sources */, + FD1BDBFB2E656539008EF998 /* TestFailureReporter.swift in Sources */, + FD6B925A2E66C997004463B5 /* ArgumentDescribing.swift in Sources */, + FD1BDBD12E653625008EF998 /* MockHandler.swift in Sources */, + FD6B926C2E6A7644004463B5 /* Async+Utilities.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; FD71160528D00BAE00B47552 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( FDE521A62E0E6C8C00061B8E /* MockNotificationsManager.swift in Sources */, - FD481A9C2CB4D58300ECC4CF /* MockSnodeAPICache.swift in Sources */, FD52CB5A2E12166F00A4DA70 /* OnboardingSpec.swift in Sources */, FD71161728D00DA400B47552 /* ThreadSettingsViewModelSpec.swift in Sources */, + FD6B92852E77821E004463B5 /* NimbleExtensions.swift in Sources */, + FD6B92652E697012004463B5 /* Mocked+SUK.swift in Sources */, FD481A972CAE0AE000ECC4CF /* MockAppContext.swift in Sources */, FD2AAAF028ED57B500A49611 /* SynchronousStorage.swift in Sources */, FD23CE292A6775650000B97C /* MockCrypto.swift in Sources */, + FD7FAAFA2E823166008D9BDA /* Mocked+SMK.swift in Sources */, FD19363F2ACA66DE004BCF0F /* DatabaseSpec.swift in Sources */, FD23CE332A67C4D90000B97C /* MockNetwork.swift in Sources */, FD71161528D00D6700B47552 /* ThreadDisappearingMessagesViewModelSpec.swift in Sources */, FD01503A2CA24328005B08A1 /* MockJobRunner.swift in Sources */, - FD23EA5E28ED00FD0058676E /* NimbleExtensions.swift in Sources */, - FD481A9B2CB4CAF100ECC4CF /* CustomArgSummaryDescribable+SessionMessagingKit.swift in Sources */, FD23EA5D28ED00FA0058676E /* TestConstants.swift in Sources */, + FD6B925E2E695ACE004463B5 /* FixtureBase.swift in Sources */, FD71161A28D00E1100B47552 /* NotificationContentViewModelSpec.swift in Sources */, FDFE75B52ABD46B700655640 /* MockUserDefaults.swift in Sources */, - FD52CB5B2E123FBC00A4DA70 /* CommonSSKMockExtensions.swift in Sources */, + FD52CB5B2E123FBC00A4DA70 /* Mocked+SNK.swift in Sources */, FD52CB5C2E12536400A4DA70 /* MockExtensionHelper.swift in Sources */, 9499E68B2DF92F4E00091434 /* ThreadNotificationSettingsViewModelSpec.swift in Sources */, - FD01504B2CA243CB005B08A1 /* Mock.swift in Sources */, - FD0969FA2A6A00B000C5C365 /* Mocked.swift in Sources */, - FD481A9A2CB4CAE500ECC4CF /* CommonSMKMockExtensions.swift in Sources */, FD3FAB6C2AF1B28B00DC5421 /* MockFileManager.swift in Sources */, - FD23EA6128ED0B260058676E /* CombineExtensions.swift in Sources */, - FD9DD2712A72516D00ECB68E /* TestExtensions.swift in Sources */, FD65318A2AA025C500DFEEAA /* TestDependencies.swift in Sources */, FD2AAAED28ED3E1000A49611 /* MockGeneralCache.swift in Sources */, FD481A992CB4CAAA00ECC4CF /* MockLibSessionCache.swift in Sources */, FDB11A572DD17D0600BEF49F /* MockLogger.swift in Sources */, FD49E2462B05C1D500FFBBB5 /* MockKeychain.swift in Sources */, - FD01502C2CA23DB7005B08A1 /* GRDBExtensions.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -7132,29 +7284,26 @@ FD99D0922D10F5EE005D2E15 /* ThreadSafeSpec.swift in Sources */, FDD23AF02E459EDD0057E853 /* _020_AddJobUniqueHash.swift in Sources */, FD83B9BF27CF2294005E1583 /* TestConstants.swift in Sources */, - FD9DD2732A72516D00ECB68E /* TestExtensions.swift in Sources */, FD3FAB6E2AF1B28C00DC5421 /* MockFileManager.swift in Sources */, FD83B9BB27CF20AF005E1583 /* SessionIdSpec.swift in Sources */, FDB11A592DD17D0600BEF49F /* MockLogger.swift in Sources */, FD8A5B302DC18D61004C689B /* GeneralCacheSpec.swift in Sources */, + FD6B92612E695ACE004463B5 /* FixtureBase.swift in Sources */, FDD23AEE2E459E470057E853 /* _001_SUK_InitialSetupMigration.swift in Sources */, - FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, FD0150402CA2433D005B08A1 /* BencodeDecoderSpec.swift in Sources */, FD0150412CA2433D005B08A1 /* BencodeEncoderSpec.swift in Sources */, FD0150422CA2433D005B08A1 /* VersionSpec.swift in Sources */, FD42ECD42E32FF2E002D03EA /* StringUtilitiesSpec.swift in Sources */, - FD23EA6328ED0B260058676E /* CombineExtensions.swift in Sources */, - FD0150482CA243CB005B08A1 /* Mock.swift in Sources */, + FD6B92642E696EE0004463B5 /* Mocked+SUK.swift in Sources */, FDFE75B42ABD46B600655640 /* MockUserDefaults.swift in Sources */, FD0150392CA24328005B08A1 /* MockJobRunner.swift in Sources */, + FD6B92882E77821E004463B5 /* NimbleExtensions.swift in Sources */, FD23CE282A67755C0000B97C /* MockCrypto.swift in Sources */, FD2AAAEE28ED3E1100A49611 /* MockGeneralCache.swift in Sources */, FD37EA1528AB42CB003AE748 /* IdentitySpec.swift in Sources */, FD0B77B229B82B7A009169BA /* ArrayUtilitiesSpec.swift in Sources */, FD23CE262A676B5B0000B97C /* DependenciesSpec.swift in Sources */, FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */, - FD0969FB2A6A00B100C5C365 /* Mocked.swift in Sources */, - FD0150292CA23DB7005B08A1 /* GRDBExtensions.swift in Sources */, FDD23AEF2E459EC90057E853 /* _012_AddJobPriority.swift in Sources */, FD481A942CAE0AE000ECC4CF /* MockAppContext.swift in Sources */, ); @@ -7166,21 +7315,21 @@ files = ( FDB5DB062A981C67002C8721 /* PreparedRequestSendingSpec.swift in Sources */, FD49E2482B05C1D500FFBBB5 /* MockKeychain.swift in Sources */, - FDB5DB082A981F8B002C8721 /* Mocked.swift in Sources */, FD6B92CD2E77B22D004463B5 /* SOGSMessageSpec.swift in Sources */, FD6B92DB2E77B597004463B5 /* CryptoSOGSAPISpec.swift in Sources */, FD336F702CABB96C00C0B51B /* BatchRequestSpec.swift in Sources */, - FD3765E22AD8F53B00DC1489 /* CommonSSKMockExtensions.swift in Sources */, - FDB5DB142A981FAE002C8721 /* CombineExtensions.swift in Sources */, + FD3765E22AD8F53B00DC1489 /* Mocked+SNK.swift in Sources */, FD0150382CA24328005B08A1 /* MockJobRunner.swift in Sources */, FD481A952CAE0AE000ECC4CF /* MockAppContext.swift in Sources */, - FD3765DF2AD8F03100DC1489 /* MockSnodeAPICache.swift in Sources */, FDB11A582DD17D0600BEF49F /* MockLogger.swift in Sources */, - FDB5DB112A981FA6002C8721 /* TestExtensions.swift in Sources */, + FDB5DB092A981F8D002C8721 /* MockCrypto.swift in Sources */, + FD65318C2AA025C500DFEEAA /* TestDependencies.swift in Sources */, + FDB5DB102A981FA3002C8721 /* TestConstants.swift in Sources */, + FDB5DB0C2A981F96002C8721 /* MockNetwork.swift in Sources */, + FD6B92672E697012004463B5 /* Mocked+SUK.swift in Sources */, FD6B92D22E77B270004463B5 /* SendDirectMessageRequestSpec.swift in Sources */, FD6B92D32E77B270004463B5 /* UpdateMessageRequestSpec.swift in Sources */, FDB5DB092A981F8D002C8721 /* MockCrypto.swift in Sources */, - FDAA167B2AC28E2F00DDBF77 /* SnodeRequestSpec.swift in Sources */, FD6B92D02E77B23B004463B5 /* CapabilitiesResponse.swift in Sources */, FD6B92D42E77B2C7004463B5 /* SOGSAPISpec.swift in Sources */, FD65318C2AA025C500DFEEAA /* TestDependencies.swift in Sources */, @@ -7192,16 +7341,15 @@ FD6B92D82E77B55D004463B5 /* PersonalizationSpec.swift in Sources */, FD336F712CABB97800C0B51B /* DestinationSpec.swift in Sources */, FD336F722CABB97800C0B51B /* BatchResponseSpec.swift in Sources */, + FD6B92872E77821E004463B5 /* NimbleExtensions.swift in Sources */, FD336F732CABB97800C0B51B /* HeaderSpec.swift in Sources */, FD336F742CABB97800C0B51B /* PreparedRequestSpec.swift in Sources */, FD336F752CABB97800C0B51B /* RequestSpec.swift in Sources */, + FD6B92602E695ACE004463B5 /* FixtureBase.swift in Sources */, FD336F762CABB97800C0B51B /* BencodeResponseSpec.swift in Sources */, FDB5DB0B2A981F92002C8721 /* MockGeneralCache.swift in Sources */, - FD0150492CA243CB005B08A1 /* Mock.swift in Sources */, FD6B92CE2E77B234004463B5 /* RoomPollInfoSpec.swift in Sources */, FD6B92CF2E77B234004463B5 /* RoomSpec.swift in Sources */, - FDB5DB122A981FA8002C8721 /* NimbleExtensions.swift in Sources */, - FD0150282CA23DB7005B08A1 /* GRDBExtensions.swift in Sources */, FDB5DB152A981FB0002C8721 /* SynchronousStorage.swift in Sources */, FDE754AC2C9B967E002A2623 /* FileUploadResponseSpec.swift in Sources */, ); @@ -7214,51 +7362,55 @@ FD72BDA72BE369DC00CF6CF6 /* CryptoOpenGroupSpec.swift in Sources */, FD96F3A529DBC3DC00401309 /* MessageSendJobSpec.swift in Sources */, FDE754A82C9B964D002A2623 /* MessageReceiverGroupsSpec.swift in Sources */, - FD01504C2CA243CB005B08A1 /* Mock.swift in Sources */, FD981BC92DC4641100564172 /* ExtensionHelperSpec.swift in Sources */, FD981BCB2DC4A21C00564172 /* MessageDeduplicationSpec.swift in Sources */, FD83B9C727CF3F10005E1583 /* CapabilitySpec.swift in Sources */, FD2AAAF128ED57B500A49611 /* SynchronousStorage.swift in Sources */, FD23CE2A2A6775660000B97C /* MockCrypto.swift in Sources */, - FD336F6F2CAA37CB00C0B51B /* MockCommunityPoller.swift in Sources */, + FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */, + FD6B925F2E695ACE004463B5 /* FixtureBase.swift in Sources */, FD481A962CAE0AE000ECC4CF /* MockAppContext.swift in Sources */, FDB11A562DD17C3300BEF49F /* MockLogger.swift in Sources */, FD0150452CA243BB005B08A1 /* LibSessionUtilSpec.swift in Sources */, FD981BCD2DC81ABF00564172 /* MockExtensionHelper.swift in Sources */, - FD336F602CAA28CF00C0B51B /* CommonSMKMockExtensions.swift in Sources */, FD336F612CAA28CF00C0B51B /* MockNotificationsManager.swift in Sources */, - FD336F622CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift in Sources */, - FD336F632CAA28CF00C0B51B /* MockOGMCache.swift in Sources */, + FD6B92862E77821E004463B5 /* NimbleExtensions.swift in Sources */, + FD336F632CAA28CF00C0B51B /* MockOpenGroupManager.swift in Sources */, + FD336F632CAA28CF00C0B51B /* MockOpenGroupManager.swift in Sources */, FD481A922CAD17DE00ECC4CF /* LibSessionGroupMembersSpec.swift in Sources */, FD336F642CAA28CF00C0B51B /* MockCommunityPollerCache.swift in Sources */, - FD336F652CAA28CF00C0B51B /* MockDisplayPictureCache.swift in Sources */, - FD336F662CAA28CF00C0B51B /* MockSwarmPoller.swift in Sources */, FD336F672CAA28CF00C0B51B /* MockGroupPollerCache.swift in Sources */, FD336F682CAA28CF00C0B51B /* MockPoller.swift in Sources */, FD336F692CAA28CF00C0B51B /* MockLibSessionCache.swift in Sources */, FD0150462CA243BB005B08A1 /* LibSessionSpec.swift in Sources */, - FD3765E02AD8F05100DC1489 /* MockSnodeAPICache.swift in Sources */, - FD9DD2722A72516D00ECB68E /* TestExtensions.swift in Sources */, FDE754AA2C9B964D002A2623 /* MessageSenderSpec.swift in Sources */, FD83B9C027CF2294005E1583 /* TestConstants.swift in Sources */, + FDE71B112E7A1D1C0023F5F9 /* CommunityPollerManagerSpec.swift in Sources */, + FDE71B662E821E230023F5F9 /* Mocked+SMK.swift in Sources */, FD65318B2AA025C500DFEEAA /* TestDependencies.swift in Sources */, FD49E2472B05C1D500FFBBB5 /* MockKeychain.swift in Sources */, - FD3765E32AD8F56200DC1489 /* CommonSSKMockExtensions.swift in Sources */, - FD23EA6228ED0B260058676E /* CombineExtensions.swift in Sources */, + FD6B92662E697012004463B5 /* Mocked+SUK.swift in Sources */, + FD3765E32AD8F56200DC1489 /* Mocked+SNK.swift in Sources */, + FD83B9C527CF3E2A005E1583 /* OpenGroupSpec.swift in Sources */, + FD23CE342A67C4D90000B97C /* MockNetwork.swift in Sources */, + FD61FCF92D308CC9005752DE /* GroupMemberSpec.swift in Sources */, + FDC2908B27D707F3005DAE71 /* SendSOGSMessageRequestSpec.swift in Sources */, + FD3765E32AD8F56200DC1489 /* Mocked+SNK.swift in Sources */, + FDE71B642E821E1D0023F5F9 /* ArgumentDescribing+SMK.swift in Sources */, FD83B9C527CF3E2A005E1583 /* OpenGroupSpec.swift in Sources */, FD23CE342A67C4D90000B97C /* MockNetwork.swift in Sources */, FD61FCF92D308CC9005752DE /* GroupMemberSpec.swift in Sources */, - FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, FD7692F72A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift in Sources */, - FD0969F92A69FFE700C5C365 /* Mocked.swift in Sources */, FD481A902CAD16F100ECC4CF /* LibSessionGroupInfoSpec.swift in Sources */, FDE754A92C9B964D002A2623 /* MessageSenderGroupsSpec.swift in Sources */, FD3FAB672AF0C47000DC5421 /* DisplayPictureDownloadJobSpec.swift in Sources */, FD72BDA42BE3690B00CF6CF6 /* CryptoSMKSpec.swift in Sources */, - FD336F6C2CAA29C600C0B51B /* CommunityPollerSpec.swift in Sources */, + FDC2908927D70656005DAE71 /* RoomPollInfoSpec.swift in Sources */, + FD481AA32CB889AE00ECC4CF /* RetrieveDefaultOpenGroupRoomsJobSpec.swift in Sources */, + FDFD645D27F273F300808CA1 /* MockGeneralCache.swift in Sources */, + FDC2908D27D70905005DAE71 /* UpdateMessageRequestSpec.swift in Sources */, FD481AA32CB889AE00ECC4CF /* RetrieveDefaultOpenGroupRoomsJobSpec.swift in Sources */, FDFD645D27F273F300808CA1 /* MockGeneralCache.swift in Sources */, - FD01502A2CA23DB7005B08A1 /* GRDBExtensions.swift in Sources */, FD01503B2CA24328005B08A1 /* MockJobRunner.swift in Sources */, FD3F2EE72DE6CC4100FD6849 /* NotificationsManagerSpec.swift in Sources */, FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */, @@ -7352,6 +7504,26 @@ target = C33FD9AA255A548A00E217F9 /* SignalUtilitiesKit */; targetProxy = C3D90A7025773A44002C9DF5 /* PBXContainerItemProxy */; }; + FD1BDBE92E655EA8008EF998 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = FD1BDBC22E6535EE008EF998 /* TestUtilities */; + targetProxy = FD1BDBE82E655EA8008EF998 /* PBXContainerItemProxy */; + }; + FD1BDBEE2E655EAC008EF998 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = FD1BDBC22E6535EE008EF998 /* TestUtilities */; + targetProxy = FD1BDBED2E655EAC008EF998 /* PBXContainerItemProxy */; + }; + FD1BDBF32E655EB1008EF998 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = FD1BDBC22E6535EE008EF998 /* TestUtilities */; + targetProxy = FD1BDBF22E655EB1008EF998 /* PBXContainerItemProxy */; + }; + FD1BDBF82E655EB5008EF998 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = FD1BDBC22E6535EE008EF998 /* TestUtilities */; + targetProxy = FD1BDBF72E655EB5008EF998 /* PBXContainerItemProxy */; + }; FD71160E28D00BAE00B47552 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = D221A088169C9E5E00537ABF /* Session */; @@ -7452,7 +7624,6 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; @@ -7521,7 +7692,6 @@ SDKROOT = iphoneos; SKIP_INSTALL = YES; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; }; @@ -7570,7 +7740,6 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; @@ -7642,7 +7811,6 @@ SDKROOT = iphoneos; SKIP_INSTALL = YES; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; }; @@ -7691,7 +7859,6 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; @@ -7765,7 +7932,6 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; @@ -7824,7 +7990,6 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; @@ -7905,7 +8070,6 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; @@ -7952,12 +8116,16 @@ MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + SessionUtil, + ); PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionNetworkingKit"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; @@ -8025,13 +8193,17 @@ MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + SessionUtil, + ); PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionNetworkingKit"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; @@ -8077,12 +8249,16 @@ MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + SessionUtil, + ); PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionUtilitiesKit"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; @@ -8150,13 +8326,17 @@ MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + SessionUtil, + ); PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionUtilitiesKit"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; @@ -8203,12 +8383,16 @@ MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + SessionUtil, + ); PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionMessagingKit"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; @@ -8276,13 +8460,17 @@ MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + SessionUtil, + ); PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionMessagingKit"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; @@ -8320,7 +8508,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = ""; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 640; + CURRENT_PROJECT_VERSION = 641; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8360,13 +8548,14 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.4; + MARKETING_VERSION = 2.15.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "-Werror=protocol"; OTHER_SWIFT_FLAGS = "-D DEBUG -Xfrontend -warn-long-expression-type-checking=100"; SDKROOT = iphoneos; STRIP_INSTALLED_PRODUCT = NO; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; VALIDATE_PRODUCT = YES; }; @@ -8401,7 +8590,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = ""; - CURRENT_PROJECT_VERSION = 640; + CURRENT_PROJECT_VERSION = 641; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -8436,7 +8625,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.4; + MARKETING_VERSION = 2.15.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-DNS_BLOCK_ASSERTIONS=1", @@ -8445,7 +8634,6 @@ SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; VALIDATE_PRODUCT = YES; }; @@ -8484,7 +8672,6 @@ "@executable_path/Frameworks", ); LLVM_LTO = NO; - OTHER_LDFLAGS = "$(inherited)"; OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; PRODUCT_NAME = Session; @@ -8493,7 +8680,6 @@ RUN_CLANG_STATIC_ANALYZER = YES; SDKROOT = iphoneos; SWIFT_OBJC_INTERFACE_HEADER_NAME = "Session-Swift.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = "1,2"; TEST_AFTER_BUILD = YES; WRAPPER_EXTENSION = app; @@ -8531,7 +8717,6 @@ "@executable_path/Frameworks", ); LLVM_LTO = NO; - OTHER_LDFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; PRODUCT_NAME = Session; PROVISIONING_PROFILE = ""; @@ -8544,6 +8729,274 @@ }; name = App_Store_Release; }; + FD1BDBC82E6535EE008EF998 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + DEBUG_INFORMATION_FORMAT = dwarf; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_TESTING_SEARCH_PATHS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Rangeproof Pty Ltd. All rights reserved."; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.getsession.TestUtilities; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_MODULE = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + FD1BDBC92E6535EE008EF998 /* Debug_Compile_LibSession */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + DEBUG_INFORMATION_FORMAT = dwarf; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_TESTING_SEARCH_PATHS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Rangeproof Pty Ltd. All rights reserved."; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.getsession.TestUtilities; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_MODULE = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug_Compile_LibSession; + }; + FD1BDBCA2E6535EE008EF998 /* App_Store_Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTING_SEARCH_PATHS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Rangeproof Pty Ltd. All rights reserved."; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.getsession.TestUtilities; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_MODULE = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + SWIFT_VERSION = 5.0; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = App_Store_Release; + }; + FD1BDBCB2E6535EE008EF998 /* App_Store_Release_Compile_LibSession */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTING_SEARCH_PATHS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Rangeproof Pty Ltd. All rights reserved."; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.getsession.TestUtilities; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_MODULE = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + SWIFT_VERSION = 5.0; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = App_Store_Release_Compile_LibSession; + }; FD2272502C32910F004D8A6C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -8592,7 +9045,6 @@ PRODUCT_BUNDLE_IDENTIFIER = io.oxen.SessionTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Session.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Session"; }; @@ -8649,7 +9101,6 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Session.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Session"; VALIDATE_PRODUCT = YES; @@ -8683,7 +9134,6 @@ PRODUCT_BUNDLE_IDENTIFIER = io.oxen.SessionUtilitiesKitTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -8739,7 +9189,6 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; @@ -8790,7 +9239,6 @@ PRODUCT_BUNDLE_IDENTIFIER = io.oxen.SessionMessagingKitTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -8846,7 +9294,6 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; @@ -8882,7 +9329,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 640; + CURRENT_PROJECT_VERSION = 641; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8919,9 +9366,10 @@ GCC_WARN_UNUSED_VALUE = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LIBRARY_SEARCH_PATHS = "$(TARGET_BUILD_DIR)"; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.4; + MARKETING_VERSION = 2.15.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "-fobjc-arc-exceptions", @@ -8931,6 +9379,8 @@ SDKROOT = iphoneos; STRIP_INSTALLED_PRODUCT = NO; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_INCLUDE_PATHS = "$(BUILT_PRODUCTS_DIR)/include/**"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; VALIDATE_PRODUCT = YES; }; @@ -8939,7 +9389,6 @@ FDC605732C71DC03009B3D45 /* Debug_Compile_LibSession */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ADDRESS_SANITIZER_CONTAINER_OVERFLOW = YES; CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES; @@ -8977,7 +9426,6 @@ RUN_CLANG_STATIC_ANALYZER = YES; SDKROOT = iphoneos; SWIFT_OBJC_INTERFACE_HEADER_NAME = "Session-Swift.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = "1,2"; TEST_AFTER_BUILD = YES; WRAPPER_EXTENSION = app; @@ -9027,7 +9475,6 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = 1; }; name = Debug_Compile_LibSession; @@ -9075,7 +9522,6 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = 1; }; name = Debug_Compile_LibSession; @@ -9132,7 +9578,6 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; @@ -9183,7 +9628,6 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; @@ -9230,12 +9674,15 @@ MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; + OTHER_LDFLAGS = ( + "$(inherited)", + "-lsession-util", + ); PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionMessagingKit"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; @@ -9282,12 +9729,15 @@ MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; + OTHER_LDFLAGS = ( + "$(inherited)", + "-lsession-util", + ); PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionNetworkingKit"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; @@ -9334,12 +9784,15 @@ MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; + OTHER_LDFLAGS = ( + "$(inherited)", + "-lsession-util", + ); PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionUtilitiesKit"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; @@ -9372,7 +9825,6 @@ PRODUCT_BUNDLE_IDENTIFIER = io.oxen.SessionTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Session.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Session"; }; @@ -9404,7 +9856,6 @@ PRODUCT_BUNDLE_IDENTIFIER = io.oxen.SessionMessagingKitTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug_Compile_LibSession; @@ -9435,7 +9886,6 @@ PRODUCT_BUNDLE_IDENTIFIER = io.oxen.SessionUtilitiesKitTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug_Compile_LibSession; @@ -9469,7 +9919,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = YES; - CURRENT_PROJECT_VERSION = 640; + CURRENT_PROJECT_VERSION = 641; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; @@ -9500,9 +9950,10 @@ GCC_WARN_UNUSED_VALUE = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LIBRARY_SEARCH_PATHS = "$(TARGET_BUILD_DIR)"; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.4; + MARKETING_VERSION = 2.15.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-DNS_BLOCK_ASSERTIONS=1", @@ -9512,7 +9963,7 @@ SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_INCLUDE_PATHS = "$(BUILT_PRODUCTS_DIR)/include/**"; SWIFT_VERSION = 5.0; VALIDATE_PRODUCT = YES; }; @@ -9521,7 +9972,6 @@ FDC605802C71DC14009B3D45 /* App_Store_Release_Compile_LibSession */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ADDRESS_SANITIZER_CONTAINER_OVERFLOW = NO; CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES; @@ -9625,7 +10075,6 @@ SDKROOT = iphoneos; SKIP_INSTALL = YES; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; }; @@ -9698,7 +10147,6 @@ SDKROOT = iphoneos; SKIP_INSTALL = YES; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; }; @@ -9779,7 +10227,6 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; @@ -9855,7 +10302,6 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; @@ -9925,13 +10371,16 @@ MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + OTHER_LDFLAGS = ( + "$(inherited)", + "-lsession-util", + ); PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionMessagingKit"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; @@ -10001,13 +10450,16 @@ MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + OTHER_LDFLAGS = ( + "$(inherited)", + "-lsession-util", + ); PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionNetworkingKit"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; @@ -10077,13 +10529,16 @@ MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + OTHER_LDFLAGS = ( + "$(inherited)", + "-lsession-util", + ); PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionUtilitiesKit"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; @@ -10141,7 +10596,6 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Session.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Session"; VALIDATE_PRODUCT = YES; @@ -10198,7 +10652,6 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; @@ -10254,7 +10707,6 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; @@ -10362,6 +10814,17 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = App_Store_Release; }; + FD1BDBC72E6535EE008EF998 /* Build configuration list for PBXNativeTarget "TestUtilities" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FD1BDBC82E6535EE008EF998 /* Debug */, + FD1BDBC92E6535EE008EF998 /* Debug_Compile_LibSession */, + FD1BDBCA2E6535EE008EF998 /* App_Store_Release */, + FD1BDBCB2E6535EE008EF998 /* App_Store_Release_Compile_LibSession */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = App_Store_Release; + }; FD2272522C32910F004D8A6C /* Build configuration list for PBXNativeTarget "SessionNetworkingKitTests" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -10478,7 +10941,7 @@ repositoryURL = "https://github.com/Quick/Quick.git"; requirement = { kind = exactVersion; - version = 7.5.0; + version = 7.6.2; }; }; FD6A39392C2AD3A300762359 /* XCRemoteSwiftPackageReference "Nimble" */ = { @@ -10486,7 +10949,7 @@ repositoryURL = "https://github.com/Quick/Nimble.git"; requirement = { kind = exactVersion; - version = 13.3.0; + version = 13.7.1; }; }; FD6DA9D52D017F480092085A /* XCRemoteSwiftPackageReference "session-grdb-swift" */ = { @@ -10523,6 +10986,11 @@ package = FD6A39392C2AD3A300762359 /* XCRemoteSwiftPackageReference "Nimble" */; productName = Nimble; }; + FD1BDBE02E655B2A008EF998 /* Nimble */ = { + isa = XCSwiftPackageProductDependency; + package = FD6A39392C2AD3A300762359 /* XCRemoteSwiftPackageReference "Nimble" */; + productName = Nimble; + }; FD22866E2C38D42300BC06F7 /* DifferenceKit */ = { isa = XCSwiftPackageProductDependency; package = FD6A38ED2C2A641200762359 /* XCRemoteSwiftPackageReference "DifferenceKit" */; @@ -10548,21 +11016,6 @@ package = FD6A38ED2C2A641200762359 /* XCRemoteSwiftPackageReference "DifferenceKit" */; productName = DifferenceKit; }; - FD6673F52D7021E700041530 /* SessionUtil */ = { - isa = XCSwiftPackageProductDependency; - package = FD6673F42D7021E700041530 /* XCRemoteSwiftPackageReference "libsession-util-spm" */; - productName = SessionUtil; - }; - FD6673F72D7021F200041530 /* SessionUtil */ = { - isa = XCSwiftPackageProductDependency; - package = FD6673F42D7021E700041530 /* XCRemoteSwiftPackageReference "libsession-util-spm" */; - productName = SessionUtil; - }; - FD6673F92D7021F800041530 /* SessionUtil */ = { - isa = XCSwiftPackageProductDependency; - package = FD6673F42D7021E700041530 /* XCRemoteSwiftPackageReference "libsession-util-spm" */; - productName = SessionUtil; - }; FD6A38E82C2A630E00762359 /* CocoaLumberjackSwift */ = { isa = XCSwiftPackageProductDependency; package = FD6A38E72C2A630E00762359 /* XCRemoteSwiftPackageReference "CocoaLumberjack" */; @@ -10618,6 +11071,11 @@ package = FD6A39392C2AD3A300762359 /* XCRemoteSwiftPackageReference "Nimble" */; productName = Nimble; }; + FD6B92712E6AB045004463B5 /* Quick */ = { + isa = XCSwiftPackageProductDependency; + package = FD6A39302C2AD33E00762359 /* XCRemoteSwiftPackageReference "Quick" */; + productName = Quick; + }; FD6DA9CE2D015B440092085A /* Lucide */ = { isa = XCSwiftPackageProductDependency; productName = Lucide; diff --git a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 40c1f21016..0f1719acd3 100644 --- a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CocoaLumberjack/CocoaLumberjack.git", "state" : { - "revision" : "4b8714a7fb84d42393314ce897127b3939885ec3", - "version" : "3.8.5" + "revision" : "a9ed4b6f9bdedce7d77046f43adfb8ce1fd54114", + "version" : "3.9.0" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Quick/Nimble.git", "state" : { - "revision" : "1c49fc1243018f81a7ea99cb5e0985b00096e9f4", - "version" : "13.3.0" + "revision" : "7795df4fff1a9cd231fe4867ae54f4dc5f5734f9", + "version" : "13.7.1" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Quick/Quick.git", "state" : { - "revision" : "26529ff2209c40ae50fd642b031f930d9d68ea02", - "version" : "7.5.0" + "revision" : "1163a1b1b114a657c7432b63dd1f92ce99fe11a6", + "version" : "7.6.2" } }, { @@ -105,8 +105,26 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/session-foundation/session-lucide.git", "state" : { - "revision" : "af00ad53d714823e07f984aadd7af38bafaae69e", - "version" : "0.473.0" + "revision" : "7da7fc6a2c42ee8549b0b9804455b43c61a0e63f", + "version" : "0.473.1" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3", + "version" : "1.6.1" } }, { @@ -118,6 +136,15 @@ "version" : "1.6.1" } }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "bbadd4b853a33fd78c4ae977d17bb2af15eb3f2a", + "version" : "1.1.0" + } + }, { "identity" : "swift-protobuf", "kind" : "remoteSourceControl", diff --git a/Session/Calls/Call Management/SessionCall.swift b/Session/Calls/Call Management/SessionCall.swift index f08656626c..7a34139d30 100644 --- a/Session/Calls/Call Management/SessionCall.swift +++ b/Session/Calls/Call Management/SessionCall.swift @@ -13,6 +13,7 @@ import SessionNetworkingKit public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { private let dependencies: Dependencies + private var networkObservationTask: Task? public let webRTCSession: WebRTCSession var currentConnectionStep: ConnectionStep @@ -176,6 +177,10 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { } } + deinit { + networkObservationTask?.cancel() + } + // stringlint:ignore_contents func reportIncomingCallIfNeeded(completion: @escaping (Error?) -> Void) { guard case .answer = mode else { @@ -217,7 +222,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { else { return } let webRTCSession: WebRTCSession = self.webRTCSession - let timestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let timestampMs: Int64 = dependencies.networkOffsetTimestampMs() let disappearingMessagesConfiguration = try? thread.disappearingMessagesConfiguration.fetchOne(db)?.forcedWithDisappearAfterReadIfNeeded() let message: CallMessage = CallMessage( uuid: self.uuid, @@ -248,7 +253,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { message: message, threadId: thread.id, interactionId: interaction?.id, - authMethod: try Authentication.with(db, swarmPublicKey: thread.id, using: dependencies) + authMethod: try Authentication.with(swarmPublicKey: thread.id, using: dependencies) ) .retry(5) // Start the timeout timer for the call @@ -476,16 +481,13 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { // Register a callback to get the current network status then remove it immediately as we only // care about the current status - dependencies[cache: .libSessionNetwork].networkStatus - .sinkUntilComplete( - receiveValue: { [weak self, dependencies] status in - guard status != .connected else { return } - - self?.reconnectTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: 5, repeats: false, using: dependencies) { _ in - self?.tryToReconnect() - } - } - ) + networkObservationTask = Task { [weak self, dependencies] in + guard await dependencies.currentNetworkStatus != .connected else { return } + + self?.reconnectTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: 5, repeats: false, using: dependencies) { _ in + self?.tryToReconnect() + } + } let sessionId: String = self.sessionId let webRTCSession: WebRTCSession = self.webRTCSession diff --git a/Session/Calls/Call Management/SessionCallManager.swift b/Session/Calls/Call Management/SessionCallManager.swift index 2d7f8eb9dc..5773282969 100644 --- a/Session/Calls/Call Management/SessionCallManager.swift +++ b/Session/Calls/Call Management/SessionCallManager.swift @@ -194,9 +194,11 @@ public final class SessionCallManager: NSObject, CallManagerProtocol { // Stop all jobs except for message sending and when completed suspend the database dependencies[singleton: .jobRunner].stopAndClearPendingJobs(exceptForVariant: .messageSend) { [dependencies] _ in if self.currentCall?.hasEnded != false { - dependencies.mutate(cache: .libSessionNetwork) { $0.suspendNetworkAccess() } - dependencies[singleton: .storage].suspendDatabaseAccess() - Log.flush() + Task { [dependencies] in + await dependencies[singleton: .network].suspendNetworkAccess() + dependencies[singleton: .storage].suspendDatabaseAccess() + Log.flush() + } } } } @@ -293,8 +295,10 @@ public final class SessionCallManager: NSObject, CallManagerProtocol { dependencies[defaults: .appGroup, key: .lastCallPreOffer] = nil if dependencies[singleton: .appContext].isNotInForeground { - dependencies[singleton: .appReadiness].runNowOrWhenAppDidBecomeReady { [dependencies] in - dependencies[singleton: .currentUserPoller].stop() + dependencies[singleton: .appReadiness].runNowOrWhenAppDidBecomeReady { [poller = dependencies[singleton: .currentUserPoller]] in + Task(priority: .userInitiated) { + await poller.stop() + } } Log.flush() } diff --git a/Session/Calls/WebRTC/WebRTCSession.swift b/Session/Calls/WebRTC/WebRTCSession.swift index d579d8a1a5..49451e71a0 100644 --- a/Session/Calls/WebRTC/WebRTCSession.swift +++ b/Session/Calls/WebRTC/WebRTCSession.swift @@ -181,20 +181,22 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { } dependencies[singleton: .storage] - .writePublisher { db -> (AuthenticationMethod, DisappearingMessagesConfiguration?) in - ( - try Authentication.with(db, swarmPublicKey: thread.id, using: dependencies), - try DisappearingMessagesConfiguration.fetchOne(db, id: thread.id) - ) + .writePublisher { db -> DisappearingMessagesConfiguration? in + try DisappearingMessagesConfiguration.fetchOne(db, id: thread.id) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .tryFlatMap { authMethod, disappearingMessagesConfiguration in - try MessageSender.preparedSend( + .tryFlatMap { disappearingMessagesConfiguration in + let authMethod: AuthenticationMethod = try Authentication.with( + swarmPublicKey: thread.id, + using: dependencies + ) + + return try MessageSender.preparedSend( message: CallMessage( uuid: uuid, kind: .offer, sdps: [ sdp.sdp ], - sentTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + sentTimestampMs: dependencies.networkOffsetTimestampMs() ) .with(disappearingMessagesConfiguration?.forcedWithDisappearAfterReadIfNeeded()), to: .contact(publicKey: thread.id), @@ -226,7 +228,7 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { let mediaConstraints: RTCMediaConstraints = mediaConstraints(false) return dependencies[singleton: .storage] - .readPublisher { [dependencies] db -> (AuthenticationMethod, DisappearingMessagesConfiguration?) in + .readPublisher { db -> DisappearingMessagesConfiguration? in /// Ensure a thread exists for the `sessionId` and that it's a `contact` thread guard SessionThread @@ -235,13 +237,15 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { .isNotEmpty(db) else { throw WebRTCSessionError.noThread } - return ( - try Authentication.with(db, swarmPublicKey: sessionId, using: dependencies), - try DisappearingMessagesConfiguration.fetchOne(db, id: sessionId) - ) + return try DisappearingMessagesConfiguration.fetchOne(db, id: sessionId) } - .flatMap { [weak self, dependencies] authMethod, disappearingMessagesConfiguration in - Future { resolver in + .tryFlatMap { [weak self, dependencies] disappearingMessagesConfiguration in + let authMethod: AuthenticationMethod = try Authentication.with( + swarmPublicKey: sessionId, + using: dependencies + ) + + return Future { resolver in self?.peerConnection?.answer(for: mediaConstraints) { [weak self] sdp, error in if let error = error { resolver(Result.failure(error)) @@ -313,7 +317,7 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { self.queuedICECandidates.removeAll() return dependencies[singleton: .storage] - .readPublisher { [dependencies] db -> (AuthenticationMethod, DisappearingMessagesConfiguration?) in + .readPublisher { db -> DisappearingMessagesConfiguration? in /// Ensure a thread exists for the `sessionId` and that it's a `contact` thread guard SessionThread @@ -322,13 +326,15 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { .isNotEmpty(db) else { throw WebRTCSessionError.noThread } - return ( - try Authentication.with(db, swarmPublicKey: contactSessionId, using: dependencies), - try DisappearingMessagesConfiguration.fetchOne(db, id: contactSessionId) - ) + return try DisappearingMessagesConfiguration.fetchOne(db, id: contactSessionId) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .tryFlatMap { [dependencies] authMethod, disappearingMessagesConfiguration in + .tryFlatMap { [dependencies] disappearingMessagesConfiguration in + let authMethod: AuthenticationMethod = try Authentication.with( + swarmPublicKey: contactSessionId, + using: dependencies + ) + Log.info(.calls, "Batch sending \(candidates.count) ICE candidates.") return try MessageSender @@ -368,7 +374,7 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { public func endCall(with sessionId: String) { return dependencies[singleton: .storage] - .readPublisher { [dependencies] db -> (AuthenticationMethod, DisappearingMessagesConfiguration?) in + .readPublisher { db -> DisappearingMessagesConfiguration? in /// Ensure a thread exists for the `sessionId` and that it's a `contact` thread guard SessionThread @@ -377,13 +383,15 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { .isNotEmpty(db) else { throw WebRTCSessionError.noThread } - return ( - try Authentication.with(db, swarmPublicKey: sessionId, using: dependencies), - try DisappearingMessagesConfiguration.fetchOne(db, id: sessionId) - ) + return try DisappearingMessagesConfiguration.fetchOne(db, id: sessionId) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .tryFlatMap { [dependencies, uuid] authMethod, disappearingMessagesConfiguration in + .tryFlatMap { [dependencies, uuid] disappearingMessagesConfiguration in + let authMethod: AuthenticationMethod = try Authentication.with( + swarmPublicKey: sessionId, + using: dependencies + ) + Log.info(.calls, "Sending end call message.") return try MessageSender diff --git a/Session/Closed Groups/EditGroupViewModel.swift b/Session/Closed Groups/EditGroupViewModel.swift index a0dd9dba89..956b90359d 100644 --- a/Session/Closed Groups/EditGroupViewModel.swift +++ b/Session/Closed Groups/EditGroupViewModel.swift @@ -532,27 +532,15 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Observa case (.some(let inviteByIdValue), _): // This could be an ONS name - let viewController = ModalActivityIndicatorViewController() { modalActivityIndicator in - Network.SnodeAPI - .getSessionID(for: inviteByIdValue, using: dependencies) - .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) - .receive(on: DispatchQueue.main, using: dependencies) - .sinkUntilComplete( - receiveCompletion: { result in - switch result { - case .finished: break - case .failure(let error): - modalActivityIndicator.dismiss { - switch error { - case SnodeAPIError.onsNotFound: - return showError("onsErrorNotRecognized".localized()) - default: - return showError("onsErrorUnableToSearch".localized()) - } - } - } - }, - receiveValue: { sessionIdHexString in + let viewController = ModalActivityIndicatorViewController() { [weak self, dependencies] modalActivityIndicator in + Task { [weak self, modalActivityIndicator, dependencies] in + do { + let sessionIdHexString: String = try await Network.StorageServer.getSessionID( + for: inviteByIdValue, + using: dependencies + ) + + await MainActor.run { guard !currentMemberIds.contains(sessionIdHexString) else { // FIXME: Localise this return showError("This Account ID or ONS belongs to an existing member") @@ -567,7 +555,20 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Observa } } } - ) + } + catch { + await MainActor.run { + modalActivityIndicator.dismiss { + switch error { + case StorageServerError.onsNotFound: + return showError("onsErrorNotRecognized".localized()) + default: + return showError("onsErrorUnableToSearch".localized()) + } + } + } + } + } } self?.transitionToScreen(viewController, transitionType: .present) } diff --git a/Session/Conversations/Context Menu/ContextMenuVC+ActionView.swift b/Session/Conversations/Context Menu/ContextMenuVC+ActionView.swift index 8ae261fc79..e9ae1e0318 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+ActionView.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+ActionView.swift @@ -133,14 +133,14 @@ extension ContextMenuVC { subtitleWidthConstraint.isActive = true // To prevent a negative timer - let timeToExpireInSeconds = max(0, (expiresStartedAtMs + expiresInSeconds * 1000 - dependencies[cache: .snodeAPI].currentOffsetTimestampMs()) / 1000) + let timeToExpireInSeconds = max(0, (expiresStartedAtMs + expiresInSeconds * 1000 - dependencies.networkOffsetTimestampMs()) / 1000) subtitleLabel.text = "disappearingMessagesCountdownBigMobile" .put(key: "time_large", value: timeToExpireInSeconds.formatted(format: .twoUnits, minimumUnit: .second)) .localized() timer = Timer.scheduledTimerOnMainThread(withTimeInterval: 1, repeats: true, using: dependencies, block: { [weak self, dependencies] _ in - let timeToExpireInSeconds: TimeInterval = (expiresStartedAtMs + expiresInSeconds * 1000 - dependencies[cache: .snodeAPI].currentOffsetTimestampMs()) / 1000 + let timeToExpireInSeconds: TimeInterval = (expiresStartedAtMs + expiresInSeconds * 1000 - dependencies.networkOffsetTimestampMs()) / 1000 if timeToExpireInSeconds <= 0 { self?.dismissWithTimerInvalidationIfNeeded() } else { diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 50caf2f972..e1b6270126 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -711,7 +711,7 @@ extension ConversationVC: // Optimistically insert the outgoing message (this will trigger a UI update) self.viewModel.sentMessageBeforeUpdate = true - let sentTimestampMs: Int64 = viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let sentTimestampMs: Int64 = viewModel.dependencies.networkOffsetTimestampMs() let optimisticData: ConversationViewModel.OptimisticMessageData = self.viewModel.optimisticallyAppendOutgoingMessage( text: processedText, sentTimestampMs: sentTimestampMs, @@ -913,7 +913,7 @@ extension ConversationVC: threadId: threadData.threadId, threadVariant: threadData.threadVariant, direction: .outgoing, - timestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + timestampMs: dependencies.networkOffsetTimestampMs() ) } } @@ -1196,7 +1196,7 @@ extension ConversationVC: ) { [weak self, dependencies = viewModel.dependencies] _ in dependencies[singleton: .storage].writeAsync { db in let userSessionId: SessionId = dependencies[cache: .general].sessionId - let currentTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let currentTimestampMs: Int64 = dependencies.networkOffsetTimestampMs() let interactionId = try messageDisappearingConfig .upserted(db) @@ -1579,7 +1579,7 @@ extension ConversationVC: variant: .contact, values: SessionThread.TargetValues( creationDateTimestamp: .useExistingOrSetTo( - (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + (dependencies.networkOffsetTimestampMs() / 1000) ), shouldBeVisible: .useLibSession, isDraft: .useExistingOrSetTo(true) @@ -1621,7 +1621,7 @@ extension ConversationVC: variant: .contact, values: SessionThread.TargetValues( creationDateTimestamp: .useExistingOrSetTo( - (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + (dependencies.networkOffsetTimestampMs() / 1000) ), shouldBeVisible: .useLibSession, isDraft: .useExistingOrSetTo(true) @@ -1823,7 +1823,7 @@ extension ConversationVC: let threadId: String = self.viewModel.threadData.threadId let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant let openGroupRoom: String? = self.viewModel.threadData.openGroupRoomToken - let sentTimestampMs: Int64 = viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let sentTimestampMs: Int64 = viewModel.dependencies.networkOffsetTimestampMs() let recentReactionTimestamps: [Int64] = viewModel.dependencies[cache: .general].recentReactionTimestamps guard @@ -2751,7 +2751,7 @@ extension ConversationVC: self.viewModel.stopAudio() // Create URL - let currentOffsetTimestamp: Int64 = viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let currentOffsetTimestamp: Int64 = viewModel.dependencies.networkOffsetTimestampMs() let directory: String = viewModel.dependencies[singleton: .fileManager].temporaryDirectory let fileName: String = "\(currentOffsetTimestamp).m4a" // stringlint:ignore let url: URL = URL(fileURLWithPath: directory).appendingPathComponent(fileName) @@ -2895,7 +2895,7 @@ extension ConversationVC: db, message: DataExtractionNotification( kind: kind, - sentTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + sentTimestampMs: dependencies.networkOffsetTimestampMs() ) .with(DisappearingMessagesConfiguration .fetchOne(db, id: threadId)? @@ -3114,7 +3114,7 @@ extension ConversationVC { threadVariant: self.viewModel.threadData.threadVariant, displayName: self.viewModel.threadData.displayName, isDraft: (self.viewModel.threadData.threadIsDraft == true), - timestampMs: viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + timestampMs: viewModel.dependencies.networkOffsetTimestampMs() ).sinkUntilComplete() } diff --git a/Session/Conversations/Message Cells/Content Views/DisappearingMessageTimerView.swift b/Session/Conversations/Message Cells/Content Views/DisappearingMessageTimerView.swift index b835fe5e86..c3db62e99a 100644 --- a/Session/Conversations/Message Cells/Content Views/DisappearingMessageTimerView.swift +++ b/Session/Conversations/Message Cells/Content Views/DisappearingMessageTimerView.swift @@ -51,7 +51,7 @@ class DisappearingMessageTimerView: UIView { return } - let timestampMs: Double = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let timestampMs: Double = dependencies.networkOffsetTimestampMs() let secondsLeft: Double = max((self.expirationTimestampMs - timestampMs) / 1000, 0) let progressRatio: Double = self.initialDurationSeconds > 0 ? secondsLeft / self.initialDurationSeconds : 0 diff --git a/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift index ff45656e18..b5a3ae8602 100644 --- a/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift @@ -357,7 +357,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, Naviga // Update the local state try updatedConfig.upserted(db) - let currentOffsetTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let currentOffsetTimestampMs: Int64 = dependencies.networkOffsetTimestampMs() let interactionId = try updatedConfig .upserted(db) .insertControlMessage( diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 8ee7a40590..62096712ec 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -1210,7 +1210,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob ) dependencies[singleton: .storage].writeAsync { db in try selectedUserInfo.forEach { userInfo in - let sentTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let sentTimestampMs: Int64 = dependencies.networkOffsetTimestampMs() let thread: SessionThread = try SessionThread.upsert( db, id: userInfo.profileId, @@ -1306,7 +1306,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob variant: .contact, values: SessionThread.TargetValues( creationDateTimestamp: .useExistingOrSetTo( - dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000 + dependencies.networkOffsetTimestampMs() / 1000 ), shouldBeVisible: .useExisting, isDraft: .useExistingOrSetTo(true) @@ -1896,7 +1896,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob }, completion: { [weak self, dependencies] result in guard - let numPinnedConversations: Int = try? result.successOrThrow(), + let numPinnedConversations: Int = try? result.get(), numPinnedConversations > 0 else { return } @@ -1934,7 +1934,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob try LibSession.deleteMessagesBefore( db, groupSessionId: SessionId(.group, hex: threadId), - timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000), + timestamp: (dependencies.networkOffsetTimestampMs() / 1000), using: dependencies ) } @@ -1947,7 +1947,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob try LibSession.deleteAttachmentsBefore( db, groupSessionId: SessionId(.group, hex: threadId), - timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000), + timestamp: (dependencies.networkOffsetTimestampMs() / 1000), using: dependencies ) } diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index cf9e8ed989..2506b9690a 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -6,6 +6,7 @@ import GRDB import DifferenceKit import SessionUIKit import SessionMessagingKit +import SessionNetworkingKit import SessionUtilitiesKit import SignalUtilitiesKit @@ -351,11 +352,11 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi let appDelegate: AppDelegate = UIApplication.shared.delegate as? AppDelegate, viewModel.dependencies[singleton: .appContext].isMainAppAndActive { - appDelegate.startPollersIfNeeded() + Task { await appDelegate.startPollersIfNeeded() } } // Onion request path countries cache - viewModel.dependencies.warmCache(cache: .ip2Country) + viewModel.dependencies.warm(singleton: .ip2Country) // Bind the UI to the view model bindViewModel() @@ -515,10 +516,9 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi displayPictureUrl: nil, profile: userProfile, profileIcon: { - switch (serviceNetwork, forceOffline) { - case (.testnet, false): return .letter("T", false) // stringlint:ignore - case (.testnet, true): return .letter("T", true) // stringlint:ignore - default: return .none + switch (serviceNetwork, serviceNetwork.title.first) { + case (.mainnet, _), (_, .none): return .none + case (_, .some(let letter)): return .letter(letter, forceOffline) } }(), additionalProfile: nil, @@ -813,7 +813,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi self.navigationController?.setViewControllers([ self, searchController ], animated: true) } - @objc private func createNewConversation() { + @MainActor @objc private func createNewConversation() { viewModel.dependencies[singleton: .app].createNewConversation() } diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 98d29d990e..3318e89c3b 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -6,6 +6,7 @@ import GRDB import DifferenceKit import SignalUtilitiesKit import SessionMessagingKit +import SessionNetworkingKit import SessionUtilitiesKit import StoreKit import SessionUIKit @@ -189,7 +190,7 @@ public class HomeViewModel: NavigatableStateHolder { isInitialQuery: Bool, using dependencies: Dependencies ) async -> State { - let startedAsNewUser: Bool = (dependencies[cache: .onboarding].initialFlow == .register) + let startedAsNewUser: Bool = (await dependencies[singleton: .onboarding].initialFlow == .register) var userProfile: Profile = previousState.userProfile var serviceNetwork: ServiceNetwork = previousState.serviceNetwork var forceOffline: Bool = previousState.forceOffline diff --git a/Session/Home/New Conversation/NewMessageScreen.swift b/Session/Home/New Conversation/NewMessageScreen.swift index 4eb0dbfa9f..e239728096 100644 --- a/Session/Home/New Conversation/NewMessageScreen.swift +++ b/Session/Home/New Conversation/NewMessageScreen.swift @@ -86,40 +86,41 @@ struct NewMessageScreen: View { // This could be an ONS name ModalActivityIndicatorViewController - .present(fromViewController: self.host.controller?.navigationController!, canCancel: false) { modalActivityIndicator in - Network.SnodeAPI - .getSessionID(for: accountIdOrONS, using: dependencies) - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .receive(on: DispatchQueue.main) - .sinkUntilComplete( - receiveCompletion: { result in - switch result { - case .finished: break - case .failure(let error): - modalActivityIndicator.dismiss { - let message: String = { - switch error { - case SnodeAPIError.onsNotFound: - return "onsErrorNotRecognized".localized() - default: - return "onsErrorUnableToSearch".localized() - } - }() - - errorString = message - } + .present(fromViewController: self.host.controller?.navigationController!, canCancel: false) { [dependencies] modalActivityIndicator in + Task { [accountIdOrONS, dependencies] in + do { + let sessionIdHexString: String = try await Network.StorageServer.getSessionID( + for: accountIdOrONS, + using: dependencies + ) + + await MainActor.run { + modalActivityIndicator.dismiss { + self.startNewDM(with: sessionIdHexString) + } } - }, - receiveValue: { sessionId in - modalActivityIndicator.dismiss { - self.startNewDM(with: sessionId) + } + catch { + await MainActor.run { + modalActivityIndicator.dismiss { + let message: String = { + switch error { + case StorageServerError.onsNotFound: + return "onsErrorNotRecognized".localized() + default: + return "onsErrorUnableToSearch".localized() + } + }() + + self.errorString = message + } } } - ) + } } } - private func startNewDM(with sessionId: String) { + @MainActor private func startNewDM(with sessionId: String) { dependencies[singleton: .app].presentConversationCreatingIfNeeded( for: sessionId, variant: .contact, diff --git a/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift index b741b3d384..6c9d76d95a 100644 --- a/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift +++ b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift @@ -41,6 +41,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect var progressiveSearchTimer: Timer? + private var networkObservationTask: Task? private var disposables: Set = Set() // MARK: - Initialization @@ -64,6 +65,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect deinit { NotificationCenter.default.removeObserver(self) + networkObservationTask?.cancel() progressiveSearchTimer?.invalidate() } @@ -104,13 +106,12 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect createViews() - dependencies[cache: .libSessionNetwork].networkStatus - .receive(on: DispatchQueue.main, using: dependencies) - .sink(receiveValue: { [weak self] _ in + networkObservationTask = Task { [weak self, dependencies] in + for await _ in dependencies.networkStatusUpdates { // Prod cells to try to load when connectivity changes. self?.ensureCellState() - }) - .store(in: &disposables) + } + } NotificationCenter.default.addObserver( self, diff --git a/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift b/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift index eb2cd33430..d4ec55c0d2 100644 --- a/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift +++ b/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift @@ -13,7 +13,7 @@ import SessionUtilitiesKit public extension Singleton { static let giphyDownloader: SingletonConfig = Dependencies.create( identifier: "giphyDownloader", - createInstance: { dependencies in + createInstance: { dependencies, _ in ProxiedContentDownloader( downloadFolderName: "GIFs", // stringlint:ignore using: dependencies diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index 28ab4d2426..de31e77ab4 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -549,7 +549,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou kind: .mediaSaved( timestamp: UInt64(currentViewController.galleryItem.interactionTimestampMs) ), - sentTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + sentTimestampMs: dependencies.networkOffsetTimestampMs() ) .with(DisappearingMessagesConfiguration .fetchOne(db, id: threadId)? diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 6c42a3751c..400668307e 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -609,7 +609,6 @@ final class MessageInfoViewController: SessionHostingViewController Bool { - /// If we are running automated tests we should process environment variables before we do anything else - DeveloperSettingsViewModel.processUnitTestEnvVariablesIfNeeded(using: dependencies) + Log.info(.cat, "didFinishLaunchingWithOptions called.") + startTime = CACurrentMediaTime() #if DEBUG /// If we are running unit tests then we don't want to run the usual application startup process (as it could slow down and/or @@ -50,116 +50,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } #endif - Log.info(.cat, "didFinishLaunchingWithOptions called.") - startTime = CACurrentMediaTime() - - // These should be the first things we do (the startup process can fail without them) + /// These should be the first things we do (the startup process can fail without them) dependencies.set(singleton: .appContext, to: MainAppContext(using: dependencies)) verifyDBKeysAvailableBeforeBackgroundLaunch() - - dependencies.warmCache(cache: .appVersion) - dependencies[singleton: .pushRegistrationManager].createVoipRegistryIfNecessary() - - // Prevent the device from sleeping during database view async registration - // (e.g. long database upgrades). - // - // This block will be cleared in storageIsReady. - dependencies[singleton: .deviceSleepManager].addBlock(blockObject: self) let mainWindow: UIWindow = TraitObservingWindow(frame: UIScreen.main.bounds) self.loadingViewController = LoadingViewController() - AppSetup.setupEnvironment( - appSpecificBlock: { [dependencies] in - Log.setup(with: Logger(primaryPrefix: "Session", using: dependencies)) - Log.info(.cat, "Setting up environment.") - - /// Create a proper `SessionCallManager` for the main app (defaults to a no-op version) - dependencies.set(singleton: .callManager, to: SessionCallManager(using: dependencies)) - - // Setup LibSession - LibSession.setupLogger(using: dependencies) - dependencies.warmCache(cache: .libSessionNetwork) - - // Configure the different targets - SNUtilitiesKit.configure( - networkMaxFileSize: Network.maxFileSize, - maxValidImageDimention: ImageDataManager.DataSource.maxValidDimension, - using: dependencies - ) - SNMessagingKit.configure(using: dependencies) - - // Update state of current call - if dependencies[singleton: .callManager].currentCall == nil { - dependencies[defaults: .appGroup, key: .isCallOngoing] = false - dependencies[defaults: .appGroup, key: .lastCallPreOffer] = nil - } - - // Note: Intentionally dispatching sync as we want to wait for these to complete before - // continuing - DispatchQueue.main.sync { - dependencies[singleton: .screenLock].setupWithRootWindow(rootWindow: mainWindow) - OWSWindowManager.shared().setup( - withRootWindow: mainWindow, - screenBlockingWindow: dependencies[singleton: .screenLock].window, - backgroundWindowLevel: .background - ) - } - }, - migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in - self?.loadingViewController?.updateProgress( - progress: progress, - minEstimatedTotalTime: minEstimatedTotalTime - ) - }, - migrationsCompletion: { [weak self, dependencies] result in - if case .failure(let error) = result { - DispatchQueue.main.async { - self?.initialLaunchFailed = true - self?.showFailedStartupAlert( - calledFrom: .finishLaunching, - error: .databaseError(error) - ) - } - return - } - - /// Because the `SessionUIKit` target doesn't depend on the `SessionUtilitiesKit` dependency (it shouldn't - /// need to since it should just be UI) but since the theme settings are stored in the database we need to pass these through - /// to `SessionUIKit` and expose a mechanism to save updated settings - this is done here (once the migrations complete) - Task { @MainActor in - SNUIKit.configure( - with: SessionSNUIKitConfig(using: dependencies), - themeSettings: dependencies.mutate(cache: .libSession) { cache -> ThemeSettings in - ( - cache.get(.theme), - cache.get(.themePrimaryColor), - cache.get(.themeMatchSystemDayNightCycle) - ) - } - ) - } - - /// Adding this to prevent new users being asked for local network permission in the wrong order in the permission chain. - /// We need to check the local nework permission status every time the app is activated to refresh the UI in Settings screen. - /// And after granting or denying a system permission request will trigger the local nework permission status check in applicationDidBecomeActive(:) - /// The only way we can check the status of local network permission will trigger the system prompt to ask for the permission. - /// So we need this to keep it the correct order of the permission chain. - /// For users who already enabled the calls permission and made calls, the local network permission should already be asked for. - /// It won't affect anything. - dependencies[defaults: .standard, key: .hasRequestedLocalNetworkPermission] = dependencies.mutate(cache: .libSession) { cache in - cache.get(.areCallsEnabled) - } - - /// Now that the theme settings have been applied we can complete the migrations - self?.completePostMigrationSetup(calledFrom: .finishLaunching) - }, - using: dependencies - ) + /// Kick of a task to perform the app setup + Task(priority: .userInitiated) { [weak self, mainWindow] in + await self?.setupEnvironment(mainWindow: mainWindow) + } - // No point continuing if we are running tests - guard !SNUtilitiesKit.isRunningTests else { return true } - self.window = mainWindow dependencies[singleton: .appContext].setMainWindow(mainWindow) @@ -197,8 +99,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD /// Apple's documentation on the matter) dependencies[singleton: .notificationsManager].setDelegate(self) - dependencies[singleton: .storage].resumeDatabaseAccess() - dependencies.mutate(cache: .libSessionNetwork) { $0.resumeNetworkAccess() } + Task { + dependencies[singleton: .storage].resumeDatabaseAccess() + await dependencies[singleton: .network].resumeNetworkAccess() + } // Reset the 'startTime' (since it would be invalid from the last launch) startTime = CACurrentMediaTime() @@ -219,31 +123,28 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } // Dispatch async so things can continue to be progressed if a migration does need to run - DispatchQueue.global(qos: .userInitiated).async { [weak self, dependencies] in - AppSetup.runPostSetupMigrations( - migrationProgressChanged: { progress, minEstimatedTotalTime in + Task(priority: .userInitiated) { [weak self, dependencies] in + do { + try await AppSetup.performDatabaseMigrations(using: dependencies) { [weak self] progress, minEstimatedTotalTime in self?.loadingViewController?.updateProgress( progress: progress, minEstimatedTotalTime: minEstimatedTotalTime ) - }, - migrationsCompletion: { result in - if case .failure(let error) = result { - DispatchQueue.main.async { - self?.showFailedStartupAlert( - calledFrom: .enterForeground(initialLaunchFailed: initialLaunchFailed), - error: .databaseError(error) - ) - } - return - } - - self?.completePostMigrationSetup( - calledFrom: .enterForeground(initialLaunchFailed: initialLaunchFailed) + } + try await AppSetup.postMigrationSetup(using: dependencies) + + await self?.completePostMigrationSetup( + calledFrom: .enterForeground(initialLaunchFailed: initialLaunchFailed) + ) + } + catch { + await MainActor.run { [weak self] in + self?.showFailedStartupAlert( + calledFrom: .enterForeground(initialLaunchFailed: initialLaunchFailed), + error: .databaseError(error) ) - }, - using: dependencies - ) + } + } } } } @@ -255,15 +156,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // NOTE: Fix an edge case where user taps on the callkit notification // but answers the call on another device - stopPollers(shouldStopUserPoller: !self.hasCallOngoing()) + Task(priority: .userInitiated) { [weak self] in + await self?.stopPollers(shouldStopUserPoller: self?.hasCallOngoing() != true) + } // Stop all jobs except for message sending and when completed suspend the database dependencies[singleton: .jobRunner].stopAndClearPendingJobs(exceptForVariant: .messageSend) { [dependencies] neededBackgroundProcessing in if !self.hasCallOngoing() && (!neededBackgroundProcessing || dependencies[singleton: .appContext].isInBackground) { - dependencies.mutate(cache: .libSessionNetwork) { $0.suspendNetworkAccess() } - dependencies[singleton: .storage].suspendDatabaseAccess() - Log.info(.cat, "completed network and database shutdowns.") - Log.flush() + Task { + await dependencies[singleton: .network].suspendNetworkAccess() + dependencies[singleton: .storage].suspendDatabaseAccess() + Log.info(.cat, "Completed network and database shutdowns.") + Log.flush() + } } } } @@ -276,7 +181,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD Log.info(.cat, "applicationWillTerminate.") Log.flush() - stopPollers() + Task(priority: .userInitiated) { [weak self] in + await self?.stopPollers() + } } func applicationDidBecomeActive(_ application: UIApplication) { @@ -287,12 +194,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD dependencies[defaults: .appGroup, key: .isMainAppActive] = true // FIXME: Seems like there are some discrepancies between the expectations of how the iOS lifecycle methods work, we should look into them and ensure the code behaves as expected (in this case there were situations where these two wouldn't get called when returning from the background) - dependencies[singleton: .storage].resumeDatabaseAccess() - dependencies.mutate(cache: .libSessionNetwork) { $0.resumeNetworkAccess() } - - ensureRootViewController(calledFrom: .didBecomeActive) + Task { + dependencies[singleton: .storage].resumeDatabaseAccess() + await ensureRootViewController(calledFrom: .didBecomeActive) + } - dependencies[singleton: .appReadiness].runNowOrWhenAppDidBecomeReady { [weak self] in + dependencies[singleton: .appReadiness].runNowOrWhenAppDidBecomeReady { [weak self, dependencies] in + /// Wait for the app to be ready before resuming network access (this is mostly for initial launch which also calls + /// `applicationDidBecomeActive` (and if we don't want then the network can be started before any env variables + /// have been processed which could be inefficient if we immediately tear down and change the network) + Task { await dependencies[singleton: .network].resumeNetworkAccess() } + self?.handleActivation() /// Clear all notifications whenever we become active once the app is ready @@ -312,7 +224,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD /// It's likely that on a fresh launch that the `libSession` cache won't have been initialised by this point, so detatch a task to /// wait for it before checking the local network permission Task.detached { [dependencies] in - try? await dependencies.waitUntilInitialised(cache: .libSession) + if #available(iOS 16.0, *) { + try? await dependencies.waitUntilInitialised(cache: .libSession) + } + else { + /// iOS 15 doesn't support dependency observation so work around it with a loop + while true { + try? await Task.sleep(for: .milliseconds(500)) + + /// If `libSession` has data we can break + if !dependencies[cache: .libSession].isEmpty { + break + } + } + } if dependencies.mutate(cache: .libSession, { $0.get(.areCallsEnabled) }) && dependencies[defaults: .standard, key: .hasRequestedLocalNetworkPermission] { Permissions.checkLocalNetworkPermission(using: dependencies) @@ -360,12 +285,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD Log.appResumedExecution() Log.info(.backgroundPoller, "Starting background fetch.") - dependencies[singleton: .storage].resumeDatabaseAccess() - dependencies.mutate(cache: .libSessionNetwork) { $0.resumeNetworkAccess() } + Task { + dependencies[singleton: .storage].resumeDatabaseAccess() + await dependencies[singleton: .network].resumeNetworkAccess(autoReconnect: false) + } let queue: DispatchQueue = DispatchQueue(label: "com.session.backgroundPoll") let poller: BackgroundPoller = BackgroundPoller() - var cancellable: AnyCancellable? + var pollTask: Task? /// Background tasks only last for a certain amount of time (which can result in a crash and a prompt appearing for the user), /// we want to avoid this and need to make sure to suspend the database again before the background task ends so we start @@ -377,15 +304,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD let timer: DispatchSourceTimer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now() + .milliseconds(durationRemainingMs)) timer.setEventHandler { [poller, dependencies] in - guard cancellable != nil else { return } + guard pollTask != nil else { return } Log.info(.backgroundPoller, "Background poll failed due to manual timeout.") - cancellable?.cancel() + pollTask?.cancel() if dependencies[singleton: .appContext].isInBackground && !self.hasCallOngoing() { - dependencies.mutate(cache: .libSessionNetwork) { $0.suspendNetworkAccess() } - dependencies[singleton: .storage].suspendDatabaseAccess() - Log.flush() + Task { + await dependencies[singleton: .network].suspendNetworkAccess() + dependencies[singleton: .storage].suspendDatabaseAccess() + Log.flush() + } } _ = poller // Capture poller to ensure it doesn't go out of scope @@ -403,58 +332,145 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD /// /// **Note:** We **MUST** capture both `poller` and `timer` strongly in the completion handler to ensure neither /// go out of scope until we want them to (we essentually want a retain cycle in this case) - cancellable = poller - .poll(using: dependencies) - .subscribe(on: queue, using: dependencies) - .receive(on: queue, using: dependencies) - .sink( - receiveCompletion: { [timer, poller] result in - // Ensure we haven't timed out yet - guard timer.isCancelled == false else { return } - - // Immediately cancel the timer to prevent the timeout being triggered - timer.cancel() - - // Update the app badge in case the unread count changed - if - let unreadCount: Int = dependencies[singleton: .storage].read({ db in - try Interaction.fetchAppBadgeUnreadCount(db, using: dependencies) - }) - { - try? dependencies[singleton: .extensionHelper].saveUserMetadata( - sessionId: dependencies[cache: .general].sessionId, - ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey, - unreadCount: unreadCount - ) - - DispatchQueue.main.async(using: dependencies) { - UIApplication.shared.applicationIconBadgeNumber = unreadCount - } - } - - // If we are still running in the background then suspend the network & database - if dependencies[singleton: .appContext].isInBackground && !self.hasCallOngoing() { - dependencies.mutate(cache: .libSessionNetwork) { $0.suspendNetworkAccess() } - dependencies[singleton: .storage].suspendDatabaseAccess() - Log.flush() - } - - _ = poller // Capture poller to ensure it doesn't go out of scope - - // Complete the background task - switch result { - case .failure: completionHandler(.failed) - case .finished: completionHandler(.newData) - } - }, - receiveValue: { _ in } - ) + pollTask = Task(priority: .userInitiated) { [dependencies] in + let hadValidMessages: Bool = await poller.poll(using: dependencies) + + do { try Task.checkCancellation() } + catch { return } + + // Ensure we haven't timed out yet + guard timer.isCancelled == false else { return } + + // Immediately cancel the timer to prevent the timeout being triggered + timer.cancel() + + // Update the app badge in case the unread count changed + if + let unreadCount: Int = try? await dependencies[singleton: .storage].readAsync(value: { db in + try Interaction.fetchAppBadgeUnreadCount(db, using: dependencies) + }) + { + try? dependencies[singleton: .extensionHelper].saveUserMetadata( + sessionId: dependencies[cache: .general].sessionId, + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey, + unreadCount: unreadCount + ) + + await MainActor.run { + UIApplication.shared.applicationIconBadgeNumber = unreadCount + } + } + + // If we are still running in the background then suspend the network & database + if dependencies[singleton: .appContext].isInBackground && !self.hasCallOngoing() { + await dependencies[singleton: .network].suspendNetworkAccess() + dependencies[singleton: .storage].suspendDatabaseAccess() + Log.flush() + } + + // Complete the background task + completionHandler(hadValidMessages ? .newData : .failed) + } } } // MARK: - App Readiness - private func completePostMigrationSetup(calledFrom lifecycleMethod: LifecycleMethod) { + private func setupEnvironment(mainWindow: UIWindow) async { + var backgroundTask: SessionBackgroundTask? = SessionBackgroundTask(label: #function, using: dependencies) + + Log.setup(with: Logger(primaryPrefix: "Session", using: dependencies)) + Log.info(.cat, "Setting up environment.") + LibSession.setupLogger(using: dependencies) + + /// If we are running automated tests we should process environment variables before we do anything else + await DeveloperSettingsViewModel.processUnitTestEnvVariablesIfNeeded(using: dependencies) + + /// Setup the VoiP registry + dependencies[singleton: .pushRegistrationManager].createVoipRegistryIfNecessary() + + /// Prevent the device from sleeping during database view async registration (e.g. long database upgrades) + /// + /// This block will be cleared in storageIsReady + dependencies[singleton: .deviceSleepManager].addBlock(blockObject: self) + + do { + /// Initial app setup + try await AppSetup.performSetup(using: dependencies) + + /// Create a proper `SessionCallManager` for the main app (defaults to a no-op version) + dependencies.set(singleton: .callManager, to: SessionCallManager(using: dependencies)) + + /// Update state of current call + if dependencies[singleton: .callManager].currentCall == nil { + dependencies[defaults: .appGroup, key: .isCallOngoing] = false + dependencies[defaults: .appGroup, key: .lastCallPreOffer] = nil + } + + /// **Note:** We want to wait for these to complete before continuing + await MainActor.run { + dependencies[singleton: .screenLock].setupWithRootWindow(rootWindow: mainWindow) + OWSWindowManager.shared().setup( + withRootWindow: mainWindow, + screenBlockingWindow: dependencies[singleton: .screenLock].window, + backgroundWindowLevel: .background + ) + } + + try await AppSetup.performDatabaseMigrations(using: dependencies) { [weak self] progress, minEstimatedTotalTime in + self?.loadingViewController?.updateProgress( + progress: progress, + minEstimatedTotalTime: minEstimatedTotalTime + ) + } + try await AppSetup.postMigrationSetup(using: dependencies) + + /// Because the `SessionUIKit` target doesn't depend on the `SessionUtilitiesKit` dependency (it shouldn't + /// need to since it should just be UI) but since the theme settings are stored in the database we need to pass these through + /// to `SessionUIKit` and expose a mechanism to save updated settings - this is done here (once the migrations complete) + await MainActor.run { + SNUIKit.configure( + with: SessionSNUIKitConfig(using: dependencies), + themeSettings: dependencies.mutate(cache: .libSession) { cache -> ThemeSettings in + ( + cache.get(.theme), + cache.get(.themePrimaryColor), + cache.get(.themeMatchSystemDayNightCycle) + ) + } + ) + } + + /// Adding this to prevent new users being asked for local network permission in the wrong order in the permission chain. + /// We need to check the local nework permission status every time the app is activated to refresh the UI in Settings screen. + /// And after granting or denying a system permission request will trigger the local nework permission status check in applicationDidBecomeActive(:) + /// The only way we can check the status of local network permission will trigger the system prompt to ask for the permission. + /// So we need this to keep it the correct order of the permission chain. + /// For users who already enabled the calls permission and made calls, the local network permission should already be asked for. + /// It won't affect anything. + dependencies[defaults: .standard, key: .hasRequestedLocalNetworkPermission] = dependencies.mutate(cache: .libSession) { cache in + cache.get(.areCallsEnabled) + } + + /// Now that the theme settings have been applied we can complete the migrations + Log.info(.cat, "Environment setup complete.") + await self.completePostMigrationSetup(calledFrom: .finishLaunching) + } + catch { + await MainActor.run { [weak self] in + self?.initialLaunchFailed = true + self?.showFailedStartupAlert( + calledFrom: .finishLaunching, + error: .databaseError(error) + ) + } + } + + /// The 'if' is only there to prevent the "variable never read" warning from showing + if backgroundTask != nil { backgroundTask = nil } + } + + private func completePostMigrationSetup(calledFrom lifecycleMethod: LifecycleMethod) async { Log.info(.cat, "Migrations completed, performing setup and ensuring rootViewController") dependencies[singleton: .jobRunner].setExecutor(SyncPushTokensJob.self, for: .syncPushTokens) @@ -468,20 +484,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD /// /// **Note:** This **MUST** be called before `dependencies[singleton: .appReadiness].setAppReady()` is /// called otherwise a user tapping on a notification may not open the conversation showing the message - dependencies[singleton: .extensionHelper].willLoadMessages() - Task(priority: .medium) { [dependencies] in do { try await dependencies[singleton: .extensionHelper].loadMessages() } catch { Log.error(.cat, "Failed to load messages from extensions: \(error)") } } // Setup the UI if needed, then trigger any post-UI setup actions - self.ensureRootViewController(calledFrom: lifecycleMethod) { [weak self, dependencies] success in + await self.ensureRootViewController(calledFrom: lifecycleMethod) { [weak self, dependencies] success in // If we didn't successfully ensure the rootViewController then don't continue as // the user is in an invalid state (and should have already been shown a modal) guard success else { return } - Log.info(.cat, "RootViewController ready for state: \(dependencies[cache: .onboarding].state), readying remaining processes") + let onboardingState: Onboarding.State = await dependencies[singleton: .onboarding].state + .first(defaultValue: .unknown) + Log.info(.cat, "RootViewController ready for state: \(onboardingState), readying remaining processes") self?.initialLaunchFailed = false /// Trigger any launch-specific jobs and start the JobRunner with `jobRunner.appDidFinishLaunching(using:)` some @@ -510,7 +526,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD dependencies.mutate(cache: .appVersion) { $0.mainAppLaunchDidComplete() } /// App won't be ready for extensions and no need to enqueue a config sync unless we successfully completed startup - dependencies[singleton: .storage].writeAsync { db in + try? await dependencies[singleton: .storage].writeAsync { db in /// Increment the launch count (guaranteed to change which results in the write actually doing something and /// outputting and error if the DB is suspended) db[.activeCounter] = ((db[.activeCounter] ?? 0) + 1) @@ -519,7 +535,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD /// ensure that any pending local state gets pushed and any jobs waiting for a successful config sync are run /// /// **Note:** We only want to do this if the app is active, and the user has completed the Onboarding process - if dependencies[singleton: .appContext].isAppForegroundAndActive && dependencies[cache: .onboarding].state == .completed { + if dependencies[singleton: .appContext].isAppForegroundAndActive && onboardingState == .completed { dependencies.mutate(cache: .libSession) { $0.syncAllPendingPushes(db) } } } @@ -595,7 +611,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD message: "databaseErrorRestoreDataWarning".localized(), preferredStyle: .alert ) - alert.addAction(UIAlertAction(title: "clear".localized(), style: .destructive) { _ in + alert.addAction(UIAlertAction(title: "clear".localized(), style: .destructive) { [weak self] _ in // Reset the current database for a clean migration dependencies[singleton: .storage].resetForCleanMigration() @@ -603,29 +619,27 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD TopBannerController.hide() // The re-run the migration (should succeed since there is no data) - AppSetup.runPostSetupMigrations( - migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in - self?.loadingViewController?.updateProgress( - progress: progress, - minEstimatedTotalTime: minEstimatedTotalTime - ) - }, - migrationsCompletion: { [weak self] result in - switch result { - case .failure: - DispatchQueue.main.async { - self?.showFailedStartupAlert( - calledFrom: lifecycleMethod, - error: .failedToRestore - ) - } - - case .success: - self?.completePostMigrationSetup(calledFrom: lifecycleMethod) + Task(priority: .userInitiated) { [weak self] in + do { + try await AppSetup.performDatabaseMigrations(using: dependencies) { [weak self] progress, minEstimatedTotalTime in + self?.loadingViewController?.updateProgress( + progress: progress, + minEstimatedTotalTime: minEstimatedTotalTime + ) } - }, - using: dependencies - ) + try await AppSetup.postMigrationSetup(using: dependencies) + + await self?.completePostMigrationSetup(calledFrom: lifecycleMethod) + } + catch { + await MainActor.run { + self?.showFailedStartupAlert( + calledFrom: lifecycleMethod, + error: .failedToRestore + ) + } + } + } }) alert.addAction(UIAlertAction(title: "cancel".localized(), style: .default) { _ in @@ -696,18 +710,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD /// There is a warning which can happen on launch because the Database read can be blocked by another database operation /// which could result in this blocking the main thread, as a result we want to check the identity exists on a background thread /// and then return to the main thread only when required - DispatchQueue.global(qos: .default).async { [weak self, dependencies] in - guard dependencies[cache: .onboarding].state == .completed else { return } + Task(priority: .medium) { [weak self, dependencies] in + guard await dependencies[singleton: .onboarding].state.first() == .completed else { return } self?.enableBackgroundRefreshIfNecessary() dependencies[singleton: .jobRunner].appDidBecomeActive() - self?.startPollersIfNeeded() - - Network.SessionNetwork.client.initialize(using: dependencies) + /// Kick off polling and fetch the Session Network info in the background + Task { await self?.startPollersIfNeeded() } + Task { await dependencies[singleton: .sessionNetworkApiClient].fetchInfoInBackground() } if dependencies[singleton: .appContext].isMainApp { - DispatchQueue.main.async { + await MainActor.run { self?.handleAppActivatedWithOngoingCallIfNeeded() } } @@ -716,8 +730,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD private func ensureRootViewController( calledFrom lifecycleMethod: LifecycleMethod, - onComplete: @escaping ((Bool) -> Void) = { _ in } - ) { + onComplete: @escaping ((Bool) async -> Void) = { _ in } + ) async { let hasInitialRootViewController: Bool = self.hasInitialRootViewController // Always call the completion block and indicate whether we successfully created the UI @@ -729,18 +743,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD lifecycleMethod == .enterForeground(initialLaunchFailed: true) ) && !hasInitialRootViewController - else { return DispatchQueue.main.async { onComplete(hasInitialRootViewController) } } + else { return await onComplete(hasInitialRootViewController) } /// Start a timeout for the creation of the rootViewController setup process (if it takes too long then we want to give the user /// the option to export their logs) - let longRunningStartupTimoutCancellable: AnyCancellable = Just(()) - .delay(for: .seconds(AppDelegate.maxRootViewControllerInitialQueryDuration), scheduler: DispatchQueue.main) - .sink( - receiveCompletion: { _ in }, - receiveValue: { [weak self] _ in - self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .startupTimeout) - } - ) + let startupTimeoutTask: Task = Task { [weak self] in + try? await Task.sleep(for: .seconds(Int(AppDelegate.maxRootViewControllerInitialQueryDuration))) + guard !Task.isCancelled else { return } + + await MainActor.run { [weak self] in + self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .startupTimeout) + } + } // All logic which needs to run after the 'rootViewController' is created let rootViewControllerSetupComplete: (UIViewController) -> Void = { [weak self, dependencies] rootViewController in @@ -793,52 +807,57 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } // Setup is completed so run any post-setup tasks - onComplete(true) + Task(priority: .high) { await onComplete(true) } } // Navigate to the approriate screen depending on the onboarding state - dependencies.warmCache(cache: .onboarding) + try? await dependencies[singleton: .onboarding].loadInitialState() - switch dependencies[cache: .onboarding].state { - case .noUser, .noUserInvalidKeyPair, .noUserInvalidSeedGeneration: - if dependencies[cache: .onboarding].state == .noUserInvalidKeyPair { + switch await dependencies[singleton: .onboarding].state.first() { + case .none, .unknown, .noUser, .noUserInvalidKeyPair, .noUserInvalidSeedGeneration: + if await dependencies[singleton: .onboarding].state.first() == .noUserInvalidKeyPair { Log.critical(.cat, "Failed to load credentials for existing user, generated a new identity.") } - else if dependencies[cache: .onboarding].state == .noUserInvalidSeedGeneration { + else if await dependencies[singleton: .onboarding].state.first() == .noUserInvalidSeedGeneration { Log.critical(.cat, "Failed to create an initial identity for a potentially new user.") } - DispatchQueue.main.async { [dependencies] in + await MainActor.run { [dependencies] in /// Once the onboarding process is complete we need to call `handleActivation` let viewController = SessionHostingViewController(rootView: LandingScreen(using: dependencies) { [weak self] in self?.handleActivation() }) viewController.setUpNavBarSessionIcon() - longRunningStartupTimoutCancellable.cancel() + startupTimeoutTask.cancel() rootViewControllerSetupComplete(viewController) } case .missingName: - DispatchQueue.main.async { [dependencies] in - let viewController = SessionHostingViewController(rootView: DisplayNameScreen(using: dependencies)) + let initialFlow: Onboarding.Flow = await dependencies[singleton: .onboarding].initialFlow + + await MainActor.run { [dependencies] in + let viewController = SessionHostingViewController( + rootView: DisplayNameScreen(flow: initialFlow, using: dependencies) + ) viewController.setUpNavBarSessionIcon() - longRunningStartupTimoutCancellable.cancel() + startupTimeoutTask.cancel() rootViewControllerSetupComplete(viewController) /// Once the onboarding process is complete we need to call `handleActivation` - dependencies[cache: .onboarding].onboardingCompletePublisher - .subscribe(on: DispatchQueue.main, using: dependencies) - .receive(on: DispatchQueue.main, using: dependencies) - .sinkUntilComplete(receiveCompletion: { [weak self] _ in self?.handleActivation() }) + Task(priority: .userInitiated) { [weak self] in + if let _ = await dependencies[singleton: .onboarding].state.first(where: { $0 == .completed }) { + self?.handleActivation() + } + } } case .completed: - DispatchQueue.main.async { [dependencies] in + await MainActor.run { [dependencies] in /// We want to start observing the changes for the 'HomeVC' and want to wait until we actually get data back before we /// continue as we don't want to show a blank home screen let viewController: HomeVC = HomeVC(using: dependencies) viewController.afterInitialConversationsLoaded { - longRunningStartupTimoutCancellable.cancel() + startupTimeoutTask.cancel() rootViewControllerSetupComplete(viewController) } } @@ -894,10 +913,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { dependencies[singleton: .appReadiness].runNowOrWhenAppDidBecomeReady { [dependencies] in - guard dependencies[cache: .onboarding].state == .completed else { return } - - dependencies[singleton: .app].createNewConversation() - completionHandler(true) + Task(priority: .userInitiated) { + guard await dependencies[singleton: .onboarding].state.first() == .completed else { return } + + await MainActor.run { [dependencies] in + dependencies[singleton: .app].createNewConversation() + } + completionHandler(true) + } } } @@ -979,27 +1002,23 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // MARK: - Polling - public func startPollersIfNeeded() { - guard dependencies[cache: .onboarding].state == .completed else { return } - - /// Start the pollers on a background thread so that any database queries they need to run don't - /// block the main thread - DispatchQueue.global(qos: .background).async { [dependencies] in - dependencies[singleton: .currentUserPoller].startIfNeeded() - dependencies.mutate(cache: .groupPollers) { $0.startAllPollers() } - dependencies.mutate(cache: .communityPollers) { $0.startAllPollers() } - } + public func startPollersIfNeeded() async { + guard await dependencies[singleton: .onboarding].state.first() == .completed else { return } + + await dependencies[singleton: .currentUserPoller].startIfNeeded() + await dependencies[singleton: .groupPollerManager].startAllPollers() + await dependencies[singleton: .communityPollerManager].startAllPollers() } - public func stopPollers(shouldStopUserPoller: Bool = true) { - guard dependencies[cache: .onboarding].state == .completed else { return } + public func stopPollers(shouldStopUserPoller: Bool = true) async { + guard await dependencies[singleton: .onboarding].state.first() == .completed else { return } if shouldStopUserPoller { - dependencies[singleton: .currentUserPoller].stop() + await dependencies[singleton: .currentUserPoller].stop() } - - dependencies.mutate(cache: .groupPollers) { $0.stopAndRemoveAllPollers() } - dependencies.mutate(cache: .communityPollers) { $0.stopAndRemoveAllPollers() } + + await dependencies[singleton: .groupPollerManager].stopAndRemoveAllPollers() + await dependencies[singleton: .communityPollerManager].stopAndRemoveAllPollers() } // MARK: - App Link diff --git a/Session/Meta/Session+SNUIKit.swift b/Session/Meta/Session+SNUIKit.swift index 2c7a2aedfe..4f1dfb8154 100644 --- a/Session/Meta/Session+SNUIKit.swift +++ b/Session/Meta/Session+SNUIKit.swift @@ -39,7 +39,7 @@ internal struct SessionSNUIKitConfig: SNUIKit.ConfigType { func navBarSessionIcon() -> NavBarSessionIcon { switch (dependencies[feature: .serviceNetwork], dependencies[feature: .forceOffline]) { case (.mainnet, false): return NavBarSessionIcon() - case (.testnet, _), (.mainnet, true): + case (.testnet, _), (.devnet, _), (.mainnet, true): return NavBarSessionIcon( showDebugUI: true, serviceNetworkTitle: dependencies[feature: .serviceNetwork].title, diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index e311b2ed71..43eadc4c9a 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -12,7 +12,7 @@ import SessionUIKit public extension Singleton { static let app: SingletonConfig = Dependencies.create( identifier: "app", - createInstance: { dependencies in SessionApp(using: dependencies) } + createInstance: { dependencies, _ in SessionApp(using: dependencies) } ) } @@ -108,7 +108,7 @@ public class SessionApp: SessionAppType { ) } - public func createNewConversation() { + @MainActor public func createNewConversation() { guard let homeViewController: HomeVC = self.homeViewController else { return } let viewController = SessionHostingViewController( @@ -126,16 +126,13 @@ public class SessionApp: SessionAppType { homeViewController.present(navigationController, animated: true, completion: nil) } - public func resetData(onReset: (() -> ())) { + public func resetData(onReset: (() async -> ())) async { homeViewController = nil dependencies.remove(cache: .general) - dependencies.remove(cache: .snodeAPI) dependencies.remove(cache: .libSession) - dependencies.mutate(cache: .libSessionNetwork) { - $0.suspendNetworkAccess() - $0.clearSnodeCache() - $0.clearCallbacks() - } + await dependencies[singleton: .network].suspendNetworkAccess() + await dependencies[singleton: .network].clearCache() + dependencies.remove(singleton: .network) dependencies[singleton: .storage].resetAllStorage() dependencies[singleton: .extensionHelper].deleteCache() dependencies[singleton: .displayPictureManager].resetStorage() @@ -144,14 +141,14 @@ public class SessionApp: SessionAppType { try? dependencies[singleton: .keychain].removeAll() UserDefaults.removeAll(using: dependencies) - onReset() + await onReset() LibSession.clearLoggers() Log.info("Data Reset Complete.") Log.flush() /// Wait until the next run loop to kill the app (hoping to avoid a crash due to the connection closes /// triggering logs) - DispatchQueue.main.async { + await MainActor.run { exit(0) } } @@ -251,11 +248,11 @@ public protocol SessionAppType { dismissing presentingViewController: UIViewController?, animated: Bool ) - func createNewConversation() - func resetData(onReset: (() -> ())) + @MainActor func createNewConversation() + func resetData(onReset: (() async -> ())) async @MainActor func showPromotedScreen() } public extension SessionAppType { - func resetData() { resetData(onReset: {}) } + func resetData() async { await resetData(onReset: {}) } } diff --git a/Session/Meta/Settings.bundle/ThirdPartyLicenses.plist b/Session/Meta/Settings.bundle/ThirdPartyLicenses.plist index 3848270117..53b5af3e6a 100644 --- a/Session/Meta/Settings.bundle/ThirdPartyLicenses.plist +++ b/Session/Meta/Settings.bundle/ThirdPartyLicenses.plist @@ -6,7 +6,7 @@ License BSD 3-Clause License -Copyright (c) 2010-2024, Deusty, LLC +Copyright (c) 2010-2025, 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: @@ -1664,6 +1664,491 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. Title session-lucide + + License + 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 + swift-algorithms + + + License + 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 + swift-argument-parser + + + License + .gitignore +**/.gitignore +.licenseignore +.gitattributes +.git-blame-ignore-revs +.mailfilter +.mailmap +.spi.yml +.swift-format +.editorconfig +.github/* +*.md +*.mdoc +*.txt +*.yml +*.yaml +*.json +*.png +*.bash +*.cmake +*.cmake.in +Package.swift +**/Package.swift +Package@*.swift +**/Package@*.swift +Package.resolved +**/Package.resolved +.unacceptablelanguageignore +**/Snapshots/* + + Title + swift-argument-parser - . + + + License + @@===----------------------------------------------------------------------===@@ +@@ +@@ This source file is part of the Swift Argument Parser open source project +@@ +@@ Copyright (c) YEARS Apple Inc. and the Swift project authors +@@ Licensed under Apache License v2.0 with Runtime Library Exception +@@ +@@ See https://swift.org/LICENSE.txt for license information +@@ +@@===----------------------------------------------------------------------===@@ + + Title + swift-argument-parser - . + License @@ -2076,6 +2561,223 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 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 + swift-numerics + + + License + 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: ## diff --git a/Session/Notifications/NotificationActionHandler.swift b/Session/Notifications/NotificationActionHandler.swift index 74b18f89f8..c9396a41ad 100644 --- a/Session/Notifications/NotificationActionHandler.swift +++ b/Session/Notifications/NotificationActionHandler.swift @@ -13,7 +13,7 @@ import SessionUtilitiesKit public extension Singleton { static let notificationActionHandler: SingletonConfig = Dependencies.create( identifier: "notificationActionHandler", - createInstance: { dependencies in NotificationActionHandler(using: dependencies) } + createInstance: { dependencies, _ in NotificationActionHandler(using: dependencies) } ) } @@ -154,7 +154,7 @@ public class NotificationActionHandler { throw NotificationError.failDebug("unable to find thread with id: \(threadId)") } - let sentTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let sentTimestampMs: Int64 = dependencies.networkOffsetTimestampMs() let destinationDisappearingMessagesConfiguration: DisappearingMessagesConfiguration? = try? DisappearingMessagesConfiguration .filter(id: threadId) .filter(DisappearingMessagesConfiguration.Columns.isEnabled == true) diff --git a/Session/Notifications/NotificationPresenter.swift b/Session/Notifications/NotificationPresenter.swift index c335c898bd..4bedb1af9e 100644 --- a/Session/Notifications/NotificationPresenter.swift +++ b/Session/Notifications/NotificationPresenter.swift @@ -31,10 +31,23 @@ public class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate, /// Populate the notification settings from `libSession` and the database Task.detached(priority: .high) { [weak self] in - do { try await dependencies.waitUntilInitialised(cache: .libSession) } - catch { - Log.error("[NotificationPresenter] Failed to wait until libSession initialised: \(error)") - return + if #available(iOS 16.0, *) { + do { try await dependencies.waitUntilInitialised(cache: .libSession) } + catch { + Log.error("[NotificationPresenter] Failed to wait until libSession initialised: \(error)") + return + } + } + else { + /// iOS 15 doesn't support dependency observation so work around it with a loop + while true { + try? await Task.sleep(for: .milliseconds(500)) + + /// If `libSession` has data we can break + if !dependencies[cache: .libSession].isEmpty { + break + } + } } typealias GlobalSettings = ( @@ -193,7 +206,7 @@ public class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate, } } - public func notificationUserInfo(threadId: String, threadVariant: SessionThread.Variant) -> [String: Any] { + public func notificationUserInfo(threadId: String, threadVariant: SessionThread.Variant) -> [String: AnyHashable] { return [ NotificationUserInfoKey.threadId: threadId, NotificationUserInfoKey.threadVariantRaw: threadVariant.rawValue diff --git a/Session/Notifications/PushRegistrationManager.swift b/Session/Notifications/PushRegistrationManager.swift index 8a64f2e822..fa5a838279 100644 --- a/Session/Notifications/PushRegistrationManager.swift +++ b/Session/Notifications/PushRegistrationManager.swift @@ -13,7 +13,7 @@ import SessionUtilitiesKit public extension Singleton { static let pushRegistrationManager: SingletonConfig = Dependencies.create( identifier: "pushRegistrationManager", - createInstance: { dependencies in PushRegistrationManager(using: dependencies) } + createInstance: { dependencies, _ in PushRegistrationManager(using: dependencies) } ) } @@ -301,13 +301,15 @@ public class PushRegistrationManager: NSObject, PKPushRegistryDelegate { Log.info(.calls, "Succeeded to report incoming call to CallKit") dependencies[singleton: .storage].resumeDatabaseAccess() - dependencies.mutate(cache: .libSessionNetwork) { $0.resumeNetworkAccess() } + Task { [network = dependencies[singleton: .network]] in await network.resumeNetworkAccess() } dependencies[singleton: .jobRunner].appDidBecomeActive() - dependencies[singleton: .appReadiness].runNowOrWhenAppDidBecomeReady { [dependencies] in + dependencies[singleton: .appReadiness].runNowOrWhenAppDidBecomeReady { [poller = dependencies[singleton: .currentUserPoller]] in // NOTE: Just start 1-1 poller so that it won't wait for polling group messages - dependencies[singleton: .currentUserPoller].startIfNeeded(forceStartInBackground: true) + Task(priority: .userInitiated) { [poller] in + await poller.startIfNeeded(forceStartInBackground: true) + } } } } diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index 5d32a61072..a1196f5187 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -34,7 +34,7 @@ public enum SyncPushTokensJob: JobExecutor { guard dependencies[defaults: .appGroup, key: .isMainAppActive] else { return deferred(job) // Don't need to do anything if it's not the main app } - guard dependencies[cache: .onboarding].state == .completed else { + guard dependencies[singleton: .onboarding].syncState.state == .completed else { Log.info(.syncPushTokensJob, "Deferred due to incomplete registration") return deferred(job) } @@ -66,45 +66,44 @@ 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 { - dependencies[singleton: .storage] - .readPublisher { db in db[.lastRecordedPushToken] } - .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[singleton: .storage].write { db in - db[.lastRecordedPushToken] = nil + Task { + // Get the last token we subscribed with + let lastRecordedPushToken: String? = try? await dependencies[singleton: .storage].readAsync { db in + db[.lastRecordedPushToken] + } + + // 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) + await UIApplication.shared.unregisterForRemoteNotifications() + + // Clear the old token + try? await dependencies[singleton: .storage].writeAsync { db in + db[.lastRecordedPushToken] = nil + } + + // Unregister from our server + if let existingToken: String = lastRecordedPushToken { + Log.info(.syncPushTokensJob, "Unregister using last recorded push token: \(redact(existingToken))") + do { + try await Network.PushNotification.unsubscribeAll( + token: Data(hex: existingToken), + using: dependencies + ) + Log.info(.syncPushTokensJob, "Unregister Completed") } - - // Unregister from our server - if let existingToken: String = lastRecordedPushToken { - Log.info(.syncPushTokensJob, "Unregister using last recorded push token: \(redact(existingToken))") - return Network.PushNotification - .unsubscribeAll(token: Data(hex: existingToken), using: dependencies) - .map { _ in () } - .eraseToAnyPublisher() + catch { + Log.error(.syncPushTokensJob, "Unregister Failed with error: \(error)") } - + return + } + else { Log.info(.syncPushTokensJob, "No previous token stored just triggering device unregister") - return Just(()) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() } - .subscribe(on: scheduler, using: dependencies) - .sinkUntilComplete( - receiveCompletion: { result in - switch result { - case .finished: Log.info(.syncPushTokensJob, "Unregister Completed") - case .failure: Log.error(.syncPushTokensJob, "Unregister Failed") - } - - // We want to complete this job regardless of success or failure - success(job, false) - } - ) + + // We want to complete this job regardless of success or failure + success(job, false) + } return } @@ -113,101 +112,104 @@ public enum SyncPushTokensJob: JobExecutor { /// **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 Log.info(.syncPushTokensJob, "Re-registering for remote notifications") + + // FIXME: We should refactor this entire process to be async/await dependencies[singleton: .pushRegistrationManager].requestPushTokens() - .flatMap { (pushToken: String, voipToken: String) -> AnyPublisher<(String, String)?, Error> in - Log.info(.syncPushTokensJob, "Received push and voip tokens, waiting for paths to build") - - return dependencies[cache: .libSessionNetwork].paths - .filter { !$0.isEmpty } - .first() // Only listen for the first callback - .map { _ in (pushToken, voipToken) } - .setFailureType(to: Error.self) - .timeout( - .seconds(5), // Give the paths a chance to build on launch - scheduler: scheduler, - customError: { NetworkError.timeout(error: "", rawData: nil) } - ) - .catch { error -> AnyPublisher<(String, String)?, Error> in - switch error { - case NetworkError.timeout: - Log.info(.syncPushTokensJob, "OS subscription completed, skipping server subscription due to path build timeout") - return Just(nil).setFailureType(to: Error.self).eraseToAnyPublisher() + .sinkUntilComplete( + receiveCompletion: { result in + /// Just succeed on failure + switch result { + case .finished: break + case .failure: success(job, false) + } + }, + receiveValue: { (pushToken: String, voipToken: String) in + Task { + let hasConnection: Bool = await withThrowingTaskGroup { group in + group.addTask { + try await dependencies.waitUntilConnected(onWillStartWaiting: { + Log.info(.syncPushTokensJob, "Waiting for network to connect.") + }) + } + group.addTask { + /// Give the paths a chance to build on launch/ + try await Task.sleep(for: .seconds(5)) + throw NetworkError.timeout(error: "", rawData: nil) + } - default: return Fail(error: error).eraseToAnyPublisher() + let output: Result? = await group.nextResult() + group.cancelAll() + + switch output { + case .failure, .none: return false + case .success: return true + } } - } - .eraseToAnyPublisher() - } - .flatMapStorageReadPublisher(using: dependencies) { db, tokenInfo -> (String?, (String, String)?) in - (db[.lastRecordedPushToken], tokenInfo) - } - .flatMap { (lastRecordedPushToken: String?, tokenInfo: (String, String)?) -> AnyPublisher in - guard let (pushToken, voipToken): (String, String) = tokenInfo else { - return Just(()) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - /// For our `subscribe` endpoint we only want to call it if: - /// • It's been longer than `SyncPushTokensJob.maxFrequency` since the last successful subscription; - /// • The token has changed; or - /// • We want to force an update - let timeSinceLastSuccessfulUpload: TimeInterval = dependencies.dateNow - .timeIntervalSince( - Date(timeIntervalSince1970: dependencies[defaults: .standard, key: .lastDeviceTokenUpload]) - ) - let uploadOnlyIfStale: Bool? = { - guard - let detailsData: Data = job.details, - let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) - else { return nil } - - return details.uploadOnlyIfStale - }() - - guard - timeSinceLastSuccessfulUpload >= SyncPushTokensJob.maxFrequency || - lastRecordedPushToken != pushToken || - uploadOnlyIfStale == false - else { - Log.info(.syncPushTokensJob, "OS subscription completed, skipping server subscription due to frequency") - return Just(()) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - Log.info(.syncPushTokensJob, "Sending push token to PN server") - return Network.PushNotification - .subscribeAll( - token: Data(hex: pushToken), - isForcedUpdate: true, - using: dependencies - ) - .retry(3, using: dependencies) - .handleEvents( - receiveCompletion: { result in - switch result { - case .failure(let error): - Log.error(.syncPushTokensJob, "Failed to register due to error: \(error)") + + /// Just log and succeed on failure + guard hasConnection else { + Log.info(.syncPushTokensJob, "OS subscription completed, skipping server subscription due to path build timeout") + return success(job, false) + } + + /// Get the last token we subscribed with + let lastRecordedPushToken: String? = try? await dependencies[singleton: .storage].readAsync { db in + db[.lastRecordedPushToken] + } + + /// For our `subscribe` endpoint we only want to call it if: + /// • It's been longer than `SyncPushTokensJob.maxFrequency` since the last successful subscription; + /// • The token has changed; or + /// • We want to force an update + let timeSinceLastSuccessfulUpload: TimeInterval = dependencies.dateNow + .timeIntervalSince( + Date(timeIntervalSince1970: dependencies[defaults: .standard, key: .lastDeviceTokenUpload]) + ) + let uploadOnlyIfStale: Bool? = { + guard + let detailsData: Data = job.details, + let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) + else { return nil } + + return details.uploadOnlyIfStale + }() + + /// No need to re-subscribe on the push server + guard + timeSinceLastSuccessfulUpload >= SyncPushTokensJob.maxFrequency || + lastRecordedPushToken != pushToken || + uploadOnlyIfStale == false + else { + Log.info(.syncPushTokensJob, "OS subscription completed, skipping server subscription due to frequency") + return success(job, false) + } + + Log.info(.syncPushTokensJob, "Sending push token to PN server") + + /// Retry up to 3 times + for _ in 0..<3 { + do { + try await Network.PushNotification.subscribeAll( + token: Data(hex: pushToken), + isForcedUpdate: true, + using: dependencies + ) + Log.debug(.syncPushTokensJob, "Recording push tokens locally. pushToken: \(redact(pushToken)), voipToken: \(redact(voipToken))") + Log.info(.syncPushTokensJob, "Completed") - case .finished: - Log.debug(.syncPushTokensJob, "Recording push tokens locally. pushToken: \(redact(pushToken)), voipToken: \(redact(voipToken))") - Log.info(.syncPushTokensJob, "Completed") - - dependencies[singleton: .storage].write { db in - db[.lastRecordedPushToken] = pushToken - db[.lastRecordedVoipToken] = voipToken - } + dependencies[singleton: .storage].write { db in + db[.lastRecordedPushToken] = pushToken + db[.lastRecordedVoipToken] = voipToken + } + break } + catch { Log.error(.syncPushTokensJob, "Failed to register due to error: \(error)") } } - ) - .map { _ in () } - .eraseToAnyPublisher() - } - .subscribe(on: scheduler, using: dependencies) - .sinkUntilComplete( - // We want to complete this job regardless of success or failure - receiveCompletion: { _ in success(job, false) } + + /// We want to complete this job regardless of success or failure + return success(job, false) + } + } ) } diff --git a/Session/Onboarding/DisplayNameScreen.swift b/Session/Onboarding/DisplayNameScreen.swift index 78e98ae7c7..9af617eefc 100644 --- a/Session/Onboarding/DisplayNameScreen.swift +++ b/Session/Onboarding/DisplayNameScreen.swift @@ -13,9 +13,11 @@ struct DisplayNameScreen: View { @State private var error: String? = nil private let dependencies: Dependencies + private let initialFlow: Onboarding.Flow - public init(using dependencies: Dependencies) { + public init(flow: Onboarding.Flow, using dependencies: Dependencies) { self.dependencies = dependencies + self.initialFlow = flow } var body: some View { @@ -28,7 +30,7 @@ struct DisplayNameScreen: View { ) { Spacer(minLength: 0) - let title: String = (dependencies[cache: .onboarding].initialFlow == .register ? + let title: String = (initialFlow == .register ? "displayNamePick".localized() : "displayNameNew".localized() ) @@ -40,7 +42,7 @@ struct DisplayNameScreen: View { Spacer(minLength: 0) .frame(maxHeight: 2 * Values.mediumSpacing) - let explanation: String = (dependencies[cache: .onboarding].initialFlow == .register ? + let explanation: String = (initialFlow == .register ? "displayNameDescription".localized() : "displayNameErrorNew".localized() ) @@ -127,46 +129,54 @@ struct DisplayNameScreen: View { return } - // Store the new name in the onboarding cache - dependencies.mutate(cache: .onboarding) { $0.setDisplayName(displayName) } - - // If we are not in the registration flow then we are finished and should go straight - // to the home screen - guard dependencies[cache: .onboarding].initialFlow == .register else { - return dependencies.mutate(cache: .onboarding) { [dependencies] onboarding in + Task(priority: .userInitiated) { + // Store the new name in the onboarding cache + await dependencies[singleton: .onboarding].setDisplayName(displayName) + + // If we are not in the registration flow then we are finished and should go straight + // to the home screen + guard initialFlow == .register else { // If the `initialFlow` is `none` then it means the user is just providing a missing displayName // and so shouldn't change the APNS setting, otherwise we should base it on the users selection // during the onboarding process - let shouldSyncPushTokens: Bool = (onboarding.initialFlow != .none && onboarding.useAPNS) - - onboarding.completeRegistration { - // Trigger the 'SyncPushTokensJob' directly as we don't want to wait for paths to build - // before requesting the permission from the user - if shouldSyncPushTokens { - SyncPushTokensJob - .run(uploadOnlyIfStale: false, using: dependencies) - .sinkUntilComplete() - } + let shouldSyncPushTokens: Bool = await { + guard initialFlow != .none else { return false } - // Go to the home screen + return await dependencies[singleton: .onboarding].useAPNS + }() + + await dependencies[singleton: .onboarding].completeRegistration() + + // Trigger the 'SyncPushTokensJob' directly as we don't want to wait for paths to build + // before requesting the permission from the user + if shouldSyncPushTokens { + SyncPushTokensJob + .run(uploadOnlyIfStale: false, using: dependencies) + .sinkUntilComplete() + } + + // Go to the home screen + return await MainActor.run { let homeVC: HomeVC = HomeVC(using: dependencies) dependencies[singleton: .app].setHomeViewController(homeVC) self.host.controller?.navigationController?.setViewControllers([ homeVC ], animated: true) } } + + // Need to get the PN mode if registering + await MainActor.run { + let viewController: SessionHostingViewController = SessionHostingViewController( + rootView: PNModeScreen(using: dependencies) + ) + viewController.setUpNavBarSessionIcon() + self.host.controller?.navigationController?.pushViewController(viewController, animated: true) + } } - - // Need to get the PN mode if registering - let viewController: SessionHostingViewController = SessionHostingViewController( - rootView: PNModeScreen(using: dependencies) - ) - viewController.setUpNavBarSessionIcon() - self.host.controller?.navigationController?.pushViewController(viewController, animated: true) } } struct DisplayNameView_Previews: PreviewProvider { static var previews: some View { - DisplayNameScreen(using: Dependencies.createEmpty()) + DisplayNameScreen(flow: .register, using: Dependencies.createEmpty()) } } diff --git a/Session/Onboarding/LandingScreen.swift b/Session/Onboarding/LandingScreen.swift index c94d6624e9..bd1a2ca95c 100644 --- a/Session/Onboarding/LandingScreen.swift +++ b/Session/Onboarding/LandingScreen.swift @@ -10,39 +10,26 @@ struct LandingScreen: View { public class ViewModel { fileprivate let dependencies: Dependencies private let onOnboardingComplete: () -> () - private var disposables: Set = Set() + private var onboardingStateObservationTask: Task? init(onOnboardingComplete: @escaping () -> Void, using dependencies: Dependencies) { self.dependencies = dependencies self.onOnboardingComplete = onOnboardingComplete } - fileprivate func register(setupComplete: () -> ()) { - // Reset the Onboarding cache to create a new user (in case the user previously went back) - dependencies.set(cache: .onboarding, to: Onboarding.Cache(flow: .register, using: dependencies)) + fileprivate func setupFor(flow: Onboarding.Flow) async { + /// Reset the Onboarding cache to create a new user (in case the user previously went back) + let onboarding: Onboarding.Manager = Onboarding.Manager(flow: flow, using: dependencies) + try? await onboarding.loadInitialState() + dependencies.set(singleton: .onboarding, to: onboarding) /// Once the onboarding process is complete we need to call `onOnboardingComplete` - dependencies[cache: .onboarding].onboardingCompletePublisher - .subscribe(on: DispatchQueue.main, using: dependencies) - .receive(on: DispatchQueue.main, using: dependencies) - .sink(receiveValue: { [weak self] _ in self?.onOnboardingComplete() }) - .store(in: &disposables) - - setupComplete() - } - - fileprivate func restore(setupComplete: () -> ()) { - // Reset the Onboarding cache to create a new user (in case the user previously went back) - dependencies.set(cache: .onboarding, to: Onboarding.Cache(flow: .restore, using: dependencies)) - - /// Once the onboarding process is complete we need to call `onOnboardingComplete` - dependencies[cache: .onboarding].onboardingCompletePublisher - .subscribe(on: DispatchQueue.main, using: dependencies) - .receive(on: DispatchQueue.main, using: dependencies) - .sink(receiveValue: { [weak self] _ in self?.onOnboardingComplete() }) - .store(in: &disposables) - - setupComplete() + onboardingStateObservationTask?.cancel() + onboardingStateObservationTask = Task { [weak self, dependencies] in + if let _ = await dependencies[singleton: .onboarding].state.first(where: { $0 == .completed }) { + self?.onOnboardingComplete() + } + } } } @@ -158,22 +145,28 @@ struct LandingScreen: View { } private func register() { - viewModel.register { - let viewController: SessionHostingViewController = SessionHostingViewController( - rootView: DisplayNameScreen(using: viewModel.dependencies) - ) - viewController.setUpNavBarSessionIcon() - self.host.controller?.navigationController?.pushViewController(viewController, animated: true) + Task(priority: .userInitiated) { + await viewModel.setupFor(flow: .register) + await MainActor.run { + let viewController: SessionHostingViewController = SessionHostingViewController( + rootView: DisplayNameScreen(flow: .register, using: viewModel.dependencies) + ) + viewController.setUpNavBarSessionIcon() + self.host.controller?.navigationController?.pushViewController(viewController, animated: true) + } } } private func restore() { - viewModel.restore { - let viewController: SessionHostingViewController = SessionHostingViewController( - rootView: LoadAccountScreen(using: viewModel.dependencies) - ) - viewController.setNavBarTitle("loadAccount".localized()) - self.host.controller?.navigationController?.pushViewController(viewController, animated: true) + Task(priority: .userInitiated) { + await viewModel.setupFor(flow: .restore) + await MainActor.run { + let viewController: SessionHostingViewController = SessionHostingViewController( + rootView: LoadAccountScreen(using: viewModel.dependencies) + ) + viewController.setNavBarTitle("loadAccount".localized()) + self.host.controller?.navigationController?.pushViewController(viewController, animated: true) + } } } diff --git a/Session/Onboarding/LoadAccountScreen.swift b/Session/Onboarding/LoadAccountScreen.swift index 7bd02faebe..0039ae9bdc 100644 --- a/Session/Onboarding/LoadAccountScreen.swift +++ b/Session/Onboarding/LoadAccountScreen.swift @@ -54,29 +54,33 @@ struct LoadAccountScreen: View { } private func continueWithSeed(seed: Data, from source: Onboarding.SeedSource, onSuccess: (() -> ())?, onError: (() -> ())?) { - do { - guard seed.count == 16 else { throw Mnemonic.DecodingError.generic } + Task(priority: .userInitiated) { + do { + guard seed.count == 16 else { throw Mnemonic.DecodingError.generic } + + try await dependencies[singleton: .onboarding].setSeedData(seed) + } + catch { + errorString = source.genericErrorMessage + try await Task.sleep(for: .seconds(1)) + await MainActor.run { onError?() } + return + } - try dependencies.mutate(cache: .onboarding) { try $0.setSeedData(seed) } - } - catch { - errorString = source.genericErrorMessage - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - onError?() + Task { + try await Task.sleep(for: .seconds(1)) + await MainActor.run { onSuccess?() } + } + + await MainActor.run { + // Otherwise continue on to request push notifications permissions + let viewController: SessionHostingViewController = SessionHostingViewController( + rootView: PNModeScreen(using: dependencies) + ) + viewController.setUpNavBarSessionIcon() + self.host.controller?.navigationController?.pushViewController(viewController, animated: true) } - return - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - onSuccess?() } - - // Otherwise continue on to request push notifications permissions - let viewController: SessionHostingViewController = SessionHostingViewController( - rootView: PNModeScreen(using: dependencies) - ) - viewController.setUpNavBarSessionIcon() - self.host.controller?.navigationController?.pushViewController(viewController, animated: true) } func continueWithHexEncodedSeed(onSuccess: (() -> ())?, onError: (() -> ())?) { diff --git a/Session/Onboarding/LoadingScreen.swift b/Session/Onboarding/LoadingScreen.swift index b731acafbd..3295344c0b 100644 --- a/Session/Onboarding/LoadingScreen.swift +++ b/Session/Onboarding/LoadingScreen.swift @@ -12,45 +12,51 @@ struct LoadingScreen: View { public class ViewModel { fileprivate let dependencies: Dependencies fileprivate let preview: Bool - fileprivate var profileRetrievalCancellable: AnyCancellable? + fileprivate let initialFlow: Onboarding.Flow + fileprivate var profileRetrievalTask: Task? - init(preview: Bool, using dependencies: Dependencies) { + init(preview: Bool, initialFlow: Onboarding.Flow, using dependencies: Dependencies) { self.preview = preview + self.initialFlow = initialFlow self.dependencies = dependencies } deinit { - profileRetrievalCancellable?.cancel() + profileRetrievalTask?.cancel() } fileprivate func observeProfileRetrieving(onComplete: @escaping (Bool) -> ()) { - profileRetrievalCancellable = dependencies[cache: .onboarding].displayNamePublisher - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .timeout(.seconds(15), scheduler: DispatchQueue.main, customError: { NetworkError.timeout(error: "", rawData: nil) }) - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { _ in }, - receiveValue: { displayName in onComplete(displayName?.isEmpty == false) } - ) - } - - fileprivate func completeRegistration(onComplete: @escaping () -> ()) { - dependencies.mutate(cache: .onboarding) { [dependencies] onboarding in - let shouldSyncPushTokens: Bool = onboarding.useAPNS - - onboarding.completeRegistration { - // Trigger the 'SyncPushTokensJob' directly as we don't want to wait for paths to build - // before requesting the permission from the user - if shouldSyncPushTokens { - SyncPushTokensJob - .run(uploadOnlyIfStale: false, using: dependencies) - .sinkUntilComplete() + profileRetrievalTask = Task(priority: .userInitiated) { [dependencies] in + await withTaskGroup { [dependencies] group in + group.addTask { + return (await dependencies[singleton: .onboarding].displayName + .compactMap { $0 } + .first(where: { _ in true }) ?? "") + } + group.addTask { + try? await Task.sleep(for: .seconds(15)) + return "" } - onComplete() + let displayName: String? = await group.next() + group.cancelAll() + onComplete((displayName ?? "").isEmpty == false) } } } + + fileprivate func completeRegistration() async { + let shouldSyncPushTokens: Bool = await dependencies[singleton: .onboarding].useAPNS + await dependencies[singleton: .onboarding].completeRegistration() + + // Trigger the 'SyncPushTokensJob' directly as we don't want to wait for paths to build + // before requesting the permission from the user + if shouldSyncPushTokens { + SyncPushTokensJob + .run(uploadOnlyIfStale: false, using: dependencies) + .sinkUntilComplete() + } + } } @EnvironmentObject var host: HostWrapper @@ -61,8 +67,16 @@ struct LoadingScreen: View { // MARK: - Initialization - public init(preview: Bool = false, using dependencies: Dependencies) { - self.viewModel = ViewModel(preview: preview, using: dependencies) + public init( + preview: Bool = false, + initialFlow: Onboarding.Flow, + using dependencies: Dependencies + ) { + self.viewModel = ViewModel( + preview: preview, + initialFlow: initialFlow, + using: dependencies + ) } // MARK: - UI @@ -123,20 +137,24 @@ struct LoadingScreen: View { } private func finishLoading(success: Bool) { - viewModel.profileRetrievalCancellable?.cancel() + viewModel.profileRetrievalTask?.cancel() animationTimer?.invalidate() animationTimer = nil guard success else { - let viewController: SessionHostingViewController = SessionHostingViewController( - rootView: DisplayNameScreen(using: viewModel.dependencies) - ) - viewController.setUpNavBarSessionIcon() - if let navigationController = self.host.controller?.navigationController { - let updatedViewControllers: [UIViewController] = navigationController.viewControllers - .filter { !$0.isKind(of: SessionHostingViewController.self) } - .appending(viewController) - navigationController.setViewControllers(updatedViewControllers, animated: true) + Task(priority: .userInitiated) { + await MainActor.run { + let viewController: SessionHostingViewController = SessionHostingViewController( + rootView: DisplayNameScreen(flow: viewModel.initialFlow, using: viewModel.dependencies) + ) + viewController.setUpNavBarSessionIcon() + if let navigationController = self.host.controller?.navigationController { + let updatedViewControllers: [UIViewController] = navigationController.viewControllers + .filter { !$0.isKind(of: SessionHostingViewController.self) } + .appending(viewController) + navigationController.setViewControllers(updatedViewControllers, animated: true) + } + } } return } @@ -145,8 +163,11 @@ struct LoadingScreen: View { withAnimation(.linear(duration: 0.3)) { self.percentage = 1 } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - viewModel.completeRegistration { + + Task(priority: .userInitiated) { + try? await Task.sleep(for: .milliseconds(500)) + await viewModel.completeRegistration() + await MainActor.run { // Go to the home screen let homeVC: HomeVC = HomeVC(using: viewModel.dependencies) viewModel.dependencies[singleton: .app].setHomeViewController(homeVC) @@ -217,6 +238,6 @@ struct CircularProgressView: View { struct LoadingView_Previews: PreviewProvider { static var previews: some View { - LoadingScreen(preview: true, using: Dependencies.createEmpty()) + LoadingScreen(preview: true, initialFlow: .register, using: Dependencies.createEmpty()) } } diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 322bf5b1ee..2a7173f962 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -9,12 +9,10 @@ import SessionNetworkingKit // MARK: - Cache -public extension Cache { - static let onboarding: CacheConfig = Dependencies.create( +public extension Singleton { + static let onboarding: SingletonConfig = Dependencies.create( identifier: "onboarding", - createInstance: { dependencies in Onboarding.Cache(flow: .none, using: dependencies) }, - mutableInstance: { $0 }, - immutableInstance: { $0 } + createInstance: { dependencies, _ in Onboarding.Manager(flow: .none, using: dependencies) } ) } @@ -28,6 +26,7 @@ public extension Log.Category { public enum Onboarding { public enum State: CustomStringConvertible { + case unknown case noUser case noUserInvalidKeyPair case noUserInvalidSeedGeneration @@ -37,6 +36,7 @@ public enum Onboarding { // stringlint:ignore_contents public var description: String { switch self { + case .unknown: return "Unknown" case .noUser: return "No User" case .noUserInvalidKeyPair: return "No User Invalid Key Pair" case .noUserInvalidSeedGeneration: return "No User Invalid Seed Generation" @@ -66,38 +66,28 @@ public enum Onboarding { } } -// MARK: - Onboarding.Cache +// MARK: - Onboarding.Manager extension Onboarding { - class Cache: OnboardingCacheType { + actor Manager: OnboardingManagerType { private let dependencies: Dependencies + nonisolated public let syncState: OnboardingManagerSyncState = OnboardingManagerSyncState() public let id: UUID public let initialFlow: Onboarding.Flow - public var state: State - private let completionSubject: CurrentValueSubject = CurrentValueSubject(false) + public var state: AsyncStream { stateStream.stream } + public var displayName: AsyncStream { displayNameStream.stream } - public var seed: Data - public var ed25519KeyPair: KeyPair - public var x25519KeyPair: KeyPair - public var userSessionId: SessionId - public var useAPNS: Bool + public var seed: Data = Data() + public var ed25519KeyPair: KeyPair = .empty + public var x25519KeyPair: KeyPair = .empty + public var userSessionId: SessionId = .invalid + public var useAPNS: Bool = false - public var displayName: String - private var _displayNamePublisher: AnyPublisher? - private var hasInitialDisplayName: Bool + private var hasInitialDisplayName: Bool = false private var userProfileConfigMessage: ProcessedMessage? - private var disposables: Set = Set() - - public var displayNamePublisher: AnyPublisher { - _displayNamePublisher ?? Fail(error: NetworkError.notFound).eraseToAnyPublisher() - } - - public var onboardingCompletePublisher: AnyPublisher { - completionSubject - .filter { $0 } - .map { _ in () } - .eraseToAnyPublisher() - } + private var retrieveDisplayNameTask: Task? + private let stateStream: CurrentValueAsyncStream = CurrentValueAsyncStream(.unknown) + private let displayNameStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) // MARK: - Initialization @@ -105,10 +95,45 @@ extension Onboarding { self.dependencies = dependencies self.id = dependencies.randomUUID() self.initialFlow = flow + } + + /// This initializer should only be used in the `DeveloperSettingsViewModel` when swapping between network service layers + init( + ed25519KeyPair: KeyPair, + x25519KeyPair: KeyPair, + displayName: String, + using dependencies: Dependencies + ) { + self.dependencies = dependencies + self.id = dependencies.randomUUID() + self.initialFlow = .devSettings + self.seed = Data() + self.ed25519KeyPair = ed25519KeyPair + self.x25519KeyPair = x25519KeyPair + self.userSessionId = SessionId(.standard, publicKey: x25519KeyPair.publicKey) + self.useAPNS = dependencies[defaults: .standard, key: .isUsingFullAPNs] + self.hasInitialDisplayName = !displayName.isEmpty + Task { [self] in + syncState.update(state: .completed) + await stateStream.send(.completed) + await displayNameStream.send(displayName) + } + } + + deinit { + Task { [stateStream, displayNameStream] in + await stateStream.finishCurrentStreams() + await displayNameStream.finishCurrentStreams() + } + } + + // MARK: - Functions + + public func loadInitialState() async throws { /// Try to load the users `ed25519SecretKey` from the general cache and generate the key pairs from it let ed25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey - let ed25519KeyPair: KeyPair = { + ed25519KeyPair = { guard !ed25519SecretKey.isEmpty, let ed25519Seed: Data = dependencies[singleton: .crypto].generate( @@ -121,7 +146,7 @@ extension Onboarding { return ed25519KeyPair }() - let x25519KeyPair: KeyPair = { + x25519KeyPair = { guard ed25519KeyPair != .empty, let x25519PublicKey: [UInt8] = dependencies[singleton: .crypto].generate( @@ -136,30 +161,24 @@ extension Onboarding { }() /// Retrieve the users `displayName` from `libSession` (the source of truth) - let displayName: String = dependencies.mutate(cache: .libSession) { $0.profile }.name - let hasInitialDisplayName: Bool = !displayName.isEmpty - - self.ed25519KeyPair = ed25519KeyPair - self.displayName = displayName - self.hasInitialDisplayName = hasInitialDisplayName - self.x25519KeyPair = x25519KeyPair - self.userSessionId = (x25519KeyPair != .empty ? + await displayNameStream.send(dependencies.mutate(cache: .libSession) { $0.profile }.name) + hasInitialDisplayName = await !(displayNameStream.currentValue ?? "").isEmpty + userSessionId = (x25519KeyPair != .empty ? SessionId(.standard, publicKey: x25519KeyPair.publicKey) : .invalid ) - self.state = { + + let expectedState: Onboarding.State = { guard ed25519KeyPair != .empty else { return .noUser } guard x25519KeyPair != .empty else { return .noUserInvalidKeyPair } guard hasInitialDisplayName else { return .missingName } return .completed }() - self.seed = Data() /// Overwritten below - self.useAPNS = false /// Overwritten below /// Update the cached values depending on the `initialState` - switch state { - case .noUser, .noUserInvalidKeyPair, .noUserInvalidSeedGeneration: + switch expectedState { + case .unknown, .noUser, .noUserInvalidKeyPair, .noUserInvalidSeedGeneration: /// Remove the `LibSession.Cache` just in case (to ensure no previous state remains) dependencies.remove(cache: .libSession) @@ -173,91 +192,71 @@ extension Onboarding { else { /// Seed or identity generation failed so leave the `Onboarding.Cache` in an invalid state for the UI to /// recover somehow - self.state = .noUserInvalidSeedGeneration + syncState.update(state: .noUserInvalidSeedGeneration) + await stateStream.send(.noUserInvalidSeedGeneration) return } /// The identity data was successfully generated so store it for the onboarding process - self.state = .noUserInvalidKeyPair - self.seed = finalSeedData - self.ed25519KeyPair = identity.ed25519KeyPair - self.x25519KeyPair = identity.x25519KeyPair - self.userSessionId = SessionId(.standard, publicKey: identity.x25519KeyPair.publicKey) - self.displayName = "" + seed = finalSeedData + ed25519KeyPair = identity.ed25519KeyPair + x25519KeyPair = identity.x25519KeyPair + userSessionId = SessionId(.standard, publicKey: identity.x25519KeyPair.publicKey) + await displayNameStream.send(nil) + syncState.update(state: .noUserInvalidKeyPair) + await stateStream.send(.noUserInvalidKeyPair) case .missingName, .completed: - self.useAPNS = dependencies[defaults: .standard, key: .isUsingFullAPNs] - - /// If we are already in a completed state then updated the completion subject accordingly - if self.state == .completed { - self.completionSubject.send(true) - } + useAPNS = dependencies[defaults: .standard, key: .isUsingFullAPNs] + syncState.update(state: expectedState) + await stateStream.send(expectedState) } } - /// This initializer should only be used in the `DeveloperSettingsViewModel` when swapping between network service layers - init( - ed25519KeyPair: KeyPair, - x25519KeyPair: KeyPair, - displayName: String, - using dependencies: Dependencies - ) { - self.dependencies = dependencies - self.id = dependencies.randomUUID() - self.state = .completed - self.initialFlow = .devSettings - self.seed = Data() - self.ed25519KeyPair = ed25519KeyPair - self.x25519KeyPair = x25519KeyPair - self.userSessionId = SessionId(.standard, publicKey: x25519KeyPair.publicKey) - self.useAPNS = dependencies[defaults: .standard, key: .isUsingFullAPNs] - self.displayName = displayName - self.hasInitialDisplayName = !displayName.isEmpty - self._displayNamePublisher = nil - } - - // MARK: - Functions - public func setSeedData(_ seedData: Data) throws { - /// Reset the disposables in case this was called with different data/ - disposables = Set() - /// Generate the keys and store them let identity: (ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair) = try Identity.generate( from: seedData, using: dependencies ) - self.seed = seedData - self.ed25519KeyPair = identity.ed25519KeyPair - self.x25519KeyPair = identity.x25519KeyPair - self.userSessionId = SessionId(.standard, publicKey: identity.x25519KeyPair.publicKey) - - /// **Note:** We trigger this as a "background poll" as doing so means the received messages will be - /// processed immediately rather than async as part of a Job - let poller: CurrentUserPoller = CurrentUserPoller( - pollerName: "Onboarding Poller", // stringlint:ignore - pollerQueue: Threading.pollerQueue, - pollerDestination: .swarm(self.userSessionId.hexString), - pollerDrainBehaviour: .alwaysRandom, - namespaces: [.configUserProfile], - shouldStoreMessages: false, - logStartAndStopCalls: false, - customAuthMethod: Authentication.standard( - sessionId: userSessionId, - ed25519PublicKey: identity.ed25519KeyPair.publicKey, - ed25519SecretKey: identity.ed25519KeyPair.secretKey - ), - using: dependencies - ) + seed = seedData + ed25519KeyPair = identity.ed25519KeyPair + x25519KeyPair = identity.x25519KeyPair + userSessionId = SessionId(.standard, publicKey: identity.x25519KeyPair.publicKey) - typealias PollResult = (configMessage: ProcessedMessage, displayName: String?) - let publisher: AnyPublisher = poller - .poll(forceSynchronousProcessing: true) - .tryMap { [userSessionId, dependencies] messages, _, _, _ -> PollResult? in + /// Kick off the request to get the display name + retrieveDisplayNameTask?.cancel() + retrieveDisplayNameTask = Task(priority: .userInitiated) { [weak self, userSessionId, dependencies] in + /// **Note:** We trigger this as a "background poll" as doing so means the received messages will be + /// processed immediately rather than async as part of a Job + let poller: CurrentUserPoller = CurrentUserPoller( + pollerName: "Onboarding Poller", // stringlint:ignore + destination: .swarm(userSessionId.hexString), + swarmDrainStrategy: .alwaysRandom, + namespaces: [.configUserProfile], + failureCount: 0, + shouldStoreMessages: false, + logStartAndStopCalls: false, + customAuthMethod: Authentication.standard( + sessionId: userSessionId, + ed25519PublicKey: identity.ed25519KeyPair.publicKey, + ed25519SecretKey: identity.ed25519KeyPair.secretKey + ), + key: nil, + using: dependencies + ) + guard !Task.isCancelled else { return } + + do { + let messages: [ProcessedMessage] = try await poller + .poll(forceSynchronousProcessing: true) + .response + guard + !Task.isCancelled, let targetMessage: ProcessedMessage = messages.last, /// Just in case there are multiple case let .config(_, _, serverHash, serverTimestampMs, data, _) = targetMessage - else { return nil } + else { return } /// In order to process the config message we need to create and load a `libSession` cache, but we don't want to load this into /// memory at this stage in case the user cancels the onboarding process part way through @@ -283,225 +282,211 @@ extension Onboarding { ] ) - return (targetMessage, cache.displayName) - } - .handleEvents( - receiveOutput: { [weak self] result in - guard let result: PollResult = result else { return } - - /// Only store the `displayName` returned from the swarm if the user hasn't provided one in the display - /// name step (otherwise the user could enter a display name and have it immediately overwritten due to the - /// config request running slow) - if - self?.hasInitialDisplayName != true, - let displayName: String = result.displayName, - !displayName.isEmpty - { - self?.displayName = displayName - } - - self?.userProfileConfigMessage = result.configMessage + guard !Task.isCancelled else { return } + + /// Only store the `displayName` returned from the swarm if the user hasn't provided one in the display + /// name step (otherwise the user could enter a display name and have it immediately overwritten due to the + /// config request running slow) + if + await self?.hasInitialDisplayName != true, + let displayName: String = cache.displayName, + !displayName.isEmpty + { + await self?.displayNameStream.send(displayName) } - ) - .map { result -> String? in result?.displayName } - .catch { error -> AnyPublisher in + + await self?.setUserProfileConfigMessage(targetMessage) + } + catch { Log.warn(.onboarding, "Failed to retrieve existing profile information due to error: \(error).") - return Just(nil) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + + /// Always emit a value if we got a response (doesn't matter it it was a successful response or not, we just want to + /// finish loading) + await self?.displayNameStream.send(self?.displayNameStream.currentValue) } - .shareReplay(1) - .eraseToAnyPublisher() - - /// Store the publisher and cancelable so we only make one request during onboarding - _displayNamePublisher = publisher - publisher - .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) - .sink(receiveCompletion: { _ in }, receiveValue: { _ in }) - .store(in: &disposables) + } } - func setUseAPNS(_ useAPNS: Bool) { + func setUseAPNS(_ useAPNS: Bool) async { self.useAPNS = useAPNS } - func setDisplayName(_ displayName: String) { - self.displayName = displayName + func setDisplayName(_ displayName: String) async { + retrieveDisplayNameTask?.cancel() + + await displayNameStream.send(displayName) + } + + private func setUserProfileConfigMessage(_ userProfileConfigMessage: ProcessedMessage) { + self.userProfileConfigMessage = userProfileConfigMessage } - func completeRegistration(onComplete: @escaping (() -> Void)) { - DispatchQueue.global(qos: .userInitiated).async(using: dependencies) { [weak self, initialFlow, originalState = state, userSessionId, ed25519KeyPair, x25519KeyPair, useAPNS, displayName, userProfileConfigMessage, dependencies] in - /// Cache the users session id (so we don't need to fetch it from the database every time) - dependencies.mutate(cache: .general) { - $0.setSecretKey(ed25519SecretKey: ed25519KeyPair.secretKey) - } - - /// If we had a proper `initialFlow` then create a new `libSession` cache for the user + func completeRegistration() async { + /// Cache the users session id (so we don't need to fetch it from the database every time) + dependencies.mutate(cache: .general) { + $0.setSecretKey(ed25519SecretKey: ed25519KeyPair.secretKey) + } + + /// If we had a proper `initialFlow` then create a new `libSession` cache for the user + if initialFlow != .none { + dependencies.set( + cache: .libSession, + to: LibSession.Cache( + userSessionId: userSessionId, + using: dependencies + ) + ) + } + + let originalState: State = await stateStream.currentValue + let displayName: String = (await displayNameStream.currentValue ?? "") + try? await dependencies[singleton: .storage].writeAsync { [self] db in + /// Only update the identity/contact/Note to Self state if we have a proper `initialFlow` if initialFlow != .none { - dependencies.set( - cache: .libSession, - to: LibSession.Cache( - userSessionId: userSessionId, - using: dependencies + /// Store the user identity information + try Identity.store(db, ed25519KeyPair: ed25519KeyPair, x25519KeyPair: x25519KeyPair) + + /// Create a contact for the current user and set their approval/trusted statuses so they don't get weird behaviours + try Contact + .fetchOrCreate(db, id: userSessionId.hexString, using: dependencies) + .upsert(db) + try Contact + .filter(id: userSessionId.hexString) + .updateAll( /// Current user `Contact` record not synced so no need to use `updateAllAndConfig` + db, + Contact.Columns.isTrusted.set(to: true), /// Always trust the current user + Contact.Columns.isApproved.set(to: true), + Contact.Columns.didApproveMe.set(to: true) ) + db.addContactEvent(id: userSessionId.hexString, change: .isTrusted(true)) + db.addContactEvent(id: userSessionId.hexString, change: .isApproved(true)) + db.addContactEvent(id: userSessionId.hexString, change: .didApproveMe(true)) + + /// Create the 'Note to Self' thread (not visible by default) + try SessionThread.upsert( + db, + id: userSessionId.hexString, + variant: .contact, + values: SessionThread.TargetValues(shouldBeVisible: .setTo(false)), + using: dependencies ) - } - - dependencies[singleton: .storage].writeAsync( - updates: { db in - /// Only update the identity/contact/Note to Self state if we have a proper `initialFlow` - if initialFlow != .none { - /// Store the user identity information - try Identity.store(db, ed25519KeyPair: ed25519KeyPair, x25519KeyPair: x25519KeyPair) - - /// Create a contact for the current user and set their approval/trusted statuses so they don't get weird behaviours - try Contact - .fetchOrCreate(db, id: userSessionId.hexString, using: dependencies) - .upsert(db) - try Contact - .filter(id: userSessionId.hexString) - .updateAll( /// Current user `Contact` record not synced so no need to use `updateAllAndConfig` - db, - Contact.Columns.isTrusted.set(to: true), /// Always trust the current user - Contact.Columns.isApproved.set(to: true), - Contact.Columns.didApproveMe.set(to: true) - ) - db.addContactEvent(id: userSessionId.hexString, change: .isTrusted(true)) - db.addContactEvent(id: userSessionId.hexString, change: .isApproved(true)) - db.addContactEvent(id: userSessionId.hexString, change: .didApproveMe(true)) - - /// Create the 'Note to Self' thread (not visible by default) - try SessionThread.upsert( - db, - id: userSessionId.hexString, - variant: .contact, - values: SessionThread.TargetValues(shouldBeVisible: .setTo(false)), - using: dependencies - ) - - /// Load the initial `libSession` state (won't have been created on launch due to lack of ed25519 key) - dependencies.mutate(cache: .libSession) { cache in - cache.loadState(db) - - /// If we have a `userProfileConfigMessage` then we should try to handle it here as if we don't then - /// we won't even process it (because the hash may be deduped via another process) - if let userProfileConfigMessage: ProcessedMessage = userProfileConfigMessage { - try? cache.handleConfigMessages( - db, - swarmPublicKey: userSessionId.hexString, - messages: ConfigMessageReceiveJob - .Details(messages: [userProfileConfigMessage]) - .messages - ) - } - - /// Update the `displayName` and trigger a dump/push of the config - try? cache.performAndPushChange(db, for: .userProfile) { - try? cache.updateProfile(displayName: displayName) - } - } - - /// Clear the `lastNameUpdate` timestamp and forcibly set the `displayName` provided - /// during the onboarding step (we do this after handling the config message because we want - /// the value provided during onboarding to superseed any retrieved from the config) - try Profile - .fetchOrCreate(db, id: userSessionId.hexString) - .upsert(db) - try Profile - .filter(id: userSessionId.hexString) - .updateAll(db, Profile.Columns.lastNameUpdate.set(to: nil)) - try Profile.updateIfNeeded( - db, - publicKey: userSessionId.hexString, - displayNameUpdate: .currentUserUpdate(displayName), - displayPictureUpdate: .none, - sentTimestamp: dependencies.dateNow.timeIntervalSince1970, - using: dependencies - ) - - /// Emit observation events (_shouldn't_ be needed since this is happening during onboarding but - /// doesn't hurt just to be safe) - db.addEvent(useAPNS, forKey: .isUsingFullAPNs) - } - /// Now that everything is saved we should update the `Onboarding.Cache` `state` to be `completed` (we do - /// this within the db write query because then `updateAllAndConfig` below will trigger a config sync which is - /// dependant on this `state` being updated) - self?.state = .completed + /// Load the initial `libSession` state (won't have been created on launch due to lack of ed25519 key) + try dependencies.mutate(cache: .libSession) { cache in + try cache.loadState(db, userEd25519SecretKey: ed25519KeyPair.secretKey) - /// We need to explicitly `updateAllAndConfig` the `shouldBeVisible` value to `false` for new accounts otherwise it - /// won't actually get synced correctly and could result in linking a second device and having the 'Note to Self' conversation incorrectly - /// being visible - if initialFlow == .register { - try SessionThread.updateVisibility( + /// If we have a `userProfileConfigMessage` then we should try to handle it here as if we don't then + /// we won't even process it (because the hash may be deduped via another process) + if let userProfileConfigMessage: ProcessedMessage = userProfileConfigMessage { + try? cache.handleConfigMessages( db, - threadId: userSessionId.hexString, - isVisible: false, - using: dependencies + swarmPublicKey: userSessionId.hexString, + messages: ConfigMessageReceiveJob + .Details(messages: [userProfileConfigMessage]) + .messages ) } - }, - completion: { _ in - /// No need to show the seed again if the user is restoring - dependencies.setAsync(.hasViewedSeed, (initialFlow == .restore)) - - /// Now that the onboarding process is completed we can store the `UserMetadata` for the Share and Notification - /// extensions (prior to this point the account is in an invalid state so they can't be used) - do { - try dependencies[singleton: .extensionHelper].saveUserMetadata( - sessionId: userSessionId, - ed25519SecretKey: ed25519KeyPair.secretKey, - unreadCount: 0 + else { + /// We need to explicitly set the `priority` value to `hiddenPriority` for new accounts + /// otherwise it won't actually get synced correctly and could result in linking a second device and + /// having the 'Note to Self' conversation incorrectly being visible + try? LibSession.updateNoteToSelf( + db, + priority: LibSession.hiddenPriority, + using: dependencies ) - } catch { Log.error(.onboarding, "Falied to save user metadata: \(error)") } - - /// Store whether the user wants to use APNS - dependencies[defaults: .standard, key: .isUsingFullAPNs] = useAPNS - - /// Log the resolution - switch (initialFlow, originalState) { - case (.none, _), (.devSettings, _): break - case (.register, _): Log.info(.onboarding, "Registration completed") - case (.restore, .missingName): Log.info(.onboarding, "Missing name replaced") - case (.restore, _): Log.info(.onboarding, "Restore account completed") } - /// Send an event indicating that registration is complete - self?.completionSubject.send(true) - - DispatchQueue.main.async(using: dependencies) { - onComplete() + /// Update the `displayName` and trigger a dump/push of the config + try? cache.performAndPushChange(db, for: .userProfile) { + try? cache.updateProfile(displayName: displayName) } } + + /// Clear the `lastNameUpdate` timestamp and forcibly set the `displayName` provided + /// during the onboarding step (we do this after handling the config message because we want + /// the value provided during onboarding to superseed any retrieved from the config) + try Profile + .fetchOrCreate(db, id: userSessionId.hexString) + .upsert(db) + try Profile + .filter(id: userSessionId.hexString) + .updateAll(db, Profile.Columns.lastNameUpdate.set(to: nil)) + try Profile.updateIfNeeded( + db, + publicKey: userSessionId.hexString, + displayNameUpdate: .currentUserUpdate(displayName), + displayPictureUpdate: .none, + sentTimestamp: dependencies.dateNow.timeIntervalSince1970, + using: dependencies + ) + + /// Emit observation events (_shouldn't_ be needed since this is happening during onboarding but + /// doesn't hurt just to be safe) + db.addEvent(useAPNS, forKey: .isUsingFullAPNs) + } + } + + /// No need to show the seed again if the user is restoring + await dependencies.set(.hasViewedSeed, (initialFlow == .restore)) + + /// Now that the onboarding process is completed we can store the `UserMetadata` for the Share and Notification + /// extensions (prior to this point the account is in an invalid state so they can't be used) + do { + try dependencies[singleton: .extensionHelper].saveUserMetadata( + sessionId: userSessionId, + ed25519SecretKey: ed25519KeyPair.secretKey, + unreadCount: 0 ) + } catch { Log.error(.onboarding, "Falied to save user metadata: \(error)") } + + /// Store whether the user wants to use APNS + dependencies[defaults: .standard, key: .isUsingFullAPNs] = useAPNS + + /// Log the resolution + switch (initialFlow, originalState) { + case (.none, _), (.devSettings, _): break + case (.register, _): Log.info(.onboarding, "Registration completed") + case (.restore, .missingName): Log.info(.onboarding, "Missing name replaced") + case (.restore, _): Log.info(.onboarding, "Restore account completed") + } + + /// Now flag the state as completed + syncState.update(state: .completed) + await stateStream.send(.completed) + + /// Perform a config sync if needed (this needs to be done for new accounts to ensure the initial state is synced correctly) + try? await dependencies[singleton: .storage].writeAsync { [self] db in + ConfigurationSyncJob.enqueue(db, swarmPublicKey: userSessionId.hexString, using: dependencies) } } } } -// MARK: - OnboardingCacheType +// MARK: - OnboardingManagerSyncState -/// This is a read-only version of the Cache designed to avoid unintentionally mutating the instance in a non-thread-safe way -public protocol OnboardingImmutableCacheType: ImmutableCacheType { - var id: UUID { get } - var state: Onboarding.State { get } - var initialFlow: Onboarding.Flow { get } +/// We manually handle thread-safety using the `NSLock` so can ensure this is `Sendable` +public final class OnboardingManagerSyncState: @unchecked Sendable { + private let lock = NSLock() + private var _state: Onboarding.State = .unknown - var seed: Data { get } - var ed25519KeyPair: KeyPair { get } - var x25519KeyPair: KeyPair { get } - var userSessionId: SessionId { get } - var useAPNS: Bool { get } - - var displayName: String { get } - var displayNamePublisher: AnyPublisher { get } - var onboardingCompletePublisher: AnyPublisher { get } + public var state: Onboarding.State { lock.withLock { _state } } + + func update(state: Onboarding.State) { + lock.withLock { self._state = state } + } } -public protocol OnboardingCacheType: OnboardingImmutableCacheType, MutableCacheType { +// MARK: - OnboardingManagerType + +public protocol OnboardingManagerType: Actor { + @available(*, deprecated, message: "Should try to refactor the code to use proper async/await") + nonisolated var syncState: OnboardingManagerSyncState { get } + var id: UUID { get } - var state: Onboarding.State { get } var initialFlow: Onboarding.Flow { get } + var state: AsyncStream { get } + var displayName: AsyncStream { get } var seed: Data { get } var ed25519KeyPair: KeyPair { get } @@ -509,21 +494,12 @@ public protocol OnboardingCacheType: OnboardingImmutableCacheType, MutableCacheT var userSessionId: SessionId { get } var useAPNS: Bool { get } - var displayName: String { get } - var displayNamePublisher: AnyPublisher { get } - var onboardingCompletePublisher: AnyPublisher { get } - - func setSeedData(_ seedData: Data) throws - func setUseAPNS(_ useAPNS: Bool) - func setDisplayName(_ displayName: String) + func loadInitialState() async throws + func setSeedData(_ seedData: Data) async throws + func setUseAPNS(_ useAPNS: Bool) async + func setDisplayName(_ displayName: String) async /// Complete the registration process storing the created/updated user state in the database and creating /// the `libSession` state if needed - /// - /// **Note:** The `onComplete` callback will be run on the main thread - func completeRegistration(onComplete: @escaping (() -> Void)) -} - -public extension OnboardingCacheType { - func completeRegistration() { completeRegistration(onComplete: {}) } + func completeRegistration() async } diff --git a/Session/Onboarding/PNModeScreen.swift b/Session/Onboarding/PNModeScreen.swift index 40100bf54e..69e64a8c59 100644 --- a/Session/Onboarding/PNModeScreen.swift +++ b/Session/Onboarding/PNModeScreen.swift @@ -126,46 +126,50 @@ struct PNModeScreen: View { } private func register() { - // Store whether we want to use APNS - dependencies.mutate(cache: .onboarding) { $0.setUseAPNS(currentSelection == .fast) } - - // If we are registering then we can just continue on - guard dependencies[cache: .onboarding].initialFlow != .register else { - return completeRegistration() + Task(priority: .userInitiated) { + // Store whether we want to use APNS + await dependencies[singleton: .onboarding].setUseAPNS(currentSelection == .fast) + + // If we are registering then we can just continue on + let initialFlow: Onboarding.Flow = await dependencies[singleton: .onboarding].initialFlow + guard initialFlow != .register else { + return await completeRegistration() + } + + // Check if we already have a profile name (ie. profile retrieval completed while waiting on + // this screen) + guard await dependencies[singleton: .onboarding].displayName.first() != nil else { + // If we have one then we can go straight to the home screen + return await self.completeRegistration() + } + + // If we don't have one then show a loading indicator and try to retrieve the existing name + await MainActor.run { + let viewController: SessionHostingViewController = SessionHostingViewController( + rootView: LoadingScreen(initialFlow: initialFlow, using: dependencies) + ) + viewController.setUpNavBarSessionIcon() + self.host.controller?.navigationController?.pushViewController(viewController, animated: true) + } } + } + + private func completeRegistration() async { + let shouldSyncPushTokens: Bool = await dependencies[singleton: .onboarding].useAPNS + await dependencies[singleton: .onboarding].completeRegistration() - // Check if we already have a profile name (ie. profile retrieval completed while waiting on - // this screen) - guard dependencies[cache: .onboarding].displayName.isEmpty else { - // If we have one then we can go straight to the home screen - return self.completeRegistration() + // Trigger the 'SyncPushTokensJob' directly as we don't want to wait for paths to build + // before requesting the permission from the user + if shouldSyncPushTokens { + SyncPushTokensJob + .run(uploadOnlyIfStale: false, using: dependencies) + .sinkUntilComplete() } - // If we don't have one then show a loading indicator and try to retrieve the existing name - let viewController: SessionHostingViewController = SessionHostingViewController( - rootView: LoadingScreen(using: dependencies) - ) - viewController.setUpNavBarSessionIcon() - self.host.controller?.navigationController?.pushViewController(viewController, animated: true) - } - - private func completeRegistration() { - dependencies.mutate(cache: .onboarding) { [dependencies] onboarding in - let shouldSyncPushTokens: Bool = onboarding.useAPNS - - onboarding.completeRegistration { - // Trigger the 'SyncPushTokensJob' directly as we don't want to wait for paths to build - // before requesting the permission from the user - if shouldSyncPushTokens { - SyncPushTokensJob - .run(uploadOnlyIfStale: false, using: dependencies) - .sinkUntilComplete() - } - - let homeVC: HomeVC = HomeVC(using: dependencies) - dependencies[singleton: .app].setHomeViewController(homeVC) - self.host.controller?.navigationController?.setViewControllers([ homeVC ], animated: true) - } + await MainActor.run { + let homeVC: HomeVC = HomeVC(using: dependencies) + dependencies[singleton: .app].setHomeViewController(homeVC) + self.host.controller?.navigationController?.setViewControllers([ homeVC ], animated: true) } } } diff --git a/Session/Open Groups/OpenGroupSuggestionGrid.swift b/Session/Open Groups/OpenGroupSuggestionGrid.swift index ddc4c72ba9..069d0fa91c 100644 --- a/Session/Open Groups/OpenGroupSuggestionGrid.swift +++ b/Session/Open Groups/OpenGroupSuggestionGrid.swift @@ -159,18 +159,14 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle heightConstraint = set(.height, to: OpenGroupSuggestionGrid.cellHeight) widthAnchor.constraint(greaterThanOrEqualToConstant: OpenGroupSuggestionGrid.cellHeight).isActive = true - dependencies[cache: .openGroupManager].defaultRoomsPublisher - .subscribe(on: DispatchQueue.global(qos: .default)) - .receive(on: DispatchQueue.main) - .sinkUntilComplete( - receiveCompletion: { [weak self] result in - switch result { - case .finished: break - case .failure: self?.update() - } - }, - receiveValue: { [weak self] roomInfo in self?.data = roomInfo } - ) + Task { @MainActor [weak self, manager = dependencies[singleton: .openGroupManager]] in + for await roomInfo in manager.defaultRooms { + guard !roomInfo.isEmpty else { continue } + + self?.data = roomInfo + self?.update() + } + } } // MARK: - Updating diff --git a/Session/Path/PathStatusView.swift b/Session/Path/PathStatusView.swift index bd3eefe57b..dec76491ca 100644 --- a/Session/Path/PathStatusView.swift +++ b/Session/Path/PathStatusView.swift @@ -31,7 +31,7 @@ final class PathStatusView: UIView { private let dependencies: Dependencies private let size: Size - private var disposables: Set = Set() + private var statusObservationTask: Task? init(size: Size = .small, using dependencies: Dependencies) { self.dependencies = dependencies @@ -41,13 +41,17 @@ final class PathStatusView: UIView { setUpViewHierarchy() setStatus(to: .unknown) // Default to the unknown status - registerObservers() + startObservingNetwork() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + deinit { + statusObservationTask?.cancel() + } + // MARK: - Layout private func setUpViewHierarchy() { @@ -72,24 +76,16 @@ final class PathStatusView: UIView { // MARK: - Functions - private func registerObservers() { - /// Register for status updates (will be called immediately with current status) - dependencies[cache: .libSessionNetwork].networkStatus - .receive(on: DispatchQueue.main, using: dependencies) - .sink( - receiveCompletion: { [weak self] _ in - /// If the stream completes it means the network cache was reset in which case we want to - /// re-register for updates in the next run loop (as the new cache should be created by then) - DispatchQueue.global(qos: .background).async { - self?.registerObservers() - } - }, - receiveValue: { [weak self] status in self?.setStatus(to: status) } - ) - .store(in: &disposables) + private func startObservingNetwork() { + statusObservationTask?.cancel() + statusObservationTask = Task.detached(priority: .userInitiated) { [weak self, dependencies] in + for await status in dependencies.networkStatusUpdates { + await self?.setStatus(to: status) + } + } } - private func setStatus(to status: NetworkStatus) { + @MainActor private func setStatus(to status: NetworkStatus) { themeBackgroundColor = status.themeColor layer.themeShadowColor = status.themeColor } diff --git a/Session/Path/PathVC.swift b/Session/Path/PathVC.swift index 2ed2fb61ad..7fd01c53c8 100644 --- a/Session/Path/PathVC.swift +++ b/Session/Path/PathVC.swift @@ -14,8 +14,7 @@ final class PathVC: BaseVC { private static let rowHeight: CGFloat = (isIPhone5OrSmaller ? 52 : 75) private let dependencies: Dependencies - private var lastPath: [LibSession.Snode] = [] - private var disposables: Set = Set() + private var statusObservationTask: Task? // MARK: - Components @@ -65,6 +64,10 @@ final class PathVC: BaseVC { fatalError("init(coder:) has not been implemented") } + deinit { + statusObservationTask?.cancel() + } + // MARK: - Lifecycle override func viewDidLoad() { @@ -72,6 +75,7 @@ final class PathVC: BaseVC { setUpNavBar() setUpViewHierarchy() + startObservingNetwork() if !dependencies[defaults: .standard, key: .hasVisitedPathScreen] { dependencies[defaults: .standard, key: .hasVisitedPathScreen] = true @@ -132,44 +136,54 @@ final class PathVC: BaseVC { // Set up spacer constraints topSpacer.heightAnchor.constraint(equalTo: bottomSpacer.heightAnchor).isActive = true - - // Register for path country updates - dependencies[cache: .ip2Country].cacheLoaded - .receive(on: DispatchQueue.main, using: dependencies) - .sink(receiveValue: { [weak self] _ in - switch (self?.lastPath, self?.lastPath.isEmpty == true) { - case (.none, _), (_, true): self?.update(paths: [], force: true) - case (.some(let lastPath), _): self?.update(paths: [lastPath], force: true) - } - }) - .store(in: &disposables) - - // Register for network updates - registerNetworkObservables() } // MARK: - Updating - private func registerNetworkObservables() { - /// Register for status updates (will be called immediately with current paths) - dependencies[cache: .libSessionNetwork].paths - .subscribe(on: DispatchQueue.global(qos: .background), using: dependencies) - .receive(on: DispatchQueue.main, using: dependencies) - .sink( - receiveCompletion: { [weak self] _ in - /// If the stream completes it means the network cache was reset in which case we want to - /// re-register for updates in the next run loop (as the new cache should be created by then) - DispatchQueue.global(qos: .background).async { - self?.registerNetworkObservables() + private func startObservingNetwork() { + statusObservationTask?.cancel() + statusObservationTask = Task { [weak self, dependencies] in + for await _ in dependencies.networkStatusUpdates { + await self?.loadPathsAsync() + } + } + } + + private func loadPathsAsync() async { + let userSessionId: SessionId = dependencies[cache: .general].sessionId + + guard + let currentUserSwarmPubkeys: Set = try? await Set(dependencies[singleton: .network] + .getSwarm(for: userSessionId.hexString) + .map { $0.ed25519PubkeyHex }), + let paths: [LibSession.Path] = try? await dependencies[singleton: .network].getActivePaths(), + let targetPath: LibSession.Path = paths + /// Sanity check to make sure the sorting doesn't crash + .filter({ !$0.nodes.isEmpty }) + /// Deterministic ordering + .sorted(by: { $0.nodes[0].ed25519PubkeyHex < $1.nodes[0].ed25519PubkeyHex }) + .first(where: { path in + switch path.category { + case .standard: return true + case .download, .upload: return false + case .none, .invalid: + guard let pubkey: String = path.destinationPubkey else { + return false + } + + return currentUserSwarmPubkeys.contains(pubkey) } - }, - receiveValue: { [weak self] paths in self?.update(paths: paths, force: false) } - ) - .store(in: &disposables) + }) + else { + self.update(path: nil, force: true) + return + } + + self.update(path: targetPath, force: true) } - private func update(paths: [[LibSession.Snode]], force: Bool) { - guard let pathToDisplay: [LibSession.Snode] = paths.first else { + @MainActor private func update(path: LibSession.Path?, force: Bool) { + guard let pathToDisplay: LibSession.Path = path else { pathStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } spinner.startAnimating() @@ -178,37 +192,44 @@ final class PathVC: BaseVC { } return } - guard force || lastPath != pathToDisplay else { return } // Cache the path that was used to avoid recreating the UI if not needed - lastPath = pathToDisplay pathStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } - let dotAnimationRepeatInterval = Double(pathToDisplay.count) + 2 - let snodeRows: [UIStackView] = pathToDisplay.enumerated().map { index, snode in - let isGuardSnode = (snode == pathToDisplay.first) + let dotAnimationRepeatInterval = Double(pathToDisplay.nodes.count) + 2 + let snodeRows: [UIStackView] = pathToDisplay.nodes.enumerated().map { index, snode in + let isGuardSnode = (snode == pathToDisplay.nodes.first) return getPathRow( - snode: snode, + title: (isGuardSnode ? + "onionRoutingPathEntryNode".localized() : + "onionRoutingPathServiceNode".localized() + ), + subtitleResolver: { [ip2Country = dependencies[singleton: .ip2Country]] in + try? await Task.sleep(for: .seconds(5), checkingEvery: .milliseconds(100)) { + await ip2Country.isLoaded + } + + return await ip2Country.country(for: snode.ip) + }, location: .middle, dotAnimationStartDelay: Double(index) + 2, - dotAnimationRepeatInterval: dotAnimationRepeatInterval, - isGuardSnode: isGuardSnode + dotAnimationRepeatInterval: dotAnimationRepeatInterval ) } let youRow = getPathRow( title: "you".localized(), - subtitle: nil, + subtitleResolver: nil, location: .top, dotAnimationStartDelay: 1, dotAnimationRepeatInterval: dotAnimationRepeatInterval ) let destinationRow = getPathRow( title: "onionRoutingPathDestination".localized(), - subtitle: nil, + subtitleResolver: nil, location: .bottom, - dotAnimationStartDelay: Double(pathToDisplay.count) + 2, + dotAnimationStartDelay: Double(pathToDisplay.nodes.count) + 2, dotAnimationRepeatInterval: dotAnimationRepeatInterval ) let rows = [ youRow ] + snodeRows + [ destinationRow ] @@ -222,7 +243,13 @@ final class PathVC: BaseVC { // MARK: - General - private func getPathRow(title: String, subtitle: String?, location: LineView.Location, dotAnimationStartDelay: Double, dotAnimationRepeatInterval: Double) -> UIStackView { + private func getPathRow( + title: String, + subtitleResolver: (() async -> String)?, + location: LineView.Location, + dotAnimationStartDelay: Double, + dotAnimationRepeatInterval: Double + ) -> UIStackView { let lineView = LineView( location: location, dotAnimationStartDelay: dotAnimationStartDelay, @@ -241,13 +268,17 @@ final class PathVC: BaseVC { let titleStackView = UIStackView(arrangedSubviews: [ titleLabel ]) titleStackView.axis = .vertical - if let subtitle = subtitle { + if let subtitleResolver: () async -> String = subtitleResolver { let subtitleLabel = UILabel() subtitleLabel.font = .systemFont(ofSize: Values.verySmallFontSize) - subtitleLabel.text = subtitle + subtitleLabel.text = "resolving".localized() subtitleLabel.themeTextColor = .textPrimary subtitleLabel.lineBreakMode = .byTruncatingTail titleStackView.addArrangedSubview(subtitleLabel) + + Task { [weak subtitleLabel] in + subtitleLabel?.text = await subtitleResolver() + } } let stackView = UIStackView(arrangedSubviews: [ lineView, titleStackView ]) @@ -257,19 +288,6 @@ final class PathVC: BaseVC { return stackView } - - private func getPathRow(snode: LibSession.Snode, location: LineView.Location, dotAnimationStartDelay: Double, dotAnimationRepeatInterval: Double, isGuardSnode: Bool) -> UIStackView { - return getPathRow( - title: (isGuardSnode ? - "onionRoutingPathEntryNode".localized() : - "onionRoutingPathServiceNode".localized() - ), - subtitle: dependencies[cache: .ip2Country].country(for: snode.ip), - location: location, - dotAnimationStartDelay: dotAnimationStartDelay, - dotAnimationRepeatInterval: dotAnimationRepeatInterval - ) - } // MARK: - Interaction @@ -283,13 +301,14 @@ final class PathVC: BaseVC { // MARK: - Line View private final class LineView: UIView { + private let dependencies: Dependencies private let location: Location private let dotAnimationStartDelay: Double private let dotAnimationRepeatInterval: Double private var dotViewWidthConstraint: NSLayoutConstraint! private var dotViewHeightConstraint: NSLayoutConstraint! private var dotViewAnimationTimer: Timer! - private var disposables: Set = Set() + private var statusObservationTask: Task? enum Location { case top, middle, bottom @@ -298,6 +317,7 @@ private final class LineView: UIView { // MARK: - Initialization init(location: Location, dotAnimationStartDelay: Double, dotAnimationRepeatInterval: Double, using dependencies: Dependencies) { + self.dependencies = dependencies self.location = location self.dotAnimationStartDelay = dotAnimationStartDelay self.dotAnimationRepeatInterval = dotAnimationRepeatInterval @@ -305,7 +325,7 @@ private final class LineView: UIView { super.init(frame: CGRect.zero) setUpViewHierarchy() - registerObservers(using: dependencies) + startObservingNetwork() } override init(frame: CGRect) { @@ -317,6 +337,7 @@ private final class LineView: UIView { } deinit { + statusObservationTask?.cancel() dotViewAnimationTimer?.invalidate() } @@ -379,21 +400,13 @@ private final class LineView: UIView { } } - private func registerObservers(using dependencies: Dependencies) { - /// Register for status updates (will be called immediately with current status) - dependencies[cache: .libSessionNetwork].networkStatus - .receive(on: DispatchQueue.main, using: dependencies) - .sink( - receiveCompletion: { [weak self] _ in - /// If the stream completes it means the network cache was reset in which case we want to - /// re-register for updates in the next run loop (as the new cache should be created by then) - DispatchQueue.global(qos: .background).async { - self?.registerObservers(using: dependencies) - } - }, - receiveValue: { [weak self] status in self?.setStatus(to: status) } - ) - .store(in: &disposables) + private func startObservingNetwork() { + statusObservationTask?.cancel() + statusObservationTask = Task.detached(priority: .userInitiated) { [weak self, dependencies] in + for await status in dependencies.networkStatusUpdates { + await self?.setStatus(to: status) + } + } } private func animate() { @@ -419,7 +432,7 @@ private final class LineView: UIView { } } - private func setStatus(to status: NetworkStatus) { + @MainActor private func setStatus(to status: NetworkStatus) { dotView.themeBackgroundColor = status.themeColor dotView.layer.themeShadowColor = status.themeColor } diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsNetworkViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsNetworkViewModel.swift new file mode 100644 index 0000000000..cd53ef98b8 --- /dev/null +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsNetworkViewModel.swift @@ -0,0 +1,1276 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import Combine +import GRDB +import DifferenceKit +import SessionUIKit +import SessionNetworkingKit +import SessionMessagingKit +import SessionUtilitiesKit + +class DeveloperSettingsNetworkViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource { + public let dependencies: Dependencies + public let navigatableState: NavigatableState = NavigatableState() + public let state: TableDataState = TableDataState() + public let observableState: ObservableTableSourceState = ObservableTableSourceState() + + private var updatedDevnetPubkey: String? + private var updatedDevnetIp: String? + private var updatedDevnetHttpPort: String? + private var updatedDevnetOmqPort: String? + + /// This value is the current state of the view + @MainActor @Published private(set) var internalState: State + private var observationTask: Task? + + // MARK: - Initialization + + @MainActor init(using dependencies: Dependencies) { + self.dependencies = dependencies + self.internalState = State.initialState(using: dependencies) + + /// Bind the state + self.observationTask = ObservationBuilder + .initialValue(self.internalState) + .debounce(for: .never) + .using(dependencies: dependencies) + .query(DeveloperSettingsNetworkViewModel.queryState) + .assign { [weak self] updatedState in + guard let self = self else { return } + + // FIXME: To slightly reduce the size of the changes this new observation mechanism is currently wired into the old SessionTableViewController observation mechanism, we should refactor it so everything uses the new mechanism + let oldState: State = self.internalState + self.internalState = updatedState + self.pendingTableDataSubject.send(updatedState.sections(viewModel: self, previousState: oldState)) + } + } + + // MARK: - Config + + public enum Section: SessionTableSection { + case general + case devnetConfig + + var title: String? { + switch self { + case .general: return nil + case .devnetConfig: return "Devnet Configuration" + } + } + + var style: SessionTableSectionStyle { + switch self { + case .general: return .padding + default: return .titleRoundedContent + } + } + } + + public enum TableItem: Hashable, Differentiable, CaseIterable { + case environment + case router + case pushNotificationService + case forceOffline + + case devnetPubkey + case devnetIp + case devnetHttpPort + case devnetOmqPort + + // MARK: - Conformance + + public typealias DifferenceIdentifier = String + + public var differenceIdentifier: String { + switch self { + case .environment: return "environment" + case .router: return "router" + case .pushNotificationService: return "pushNotificationService" + case .forceOffline: return "forceOffline" + + case .devnetPubkey: return "devnetPubkey" + case .devnetIp: return "devnetIp" + case .devnetHttpPort: return "devnetHttpPort" + case .devnetOmqPort: return "devnetOmqPort" + } + } + + public func isContentEqual(to source: TableItem) -> Bool { + self.differenceIdentifier == source.differenceIdentifier + } + + public static var allCases: [TableItem] { + var result: [TableItem] = [] + switch TableItem.environment { + case .environment: result.append(.environment); fallthrough + case .router: result.append(.router); fallthrough + case .pushNotificationService: result.append(.pushNotificationService); fallthrough + case .forceOffline: result.append(.forceOffline); fallthrough + + case .devnetPubkey: result.append(.devnetPubkey); fallthrough + case .devnetIp: result.append(.devnetIp); fallthrough + case .devnetHttpPort: result.append(.devnetHttpPort); fallthrough + case .devnetOmqPort: result.append(.devnetOmqPort) + } + + return result + } + } + + // MARK: - Content + + public struct State: Equatable, ObservableKeyProvider { + struct NetworkState: Equatable, Hashable { + let environment: ServiceNetwork + let router: Router + let pushNotificationService: Network.PushNotification.Service + let forceOffline: Bool + + let devnetConfig: ServiceNetwork.DevnetConfiguration + + public func with( + environment: ServiceNetwork? = nil, + router: Router? = nil, + pushNotificationService: Network.PushNotification.Service? = nil, + forceOffline: Bool? = nil, + devnetConfig: ServiceNetwork.DevnetConfiguration? = nil + ) -> NetworkState { + return NetworkState( + environment: (environment ?? self.environment), + router: (router ?? self.router), + pushNotificationService: (pushNotificationService ?? self.pushNotificationService), + forceOffline: (forceOffline ?? self.forceOffline), + devnetConfig: (devnetConfig ?? self.devnetConfig) + ) + } + } + + let initialState: NetworkState + let pendingState: NetworkState + + @MainActor public func sections(viewModel: DeveloperSettingsNetworkViewModel, previousState: State) -> [SectionModel] { + DeveloperSettingsNetworkViewModel.sections( + state: self, + previousState: previousState, + viewModel: viewModel + ) + } + + public let observedKeys: Set = [ + .updateScreen(DeveloperSettingsNetworkViewModel.self) + ] + + static func initialState(using dependencies: Dependencies) -> State { + let initialState: NetworkState = NetworkState( + environment: dependencies[feature: .serviceNetwork], + router: dependencies[feature: .router], + pushNotificationService: dependencies[feature: .pushNotificationService], + forceOffline: dependencies[feature: .forceOffline], + devnetConfig: dependencies[feature: .devnetConfig] + ) + + return State( + initialState: initialState, + pendingState: initialState + ) + } + } + + let title: String = "Developer Network Settings" + + lazy var footerButtonInfo: AnyPublisher = $internalState + .map { [weak self] state -> SessionButton.Info? in + return SessionButton.Info( + style: .bordered, + title: "set".localized(), + isEnabled: { + guard state.initialState != state.pendingState else { return false } + + switch (state.pendingState.environment, state.pendingState.router) { + case (.devnet, _): return state.pendingState.devnetConfig.isValid + case (.testnet, .lokinet): return true + case (_, .lokinet): return false + default: return true + } + }(), + accessibility: Accessibility( + identifier: "Set button", + label: "Set button" + ), + minWidth: 110, + onTap: { [weak self] in + Task { [weak self] in + await self?.saveChanges() + } + } + ) + } + .eraseToAnyPublisher() + + @Sendable private static func queryState( + previousState: State, + events: [ObservedEvent], + isInitialQuery: Bool, + using dependencies: Dependencies + ) async -> State { + return State( + initialState: previousState.initialState, + pendingState: (events.first?.value as? State.NetworkState ?? previousState.pendingState) + ) + } + + private static func sections( + state: State, + previousState: State, + viewModel: DeveloperSettingsNetworkViewModel + ) -> [SectionModel] { + let general: SectionModel = SectionModel( + model: .general, + elements: [ + SessionCell.Info( + id: .environment, + title: "Environment", + subtitle: """ + The environment used for sending requests and storing messages. + + Current: \(state.pendingState.environment.title) + """, + trailingAccessory: .icon(.squarePen), + onTap: { [weak viewModel] in + viewModel?.showEnvironmentModal(pendingState: state.pendingState) + } + ), + SessionCell.Info( + id: .router, + title: "Router", + subtitle: """ + The routing method which should be used when making network requests. + + The Lokinet option only works on Testnet. + + Current: \(state.pendingState.router.title) + """, + trailingAccessory: .icon(.squarePen), + onTap: { [weak viewModel] in + viewModel?.showRoutingModal(pendingState: state.pendingState) + } + ), + SessionCell.Info( + id: .pushNotificationService, + title: "Push Notification Service", + subtitle: """ + The service used for subscribing for push notifications. + + The production service only works for production builds and neither option works in the Simulator. + + Current: \(state.pendingState.pushNotificationService.title) + """, + trailingAccessory: .icon(.squarePen), + onTap: { [weak viewModel] in + viewModel?.showPushServiceModal(pendingState: state.pendingState) + } + ), + SessionCell.Info( + id: .forceOffline, + title: "Force Offline", + subtitle: """ + Shut down the current network and cause all future network requests to fail after a 1 second delay with a 'serviceUnavailable' error. + """, + trailingAccessory: .toggle( + state.pendingState.forceOffline, + oldValue: previousState.pendingState.forceOffline + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(DeveloperSettingsNetworkViewModel.self), + value: state.pendingState.with(forceOffline: !state.pendingState.forceOffline) + ) + } + ) + ] + ) + + /// Only show the `devnetConfig` section if the environment is set to `devnet` + guard state.pendingState.environment == .devnet else { + return [general] + } + + let devnetConfig: SectionModel = SectionModel( + model: .devnetConfig, + elements: [ + SessionCell.Info( + id: .devnetPubkey, + title: "Public Key", + subtitle: """ + The public key for the devnet seed node. + + Current Value: \(state.pendingState.devnetConfig.pubkey.isEmpty ? "None" : state.pendingState.devnetConfig.pubkey) + """, + trailingAccessory: .icon(.squarePen), + onTap: { [weak viewModel] in + viewModel?.showDevnetPubkeyModal(pendingState: state.pendingState) + } + ), + SessionCell.Info( + id: .devnetIp, + title: "IP Address", + subtitle: """ + The IP address for the devnet seed node. + + Current Value: \(state.pendingState.devnetConfig.ip.isEmpty ? "None" : state.pendingState.devnetConfig.ip) + """, + trailingAccessory: .icon(.squarePen), + onTap: { [weak viewModel] in + viewModel?.showDevnetIpModal(pendingState: state.pendingState) + } + ), + SessionCell.Info( + id: .devnetIp, + title: "HTTP Port", + subtitle: """ + The HTTP port for the devnet seed node. + + Current Value: \(state.pendingState.devnetConfig.httpPort) + """, + trailingAccessory: .icon(.squarePen), + onTap: { [weak viewModel] in + viewModel?.showDevnetHttpPortModal(pendingState: state.pendingState) + } + ), + SessionCell.Info( + id: .devnetIp, + title: "QUIC Port", + subtitle: """ + The QUIC port for the devnet seed node. + + Current Value: \(state.pendingState.devnetConfig.omqPort) + """, + trailingAccessory: .icon(.squarePen), + onTap: { [weak viewModel] in + viewModel?.showDevnetOmqPortModal(pendingState: state.pendingState) + } + ) + ] + ) + + return [general, devnetConfig] + } + + // MARK: - Internal Functions + + private func showEnvironmentModal(pendingState: State.NetworkState) { + self.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "Environment", + body: .radio( + explanation: ThemedAttributedString( + string: "The environment used for sending requests and storing messages." + ), + warning: nil, + options: ServiceNetwork.allCases.map { network in + ConfirmationModal.Info.Body.RadioOptionInfo( + title: network.title, + descriptionText: network.subtitle.map { ThemedAttributedString(string: $0) }, + enabled: true, + selected: pendingState.environment == network + ) + } + ), + confirmTitle: "select".localized(), + cancelStyle: .alert_text, + onConfirm: { [dependencies] modal in + let selected: ServiceNetwork = { + switch modal.info.body { + case .radio(_, _, let options): + return options + .enumerated() + .first(where: { _, value in value.selected }) + .map { index, _ in + guard index < ServiceNetwork.allCases.count else { + return nil + } + + return ServiceNetwork.allCases[index] + } + .defaulting(to: .mainnet) + + default: return .mainnet + } + }() + + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(DeveloperSettingsNetworkViewModel.self), + value: pendingState.with(environment: selected) + ) + } + ) + ), + transitionType: .present + ) + } + + private func showRoutingModal(pendingState: State.NetworkState) { + self.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "Router", + body: .radio( + explanation: ThemedAttributedString( + string: "The routing method which should be used when making network requests." + ), + warning: nil, + options: Router.allCases.map { router in + ConfirmationModal.Info.Body.RadioOptionInfo( + title: router.title, + descriptionText: router.subtitle.map { ThemedAttributedString(string: $0) }, + enabled: true, + selected: pendingState.router == router + ) + } + ), + confirmTitle: "select".localized(), + cancelStyle: .alert_text, + onConfirm: { [dependencies] modal in + let selected: Router = { + switch modal.info.body { + case .radio(_, _, let options): + return options + .enumerated() + .first(where: { _, value in value.selected }) + .map { index, _ in + guard index < Router.allCases.count else { + return nil + } + + return Router.allCases[index] + } + .defaulting(to: .onionRequests) + + default: return .onionRequests + } + }() + + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(DeveloperSettingsNetworkViewModel.self), + value: pendingState.with(router: selected) + ) + } + ) + ), + transitionType: .present + ) + } + + private func showPushServiceModal(pendingState: State.NetworkState) { + self.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "Push Notification Service", + body: .radio( + explanation: ThemedAttributedString( + string: "The service used for subscribing for push notifications." + ), + warning: ThemedAttributedString( + string: "The production service only works for production builds and neither option works in the Simulator." + ), + options: Network.PushNotification.Service.allCases.map { network in + ConfirmationModal.Info.Body.RadioOptionInfo( + title: network.title, + enabled: true, + selected: pendingState.pushNotificationService == network + ) + } + ), + confirmTitle: "select".localized(), + cancelStyle: .alert_text, + onConfirm: { [dependencies] modal in + let selected: Network.PushNotification.Service = { + switch modal.info.body { + case .radio(_, _, let options): + return options + .enumerated() + .first(where: { _, value in value.selected }) + .map { index, _ in + guard index < Network.PushNotification.Service.allCases.count else { + return nil + } + + return Network.PushNotification.Service.allCases[index] + } + .defaulting(to: .apns) + + default: return .apns + } + }() + + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(DeveloperSettingsNetworkViewModel.self), + value: pendingState.with(pushNotificationService: selected) + ) + } + ) + ), + transitionType: .present + ) + } + + private func showDevnetPubkeyModal(pendingState: State.NetworkState) { + self.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "Devnet Pubkey", + body: .input( + explanation: ThemedAttributedString( + string: """ + The public key for the devnet seed node. + + This is 64 character hexadecimal value. + """ + ), + info: ConfirmationModal.Info.Body.InputInfo( + placeholder: "Enter Pubkey", + initialValue: pendingState.devnetConfig.pubkey, + inputChecker: { text in + guard text.count <= 64 else { + return "Value must be a 64 character hexadecimal string." + } + + return nil + } + ), + onChange: { [weak self] value in + self?.updatedDevnetPubkey = value + } + ), + confirmTitle: "save".localized(), + confirmEnabled: .afterChange { [weak self] _ in + guard let value: String = self?.updatedDevnetPubkey else { + return false + } + + return ( + Hex.isValid(value) && + value.trimmingCharacters(in: .whitespacesAndNewlines).count == 64 + ) + }, + cancelStyle: .alert_text, + dismissOnConfirm: false, + onConfirm: { [weak self, dependencies] modal in + guard + let value: String = self?.updatedDevnetPubkey, + Hex.isValid(value), + value.trimmingCharacters(in: .whitespacesAndNewlines).count == 64 + else { + modal.updateContent( + withError: "Value must be a 64 character hexadecimal string." + ) + return + } + + modal.dismiss(animated: true) + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(DeveloperSettingsNetworkViewModel.self), + value: pendingState.with( + devnetConfig: pendingState.devnetConfig.with( + pubkey: value + ) + ) + ) + } + ) + ), + transitionType: .present + ) + } + + private func showDevnetIpModal(pendingState: State.NetworkState) { + self.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "Devnet IP", + body: .input( + explanation: ThemedAttributedString( + string: """ + The IP address for the devnet seed node. + + This must be in the format: '255.255.255.255' + """ + ), + info: ConfirmationModal.Info.Body.InputInfo( + placeholder: "Enter IP", + initialValue: pendingState.devnetConfig.ip + ), + onChange: { [weak self] value in self?.updatedDevnetIp = value } + ), + confirmTitle: "save".localized(), + confirmEnabled: .afterChange { [weak self] _ in + guard let value: String = self?.updatedDevnetIp else { + return false + } + + return ( + value.split(separator: ".").count == 4 && + value.split(separator: ".").allSatisfy({ part in + UInt8(part, radix: 10) != nil + }) + ) + }, + cancelStyle: .alert_text, + dismissOnConfirm: false, + onConfirm: { [weak self, dependencies] modal in + guard + let value: String = self?.updatedDevnetIp, + value.split(separator: ".").count == 4, + value.split(separator: ".").allSatisfy({ part in + UInt8(part, radix: 10) != nil + }) + else { + modal.updateContent( + withError: "Value must be a valid IP address in the format: '255.255.255.255'." + ) + return + } + + modal.dismiss(animated: true) + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(DeveloperSettingsNetworkViewModel.self), + value: pendingState.with( + devnetConfig: pendingState.devnetConfig.with( + ip: value + ) + ) + ) + } + ) + ), + transitionType: .present + ) + } + + private func showDevnetHttpPortModal(pendingState: State.NetworkState) { + self.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "Devnet HTTP Port", + body: .input( + explanation: ThemedAttributedString( + string: """ + The HTTP port for the devnet seed node. + + Value must be a number between 0 and 65,535. + """ + ), + info: ConfirmationModal.Info.Body.InputInfo( + placeholder: "Enter HTTP port", + initialValue: "\(pendingState.devnetConfig.httpPort)" + ), + onChange: { [weak self] value in self?.updatedDevnetHttpPort = value } + ), + confirmTitle: "save".localized(), + confirmEnabled: .afterChange { [weak self] _ in + guard let value: String = self?.updatedDevnetHttpPort else { + return false + } + + return (UInt16(value, radix: 10) != nil) + }, + cancelStyle: .alert_text, + dismissOnConfirm: false, + onConfirm: { [weak self, dependencies] modal in + guard + let value: String = self?.updatedDevnetHttpPort, + let httpPort: UInt16 = UInt16(value, radix: 10) + else { + modal.updateContent( + withError: "Value must be a number between 0 and 65,535." + ) + return + } + + modal.dismiss(animated: true) + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(DeveloperSettingsNetworkViewModel.self), + value: pendingState.with( + devnetConfig: pendingState.devnetConfig.with( + httpPort: httpPort + ) + ) + ) + } + ) + ), + transitionType: .present + ) + } + + private func showDevnetOmqPortModal(pendingState: State.NetworkState) { + self.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "Devnet QUIC Port", + body: .input( + explanation: ThemedAttributedString( + string: """ + The QUIC port for the devnet seed node. + + Value must be a number between 0 and 65,535. + """ + ), + info: ConfirmationModal.Info.Body.InputInfo( + placeholder: "Enter QUIC port", + initialValue: "\(pendingState.devnetConfig.omqPort)" + ), + onChange: { [weak self] value in self?.updatedDevnetOmqPort = value } + ), + confirmTitle: "save".localized(), + confirmEnabled: .afterChange { [weak self] _ in + guard let value: String = self?.updatedDevnetOmqPort else { + return false + } + + return (UInt16(value, radix: 10) != nil) + }, + cancelStyle: .alert_text, + dismissOnConfirm: false, + onConfirm: { [weak self, dependencies] modal in + guard + let value: String = self?.updatedDevnetOmqPort, + let omqPort: UInt16 = UInt16(value, radix: 10) + else { + modal.updateContent( + withError: "Value must be a number between 0 and 65,535." + ) + return + } + + modal.dismiss(animated: true) + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(DeveloperSettingsNetworkViewModel.self), + value: pendingState.with( + devnetConfig: pendingState.devnetConfig.with( + omqPort: omqPort + ) + ) + ) + } + ) + ), + transitionType: .present + ) + } + + // MARK: - Reverting + + public static func disableDeveloperMode(using dependencies: Dependencies) async { + /// First determine if any changes need to be made to the environment + var needsEnvironmentUpdate: Bool = false + var needsRouterUpdate: Bool = false + var needsPushServiceUpdate: Bool = false + + for feature in TableItem.allCases { + switch feature { + case .devnetPubkey, .devnetIp, .devnetHttpPort, .devnetOmqPort: break + case .environment: needsEnvironmentUpdate = (dependencies[feature: .serviceNetwork] != .mainnet) + case .router: needsRouterUpdate = (dependencies[feature: .router] != .onionRequests) + case .pushNotificationService: + needsPushServiceUpdate = (dependencies[feature: .pushNotificationService] != .apns) + + case .forceOffline: + guard dependencies.hasSet(feature: .forceOffline) else { break } + + dependencies.set(feature: .forceOffline, to: nil) + } + } + + /// Then make the changes needed + switch (needsEnvironmentUpdate, needsRouterUpdate) { + case (true, true): + /// If we are updating both the environment and the router then swap the router over first and trigger the environment + /// update (as that resets the state anyway, also calling `updateRouter` would be inefficient in this case) + dependencies.set(feature: .router, to: .onionRequests) + + await DeveloperSettingsNetworkViewModel.updateEnvironment( + serviceNetwork: .mainnet, + devnetConfig: nil, + using: dependencies + ) + + case (true, false): + await DeveloperSettingsNetworkViewModel.updateEnvironment( + serviceNetwork: .mainnet, + devnetConfig: nil, + using: dependencies + ) + + case (false, true): + await DeveloperSettingsNetworkViewModel.updateRouter( + router: .onionRequests, + using: dependencies + ) + + default: break + } + + if needsPushServiceUpdate { + await DeveloperSettingsNetworkViewModel.updatePushNotificationService( + service: .apns, + using: dependencies + ) + } + } + + // MARK: - Saving + + @MainActor private func saveChanges(hasConfirmed: Bool = false) async { + guard internalState.initialState != internalState.pendingState else { return } + + let networkEnvironmentChanged: Bool = ( + internalState.initialState.environment != internalState.pendingState.environment || ( + internalState.initialState.environment == .devnet && + internalState.initialState.devnetConfig.isValid && + internalState.initialState.devnetConfig != internalState.pendingState.devnetConfig + ) + ) + let routerChanged: Bool = ( + internalState.initialState.router != internalState.pendingState.router + ) + let pushServiceChanged: Bool = ( + internalState.initialState.pushNotificationService != internalState.pendingState.pushNotificationService + ) + + /// Changing the network settings can result in data being cleared from the database so we should confirm that is desired before + /// we make the changes + guard hasConfirmed else { + /// If we don't need confirmation then just go ahead (eg. `forceOffline` (or some new) change) + guard networkEnvironmentChanged || routerChanged || pushServiceChanged else { + return await self.saveChanges(hasConfirmed: true) + } + + let message: ThemedAttributedString = ThemedAttributedString(string: "Are you sure you want to update the network settings to:\n") + + let style: NSMutableParagraphStyle = NSMutableParagraphStyle() + style.alignment = .left + + /// Append the list of state changes + if networkEnvironmentChanged { + message.append( + ThemedAttributedString( + stringWithHTMLTags: """ + \nEnvironment: \(internalState.pendingState.environment.title) + """, + font: ConfirmationModal.explanationFont + ).addingAttribute(.paragraphStyle, value: style) + ) + } + + if routerChanged { + message.append( + ThemedAttributedString( + stringWithHTMLTags: """ + \nRouter: \(internalState.pendingState.router.title) + """, + font: ConfirmationModal.explanationFont + ).addingAttribute(.paragraphStyle, value: style) + ) + } + + if pushServiceChanged { + message.append( + ThemedAttributedString( + stringWithHTMLTags: """ + \nPN Service: \(internalState.pendingState.pushNotificationService.title) + """, + font: ConfirmationModal.explanationFont + ).addingAttribute(.paragraphStyle, value: style) + ) + } + + /// Add the warnings + message.append( + ThemedAttributedString( + stringWithHTMLTags: "\n\nWarning this will result in:", + font: ConfirmationModal.explanationFont + ) + .addingAttribute(.paragraphStyle, value: style) + .addingAttribute(.themeForegroundColor, value: ThemeValue.warning) + ) + + if networkEnvironmentChanged { + message.append(NSAttributedString( + string: "\n• All conversation and snode data being cleared and any pending network requests being cancelled.", + attributes: [NSAttributedString.Key.paragraphStyle: style] + )) + } + if routerChanged && !networkEnvironmentChanged { + message.append(NSAttributedString( + string: "\n• Any pending network requests being cancelled.", + attributes: [NSAttributedString.Key.paragraphStyle: style] + )) + } + if pushServiceChanged { + message.append(NSAttributedString( + string: "\n• Resubscribing for push notifications, which may take a few minutes.", + attributes: [NSAttributedString.Key.paragraphStyle: style] + )) + } + + if #unavailable(iOS 16.0), (networkEnvironmentChanged || routerChanged) { + message.append(ThemedAttributedString( + string: "\n\nThe app will need to be restarted for these changes to take effect.", + attributes: [ + .paragraphStyle: style, + .themeForegroundColor: ThemeValue.danger + ] + )) + } + + self.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "Change Network Settings", + body: .attributedText(message, scrollMode: .never), + confirmTitle: { + if #unavailable(iOS 16.0) { + return "Close App" + } + + return "confirm".localized() + }(), + confirmStyle: .danger, + cancelStyle: .alert_text, + onConfirm: { [weak self] _ in + Task { [weak self] in + await self?.saveChanges(hasConfirmed: true) + } + } + ) + ), + transitionType: .present + ) + return + } + + /// If the `forceOffline` value changed then apply the change + if internalState.initialState.forceOffline != internalState.pendingState.forceOffline { + dependencies.set(feature: .forceOffline, to: internalState.pendingState.forceOffline) + + if !internalState.pendingState.forceOffline { + await dependencies[singleton: .network].resetNetworkStatus() + } + else { + await dependencies[singleton: .network].setNetworkStatus(status: .disconnected) + } + } + + /// If the network environment changed then we should make those changes first (since they result in the database being cleared) + if networkEnvironmentChanged { + let state: State.NetworkState = internalState.pendingState + + await DeveloperSettingsNetworkViewModel.updateEnvironment( + serviceNetwork: state.environment, + devnetConfig: (state.environment == .devnet && state.devnetConfig.isValid ? + state.devnetConfig : + nil + ), + additionalChanges: (routerChanged ? + /// If the router was also changed then we also need to change it during the `updateEnvironment` call + { [dependencies] in dependencies.set(feature: .router, to: state.router) } : + nil + ), + using: dependencies + ) + } + + /// If the router changed then we need to recreate the `network` instance, but updating the environment does the same so + /// no need to do it again in that case (we will have already updated the `router` feature value above in this case) + if routerChanged && !networkEnvironmentChanged { + let state: State.NetworkState = internalState.pendingState + + await DeveloperSettingsNetworkViewModel.updateRouter( + router: state.router, + using: dependencies + ) + } + + /// Now that any environment changes have been made (which may result in rebuilding the network state, and likely clearing the + /// database) we can trigger the push service change + if pushServiceChanged && dependencies[defaults: .standard, key: .isUsingFullAPNs] { + let state: State.NetworkState = internalState.pendingState + + await DeveloperSettingsNetworkViewModel.updatePushNotificationService( + service: state.pushNotificationService, + using: dependencies + ) + } + + /// Changes have been saved so we can dismiss the screen + self.dismissScreen() + } + + // MARK: - Environment Changing + + internal static func updateEnvironment( + serviceNetwork: ServiceNetwork, + devnetConfig: ServiceNetwork.DevnetConfiguration?, + additionalChanges: (() -> Void)? = nil, + using dependencies: Dependencies + ) async { + struct IdentityData { + let ed25519KeyPair: KeyPair + let x25519KeyPair: KeyPair + } + + /// Make sure we are actually changing the network before clearing all of the data + guard + serviceNetwork != dependencies[feature: .serviceNetwork] || ( + serviceNetwork == .devnet && + devnetConfig?.isValid == true && + devnetConfig != dependencies[feature: .devnetConfig] + ) + else { return } + + /// Need to ensure we can retrieve the identity data before resetting everything (otherwise it'll wipe everything which we don't want) + let identityData: IdentityData + + do { + identityData = try await dependencies[singleton: .storage].readAsync(value: { db in + IdentityData( + ed25519KeyPair: KeyPair( + publicKey: Array(try Identity + .filter(Identity.Columns.variant == Identity.Variant.ed25519PublicKey) + .fetchOne(db, orThrow: StorageError.objectNotFound) + .data), + secretKey: Array(try Identity + .filter(Identity.Columns.variant == Identity.Variant.ed25519SecretKey) + .fetchOne(db, orThrow: StorageError.objectNotFound) + .data) + ), + x25519KeyPair: KeyPair( + publicKey: Array(try Identity + .filter(Identity.Columns.variant == Identity.Variant.x25519PublicKey) + .fetchOne(db, orThrow: StorageError.objectNotFound) + .data), + secretKey: Array(try Identity + .filter(Identity.Columns.variant == Identity.Variant.x25519PrivateKey) + .fetchOne(db, orThrow: StorageError.objectNotFound) + .data) + ) + ) + }) + } + catch { return Log.warn("[DevSettings] Environment change ignored due to error fetching identity data: \(error)") } + + Log.info("[DevSettings] Swapping environment to \(String(describing: serviceNetwork)), clearing data") + + /// Stop all pollers + dependencies.remove(singleton: .currentUserPoller) + dependencies.remove(singleton: .groupPollerManager) + dependencies.remove(singleton: .communityPollerManager) + + /// Reset the network (only if it's already been created - don't want to initialise the network if it hasn't already been started) + /// + /// **Note:** We need to set this to a `NoopNetwork` because a number of objects observe the `networkStatus` which + /// would result in automatic re-creation of the network with it's current config (since the `serviceNetwork` hasn't been updated + /// yet) + if dependencies.has(singleton: .network) { + await dependencies[singleton: .network].suspendNetworkAccess() + await dependencies[singleton: .network].finishCurrentObservations() + await dependencies[singleton: .network].clearCache() + } + + dependencies.set(singleton: .network, to: LibSession.NoopNetwork(using: dependencies)) + + /// If we have a push token then retrieve any auth details for them so we can unsubscribe once we have the new network layer + /// setup (since these will be server requests they aren't dependant on the `serviceNetwork` so can be run after we finish + /// updating the environment) + let existingPushInfo: (token: String, [AuthenticationMethod])? = await { + let maybeToken: String? = try? await dependencies[singleton: .storage].readAsync { db in + db[.lastRecordedPushToken] + } + let maybeSwarmAuth: [AuthenticationMethod]? = try? await Network.PushNotification.retrieveAllSwarmAuth( + using: dependencies + ) + + guard + let token: String = maybeToken, + let swarmAuth: [AuthenticationMethod] = maybeSwarmAuth, + !swarmAuth.isEmpty + else { return nil } + + return (token, swarmAuth) + }() + + /// Remove the libSession state (store the profile locally to maintain the name between environments) + let existingProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } + dependencies.remove(cache: .libSession) + + /// Remove any network-specific data + try? await dependencies[singleton: .storage].writeAsync { [dependencies] db in + let userSessionId: SessionId = dependencies[cache: .general].sessionId + + _ = try SnodeReceivedMessageInfo.deleteAll(db) + _ = try SessionThread.deleteAll(db) + _ = try MessageDeduplication.deleteAll(db) + _ = try ClosedGroup.deleteAll(db) + _ = try OpenGroup.deleteAll(db) + _ = try Capability.deleteAll(db) + _ = try GroupMember.deleteAll(db) + _ = try Contact + .filter(Contact.Columns.id != userSessionId.hexString) + .deleteAll(db) + _ = try Profile + .filter(Profile.Columns.id != userSessionId.hexString) + .deleteAll(db) + _ = try BlindedIdLookup.deleteAll(db) + _ = try ConfigDump.deleteAll(db) + } + + /// Remove the `ExtensionHelper` cache + dependencies[singleton: .extensionHelper].deleteCache() + + Log.info("[DevSettings] Reloading state for \(String(describing: serviceNetwork))") + + /// Update to the new `ServiceNetwork` + dependencies.set(feature: .serviceNetwork, to: serviceNetwork) + + if let devnetConfig: ServiceNetwork.DevnetConfiguration = devnetConfig { + dependencies.set(feature: .devnetConfig, to: devnetConfig) + } + + /// Perform any additional changes (eg. updating the `router`) + additionalChanges?() + + /// Run the onboarding process as if we are recovering an account (will setup the device in it's proper state) + let updatedOnboarding: Onboarding.Manager = Onboarding.Manager( + ed25519KeyPair: identityData.ed25519KeyPair, + x25519KeyPair: identityData.x25519KeyPair, + displayName: existingProfile.name + .nullIfEmpty + .defaulting(to: "Anonymous"), + using: dependencies + ) + await updatedOnboarding.completeRegistration() + + /// Re-enable developer mode + dependencies.setAsync(.developerModeEnabled, true) + + if #unavailable(iOS 16.0) { + /// iOS 15 doesn't support live environment changes so we need to kill the app here + Log.info("[DevSettings] Completed swap to \(String(describing: serviceNetwork))") + Log.flush() + dependencies[singleton: .storage].suspendDatabaseAccess() + exit(0) + } + + /// Store the updated oboarding + dependencies.set(singleton: .onboarding, to: updatedOnboarding) + + /// Remove the temporary NoopNetwork and warm a new instance now that the `serviceNetwork` has been updated + dependencies.remove(singleton: .network) + dependencies.warm(singleton: .network) + + /// Restart the current user poller (there won't be any other pollers though) + Task { @MainActor [poller = dependencies[singleton: .currentUserPoller]] in + await poller.startIfNeeded() + } + + /// Unsubscribe from old push notifications and re-sync the push tokens for the account on the new `serviceNetwork` (if there are any) + switch existingPushInfo { + case .none: SyncPushTokensJob.run(uploadOnlyIfStale: false, using: dependencies).sinkUntilComplete() + case .some((let token, let swarmAuth)): + Task.detached(priority: .userInitiated) { + _ = try? await Network.PushNotification.unsubscribe( + token: Data(hex: token), + swarmAuthentication: swarmAuth, + using: dependencies + ) + + SyncPushTokensJob.run(uploadOnlyIfStale: false, using: dependencies).sinkUntilComplete() + } + } + + Log.info("[DevSettings] Completed swap to \(String(describing: serviceNetwork))") + } + + internal static func updateRouter( + router: Router, + using dependencies: Dependencies + ) async { + /// Make sure we are actually changing the router before recreating the network + guard router != dependencies[feature: .router] else { return } + + Log.info("[DevSettings] Swapping router to \(String(describing: router))") + + /// Stop all pollers + dependencies.remove(singleton: .currentUserPoller) + dependencies.remove(singleton: .groupPollerManager) + dependencies.remove(singleton: .communityPollerManager) + + /// Reset the network (only if it's already been created - don't want to initialise the network if it hasn't already been started) + /// + /// **Note:** We need to set this to a `NoopNetwork` because a number of objects observe the `networkStatus` which + /// would result in automatic re-creation of the network with it's current config (since the `serviceNetwork` hasn't been updated + /// yet) + /// + /// **Note 2:** No need to clear the snode cache in this case as we aren't swapping the environment + if dependencies.has(singleton: .network) { + await dependencies[singleton: .network].suspendNetworkAccess() + await dependencies[singleton: .network].finishCurrentObservations() + } + + dependencies.set(singleton: .network, to: LibSession.NoopNetwork(using: dependencies)) + + /// Update to the new `Router` + dependencies.set(feature: .router, to: router) + + /// Remove the temporary NoopNetwork and warm a new instance now that the `router` has been updated + dependencies.remove(singleton: .network) + dependencies.warm(singleton: .network) + + /// Restart all pollers + Task { @MainActor [dependencies] in + guard await dependencies[singleton: .onboarding].state.first() == .completed else { return } + + await dependencies[singleton: .currentUserPoller].startIfNeeded() + await dependencies[singleton: .groupPollerManager].startAllPollers() + await dependencies[singleton: .communityPollerManager].startAllPollers() + } + + Log.info("[DevSettings] Completed swap to \(String(describing: router))") + } + + internal static func updatePushNotificationService( + service: Network.PushNotification.Service, + using dependencies: Dependencies + ) async { + guard dependencies[defaults: .standard, key: .isUsingFullAPNs] else { return } + + /// Disable push notifications to trigger the unsubscribe, then re-enable them after updating the feature setting + dependencies[defaults: .standard, key: .isUsingFullAPNs] = false + + SyncPushTokensJob + .run(uploadOnlyIfStale: false, using: dependencies) + .handleEvents( + receiveOutput: { [dependencies] _ in + dependencies.set(feature: .pushNotificationService, to: service) + dependencies[defaults: .standard, key: .isUsingFullAPNs] = true + } + ) + .flatMap { [dependencies] _ in + SyncPushTokensJob.run(uploadOnlyIfStale: false, using: dependencies) + } + .sinkUntilComplete() + } +} diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index 0eb37764ab..a0a4fb4bc1 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -368,7 +368,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold guard let product: Product = products.first else { Log.error("[DevSettings] Unable to purchase subscription due to error: No products found") - dependencies.notifyAsync( + await dependencies.notify( key: .updateScreen(DeveloperSettingsProViewModel.self), value: DeveloperSettingsProEvent.purchasedProduct([], nil, "No products found", nil, nil) ) @@ -379,26 +379,26 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold switch result { case .success(let verificationResult): let transaction = try verificationResult.payloadValue - dependencies.notifyAsync( + await dependencies.notify( key: .updateScreen(DeveloperSettingsProViewModel.self), value: DeveloperSettingsProEvent.purchasedProduct(products, product, nil, "Successful", transaction.id) ) await transaction.finish() case .pending: - dependencies.notifyAsync( + await dependencies.notify( key: .updateScreen(DeveloperSettingsProViewModel.self), value: DeveloperSettingsProEvent.purchasedProduct(products, product, nil, "Pending approval", nil) ) case .userCancelled: - dependencies.notifyAsync( + await dependencies.notify( key: .updateScreen(DeveloperSettingsProViewModel.self), value: DeveloperSettingsProEvent.purchasedProduct(products, product, nil, "User cancelled", nil) ) @unknown default: - dependencies.notifyAsync( + await dependencies.notify( key: .updateScreen(DeveloperSettingsProViewModel.self), value: DeveloperSettingsProEvent.purchasedProduct(products, product, "Unknown Error", nil, nil) ) @@ -407,7 +407,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold } catch { Log.error("[DevSettings] Unable to purchase subscription due to error: \(error)") - dependencies.notifyAsync( + await dependencies.notify( key: .updateScreen(DeveloperSettingsProViewModel.self), value: DeveloperSettingsProEvent.purchasedProduct([], nil, "Failed: \(error)", nil, nil) ) @@ -421,7 +421,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold do { try await AppStore.showManageSubscriptions(in: scene) - print("AS") } catch { Log.error("[DevSettings] Unable to show manage subscriptions: \(error)") diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel+Testing.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel+Testing.swift index 09381d297d..85816d4b48 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel+Testing.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel+Testing.swift @@ -4,6 +4,7 @@ import UIKit import SessionUtilitiesKit +import SessionNetworkingKit // MARK: - Automated Test Convenience @@ -27,9 +28,9 @@ extension DeveloperSettingsViewModel { /// ``` /// /// **Note:** All values need to be provided as strings (eg. booleans) - static func processUnitTestEnvVariablesIfNeeded(using dependencies: Dependencies) { + static func processUnitTestEnvVariablesIfNeeded(using dependencies: Dependencies) async { #if targetEnvironment(simulator) - enum EnvironmentVariable: String { + enum EnvironmentVariable: String, CaseIterable { /// Disables animations for the app (where possible) /// /// **Value:** `true`/`false` (default: `true`) @@ -45,17 +46,57 @@ extension DeveloperSettingsViewModel { /// **Value:** `true`/`false` (default: `true` in debug builds, `false` otherwise) case truncatePubkeysInLogs - /// Controls whether the app communicates with mainnet or testnet by default - /// - /// **Value:** `"mainnet"`/`"testnet"` (default: `"mainnet"`) - case serviceNetwork - /// Controls whether the app should trigger it's "Force Offline" behaviour (the network doesn't connect and all requests /// fail after a 1 second delay with a serviceUnavailable error) /// /// **Value:** `true`/`false` (default: `false`) case forceOffline + /// Controls which routing method the app uses to send network requets + /// + /// **Value:** `"onionRequests"`/`"lokinet"`/`"direct"` (default: `"onionRequests"`) + /// + /// **Note:** When set to `lokinet` the `serviceNetwork` **MUST** be set to `testnet` will be used + /// if it's not then `onionRequests` will be used. Additionally `direct` is not currently supported, so + /// `onionRequests` will also be used in that case. + case router + + /// Controls whether the app communicates with mainnet or testnet by default + /// + /// **Value:** `"mainnet"`/`"testnet"`/`"devnet"` (default: `"mainnet"`) + /// + /// **Note:** When set to `devnet` the `devnetPubkey`, `devnetIp`, `devnetHttpPort` and + /// `devnetOmqPort` values all must be provided, if any are missing then `testnet` will be used instead + case serviceNetwork + + /// Controls the pubkey which is used for the seed node when `devnet` is used + /// + /// **Value:** 64 character hex encoded public key + /// + /// **Note:** This will be ignored if `serviceNetwork` is not `devnet` + case devnetPubkey + + /// Controls the ip address which is used for the seed node when `devnet` is used + /// + /// **Value:** IP address in the form of `"255.255.255.255"` + /// + /// **Note:** This will be ignored if `serviceNetwork` is not `devnet` + case devnetIp + + /// Controls the port which is used for HTTP connections to the seed node when `devnet` is used + /// + /// **Value:** `0-65,535` + /// + /// **Note:** This will be ignored if `serviceNetwork` is not `devnet` + case devnetHttpPort + + /// Controls the port which is used for QUIC connections to the seed node when `devnet` is used + /// + /// **Value:** `0-65,535` + /// + /// **Note:** This will be ignored if `serviceNetwork` is not `devnet` + case devnetOmqPort + /// Controls whether the app should offer the debug durations for disappearing messages (eg. `10s`, `30s`, etc.) /// /// **Value:** `true`/`false` (default: `false`) @@ -67,16 +108,28 @@ extension DeveloperSettingsViewModel { case communityPollLimit } - ProcessInfo.processInfo.environment.forEach { key, value in - guard let variable: EnvironmentVariable = EnvironmentVariable(rawValue: key) else { return } + let envVars: [EnvironmentVariable: String] = ProcessInfo.processInfo.environment + .reduce(into: [:]) { result, next in + guard let variable: EnvironmentVariable = EnvironmentVariable(rawValue: next.key) else { + return + } + + result[variable] = next.value + } + let allKeys: Set = Set(envVars.keys) + + /// The order the the environment variables are applied in is important (configuring the network needs to happen in a certain + /// order to simplify the below logic) + for key in EnvironmentVariable.allCases { + guard let value: String = envVars[key] else { continue } - switch variable { + switch key { case .animationsEnabled: dependencies.set(feature: .animationsEnabled, to: (value == "true")) guard value == "false" else { return } - UIView.setAnimationsEnabled(false) + await UIView.setAnimationsEnabled(false) case .showStringKeys: dependencies.set(feature: .showStringKeys, to: (value == "true")) @@ -84,18 +137,117 @@ extension DeveloperSettingsViewModel { case .truncatePubkeysInLogs: dependencies.set(feature: .truncatePubkeysInLogs, to: (value == "true")) - case .serviceNetwork: - let network: ServiceNetwork + case .forceOffline: + dependencies.set(feature: .forceOffline, to: (value == "true")) + + case .router: + let router: Router switch value { - case "testnet": network = .testnet - default: network = .mainnet + case "onionRequests": router = .onionRequests + case "lokinet": + if envVars[.serviceNetwork] != "testnet" { + Log.warn("Router option '\(value)' can only be used on 'testnet', falling back to onion requests") + router = .onionRequests + } + else { + router = .lokinet + } + + case "direct": + router = .onionRequests + Log.warn("Invalid router option '\(value)' provided, falling back to onion requests") + + default: + Log.warn("Invalid router option '\(value)' provided, falling back to onion requests") + router = .onionRequests } - DeveloperSettingsViewModel.updateServiceNetwork(to: network, using: dependencies) + dependencies.set(feature: .router, to: router) - case .forceOffline: - dependencies.set(feature: .forceOffline, to: (value == "true")) + case .serviceNetwork: + let (network, devnetConfig): (ServiceNetwork, ServiceNetwork.DevnetConfiguration?) = { + switch value { + case "testnet": return (.testnet, nil) + case "devnet": + /// Ensure values were provided first + guard + let pubkey: String = envVars[.devnetPubkey], + let ip: String = envVars[.devnetIp], + let httpPort: String = envVars[.devnetHttpPort], + let omqPort: String = envVars[.devnetOmqPort] + else { + let requiredKeys: Set = [ + .devnetPubkey, + .devnetIp, + .devnetHttpPort, + .devnetOmqPort + ] + let missingKeys: Set = requiredKeys.subtracting(allKeys) + Log.warn("Using testnet as required devnet environment variables are missing: \(missingKeys.map { "'\($0.rawValue)'" }.joined(separator: ", "))") + return (.testnet, nil) + } + + /// Validate each value + var errors: [String] = [] + var finalHttpPort: UInt16 = 0 + var finalOmqPort: UInt16 = 0 + + if !Hex.isValid(pubkey) || pubkey.count != 64 { + errors.append("'devnetPubkey' must be a 64 character hex string") + } + + if + ip.split(separator: ".").count != 4 || + !ip.split(separator: ".").allSatisfy({ part in + UInt8(part, radix: 10) != nil + }) + { + errors.append("'devnetIp' must be in the format: '255.255.255.255'") + } + + if let parsedHttpPort: UInt16 = UInt16(httpPort, radix: 10) { + finalHttpPort = parsedHttpPort + } + else { + errors.append("'devnetHttpPort' must be a number between 0 and 65,535") + } + + if let parsedOmqPort: UInt16 = UInt16(omqPort, radix: 10) { + finalOmqPort = parsedOmqPort + } + else { + errors.append("'devnetOmqPort' must be a number between 0 and 65,535") + } + + guard errors.isEmpty else { + Log.warn("Using testnet environment as devnet environment variables are invalid: \(errors.map { "\($0)" }.joined(separator: ", "))") + return (.testnet, nil) + } + + /// We have a valid devnet config so use it + return ( + .devnet, + ServiceNetwork.DevnetConfiguration( + pubkey: pubkey, + ip: ip, + httpPort: finalHttpPort, + omqPort: finalOmqPort + ) + ) + + default: return (.mainnet, nil) + } + }() + + await DeveloperSettingsNetworkViewModel.updateEnvironment( + serviceNetwork: network, + devnetConfig: devnetConfig, + using: dependencies + ) + + /// These are handled in the `serviceNetwork` case + case .devnetPubkey, .devnetIp, .devnetHttpPort, .devnetOmqPort: break case .debugDisappearingMessageDurations: dependencies.set(feature: .debugDisappearingMessageDurations, to: (value == "true")) diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift index b32ecb10ad..05be8a89c8 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift @@ -71,6 +71,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, public enum TableItem: Hashable, Differentiable, CaseIterable { case developerMode + case networkConfig + case resetSnodeCache case proConfig case groupConfig @@ -86,11 +88,6 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case advancedLogging case loggingCategory(String) - case serviceNetwork - case forceOffline - case resetSnodeCache - case pushNotificationService - case debugDisappearingMessageDurations case communityPollLimit @@ -111,7 +108,9 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, public var differenceIdentifier: String { switch self { case .developerMode: return "developerMode" - + + case .networkConfig: return "networkConfig" + case .resetSnodeCache: return "resetSnodeCache" case .proConfig: return "proConfig" case .groupConfig: return "groupConfig" @@ -127,11 +126,6 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .advancedLogging: return "advancedLogging" case .loggingCategory(let categoryIdentifier): return "loggingCategory-\(categoryIdentifier)" - case .serviceNetwork: return "serviceNetwork" - case .forceOffline: return "forceOffline" - case .resetSnodeCache: return "resetSnodeCache" - case .pushNotificationService: return "pushNotificationService" - case .debugDisappearingMessageDurations: return "debugDisappearingMessageDurations" case .communityPollLimit: return "communityPollLimit" @@ -154,7 +148,9 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, var result: [TableItem] = [] switch TableItem.developerMode { case .developerMode: result.append(.developerMode); fallthrough - + + case .networkConfig: result.append(.networkConfig); fallthrough + case .resetSnodeCache: result.append(.resetSnodeCache); fallthrough case .proConfig: result.append(.proConfig); fallthrough case .groupConfig: result.append(.groupConfig); fallthrough @@ -170,11 +166,6 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .advancedLogging: result.append(.advancedLogging); fallthrough case .loggingCategory: result.append(.loggingCategory("")); fallthrough - case .serviceNetwork: result.append(.serviceNetwork); fallthrough - case .forceOffline: result.append(.forceOffline); fallthrough - case .resetSnodeCache: result.append(.resetSnodeCache); fallthrough - case .pushNotificationService: result.append(.pushNotificationService); fallthrough - case .debugDisappearingMessageDurations: result.append(.debugDisappearingMessageDurations); fallthrough case .communityPollLimit: result.append(.communityPollLimit); fallthrough @@ -206,10 +197,6 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, let advancedLogging: Bool let loggingCategories: [Log.Category: Log.Level] - let serviceNetwork: ServiceNetwork - let forceOffline: Bool - let pushNotificationService: Network.PushNotification.Service - let debugDisappearingMessageDurations: Bool let communityPollLimit: Int @@ -249,10 +236,6 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, advancedLogging: (self?.showAdvancedLogging == true), loggingCategories: dependencies[feature: .allLogLevels].currentValues(using: dependencies), - serviceNetwork: dependencies[feature: .serviceNetwork], - forceOffline: dependencies[feature: .forceOffline], - pushNotificationService: dependencies[feature: .pushNotificationService], - debugDisappearingMessageDurations: dependencies[feature: .debugDisappearingMessageDurations], communityPollLimit: dependencies[feature: .communityPollLimit], @@ -284,8 +267,41 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, onTap: { [weak self] in guard current.developerMode else { return } - self?.disableDeveloperMode() + Task { [weak self] in await self?.disableDeveloperMode() } + } + ) + ] + ) + let network: SectionModel = SectionModel( + model: .network, + elements: [ + SessionCell.Info( + id: .networkConfig, + title: "Network Configuration", + subtitle: """ + Configure settings related to how and where network requests are sent. + + Service Network: \(dependencies[feature: .serviceNetwork].title) + Router: \(dependencies[feature: .router].title) + PN Service: \(dependencies[feature: .pushNotificationService].title) + """, + trailingAccessory: .icon(.chevronRight), + onTap: { [weak self, dependencies] in + self?.transitionToScreen( + SessionTableViewController( + viewModel: DeveloperSettingsNetworkViewModel(using: dependencies) + ) + ) } + ), + SessionCell.Info( + id: .resetSnodeCache, + title: "Reset Service Node Cache", + subtitle: """ + Reset and rebuild the service node cache and rebuild the paths. + """, + trailingAccessory: .highlightingBackgroundLabel(title: "Reset Cache"), + onTap: { [weak self] in self?.resetServiceNodeCache() } ) ] ) @@ -514,84 +530,6 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, } ) ) - let network: SectionModel = SectionModel( - model: .network, - elements: [ - SessionCell.Info( - id: .serviceNetwork, - title: "Environment", - subtitle: """ - The environment used for sending requests and storing messages. - - Warning: - Changing between some of these options can result in all conversation and snode data being cleared and any pending network requests being cancelled. - """, - trailingAccessory: .dropDown { current.serviceNetwork.title }, - onTap: { [weak self, dependencies] in - self?.transitionToScreen( - SessionTableViewController( - viewModel: SessionListViewModel( - title: "Environment", - options: ServiceNetwork.allCases, - behaviour: .autoDismiss( - initialSelection: current.serviceNetwork, - onOptionSelected: self?.updateServiceNetwork - ), - using: dependencies - ) - ) - ) - } - ), - SessionCell.Info( - id: .forceOffline, - title: "Force Offline", - subtitle: """ - Shut down the current network and cause all future network requests to fail after a 1 second delay with a 'serviceUnavailable' error. - """, - trailingAccessory: .toggle( - current.forceOffline, - oldValue: previous?.forceOffline - ), - onTap: { [weak self] in self?.updateForceOffline(current: current.forceOffline) } - ), - SessionCell.Info( - id: .resetSnodeCache, - title: "Reset Service Node Cache", - subtitle: """ - Reset and rebuild the service node cache and rebuild the paths. - """, - trailingAccessory: .highlightingBackgroundLabel(title: "Reset Cache"), - onTap: { [weak self] in self?.resetServiceNodeCache() } - ), - SessionCell.Info( - id: .pushNotificationService, - title: "Push Notification Service", - subtitle: """ - The service used for subscribing for push notifications. The production service only works for production builds and neither service work in the Simulator. - - Warning: - Changing this option will result in unsubscribing from the current service and subscribing to the new service which may take a few minutes. - """, - trailingAccessory: .dropDown { current.pushNotificationService.title }, - onTap: { [weak self, dependencies] in - self?.transitionToScreen( - SessionTableViewController( - viewModel: SessionListViewModel( - title: "Push Notification Service", - options: Network.PushNotification.Service.allCases, - behaviour: .autoDismiss( - initialSelection: current.pushNotificationService, - onOptionSelected: self?.updatePushNotificationService - ), - using: dependencies - ) - ) - ) - } - ) - ] - ) let disappearingMessages: SectionModel = SectionModel( model: .disappearingMessages, elements: [ @@ -743,11 +681,11 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, return [ developerMode, + network, sessionPro, groups, general, logging, - network, disappearingMessages, communities, sessionNetwork, @@ -757,70 +695,58 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, // MARK: - Functions - private func disableDeveloperMode() { + private func disableDeveloperMode() async { /// Loop through all of the sections and reset the features back to default for each one as needed (this way if a new section is added /// then we will get a compile error if it doesn't get resetting instructions added) - TableItem.allCases.forEach { item in + for item in TableItem.allCases { switch item { case .developerMode, .versionBlindedID, .scheduleLocalNotification, .copyDocumentsPath, .copyAppGroupPath, .resetSnodeCache, .createMockContacts, .exportDatabase, .importDatabase, .advancedLogging, .resetAppReviewPrompt: break /// These are actions rather than values stored as "features" so no need to do anything + + case .networkConfig: + await DeveloperSettingsNetworkViewModel.disableDeveloperMode(using: dependencies) + + case .groupConfig: DeveloperSettingsGroupsViewModel.disableDeveloperMode(using: dependencies) + case .proConfig: DeveloperSettingsProViewModel.disableDeveloperMode(using: dependencies) case .animationsEnabled: - guard dependencies.hasSet(feature: .animationsEnabled) else { return } + guard dependencies.hasSet(feature: .animationsEnabled) else { break } updateFlag(for: .animationsEnabled, to: nil) case .showStringKeys: - guard dependencies.hasSet(feature: .showStringKeys) else { return } + guard dependencies.hasSet(feature: .showStringKeys) else { break } updateFlag(for: .showStringKeys, to: nil) case .truncatePubkeysInLogs: - guard dependencies.hasSet(feature: .truncatePubkeysInLogs) else { return } + guard dependencies.hasSet(feature: .truncatePubkeysInLogs) else { break } updateFlag(for: .truncatePubkeysInLogs, to: nil) case .simulateAppReviewLimit: - guard dependencies.hasSet(feature: .simulateAppReviewLimit) else { return } + guard dependencies.hasSet(feature: .simulateAppReviewLimit) else { break } updateFlag(for: .simulateAppReviewLimit, to: nil) case .defaultLogLevel: updateDefaulLogLevel(to: nil) // Always reset case .loggingCategory: resetLoggingCategories() // Always reset - case .serviceNetwork: - guard dependencies.hasSet(feature: .serviceNetwork) else { return } - - updateServiceNetwork(to: nil) - - case .forceOffline: - guard dependencies.hasSet(feature: .forceOffline) else { return } - - updateFlag(for: .forceOffline, to: nil) - - case .pushNotificationService: - guard dependencies.hasSet(feature: .pushNotificationService) else { return } - - updatePushNotificationService(to: nil) - case .debugDisappearingMessageDurations: - guard dependencies.hasSet(feature: .debugDisappearingMessageDurations) else { return } + guard dependencies.hasSet(feature: .debugDisappearingMessageDurations) else { break } updateFlag(for: .debugDisappearingMessageDurations, to: nil) case .communityPollLimit: - guard dependencies.hasSet(feature: .communityPollLimit) else { return } + guard dependencies.hasSet(feature: .communityPollLimit) else { break } dependencies.set(feature: .communityPollLimit, to: nil) forceRefresh(type: .databaseQuery) - case .groupConfig: DeveloperSettingsGroupsViewModel.disableDeveloperMode(using: dependencies) - case .proConfig: DeveloperSettingsProViewModel.disableDeveloperMode(using: dependencies) - case .forceSlowDatabaseQueries: - guard dependencies.hasSet(feature: .forceSlowDatabaseQueries) else { return } + guard dependencies.hasSet(feature: .forceSlowDatabaseQueries) else { break } updateFlag(for: .forceSlowDatabaseQueries, to: nil) } @@ -856,188 +782,12 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, forceRefresh(type: .databaseQuery) } - private func updateServiceNetwork(to updatedNetwork: ServiceNetwork?) { - DeveloperSettingsViewModel.updateServiceNetwork(to: updatedNetwork, using: dependencies) - forceRefresh(type: .databaseQuery) - } - - private func updatePushNotificationService(to updatedService: Network.PushNotification.Service?) { - guard - dependencies[defaults: .standard, key: .isUsingFullAPNs], - updatedService != dependencies[feature: .pushNotificationService] - else { - forceRefresh(type: .databaseQuery) - return - } - - /// Disable push notifications to trigger the unsubscribe, then re-enable them after updating the feature setting - dependencies[defaults: .standard, key: .isUsingFullAPNs] = false - - SyncPushTokensJob - .run(uploadOnlyIfStale: false, using: dependencies) - .handleEvents( - receiveOutput: { [weak self, dependencies] _ in - dependencies.set(feature: .pushNotificationService, to: updatedService) - dependencies[defaults: .standard, key: .isUsingFullAPNs] = true - - self?.forceRefresh(type: .databaseQuery) - } - ) - .flatMap { [dependencies] _ in SyncPushTokensJob.run(uploadOnlyIfStale: false, using: dependencies) } - .sinkUntilComplete() - } - - internal static func updateServiceNetwork( - to updatedNetwork: ServiceNetwork?, - using dependencies: Dependencies - ) { - struct IdentityData { - let ed25519KeyPair: KeyPair - let x25519KeyPair: KeyPair - } - - /// Make sure we are actually changing the network before clearing all of the data - guard - updatedNetwork != dependencies[feature: .serviceNetwork], - let identityData: IdentityData = dependencies[singleton: .storage].read({ db in - IdentityData( - ed25519KeyPair: KeyPair( - publicKey: Array(try Identity - .filter(Identity.Columns.variant == Identity.Variant.ed25519PublicKey) - .fetchOne(db, orThrow: StorageError.objectNotFound) - .data), - secretKey: Array(try Identity - .filter(Identity.Columns.variant == Identity.Variant.ed25519SecretKey) - .fetchOne(db, orThrow: StorageError.objectNotFound) - .data) - ), - x25519KeyPair: KeyPair( - publicKey: Array(try Identity - .filter(Identity.Columns.variant == Identity.Variant.x25519PublicKey) - .fetchOne(db, orThrow: StorageError.objectNotFound) - .data), - secretKey: Array(try Identity - .filter(Identity.Columns.variant == Identity.Variant.x25519PrivateKey) - .fetchOne(db, orThrow: StorageError.objectNotFound) - .data) - ) - ) - }) - else { return } - - Log.info("[DevSettings] Swapping to \(String(describing: updatedNetwork)), clearing data") - - /// Stop all pollers - dependencies[singleton: .currentUserPoller].stop() - dependencies.remove(cache: .groupPollers) - dependencies.remove(cache: .communityPollers) - - /// Reset the network - /// - /// **Note:** We need to store the current `libSessionNetwork` cache until after we swap the `serviceNetwork` - /// and start warming the new cache, otherwise it's destruction can result in automatic recreation due to path and network - /// status observers - dependencies.mutate(cache: .libSessionNetwork) { - $0.setPaths(paths: []) - $0.setNetworkStatus(status: .unknown) - $0.suspendNetworkAccess() - $0.clearSnodeCache() - $0.clearCallbacks() - } - var oldNetworkCache: LibSession.NetworkImmutableCacheType? = dependencies[cache: .libSessionNetwork] - dependencies.remove(cache: .libSessionNetwork) - - /// Unsubscribe from push notifications (do this after resetting the network as they are server requests so aren't dependant on a service - /// layer and we don't want these to be cancelled) - if let existingToken: String = dependencies[singleton: .storage].read({ db in db[.lastRecordedPushToken] }) { - Network.PushNotification - .unsubscribeAll(token: Data(hex: existingToken), using: dependencies) - .sinkUntilComplete() - } - - /// Clear the snodeAPI caches - dependencies.remove(cache: .snodeAPI) - - /// Remove the libSession state (store the profile locally to maintain the name between environments) - let existingProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } - dependencies.remove(cache: .libSession) - - /// Remove any network-specific data - dependencies[singleton: .storage].write { [dependencies] db in - let userSessionId: SessionId = dependencies[cache: .general].sessionId - - _ = try SnodeReceivedMessageInfo.deleteAll(db) - _ = try SessionThread.deleteAll(db) - _ = try MessageDeduplication.deleteAll(db) - _ = try ClosedGroup.deleteAll(db) - _ = try OpenGroup.deleteAll(db) - _ = try Capability.deleteAll(db) - _ = try GroupMember.deleteAll(db) - _ = try Contact - .filter(Contact.Columns.id != userSessionId.hexString) - .deleteAll(db) - _ = try Profile - .filter(Profile.Columns.id != userSessionId.hexString) - .deleteAll(db) - _ = try BlindedIdLookup.deleteAll(db) - _ = try ConfigDump.deleteAll(db) - } - - /// Remove the `ExtensionHelper` cache - dependencies[singleton: .extensionHelper].deleteCache() - - Log.info("[DevSettings] Reloading state for \(String(describing: updatedNetwork))") - - /// Update to the new `ServiceNetwork` - dependencies.set(feature: .serviceNetwork, to: updatedNetwork) - - /// Start the new network cache and clear out the old one - dependencies.warmCache(cache: .libSessionNetwork) - - /// Free the `oldNetworkCache` so it can be destroyed(the 'if' is only there to prevent the "variable never read" warning) - if oldNetworkCache != nil { oldNetworkCache = nil } - - /// Run the onboarding process as if we are recovering an account (will setup the device in it's proper state) - Onboarding.Cache( - ed25519KeyPair: identityData.ed25519KeyPair, - x25519KeyPair: identityData.x25519KeyPair, - displayName: existingProfile.name - .nullIfEmpty - .defaulting(to: "Anonymous"), - using: dependencies - ).completeRegistration { [dependencies] in - /// Re-enable developer mode - dependencies.setAsync(.developerModeEnabled, true) - - /// Restart the current user poller (there won't be any other pollers though) - Task { @MainActor [currentUserPoller = dependencies[singleton: .currentUserPoller]] in - currentUserPoller.startIfNeeded() - } - - /// Re-sync the push tokens (if there are any) - SyncPushTokensJob.run(uploadOnlyIfStale: false, using: dependencies).sinkUntilComplete() - - Log.info("[DevSettings] Completed swap to \(String(describing: updatedNetwork))") - } - } - private func updateFlag(for feature: FeatureConfig, to updatedFlag: Bool?) { /// Update to the new flag dependencies.set(feature: feature, to: updatedFlag) forceRefresh(type: .databaseQuery) } - private func updateForceOffline(current: Bool) { - updateFlag(for: .forceOffline, to: !current) - - // Reset the network cache - dependencies.mutate(cache: .libSessionNetwork) { - $0.setPaths(paths: []) - $0.setNetworkStatus(status: current ? .unknown : .disconnected) - } - dependencies.remove(cache: .libSessionNetwork) - } - private func resetServiceNodeCache() { self.transitionToScreen( ConfirmationModal( @@ -1049,11 +799,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, cancelStyle: .alert_text, dismissOnConfirm: true, onConfirm: { [dependencies] _ in - /// Clear the snodeAPI cache - dependencies.remove(cache: .snodeAPI) - /// Clear the snode cache - dependencies.mutate(cache: .libSessionNetwork) { $0.clearSnodeCache() } + Task { await dependencies[singleton: .network].clearCache() } } ) ), @@ -1099,7 +846,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, modal.dismiss(animated: true) { let viewController: UIViewController = ModalActivityIndicatorViewController(canCancel: false) { indicator in - let timestampMs: Double = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let timestampMs: Double = dependencies.networkOffsetTimestampMs() let currentUserSessionId: SessionId = dependencies[cache: .general].sessionId dependencies[singleton: .storage].writeAsync( @@ -1500,142 +1247,146 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, guard let url: URL = url else { return } let viewController: UIViewController = ModalActivityIndicatorViewController(canCancel: false) { [weak self, password = self.databaseKeyEncryptionPassword, dependencies = self.dependencies] modalActivityIndicator in - do { - let tmpUnencryptPath: String = "\(dependencies[singleton: .fileManager].temporaryDirectory)/new_session.bak" - let (paths, additionalFilePaths): ([String], [String]) = try DirectoryArchiver.unarchiveDirectory( - archivePath: url.path, - destinationPath: tmpUnencryptPath, - password: password, - progressChanged: { filesSaved, totalFiles, fileProgress, fileSize in - let percentage: Int = { - guard fileSize > 0 else { return 0 } + Task { + do { + let tmpUnencryptPath: String = "\(dependencies[singleton: .fileManager].temporaryDirectory)/new_session.bak" + let (paths, additionalFilePaths): ([String], [String]) = try DirectoryArchiver.unarchiveDirectory( + archivePath: url.path, + destinationPath: tmpUnencryptPath, + password: password, + progressChanged: { filesSaved, totalFiles, fileProgress, fileSize in + let percentage: Int = { + guard fileSize > 0 else { return 0 } + + return Int((Double(fileProgress) / Double(fileSize)) * 100) + }() - return Int((Double(fileProgress) / Double(fileSize)) * 100) - }() - - DispatchQueue.main.async { - modalActivityIndicator.setMessage([ - "Decryption progress: \(percentage)%", - "Files imported: \(filesSaved)/\(totalFiles)" - ].compactMap { $0 }.joined(separator: "\n")) + DispatchQueue.main.async { + modalActivityIndicator.setMessage([ + "Decryption progress: \(percentage)%", + "Files imported: \(filesSaved)/\(totalFiles)" + ].compactMap { $0 }.joined(separator: "\n")) + } } - } - ) - - /// Test that we actually have valid access to the database - guard - let encKeyPath: String = additionalFilePaths - .first(where: { $0.hasSuffix(Storage.encKeyFilename) }), - let databasePath: String = paths - .first(where: { $0.hasSuffix(Storage.dbFileName) }) - else { throw ArchiveError.unableToFindDatabaseKey } - - DispatchQueue.main.async { - modalActivityIndicator.setMessage( - "Checking for valid database..." ) - } - - let testStorage: Storage = try Storage( - testAccessTo: databasePath, - encryptedKeyPath: encKeyPath, - encryptedKeyPassword: password, - using: dependencies - ) - - guard testStorage.isValid else { - throw ArchiveError.decryptionFailed(ArchiveError.unarchiveFailed) - } - - /// Now that we have confirmed access to the replacement database we need to - /// stop the current account from doing anything - DispatchQueue.main.async { - modalActivityIndicator.setMessage( - "Clearing current account data..." + + /// Test that we actually have valid access to the database + guard + let encKeyPath: String = additionalFilePaths + .first(where: { $0.hasSuffix(Storage.encKeyFilename) }), + let databasePath: String = paths + .first(where: { $0.hasSuffix(Storage.dbFileName) }) + else { throw ArchiveError.unableToFindDatabaseKey } + + DispatchQueue.main.async { + modalActivityIndicator.setMessage( + "Checking for valid database..." + ) + } + + let testStorage: Storage = try Storage( + testAccessTo: databasePath, + encryptedKeyPath: encKeyPath, + encryptedKeyPassword: password, + using: dependencies ) - (UIApplication.shared.delegate as? AppDelegate)?.stopPollers() - } - - /// Need to shut everything down before the swap out the data to prevent crashes - dependencies[singleton: .jobRunner].stopAndClearPendingJobs() - dependencies.remove(cache: .libSession) - dependencies.mutate(cache: .libSessionNetwork) { $0.suspendNetworkAccess() } - dependencies[singleton: .storage].suspendDatabaseAccess() - try dependencies[singleton: .storage].closeDatabase() - LibSession.clearLoggers() - - let deleteEnumerator: FileManager.DirectoryEnumerator? = FileManager.default.enumerator( - at: URL( - fileURLWithPath: dependencies[singleton: .fileManager].appSharedDataDirectoryPath - ), - includingPropertiesForKeys: [.isRegularFileKey, .isHiddenKey] - ) - let fileUrls: [URL] = (deleteEnumerator?.allObjects - .compactMap { $0 as? URL } - .filter { url -> Bool in - guard let resourceValues = try? url.resourceValues(forKeys: [.isHiddenKey]) else { - return true - } + guard testStorage.isValid else { + throw ArchiveError.decryptionFailed(ArchiveError.unarchiveFailed) + } + + /// Now that we have confirmed access to the replacement database we need to + /// stop the current account from doing anything + DispatchQueue.main.async { + modalActivityIndicator.setMessage( + "Clearing current account data..." + ) - return (resourceValues.isHidden != true) - }) - .defaulting(to: []) - try fileUrls.forEach { url in - /// The database `wal` and `shm` files might not exist anymore at this point - /// so we should only remove files which exist to prevent errors - guard FileManager.default.fileExists(atPath: url.path) else { return } + Task(priority: .userInitiated) { + await (UIApplication.shared.delegate as? AppDelegate)?.stopPollers() + } + } - try FileManager.default.removeItem(atPath: url.path) - } - - /// Current account data has been removed, we now need to copy over the - /// newly imported data - DispatchQueue.main.async { - modalActivityIndicator.setMessage( - "Moving imported data..." - ) - } - - try paths.forEach { path in - /// Need to ensure the destination directry - let targetPath: String = [ - dependencies[singleton: .fileManager].appSharedDataDirectoryPath, - path.replacingOccurrences(of: tmpUnencryptPath, with: "") - ].joined() // Already has '/' after 'appSharedDataDirectoryPath' + /// Need to shut everything down before the swap out the data to prevent crashes + dependencies[singleton: .jobRunner].stopAndClearPendingJobs() + dependencies.remove(cache: .libSession) + await dependencies[singleton: .network].suspendNetworkAccess() + dependencies[singleton: .storage].suspendDatabaseAccess() + try dependencies[singleton: .storage].closeDatabase() + LibSession.clearLoggers() - try FileManager.default.createDirectory( - atPath: URL(fileURLWithPath: targetPath) - .deletingLastPathComponent() - .path, - withIntermediateDirectories: true - ) - try FileManager.default.moveItem(atPath: path, toPath: targetPath) - } - - /// All of the main files have been moved across, we now need to replace the current database key with - /// the one included in the backup - try dependencies[singleton: .storage].replaceDatabaseKey(path: encKeyPath, password: password) - - /// The import process has completed so we need to restart the app - DispatchQueue.main.async { - self?.transitionToScreen( - ConfirmationModal( - info: ConfirmationModal.Info( - title: "Import Complete", - body: .text("The import completed successfully, Session must be reopened in order to complete the process."), - cancelTitle: "Exit", - cancelStyle: .alert_text, - onCancel: { _ in exit(0) } - ) + let deleteEnumerator: FileManager.DirectoryEnumerator? = FileManager.default.enumerator( + at: URL( + fileURLWithPath: dependencies[singleton: .fileManager].appSharedDataDirectoryPath ), - transitionType: .present + includingPropertiesForKeys: [.isRegularFileKey, .isHiddenKey] ) + let fileUrls: [URL] = (deleteEnumerator?.allObjects + .compactMap { $0 as? URL } + .filter { url -> Bool in + guard let resourceValues = try? url.resourceValues(forKeys: [.isHiddenKey]) else { + return true + } + + return (resourceValues.isHidden != true) + }) + .defaulting(to: []) + try fileUrls.forEach { url in + /// The database `wal` and `shm` files might not exist anymore at this point + /// so we should only remove files which exist to prevent errors + guard FileManager.default.fileExists(atPath: url.path) else { return } + + try FileManager.default.removeItem(atPath: url.path) + } + + /// Current account data has been removed, we now need to copy over the + /// newly imported data + DispatchQueue.main.async { + modalActivityIndicator.setMessage( + "Moving imported data..." + ) + } + + try paths.forEach { path in + /// Need to ensure the destination directry + let targetPath: String = [ + dependencies[singleton: .fileManager].appSharedDataDirectoryPath, + path.replacingOccurrences(of: tmpUnencryptPath, with: "") + ].joined() // Already has '/' after 'appSharedDataDirectoryPath' + + try FileManager.default.createDirectory( + atPath: URL(fileURLWithPath: targetPath) + .deletingLastPathComponent() + .path, + withIntermediateDirectories: true + ) + try FileManager.default.moveItem(atPath: path, toPath: targetPath) + } + + /// All of the main files have been moved across, we now need to replace the current database key with + /// the one included in the backup + try dependencies[singleton: .storage].replaceDatabaseKey(path: encKeyPath, password: password) + + /// The import process has completed so we need to restart the app + DispatchQueue.main.async { + self?.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "Import Complete", + body: .text("The import completed successfully, Session must be reopened in order to complete the process."), + cancelTitle: "Exit", + cancelStyle: .alert_text, + onCancel: { _ in exit(0) } + ) + ), + transitionType: .present + ) + } } - } - catch { - modalActivityIndicator.dismiss { - showError(error) + catch { + modalActivityIndicator.dismiss { + showError(error) + } } } } @@ -1771,12 +1522,6 @@ final class PollLimitInputView: UIView, UITextFieldDelegate, SessionCell.Accesso // MARK: - Listable Conformance -extension ServiceNetwork: @retroactive ContentIdentifiable {} -extension ServiceNetwork: @retroactive ContentEquatable {} -extension ServiceNetwork: Listable {} -extension Network.PushNotification.Service: @retroactive ContentIdentifiable {} -extension Network.PushNotification.Service: @retroactive ContentEquatable {} -extension Network.PushNotification.Service: Listable {} extension Log.Level: @retroactive ContentIdentifiable {} extension Log.Level: @retroactive ContentEquatable {} extension Log.Level: Listable {} diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index ffc90bcd3d..6920d08dda 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -177,14 +177,9 @@ final class NukeDataModal: Modal { ModalActivityIndicatorViewController .present(fromViewController: presentedViewController, canCancel: false) { [weak self, dependencies] _ in - dependencies[singleton: .storage] - .readPublisher { db -> (AuthenticationMethod, [AuthenticationMethod]) in - ( - try Authentication.with( - db, - swarmPublicKey: dependencies[cache: .general].sessionId.hexString, - using: dependencies - ), + Task(priority: .userInitiated) { [weak self, dependencies] in + do { + let communityAuth: [AuthenticationMethod] = try await dependencies[singleton: .storage].readAsync { db in try OpenGroup .filter(OpenGroup.Columns.isActive == true) .select(.server) @@ -192,68 +187,66 @@ final class NukeDataModal: Modal { .asRequest(of: String.self) .fetchSet(db) .map { try Authentication.with(db, server: $0, using: dependencies) } - ) - } - .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) - .tryFlatMap { (userAuth: AuthenticationMethod, communityAuth: [AuthenticationMethod]) -> AnyPublisher<(AuthenticationMethod, [String]), Error> in - Publishers - .MergeMany( - try communityAuth.compactMap { authMethod in - switch authMethod.info { - case .community(let server, _, _, _, _): - return try Network.SOGS.preparedClearInbox( - requestAndPathBuildTimeout: Network.defaultTimeout, - authMethod: authMethod, - using: dependencies - ) - .map { _, _ in server } - .send(using: dependencies) - - default: return nil - } + } + + /// Clear the inbox of any known communities in case the user had sent messages to them + let clearedServers: [String] = try await withThrowingTaskGroup { group in + for authMethod in communityAuth { + guard case .community(let server, _, _, _, _) = authMethod.info else { continue } + + group.addTask { + _ = try await Network.SOGS + .preparedClearInbox( + overallTimeout: Network.defaultTimeout, + authMethod: authMethod, + using: dependencies + ) + .send(using: dependencies) + .value + + return server } - ) - .collect() - .map { response in (userAuth, response.map { $0.1 }) } - .eraseToAnyPublisher() - } - .tryFlatMap { authMethod, clearedServers in - try Network.SnodeAPI + } + + var result: [String] = [] + while !group.isEmpty { + guard let value: String = try await group.next() else { + throw NetworkError.invalidResponse + } + + result.append(value) + } + + return result + } + + /// Get the latest network time before sending (to reduce the chance that the request will fail due to the + /// device clock being out of sync with the network) + let swarm: Set = try await dependencies[singleton: .network] + .getSwarm(for: dependencies[cache: .general].sessionId.hexString) + let snode: LibSession.Snode = try await SwarmDrainer(swarm: swarm, using: dependencies) + .selectNextNode() + try await Network.StorageServer.getNetworkTime(from: snode, using: dependencies) + + /// Clear the users swarm + let userAuth: AuthenticationMethod = try Authentication.with( + swarmPublicKey: dependencies[cache: .general].sessionId.hexString, + using: dependencies + ) + var confirmations: [String: Bool] = try await Network.StorageServer .preparedDeleteAllMessages( namespace: .all, - requestAndPathBuildTimeout: Network.defaultTimeout, - authMethod: authMethod, + snode: snode, + overallTimeout: Network.defaultTimeout, + authMethod: userAuth, using: dependencies ) .send(using: dependencies) - .map { _, data in - clearedServers.reduce(into: data) { result, next in result[next] = true } - } - } - .receive(on: DispatchQueue.main, using: dependencies) - .sinkUntilComplete( - receiveCompletion: { result in - switch result { - case .finished: break - case .failure: - self?.dismiss(animated: true, completion: nil) // Dismiss the loader - - let modal: ConfirmationModal = ConfirmationModal( - targetView: self?.view, - info: ConfirmationModal.Info( - title: "clearDataAll".localized(), - body: .text("clearDataErrorDescriptionGeneric".localized()), - confirmTitle: "clearDevice".localized(), - confirmStyle: .danger, - cancelStyle: .alert_text - ) { [weak self] _ in - self?.clearDeviceOnly() - } - ) - self?.present(modal, animated: true) - } - }, - receiveValue: { confirmations in + + /// Add the cleared Community servers so we have a full list + clearedServers.forEach { confirmations[$0] = true } + + await MainActor.run { self?.dismiss(animated: true, completion: nil) // Dismiss the loader // Get a list of nodes which failed to delete the data @@ -281,7 +274,27 @@ final class NukeDataModal: Modal { ) self?.present(modal, animated: true) } - ) + } + catch { + await MainActor.run { + self?.dismiss(animated: true, completion: nil) // Dismiss the loader + + let modal: ConfirmationModal = ConfirmationModal( + targetView: self?.view, + info: ConfirmationModal.Info( + title: "clearDataAll".localized(), + body: .text("clearDataErrorDescriptionGeneric".localized()), + confirmTitle: "clearDevice".localized(), + confirmStyle: .danger, + cancelStyle: .alert_text + ) { [weak self] _ in + self?.clearDeviceOnly() + } + ) + self?.present(modal, animated: true) + } + } + } } } @@ -296,9 +309,12 @@ final class NukeDataModal: Modal { UIApplication.shared.unregisterForRemoteNotifications() if let deviceToken: String = maybeDeviceToken, dependencies[singleton: .storage].isValid { - Network.PushNotification - .unsubscribeAll(token: Data(hex: deviceToken), using: dependencies) - .sinkUntilComplete() + Task.detached(priority: .userInitiated) { + try? await Network.PushNotification.unsubscribeAll( + token: Data(hex: deviceToken), + using: dependencies + ) + } } } @@ -312,21 +328,23 @@ final class NukeDataModal: Modal { dependencies[singleton: .notificationsManager].clearAllNotifications() UIApplication.shared.applicationIconBadgeNumber = 0 - // Stop any pollers - (UIApplication.shared.delegate as? AppDelegate)?.stopPollers() - // Call through to the SessionApp's "resetAppData" which will wipe out logs, database and // profile storage let wasUnlinked: Bool = dependencies[defaults: .standard, key: .wasUnlinked] let serviceNetwork: ServiceNetwork = dependencies[feature: .serviceNetwork] - dependencies[singleton: .app].resetData { [dependencies] in - // Resetting the data clears the old user defaults. We need to restore the unlink default. - dependencies[defaults: .standard, key: .wasUnlinked] = wasUnlinked + Task { + // Stop any pollers + await (UIApplication.shared.delegate as? AppDelegate)?.stopPollers() - // We also want to keep the `ServiceNetwork` setting (so someone testing can delete and restore - // accounts on Testnet without issue - dependencies.set(feature: .serviceNetwork, to: serviceNetwork) + await dependencies[singleton: .app].resetData { [dependencies] in + // Resetting the data clears the old user defaults. We need to restore the unlink default. + dependencies[defaults: .standard, key: .wasUnlinked] = wasUnlinked + + // We also want to keep the `ServiceNetwork` setting (so someone testing can delete and restore + // accounts on Testnet without issue + dependencies.set(feature: .serviceNetwork, to: serviceNetwork) + } } } } diff --git a/Session/Settings/SessionNetworkScreen/SessionNetworkScreen+ViewModel.swift b/Session/Settings/SessionNetworkScreen/SessionNetworkScreen+ViewModel.swift index dfefd70e69..7b2b033f57 100644 --- a/Session/Settings/SessionNetworkScreen/SessionNetworkScreen+ViewModel.swift +++ b/Session/Settings/SessionNetworkScreen/SessionNetworkScreen+ViewModel.swift @@ -11,84 +11,190 @@ import SessionMessagingKit extension SessionNetworkScreenContent { public class ViewModel: ObservableObject, ViewModelType { - @Published public var dataModel: DataModel + @Published public var state: State @Published public var isRefreshing: Bool = false @Published public var lastRefreshWasSuccessful: Bool = false @Published public var errorString: String? = nil @Published public var lastUpdatedTimeString: String? = nil - private var observationCancellable: AnyCancellable? - private var dependencies: Dependencies - - private var disposables = Set() + private let dependencies: Dependencies + private var observationTask: Task? + private var getInfoTask: Task? private var timer: Timer? = nil + public struct ObservableState: ObservableKeyProvider { + public let state: State + + public var observedKeys: Set = [ + .keyValue(.contractAddress), + .keyValue(.tokenUsd), + .keyValue(.priceTimestampMs), + .keyValue(.stakingRequirement), + .keyValue(.networkSize), + .keyValue(.networkStakedUSD), + .keyValue(.stakingRewardPool), + .keyValue(.marketCapUsd), + .keyValue(.lastUpdatedTimestampMs), + .conversationCreated, + .anyConversationDeleted + ] + } + + init(dependencies: Dependencies) { self.dependencies = dependencies - self.dataModel = DataModel() + self.state = State( + snodesInCurrentSwarm: 0, + snodesInTotal: 0, + contractAddress: nil, + tokenUSD: nil, + priceTimestampMs: 0, + stakingRequirement: 0, + networkSize: 0, + networkStakedTokens: 0, + networkStakedUSD: 0, + stakingRewardPool: nil, + marketCapUSD: nil, + totalTargetConversations: 0, + lastUpdatedTimestampMs: nil + ) - let userSessionId: SessionId = dependencies[cache: .general].sessionId - self.observationCancellable = ObservationBuilderOld - .databaseObservation(dependencies) { [dependencies] db in - let swarmNodesCount: Int = dependencies[cache: .libSessionNetwork].snodeNumber[userSessionId.hexString] ?? 0 - let snodeInTotal: Int = { - let pathsCount: Int = dependencies[cache: .libSessionNetwork].currentPaths.count - let validThreadVariants: [SessionThread.Variant] = [.contact, .group, .legacyGroup] - let convosInTotal: Int = ( - try? SessionThread - .filter(validThreadVariants.contains(SessionThread.Columns.variant)) - .fetchAll(db) - ) - .defaulting(to: []) - .count - let calculatedSnodeInTotal = swarmNodesCount + pathsCount * 3 + convosInTotal * 6 - if let networkSize = db[.networkSize] { - return min(networkSize, calculatedSnodeInTotal) - } - return calculatedSnodeInTotal - }() + self.observationTask = ObservationBuilder + .initialValue(ObservableState(state: state)) + .debounce(for: .milliseconds(250)) + .using(dependencies: dependencies) + .query(ViewModel.queryState) + .assign { [weak self] updatedState in + self?.state = updatedState.state + self?.updateLastUpdatedTimeString() + } + } + + deinit { + getInfoTask?.cancel() + observationTask?.cancel() + } + + @Sendable private static func queryState( + previousState: ObservableState, + events: [ObservedEvent], + isInitialQuery: Bool, + using dependencies: Dependencies + ) async -> ObservableState { + var contractAddress: String? = previousState.state.contractAddress + var tokenUSD: Double? = previousState.state.tokenUSD + var priceTimestampMs: Int64 = previousState.state.priceTimestampMs + var stakingRequirement: Double = previousState.state.stakingRequirement + var networkSize: Int = previousState.state.networkSize + var networkStakedTokens: Double = previousState.state.networkStakedTokens + var networkStakedUSD: Double = previousState.state.networkStakedUSD + var stakingRewardPool: Double? = previousState.state.stakingRewardPool + var marketCapUSD: Double? = previousState.state.marketCapUSD + var lastUpdatedTimestampMs: Int64? = previousState.state.lastUpdatedTimestampMs + var totalTargetConversations: Int = previousState.state.totalTargetConversations + let validThreadVariants: [SessionThread.Variant] = [.contact, .group, .legacyGroup] + + /// On the first query we want to load the state from the database + if isInitialQuery { + try? await dependencies[singleton: .storage].readAsync { db in + contractAddress = (db[.contractAddress] ?? contractAddress) + tokenUSD = (db[.tokenUsd] ?? tokenUSD) + priceTimestampMs = (db[.priceTimestampMs] ?? priceTimestampMs) + stakingRequirement = (db[.stakingRequirement] ?? stakingRequirement) + networkSize = (db[.networkSize] ?? networkSize) + networkStakedTokens = (db[.networkStakedTokens] ?? networkStakedTokens) + networkStakedUSD = (db[.networkStakedUSD] ?? networkStakedUSD) + stakingRewardPool = (db[.stakingRewardPool] ?? stakingRewardPool) + marketCapUSD = (db[.marketCapUsd] ?? marketCapUSD) + lastUpdatedTimestampMs = (db[.lastUpdatedTimestampMs] ?? lastUpdatedTimestampMs) - return DataModel( - snodesInCurrentSwarm: swarmNodesCount, - snodesInTotal: snodeInTotal, - contractAddress: db[.contractAddress], - tokenUSD: db[.tokenUsd], - priceTimestampMs: db[.priceTimestampMs] ?? 0, - stakingRequirement: db[.stakingRequirement] ?? 0, - networkSize: db[.networkSize] ?? 0, - networkStakedTokens: db[.networkStakedTokens] ?? 0, - networkStakedUSD: db[.networkStakedUSD] ?? 0, - stakingRewardPool: db[.stakingRewardPool], - marketCapUSD: db[.marketCapUsd], - lastUpdatedTimestampMs: db[.lastUpdatedTimestampMs] - ) + totalTargetConversations = (try? SessionThread + .filter(validThreadVariants.contains(SessionThread.Columns.variant)) + .fetchCount(db)) + .defaulting(to: 0) } - .sink( - receiveCompletion: { _ in /* ignore error */ }, - receiveValue: { [weak self] dataModel in - self?.dataModel = dataModel - self?.updateLastUpdatedTimeString() - } + } + + /// Re-fetch the total conversation count if needed + if events.contains(where: { $0.key == .conversationCreated || $0.key == .anyConversationDeleted }) { + try? await dependencies[singleton: .storage].readAsync { db in + totalTargetConversations = (try? SessionThread + .filter(validThreadVariants.contains(SessionThread.Columns.variant)) + .fetchCount(db)) + .defaulting(to: 0) + } + } + + /// Extract data changes from events + events.forEach { event in + switch (event.key, event.value) { + case (.keyValue(.contractAddress), let value as String): contractAddress = value + case (.keyValue(.tokenUsd), let value as Double): tokenUSD = value + case (.keyValue(.priceTimestampMs), let value as Int64): priceTimestampMs = value + case (.keyValue(.stakingRequirement), let value as Double): stakingRequirement = value + case (.keyValue(.networkSize), let value as Int): networkSize = value + case (.keyValue(.networkStakedTokens), let value as Double): networkStakedTokens = value + case (.keyValue(.networkStakedUSD), let value as Double): networkStakedUSD = value + case (.keyValue(.networkStakedTokens), let value as Double): networkStakedTokens = value + case (.keyValue(.stakingRewardPool), let value as Double?): stakingRewardPool = value + case (.keyValue(.marketCapUsd), let value as Double?): marketCapUSD = value + case (.keyValue(.lastUpdatedTimestampMs), let value as Int64?): lastUpdatedTimestampMs = value + case (.conversationCreated, _), (.anyConversationDeleted, _): break + default: + Log.warn("[SessionNetworkScreen] Received update event with unknown key: \(event.key)") + break + } + } + + /// Retrieve the latest state from the network + let userSessionId: SessionId = dependencies[cache: .general].sessionId + let snodesInCurrentSwarm: Int = ((try? await dependencies[singleton: .network] + .getSwarm(for: userSessionId.hexString) + .count) ?? 0) + let pathsCount: Int = ((try? await dependencies[singleton: .network].getActivePaths().count) ?? 0) + let calculatedSnodeInTotal: Int = (snodesInCurrentSwarm + pathsCount * 3 + totalTargetConversations * 6) + let snodesInTotal: Int = min(networkSize, calculatedSnodeInTotal) + + return ObservableState( + state: State( + snodesInCurrentSwarm: snodesInCurrentSwarm, + snodesInTotal: snodesInTotal, + contractAddress: contractAddress, + tokenUSD: tokenUSD, + priceTimestampMs: priceTimestampMs, + stakingRequirement: stakingRequirement, + networkSize: networkSize, + networkStakedTokens: networkStakedTokens, + networkStakedUSD: networkStakedUSD, + stakingRewardPool: stakingRewardPool, + marketCapUSD: marketCapUSD, + totalTargetConversations: totalTargetConversations, + lastUpdatedTimestampMs: lastUpdatedTimestampMs ) + ) } public func fetchDataFromNetwork() { guard !self.isRefreshing else { return } + self.isRefreshing.toggle() self.lastRefreshWasSuccessful = false - Network.SessionNetwork.client.getInfo(using: dependencies) - .subscribe(on: Network.SessionNetwork.workQueue, using: dependencies) - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { _ in }, - receiveValue: { [weak self] didRefreshSuccessfully in - self?.lastRefreshWasSuccessful = didRefreshSuccessfully - self?.isRefreshing.toggle() + getInfoTask = Task { [weak self, client = dependencies[singleton: .sessionNetworkApiClient]] in + do { + _ = try await client.getInfo() + await MainActor.run { [weak self] in + self?.lastRefreshWasSuccessful = true } - ) - .store(in: &disposables) + } catch { + await MainActor.run { [weak self] in + self?.lastRefreshWasSuccessful = false + } + } + + self?.isRefreshing.toggle() + } } public func openURL(_ url: URL) { @@ -97,7 +203,7 @@ extension SessionNetworkScreenContent { private func updateLastUpdatedTimeString() { self.lastUpdatedTimeString = { - guard let lastUpdatedTimestampMs = dataModel.lastUpdatedTimestampMs else { return nil } + guard let lastUpdatedTimestampMs = state.lastUpdatedTimestampMs else { return nil } return String.formattedRelativeTime( lastUpdatedTimestampMs, minimumUnit: .minute @@ -106,7 +212,7 @@ extension SessionNetworkScreenContent { self.timer?.invalidate() self.timer = Timer.scheduledTimerOnMainThread(withTimeInterval: 60, repeats: true, using: dependencies) { [weak self] _ in self?.lastUpdatedTimeString = { - guard let lastUpdatedTimestampMs = self?.dataModel.lastUpdatedTimestampMs else { return nil } + guard let lastUpdatedTimestampMs = self?.state.lastUpdatedTimestampMs else { return nil } return String.formattedRelativeTime( lastUpdatedTimestampMs, minimumUnit: .minute diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index f8fa6a6a42..dd08b5ee4d 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -7,6 +7,7 @@ import GRDB import DifferenceKit import SessionUIKit import SessionMessagingKit +import SessionNetworkingKit import SessionUtilitiesKit import SignalUtilitiesKit @@ -260,10 +261,9 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl size: .hero, profile: state.profile, profileIcon: { - switch (state.serviceNetwork, state.forceOffline) { - case (.testnet, false): return .letter("T", false) // stringlint:ignore - case (.testnet, true): return .letter("T", true) // stringlint:ignore - default: return .none + switch (state.serviceNetwork, state.serviceNetwork.title.first) { + case (.mainnet, _), (_, .none): return .none + case (_, .some(let letter)): return .letter(letter, state.forceOffline) } }() ), diff --git a/Session/Shared/ScreenLockWindow.swift b/Session/Shared/ScreenLockWindow.swift index 0a2c4e9a9e..077f63468c 100644 --- a/Session/Shared/ScreenLockWindow.swift +++ b/Session/Shared/ScreenLockWindow.swift @@ -11,7 +11,7 @@ import SessionUtilitiesKit public extension Singleton { static let screenLock: SingletonConfig = Dependencies.create( identifier: "screenLock", - createInstance: { dependencies in ScreenLockWindow(using: dependencies) } + createInstance: { dependencies, _ in ScreenLockWindow(using: dependencies) } ) } diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index b03942a719..c5deb43dbe 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -17,208 +17,197 @@ public extension Log.Category { // MARK: - BackgroundPoller -public final class BackgroundPoller { - typealias Pollers = ( - currentUser: CurrentUserPoller, - groups: [GroupPoller], - communities: [CommunityPoller] - ) - - public func poll(using dependencies: Dependencies) -> AnyPublisher { - let pollStart: TimeInterval = dependencies.dateNow.timeIntervalSince1970 +public actor BackgroundPoller { + public func poll(using dependencies: Dependencies) async -> Bool { + typealias PollerData = ( + groupIds: Set, + servers: Set, + rooms: [String] + ) - return dependencies[singleton: .storage] - .readPublisher { db -> (Set, Set, [String]) in - ( - try ClosedGroup - .select(.threadId) - .joining( - required: ClosedGroup.members - .filter(GroupMember.Columns.profileId == dependencies[cache: .general].sessionId.hexString) - ) - .asRequest(of: String.self) - .fetchSet(db), - /// 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 - /// - /// We also want to exclude any rooms which have failed to poll too many times in a row from - /// the background poll as they are likely to fail again - try OpenGroup - .select(.server) - .filter( - OpenGroup.Columns.roomToken != "" && - OpenGroup.Columns.isActive && - OpenGroup.Columns.pollFailureCount < CommunityPoller.maxRoomFailureCountForBackgroundPoll - ) - .distinct() - .asRequest(of: String.self) - .fetchSet(db), - try OpenGroup - .select(.roomToken) - .filter( - OpenGroup.Columns.roomToken != "" && - OpenGroup.Columns.isActive && - OpenGroup.Columns.pollFailureCount < CommunityPoller.maxRoomFailureCountForBackgroundPoll - ) - .distinct() - .asRequest(of: String.self) - .fetchAll(db) - ) - } - .catch { _ in Just(([], [], [])).eraseToAnyPublisher() } - .handleEvents( - receiveOutput: { groupIds, servers, rooms in - Log.info(.backgroundPoller, "Fetching Users: 1, Groups: \(groupIds.count), Communities: \(servers.count) (\(rooms.count) room(s)).") - } - ) - .map { groupIds, servers, _ -> Pollers in - let currentUserPoller: CurrentUserPoller = CurrentUserPoller( - pollerName: "Background Main Poller", - pollerQueue: DispatchQueue.main, - pollerDestination: .swarm(dependencies[cache: .general].sessionId.hexString), - pollerDrainBehaviour: .limitedReuse(count: 6), - namespaces: CurrentUserPoller.namespaces, - shouldStoreMessages: true, - logStartAndStopCalls: false, - using: dependencies - ) - let groupPollers: [GroupPoller] = groupIds.map { groupId in - GroupPoller( - pollerName: "Background Group poller for: \(groupId)", // stringlint:ignore - pollerQueue: DispatchQueue.main, - pollerDestination: .swarm(groupId), - pollerDrainBehaviour: .alwaysRandom, - namespaces: GroupPoller.namespaces(swarmPublicKey: groupId), - shouldStoreMessages: true, - logStartAndStopCalls: false, - using: dependencies + let pollStart: TimeInterval = dependencies.dateNow.timeIntervalSince1970 + let maybeData: PollerData? = try? await dependencies[singleton: .storage].readAsync { db in + ( + try ClosedGroup + .select(.threadId) + .joining( + required: ClosedGroup.members + .filter(GroupMember.Columns.profileId == dependencies[cache: .general].sessionId.hexString) ) - } - let communityPollers: [CommunityPoller] = servers.map { server in - CommunityPoller( - pollerName: "Background Community poller for: \(server)", // stringlint:ignore - pollerQueue: DispatchQueue.main, - pollerDestination: .server(server), - failureCount: 0, - shouldStoreMessages: true, - logStartAndStopCalls: false, - using: dependencies + .asRequest(of: String.self) + .fetchSet(db), + /// 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 + /// + /// We also want to exclude any rooms which have failed to poll too many times in a row from + /// the background poll as they are likely to fail again + try OpenGroup + .select(.server) + .filter( + OpenGroup.Columns.roomToken != "" && + OpenGroup.Columns.isActive && + OpenGroup.Columns.pollFailureCount < CommunityPoller.maxRoomFailureCountForBackgroundPoll ) - } - - return (currentUserPoller, groupPollers, communityPollers) - } - .flatMap { currentUserPoller, groupPollers, communityPollers in - /// Need to map back to the pollers to ensure they don't get released until after the polling finishes - Publishers.MergeMany( - [BackgroundPoller.pollUserMessages(poller: currentUserPoller, using: dependencies)] - .appending(contentsOf: BackgroundPoller.poll(pollers: groupPollers, using: dependencies)) - .appending(contentsOf: BackgroundPoller.poll(pollerInfo: communityPollers, using: dependencies)) - ) - .collect() - .map { _ in (currentUserPoller, groupPollers, communityPollers) } - } - .map { _ in () } - .handleEvents( - receiveOutput: { _ in - let endTime: TimeInterval = dependencies.dateNow.timeIntervalSince1970 - let duration: TimeUnit = .seconds(endTime - pollStart) - Log.info(.backgroundPoller, "Finished polling after \(duration, unit: .s).") - } + .distinct() + .asRequest(of: String.self) + .fetchSet(db), + try OpenGroup + .select(.roomToken) + .filter( + OpenGroup.Columns.roomToken != "" && + OpenGroup.Columns.isActive && + OpenGroup.Columns.pollFailureCount < CommunityPoller.maxRoomFailureCountForBackgroundPoll + ) + .distinct() + .asRequest(of: String.self) + .fetchAll(db) + ) + } + + guard let data: PollerData = maybeData else { return false } + + Log.info(.backgroundPoller, "Fetching Users: 1, Groups: \(data.groupIds.count), Communities: \(data.servers.count) (\(data.rooms.count) room(s)).") + let currentUserPoller: CurrentUserPoller = CurrentUserPoller( + pollerName: "Background Main Poller", + destination: .swarm(dependencies[cache: .general].sessionId.hexString), + swarmDrainStrategy: .alwaysRandom, + namespaces: CurrentUserPoller.namespaces, + shouldStoreMessages: true, + logStartAndStopCalls: false, + key: nil, + using: dependencies + ) + let groupPollers: [GroupPoller] = data.groupIds.map { groupId in + GroupPoller( + pollerName: "Background Group poller for: \(groupId)", // stringlint:ignore + destination: .swarm(groupId), + swarmDrainStrategy: .alwaysRandom, + namespaces: GroupPoller.namespaces(swarmPublicKey: groupId), + shouldStoreMessages: true, + logStartAndStopCalls: false, + key: nil, + using: dependencies + ) + } + let communityPollers: [CommunityPoller] = data.servers.map { server in + CommunityPoller( + pollerName: "Background Community poller for: \(server)", // stringlint:ignore + destination: .server(server), + failureCount: 0, + shouldStoreMessages: true, + logStartAndStopCalls: false, + using: dependencies + ) + } + + let hadMessages: Bool = await withTaskGroup { group in + BackgroundPoller.pollUserMessages( + poller: currentUserPoller, + in: &group, + using: dependencies ) - .eraseToAnyPublisher() + BackgroundPoller.poll( + pollers: groupPollers, + in: &group, + using: dependencies + ) + BackgroundPoller.poll( + pollerInfo: communityPollers, + in: &group, + using: dependencies + ) + + return await group.reduce(false) { $0 || $1 } + } + + let endTime: TimeInterval = dependencies.dateNow.timeIntervalSince1970 + let duration: TimeUnit = .seconds(endTime - pollStart) + Log.info(.backgroundPoller, "Finished polling after \(duration, unit: .s).") + + return hadMessages } private static func pollUserMessages( poller: CurrentUserPoller, + in group: inout TaskGroup, using dependencies: Dependencies - ) -> AnyPublisher { - let pollStart: TimeInterval = dependencies.dateNow.timeIntervalSince1970 - - return poller - .pollFromBackground() - .handleEvents( - receiveOutput: { [pollerName = poller.pollerName] _, _, validMessageCount, _ in - let endTime: TimeInterval = dependencies.dateNow.timeIntervalSince1970 - let duration: TimeUnit = .seconds(endTime - pollStart) - Log.info(.backgroundPoller, "\(pollerName) received \(validMessageCount) valid message(s) after \(duration, unit: .s).") - }, - receiveCompletion: { [pollerName = poller.pollerName] result in - switch result { - case .finished: break - case .failure(let error): - let endTime: TimeInterval = dependencies.dateNow.timeIntervalSince1970 - let duration: TimeUnit = .seconds(endTime - pollStart) - Log.error(.backgroundPoller, "\(pollerName) failed after \(duration, unit: .s) due to error: \(error).") - } - } - ) - .map { _ in () } - .catch { _ in Just(()).eraseToAnyPublisher() } - .eraseToAnyPublisher() + ) { + group.addTask { + let pollStart: TimeInterval = dependencies.dateNow.timeIntervalSince1970 + + do { + let validMessageCount: Int = try await poller.pollFromBackground().validMessageCount + let endTime: TimeInterval = dependencies.dateNow.timeIntervalSince1970 + let duration: TimeUnit = .seconds(endTime - pollStart) + Log.info(.backgroundPoller, "\(await poller.pollerName) received \(validMessageCount) valid message(s) after \(duration, unit: .s).") + + return (validMessageCount > 0) + } + catch { + let endTime: TimeInterval = dependencies.dateNow.timeIntervalSince1970 + let duration: TimeUnit = .seconds(endTime - pollStart) + Log.error(.backgroundPoller, "\(await poller.pollerName) failed after \(duration, unit: .s) due to error: \(error).") + + return false + } + } } private static func poll( pollers: [GroupPoller], + in group: inout TaskGroup, 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) - return pollers.map { poller in - let pollStart: TimeInterval = dependencies.dateNow.timeIntervalSince1970 - - return poller - .pollFromBackground() - .handleEvents( - receiveOutput: { [pollerName = poller.pollerName] _, _, validMessageCount, _ in - let endTime: TimeInterval = dependencies.dateNow.timeIntervalSince1970 - let duration: TimeUnit = .seconds(endTime - pollStart) - Log.info(.backgroundPoller, "\(pollerName) received \(validMessageCount) valid message(s) after \(duration, unit: .s).") - }, - receiveCompletion: { [pollerName = poller.pollerName] result in - switch result { - case .finished: break - case .failure(let error): - let endTime: TimeInterval = dependencies.dateNow.timeIntervalSince1970 - let duration: TimeUnit = .seconds(endTime - pollStart) - Log.error(.backgroundPoller, "\(pollerName) failed after \(duration, unit: .s) due to error: \(error).") - } - } - ) - .map { _ in () } - .catch { _ in Just(()).eraseToAnyPublisher() } - .eraseToAnyPublisher() + for poller in pollers { + group.addTask { + let pollStart: TimeInterval = dependencies.dateNow.timeIntervalSince1970 + + do { + let validMessageCount: Int = try await poller.pollFromBackground().validMessageCount + let endTime: TimeInterval = dependencies.dateNow.timeIntervalSince1970 + let duration: TimeUnit = .seconds(endTime - pollStart) + Log.info(.backgroundPoller, "\(await poller.pollerName) received \(validMessageCount) valid message(s) after \(duration, unit: .s).") + + return (validMessageCount > 0) + } + catch { + let endTime: TimeInterval = dependencies.dateNow.timeIntervalSince1970 + let duration: TimeUnit = .seconds(endTime - pollStart) + Log.error(.backgroundPoller, "\(await poller.pollerName) failed after \(duration, unit: .s) due to error: \(error).") + + return false + } + } } } private static func poll( pollerInfo: [CommunityPoller], + in group: inout TaskGroup, using dependencies: Dependencies - ) -> [AnyPublisher] { - return pollerInfo.map { poller -> AnyPublisher in - let pollStart: TimeInterval = dependencies.dateNow.timeIntervalSince1970 - - return poller - .pollFromBackground() - .handleEvents( - receiveOutput: { [pollerName = poller.pollerName] _, _, rawMessageCount, _ in - let endTime: TimeInterval = dependencies.dateNow.timeIntervalSince1970 - let duration: TimeUnit = .seconds(endTime - pollStart) - Log.info(.backgroundPoller, "\(pollerName) received \(rawMessageCount) message(s) succeeded after \(duration, unit: .s).") - }, - receiveCompletion: { [pollerName = poller.pollerName] result in - switch result { - case .finished: break - case .failure(let error): - let endTime: TimeInterval = dependencies.dateNow.timeIntervalSince1970 - let duration: TimeUnit = .seconds(endTime - pollStart) - Log.error(.backgroundPoller, "\(pollerName) failed after \(duration, unit: .s) due to error: \(error).") - } - } - ) - .map { _ in () } - .catch { _ in Just(()).eraseToAnyPublisher() } - .eraseToAnyPublisher() + ) { + for poller in pollerInfo { + group.addTask { + let pollStart: TimeInterval = dependencies.dateNow.timeIntervalSince1970 + + do { + let rawMessageCount: Int = try await poller.pollFromBackground().rawMessageCount + let endTime: TimeInterval = dependencies.dateNow.timeIntervalSince1970 + let duration: TimeUnit = .seconds(endTime - pollStart) + Log.info(.backgroundPoller, "\(await poller.pollerName) received \(rawMessageCount) message(s) succeeded after \(duration, unit: .s).") + + return (rawMessageCount > 0) + } + catch { + let endTime: TimeInterval = dependencies.dateNow.timeIntervalSince1970 + let duration: TimeUnit = .seconds(endTime - pollStart) + Log.error(.backgroundPoller, "\(await poller.pollerName) failed after \(duration, unit: .s) due to error: \(error).") + + return false + } + } } } } diff --git a/Session/Utilities/IP2Country.swift b/Session/Utilities/IP2Country.swift index 56b9102bba..b5928ed3b6 100644 --- a/Session/Utilities/IP2Country.swift +++ b/Session/Utilities/IP2Country.swift @@ -10,12 +10,10 @@ import SessionUtilitiesKit // MARK: - Cache -public extension Cache { - static let ip2Country: CacheConfig = Dependencies.create( +public extension Singleton { + static let ip2Country: SingletonConfig = Dependencies.create( identifier: "ip2Country", - createInstance: { dependencies in IP2Country(using: dependencies) }, - mutableInstance: { $0 }, - immutableInstance: { $0 } + createInstance: { dependencies, _ in IP2Country(using: dependencies) } ) } @@ -27,9 +25,8 @@ public extension Log.Category { // MARK: - IP2Country -fileprivate class IP2Country: IP2CountryCacheType { +fileprivate actor IP2Country: IP2CountryType { private var countryNamesCache: [String: String] = [:] - private let _cacheLoaded: CurrentValueSubject = CurrentValueSubject(false) private var disposables: Set = Set() private var currentLocale: String { let result: String? = Locale.current.identifier @@ -44,9 +41,7 @@ fileprivate class IP2Country: IP2CountryCacheType { return (result ?? "en") // Fallback to English } - public var cacheLoaded: AnyPublisher { - _cacheLoaded.filter { $0 }.eraseToAnyPublisher() - } + public var isLoaded: Bool = false // MARK: - Tables @@ -166,91 +161,50 @@ fileprivate class IP2Country: IP2CountryCacheType { // MARK: - Initialization init(using dependencies: Dependencies) { - /// Ensure the lookup tables get loaded in the background - DispatchQueue.global(qos: .utility).async { [weak self] in - _ = self?.cache - - /// Then register for path change callbacks which will be used to update the country name cache - self?.registerNetworkObservables(using: dependencies) + Task { [weak self] in + _ = await self?.cache + await self?.setLoaded(true) + Log.info(.ip2Country, "IP2Country cache loaded.") } } // MARK: - Functions - private func registerNetworkObservables(using dependencies: Dependencies) { - /// Register for path change callbacks which will be used to update the country name cache - dependencies[cache: .libSessionNetwork].paths - .subscribe(on: DispatchQueue.global(qos: .utility), using: dependencies) - .receive(on: DispatchQueue.global(qos: .utility), using: dependencies) - .sink( - receiveCompletion: { [weak self] _ in - /// If the stream completes it means the network cache was reset in which case we want to - /// re-register for updates in the next run loop (as the new cache should be created by then) - DispatchQueue.global(qos: .background).async { - self?.registerNetworkObservables(using: dependencies) - } - }, - receiveValue: { [weak self] paths in - dependencies.mutate(cache: .ip2Country) { _ in - self?.populateCacheIfNeeded(paths: paths) - } - } - ) - .store(in: &disposables) - } - - private func populateCacheIfNeeded(paths: [[LibSession.Snode]]) { - guard !paths.isEmpty else { return } - - paths.forEach { path in - path.forEach { snode in - self.cacheCountry(for: snode.ip, inCache: &countryNamesCache) - } - } - - self._cacheLoaded.send(true) - Log.info(.ip2Country, "Update onion request path countries.") + private func setLoaded(_ loaded: Bool) { + self.isLoaded = loaded } - private func cacheCountry(for ip: String, inCache nameCache: inout [String: String]) { - let currentLocale: String = self.currentLocale // Store local copy for efficiency - - guard nameCache["\(ip)-\(currentLocale)"] == nil else { return } + public func country(for ip: String) async -> String { + guard isLoaded else { return "onionRoutingPathUnknownCountry".localized() } - guard - let ipAsInt: Int64 = IPv4.toInt(ip), - let countryBlockGeonameIdIndex: Int = cache.countryBlocksIPInt.firstIndex(where: { $0 > ipAsInt }).map({ $0 - 1 }), - let localeStartIndex: Int = cache.countryLocationsLocaleCode.firstIndex(where: { $0 == currentLocale }), - let countryNameIndex: Int = Array(cache.countryLocationsGeonameId[localeStartIndex...]).firstIndex(where: { geonameId in - geonameId == cache.countryBlocksGeonameId[countryBlockGeonameIdIndex] - }), - (localeStartIndex + countryNameIndex) < cache.countryLocationsCountryName.count - else { return } - - let result: String = cache.countryLocationsCountryName[localeStartIndex + countryNameIndex] - nameCache["\(ip)-\(currentLocale)"] = result - } - - // MARK: - Functions - - public func country(for ip: String) -> String { - guard _cacheLoaded.value else { return "resolving".localized() } + let currentLocale: String = self.currentLocale /// Store local copy for efficiency + let key: String = "\(ip)-\(currentLocale)" - return (countryNamesCache["\(ip)-\(currentLocale)"] ?? "onionRoutingPathUnknownCountry".localized()) + switch countryNamesCache[key] { + case .some(let value): return value + case .none: + guard + let ipAsInt: Int64 = IPv4.toInt(ip), + let countryBlockGeonameIdIndex: Int = cache.countryBlocksIPInt.firstIndex(where: { $0 > ipAsInt }).map({ $0 - 1 }), + let localeStartIndex: Int = cache.countryLocationsLocaleCode.firstIndex(where: { $0 == currentLocale }), + let countryNameIndex: Int = Array(cache.countryLocationsGeonameId[localeStartIndex...]).firstIndex(where: { geonameId in + geonameId == cache.countryBlocksGeonameId[countryBlockGeonameIdIndex] + }), + (localeStartIndex + countryNameIndex) < cache.countryLocationsCountryName.count + else { return "onionRoutingPathUnknownCountry".localized() } + + let result: String = cache.countryLocationsCountryName[localeStartIndex + countryNameIndex] + countryNamesCache[key] = result + + return result + } } } -// MARK: - IP2CountryCacheType - -/// This is a read-only version of the Cache designed to avoid unintentionally mutating the instance in a non-thread-safe way -public protocol IP2CountryImmutableCacheType: ImmutableCacheType { - var cacheLoaded: AnyPublisher { get } - - func country(for ip: String) -> String -} +// MARK: - IP2CountryType -public protocol IP2CountryCacheType: IP2CountryImmutableCacheType, MutableCacheType { - var cacheLoaded: AnyPublisher { get } +public protocol IP2CountryType { + var isLoaded: Bool { get async } - func country(for ip: String) -> String + func country(for ip: String) async -> String } diff --git a/Session/Utilities/UIContextualAction+Utilities.swift b/Session/Utilities/UIContextualAction+Utilities.swift index c0edbc3b20..e9c932b671 100644 --- a/Session/Utilities/UIContextualAction+Utilities.swift +++ b/Session/Utilities/UIContextualAction+Utilities.swift @@ -122,7 +122,7 @@ public extension UIContextualAction { case .clear: return UIContextualAction( title: "clear".localized(), - icon: Lucide.image(icon: .trash2, size: 24, color: .white), + icon: Lucide.image(icon: .trash2, size: 24), themeTintColor: .white, themeBackgroundColor: themeBackgroundColor, side: side, @@ -615,7 +615,7 @@ public extension UIContextualAction { case .delete: return UIContextualAction( title: "delete".localized(), - icon: Lucide.image(icon: .trash2, size: 24, color: .white), + icon: Lucide.image(icon: .trash2, size: 24), themeTintColor: .white, themeBackgroundColor: themeBackgroundColor, accessibility: Accessibility(identifier: "Delete button"), diff --git a/SessionMessagingKit/Calls/CallManagerProtocol.swift b/SessionMessagingKit/Calls/CallManagerProtocol.swift index 06ea278015..7053d5d9a4 100644 --- a/SessionMessagingKit/Calls/CallManagerProtocol.swift +++ b/SessionMessagingKit/Calls/CallManagerProtocol.swift @@ -9,7 +9,7 @@ import SessionUtilitiesKit public extension Singleton { static let callManager: SingletonConfig = Dependencies.create( identifier: "sessionCallManager", - createInstance: { _ in NoopSessionCallManager() } + createInstance: { _, _ in NoopSessionCallManager() } ) } diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index 5260915ddc..5c34f09724 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -90,7 +90,8 @@ public enum SNMessagingKit { // Just to make the external API nice (.updateProfilePicture, .recurringOnActive, false, false), (.retrieveDefaultOpenGroupRooms, .recurringOnActive, false, false), (.garbageCollection, .recurringOnActive, false, false), - (.failedGroupInvitesAndPromotions, .recurringOnLaunch, true, false) + (.failedGroupInvitesAndPromotions, .recurringOnLaunch, true, false), + (.checkForAppUpdates, .recurring, false, false) ] ) } diff --git a/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift b/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift index ff92a72277..11adea6eda 100644 --- a/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift +++ b/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift @@ -199,7 +199,7 @@ public extension Crypto.Generator { static func messageServerHash( swarmPubkey: String, - namespace: Network.SnodeAPI.Namespace, + namespace: Network.StorageServer.Namespace, data: Data ) -> Crypto.Generator { return Crypto.Generator( diff --git a/SessionMessagingKit/Database/Migrations/_023_SplitSnodeReceivedMessageInfo.swift b/SessionMessagingKit/Database/Migrations/_023_SplitSnodeReceivedMessageInfo.swift index 91746c8cef..685731ad5d 100644 --- a/SessionMessagingKit/Database/Migrations/_023_SplitSnodeReceivedMessageInfo.swift +++ b/SessionMessagingKit/Database/Migrations/_023_SplitSnodeReceivedMessageInfo.swift @@ -91,10 +91,10 @@ enum _023_SplitSnodeReceivedMessageInfo: Migration { let targetNamespace: Int = { guard swarmPublicKeySplitComponents.count == 2 else { - return Network.SnodeAPI.Namespace.default.rawValue + return Network.StorageServer.Namespace.default.rawValue } - return (Int(swarmPublicKeySplitComponents[1]) ?? Network.SnodeAPI.Namespace.default.rawValue) + return (Int(swarmPublicKeySplitComponents[1]) ?? Network.StorageServer.Namespace.default.rawValue) }() let wasDeletedOrInvalid: Bool? = info["wasDeletedOrInvalid"] diff --git a/SessionMessagingKit/Database/Migrations/_024_ResetUserConfigLastHashes.swift b/SessionMessagingKit/Database/Migrations/_024_ResetUserConfigLastHashes.swift index 60052a5eda..e3525f3551 100644 --- a/SessionMessagingKit/Database/Migrations/_024_ResetUserConfigLastHashes.swift +++ b/SessionMessagingKit/Database/Migrations/_024_ResetUserConfigLastHashes.swift @@ -15,7 +15,7 @@ enum _024_ResetUserConfigLastHashes: Migration { static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.execute(literal: """ DELETE FROM snodeReceivedMessageInfo - WHERE namespace IN (\(Network.SnodeAPI.Namespace.configContacts.rawValue), \(Network.SnodeAPI.Namespace.configUserProfile.rawValue), \(Network.SnodeAPI.Namespace.configUserGroups.rawValue), \(Network.SnodeAPI.Namespace.configConvoInfoVolatile.rawValue)) + WHERE namespace IN (\(Network.StorageServer.Namespace.configContacts.rawValue), \(Network.StorageServer.Namespace.configUserProfile.rawValue), \(Network.StorageServer.Namespace.configUserGroups.rawValue), \(Network.StorageServer.Namespace.configConvoInfoVolatile.rawValue)) """) MigrationExecution.updateProgress(1) diff --git a/SessionMessagingKit/Database/Migrations/_027_SessionUtilChanges.swift b/SessionMessagingKit/Database/Migrations/_027_SessionUtilChanges.swift index 57e76b93a9..ffa5afc3ef 100644 --- a/SessionMessagingKit/Database/Migrations/_027_SessionUtilChanges.swift +++ b/SessionMessagingKit/Database/Migrations/_027_SessionUtilChanges.swift @@ -213,7 +213,7 @@ enum _027_SessionUtilChanges: Migration { arguments: [ userSessionId.hexString, SessionThread.Variant.contact.rawValue, - (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000), + (dependencies.networkOffsetTimestampMs() / 1000), false, // Not visible false, false, diff --git a/SessionMessagingKit/Database/Migrations/_036_GroupsRebuildChanges.swift b/SessionMessagingKit/Database/Migrations/_036_GroupsRebuildChanges.swift index 55cb03346f..846fdc19f6 100644 --- a/SessionMessagingKit/Database/Migrations/_036_GroupsRebuildChanges.swift +++ b/SessionMessagingKit/Database/Migrations/_036_GroupsRebuildChanges.swift @@ -141,26 +141,27 @@ enum _036_GroupsRebuildChanges: Migration { """, arguments: [group.groupIdentityPrivateKey, group.authData] ) + } + + /// If the group isn't in the invited state then make sure to subscribe for PNs once the migrations are done + if let token: String = dependencies[defaults: .standard, key: .deviceToken] { + let maybeAuthMethods: [AuthenticationMethod] = extractedUserGroups.groups + .filter { group in !group.invited } + .compactMap { group in + try? Authentication.with( + swarmPublicKey: group.groupSessionId, + using: dependencies + ) + } - /// If the group isn't in the invited state then make sure to subscribe for PNs once the migrations are done - if !group.invited, let token: String = dependencies[defaults: .standard, key: .deviceToken] { - let maybeAuthMethod: AuthenticationMethod? = try? Authentication.with( - db, - swarmPublicKey: group.groupSessionId, - using: dependencies - ) - - if let authMethod: AuthenticationMethod = maybeAuthMethod { - db.afterCommit { - try? Network.PushNotification - .preparedSubscribe( - token: Data(hex: token), - swarms: [(SessionId(.group, hex: group.groupSessionId), authMethod)], - using: dependencies - ) - .send(using: dependencies) - .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) - .sinkUntilComplete() + if !maybeAuthMethods.isEmpty { + db.afterCommit { + Task.detached(priority: .userInitiated) { + try? await Network.PushNotification.subscribe( + token: Data(hex: token), + swarmAuthentication: maybeAuthMethods, + using: dependencies + ) } } } @@ -169,7 +170,7 @@ enum _036_GroupsRebuildChanges: Migration { // Move the `imageData` out of the `OpenGroup` table and on to disk to be consistent with // the other display picture logic - let timestampMs: TimeInterval = TimeInterval(dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + let timestampMs: TimeInterval = TimeInterval(dependencies.networkOffsetTimestampMs() / 1000) let existingImageInfo: [Row] = try Row.fetchAll(db, sql: """ SELECT threadid, imageData FROM openGroup @@ -194,9 +195,13 @@ enum _036_GroupsRebuildChanges: Migration { .path // Save the decrypted display picture to disk - try? imageData.write(to: URL(fileURLWithPath: filePath), options: [.atomic]) + try? dependencies[singleton: .fileManager].write( + data: imageData, + to: URL(fileURLWithPath: filePath), + options: .atomic + ) - guard UIImage(contentsOfFile: filePath) != nil else { + guard dependencies[singleton: .fileManager].imageContents(atPath: filePath) != nil else { Log.error("[GroupsRebuildChanges] Failed to save Community imageData for \(threadId)") return } diff --git a/SessionMessagingKit/Database/Migrations/_040_MessageDeduplicationTable.swift b/SessionMessagingKit/Database/Migrations/_040_MessageDeduplicationTable.swift index 5431858f19..ab0c2743a0 100644 --- a/SessionMessagingKit/Database/Migrations/_040_MessageDeduplicationTable.swift +++ b/SessionMessagingKit/Database/Migrations/_040_MessageDeduplicationTable.swift @@ -30,7 +30,7 @@ enum _040_MessageDeduplicationTable: Migration { /// **oldestNotificationDedupeTimestampMs:** We probably only need to create "dedupe" records for the PN extension /// for messages sent within the last ~60 mins (any older and the user probably wouldn't get a PN let timestampNowInSec: Int64 = Int64(dependencies.dateNow.timeIntervalSince1970) - let oldestSnodeTimestampMs: Int64 = ((timestampNowInSec * 1000) - SnodeReceivedMessage.defaultExpirationMs) + let oldestSnodeTimestampMs: Int64 = ((timestampNowInSec * 1000) - Network.StorageServer.Message.defaultExpirationMs) let oldestNotificationDedupeTimestampMs: Int64 = ((timestampNowInSec - (60 * 60)) * 1000) try db.create(table: "messageDeduplication") { t in @@ -191,24 +191,24 @@ enum _040_MessageDeduplicationTable: Migration { /// If we got here then it means we have no way to know when the message should expire but messages stored on /// a snode as well as outgoing blinded message reuqests stored on a SOGS both have a similar default expiration - /// so create one manually by using `SnodeReceivedMessage.defaultExpirationMs` + /// so create one manually by using `Network.StorageServer.Message.defaultExpirationMs` /// /// For a `contact` conversation at the time of writing this migration there _shouldn't_ be any type of message /// which never expires or has it's TTL extended (outside of config messages) /// /// If we have a `timestampMs` then base our custom expiration on that if let timestampMs: Int64 = row["timestampMs"] { - return ((timestampMs + SnodeReceivedMessage.defaultExpirationMs) / 1000) + return ((timestampMs + Network.StorageServer.Message.defaultExpirationMs) / 1000) } /// Otherwise just use the current time if we somehow don't have a timestamp (this case shouldn't be possible) - return (timestampNowInSec + (SnodeReceivedMessage.defaultExpirationMs / 1000)) + return (timestampNowInSec + (Network.StorageServer.Message.defaultExpirationMs / 1000)) }() - /// Add `(SnodeReceivedMessage.serverClockToleranceMs * 2)` to `expirationTimestampSeconds` + /// Add `(Network.StorageServer.Message.serverClockToleranceMs * 2)` to `expirationTimestampSeconds` /// in order to try to ensure that our deduplication record outlasts the message lifetime on the storage server let finalExpiryTimestampSeconds: Int64? = expirationTimestampSeconds - .map { $0 + ((SnodeReceivedMessage.serverClockToleranceMs * 2) / 1000) } + .map { $0 + ((Network.StorageServer.Message.serverClockToleranceMs * 2) / 1000) } /// If this record would have already expired then there is no need to insert a record for it guard (finalExpiryTimestampSeconds ?? timestampNowInSec) < timestampNowInSec else { return } @@ -273,19 +273,19 @@ enum _040_MessageDeduplicationTable: Migration { /// If we got here then it means we have no way to know when the message should expire but messages stored on /// a snode as well as outgoing blinded message reuqests stored on a SOGS both have a similar default expiration - /// so create one manually by using `SnodeReceivedMessage.defaultExpirationMs` + /// so create one manually by using `Network.StorageServer.Message.defaultExpirationMs` /// /// For a `contact` conversation at the time of writing this migration there _shouldn't_ be any type of message /// which never expires or has it's TTL extended (outside of config messages) /// /// If we have a `timestampMs` then base our custom expiration on that - return ((timestampMs + SnodeReceivedMessage.defaultExpirationMs) / 1000) + return ((timestampMs + Network.StorageServer.Message.defaultExpirationMs) / 1000) }() - /// Add `(SnodeReceivedMessage.serverClockToleranceMs * 2)` to `expirationTimestampSeconds` + /// Add `(Network.StorageServer.Message.serverClockToleranceMs * 2)` to `expirationTimestampSeconds` /// in order to try to ensure that our deduplication record outlasts the message lifetime on the storage server let finalExpiryTimestampSeconds: Int64? = expirationTimestampSeconds - .map { $0 + ((SnodeReceivedMessage.serverClockToleranceMs * 2) / 1000) } + .map { $0 + ((Network.StorageServer.Message.serverClockToleranceMs * 2) / 1000) } /// If this record would have already expired then there is no need to insert a record for it guard (finalExpiryTimestampSeconds ?? timestampNowInSec) < timestampNowInSec else { return } diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 072306a4d3..27ba16c021 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -685,7 +685,7 @@ extension Attachment { let path: String = try? dependencies[singleton: .attachmentManager].path(for: downloadUrl) else { return false } - try data.write(to: URL(fileURLWithPath: path)) + try dependencies[singleton: .fileManager].write(data: data, to: URL(fileURLWithPath: path)) return true } diff --git a/SessionMessagingKit/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift index da2655eb59..5b62d2246e 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroup.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -220,27 +220,23 @@ public extension ClosedGroup { } /// Start the poller - dependencies.mutate(cache: .groupPollers) { - $0.getOrCreatePoller(for: group.id).startIfNeeded() + Task.detached(priority: .userInitiated) { [manager = dependencies[singleton: .groupPollerManager]] in + await manager.getOrCreatePoller(for: group.id).startIfNeeded() } /// Subscribe for group push notifications if let token: String = dependencies[defaults: .standard, key: .deviceToken] { - let maybeAuthMethod: AuthenticationMethod? = try? Authentication.with( - db, - swarmPublicKey: group.id, - using: dependencies - ) + let maybeAuthMethod: AuthenticationMethod? = try? Authentication + .with(swarmPublicKey: group.id, using: dependencies) if let authMethod: AuthenticationMethod = maybeAuthMethod { - try? Network.PushNotification - .preparedSubscribe( + Task.detached(priority: .userInitiated) { + try? await Network.PushNotification.subscribe( token: Data(hex: token), - swarms: [(SessionId(.group, hex: group.id), authMethod)], + swarmAuthentication: [authMethod], using: dependencies ) - .send(using: dependencies) - .sinkUntilComplete() + } } } } @@ -282,7 +278,9 @@ public extension ClosedGroup { if !dataToRemove.asSet().intersection([.poller, .pushNotifications, .libSessionState]).isEmpty { threadIds.forEach { threadId in if dataToRemove.contains(.poller) { - dependencies.mutate(cache: .groupPollers) { $0.stopAndRemovePoller(for: threadId) } + Task { [manager = dependencies[singleton: .groupPollerManager]] in + await manager.stopAndRemovePoller(for: threadId) + } } if dataToRemove.contains(.libSessionState) { @@ -311,24 +309,21 @@ public extension ClosedGroup { /// Bulk unsubscripe from updated groups being removed if dataToRemove.contains(.pushNotifications) && threadVariants.contains(where: { $0.variant == .group }) { if let token: String = dependencies[defaults: .standard, key: .deviceToken] { - try? Network.PushNotification - .preparedUnsubscribe( - token: Data(hex: token), - swarms: threadVariants - .filter { $0.variant == .group } - .compactMap { info in - let authMethod: AuthenticationMethod? = try? Authentication.with( - db, - swarmPublicKey: info.id, - using: dependencies - ) - - return authMethod.map { (SessionId(.group, hex: info.id), $0) } - }, - using: dependencies - ) - .send(using: dependencies) - .sinkUntilComplete() + let authData: [AuthenticationMethod] = threadVariants + .filter { $0.variant == .group } + .compactMap { try? Authentication.with(swarmPublicKey: $0.id, using: dependencies) } + + if !authData.isEmpty { + Task.detached(priority: .userInitiated) { [dependencies] in + try? await Network.PushNotification.unsubscribe( + token: Data(hex: token), + swarmAuthentication: threadVariants + .filter { $0.variant == .group } + .compactMap { try? Authentication.with(swarmPublicKey: $0.id, using: dependencies) }, + using: dependencies + ) + } + } } } } diff --git a/SessionMessagingKit/Database/Models/ConfigDump.swift b/SessionMessagingKit/Database/Models/ConfigDump.swift index eed9528aa9..68c987a8d9 100644 --- a/SessionMessagingKit/Database/Models/ConfigDump.swift +++ b/SessionMessagingKit/Database/Models/ConfigDump.swift @@ -86,7 +86,7 @@ public extension ConfigDump.Variant { .groupInfo, .groupMembers, .groupKeys ] - init(namespace: Network.SnodeAPI.Namespace) { + init(namespace: Network.StorageServer.Namespace) { switch namespace { case .configUserProfile: self = .userProfile case .configContacts: self = .contacts @@ -104,19 +104,19 @@ public extension ConfigDump.Variant { /// Config messages should last for 30 days rather than the standard 14 var ttl: UInt64 { 30 * 24 * 60 * 60 * 1000 } - var namespace: Network.SnodeAPI.Namespace { + var namespace: Network.StorageServer.Namespace { switch self { - case .userProfile: return Network.SnodeAPI.Namespace.configUserProfile - case .contacts: return Network.SnodeAPI.Namespace.configContacts - case .convoInfoVolatile: return Network.SnodeAPI.Namespace.configConvoInfoVolatile - case .userGroups: return Network.SnodeAPI.Namespace.configUserGroups - case .local: return Network.SnodeAPI.Namespace.configLocal + case .userProfile: return Network.StorageServer.Namespace.configUserProfile + case .contacts: return Network.StorageServer.Namespace.configContacts + case .convoInfoVolatile: return Network.StorageServer.Namespace.configConvoInfoVolatile + case .userGroups: return Network.StorageServer.Namespace.configUserGroups + case .local: return Network.StorageServer.Namespace.configLocal - case .groupInfo: return Network.SnodeAPI.Namespace.configGroupInfo - case .groupMembers: return Network.SnodeAPI.Namespace.configGroupMembers - case .groupKeys: return Network.SnodeAPI.Namespace.configGroupKeys + case .groupInfo: return Network.StorageServer.Namespace.configGroupInfo + case .groupMembers: return Network.StorageServer.Namespace.configGroupMembers + case .groupKeys: return Network.StorageServer.Namespace.configGroupKeys - case .invalid: return Network.SnodeAPI.Namespace.unknown + case .invalid: return Network.StorageServer.Namespace.unknown } } diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 43b0dfdcb7..e041a6f2e6 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -345,7 +345,7 @@ public struct Interaction: Codable, Identifiable, Equatable, Hashable, Fetchable self.receivedAtTimestampMs = { switch variant { case .standardIncoming, .standardOutgoing: - return dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + return dependencies.networkOffsetTimestampMs() /// For TSInteractions which are not `standardIncoming` and `standardOutgoing` use the `timestampMs` value default: return timestampMs @@ -390,7 +390,10 @@ public struct Interaction: Codable, Identifiable, Equatable, Hashable, Fetchable switch ObservationContext.observingDb { case .none: Log.error("[Interaction] Could not process 'aroundInsert' due to missing observingDb.") case .some(let observingDb): - observingDb.dependencies.setAsync(.hasSavedMessage, true) + observingDb.afterCommit { [dependencies = observingDb.dependencies] in + dependencies.setAsync(.hasSavedMessage, true) + } + observingDb.addMessageEvent(id: id, threadId: threadId, type: .created) if self.expiresStartedAtMs != nil { @@ -570,8 +573,8 @@ public extension Interaction { JOIN \(SessionThread.self) ON ( \(thread[.id]) = \(interaction[.threadId]) AND -- Ignore message request threads (these should be counted by the PN extension but - -- seeing the "Message Requests" banner is considered marking the "Unread Message - -- Request" notification as read) + -- seeing the 'Message Requests' banner is considered marking the 'Unread Message + -- Request' notification as read) \(thread[.id]) NOT IN \(messageRequestThreadIds) AND ( -- Ignore muted threads \(thread[.mutedUntilTimestamp]) IS NULL OR @@ -833,7 +836,7 @@ public extension Interaction { job: DisappearingMessagesJob.updateNextRunIfNeeded( db, interactionIds: interactionInfo.map { $0.id }, - startedAtMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs(), + startedAtMs: dependencies.networkOffsetTimestampMs(), threadId: threadId, using: dependencies ), @@ -1458,6 +1461,19 @@ public extension Interaction { db.addAttachmentEvent(id: info.attachmentId, messageId: info.interactionId, type: .deleted) } + /// If we had attachments then we want to try to delete their associated files immediately (in the next run loop) as that's the + /// behaviour users would expect, if this fails for some reason then they will be cleaned up by the `GarbageCollectionJob` + /// but we should still try to handle it immediately + if !attachments.isEmpty { + let attachmentPaths: [String] = attachments.compactMap { + try? dependencies[singleton: .attachmentManager].path(for: $0.downloadUrl) + } + + DispatchQueue.global(qos: .background).async { + attachmentPaths.forEach { try? dependencies[singleton: .fileManager].removeItem(atPath: $0) } + } + } + /// Delete the reactions from the database _ = try Reaction .filter(interactionIds.contains(Reaction.Columns.interactionId)) @@ -1529,19 +1545,6 @@ public extension Interaction { interactionIds.forEach { id in db.addMessageEvent(id: id, threadId: threadId, type: .deleted) } - - /// If we had attachments then we want to try to delete their associated files immediately (in the next run loop) as that's the - /// behaviour users would expect, if this fails for some reason then they will be cleaned up by the `GarbageCollectionJob` - /// but we should still try to handle it immediately - if !attachments.isEmpty { - let attachmentPaths: [String] = attachments.compactMap { - try? dependencies[singleton: .attachmentManager].path(for: $0.downloadUrl) - } - - DispatchQueue.global(qos: .background).async { - attachmentPaths.forEach { try? dependencies[singleton: .fileManager].removeItem(atPath: $0) } - } - } } /// Whenever a message gets deleted we need to send an event to ensure the home screen updates correctly, this function manages diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 0c8cada73f..8104d55779 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -69,7 +69,7 @@ public struct LinkPreview: Codable, Equatable, Hashable, FetchableRecord, Persis ) { self.url = url self.timestamp = (timestamp ?? LinkPreview.timestampFor( - sentTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() // Default to now + sentTimestampMs: dependencies.networkOffsetTimestampMs() // Default to now )) self.variant = variant self.title = title @@ -137,7 +137,11 @@ public extension LinkPreview { guard let mimeType: String = type.preferredMIMEType else { return nil } let filePath = dependencies[singleton: .fileManager].temporaryFilePath(fileExtension: fileExtension) - try imageData.write(to: NSURL.fileURL(withPath: filePath), options: .atomicWrite) + try dependencies[singleton: .fileManager].write( + data: imageData, + to: URL(fileURLWithPath: filePath), + options: .atomic + ) let dataSource: DataSourcePath = DataSourcePath( filePath: filePath, sourceFilename: nil, diff --git a/SessionMessagingKit/Database/Models/MessageDeduplication.swift b/SessionMessagingKit/Database/Models/MessageDeduplication.swift index e8b5a8f531..e5e5be458e 100644 --- a/SessionMessagingKit/Database/Models/MessageDeduplication.swift +++ b/SessionMessagingKit/Database/Models/MessageDeduplication.swift @@ -66,10 +66,10 @@ public extension MessageDeduplication { ) } - /// Add `(SnodeReceivedMessage.serverClockToleranceMs * 2)` to `expirationTimestampSeconds` + /// Add `(Network.StorageServer.Message.serverClockToleranceMs * 2)` to `expirationTimestampSeconds` /// in order to try to ensure that our deduplication record outlasts the message lifetime on the storage server let finalExpiryTimestampSeconds: Int64? = serverExpirationTimestamp - .map { Int64($0) + ((SnodeReceivedMessage.serverClockToleranceMs * 2) / 1000) } + .map { Int64($0) + ((Network.StorageServer.Message.serverClockToleranceMs * 2) / 1000) } /// When we delete a `contact` conversation we want to keep the dedupe records around because, if we don't, the /// conversation will just reappear (this isn't an issue for `legacyGroup` conversations because they no longer poll) @@ -428,13 +428,13 @@ private extension MessageDeduplication { /// which never expires or has it's TTL extended (outside of config messages) /// /// If we have a `timestampMs` then base our custom expiration on that - return ((timestampMs + SnodeReceivedMessage.defaultExpirationMs) / 1000) + return ((timestampMs + Network.StorageServer.Message.defaultExpirationMs) / 1000) }() - /// Add `(SnodeReceivedMessage.serverClockToleranceMs * 2)` to `expirationTimestampSeconds` + /// Add `(Network.StorageServer.Message.serverClockToleranceMs * 2)` to `expirationTimestampSeconds` /// in order to try to ensure that our deduplication record outlasts the message lifetime on the storage server let finalExpiryTimestampSeconds: Int64? = expirationTimestampSeconds - .map { $0 + ((SnodeReceivedMessage.serverClockToleranceMs * 2) / 1000) } + .map { $0 + ((Network.StorageServer.Message.serverClockToleranceMs * 2) / 1000) } /// When we delete a `contact` conversation we want to keep the dedupe records around because, if we don't, the /// conversation will just reappear (this isn't an issue for `legacyGroup` conversations because they no longer poll) diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index c3893cbeb1..acc3d7e0a6 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -149,7 +149,13 @@ public struct SessionThread: Codable, Identifiable, Equatable, Hashable, Fetchab switch ObservationContext.observingDb { case .none: Log.error("[SessionThread] Could not process 'aroundInsert' due to missing observingDb.") case .some(let observingDb): - observingDb.dependencies.setAsync(.hasSavedThread, true) + /// Only set the `hasSavedThread` value if it's not the 'Note to Self' thread + if id != observingDb.dependencies[cache: .general].sessionId.hexString { + observingDb.afterCommit { [dependencies = observingDb.dependencies] in + dependencies.setAsync(.hasSavedThread, true) + } + } + observingDb.addConversationEvent(id: id, type: .created) } } @@ -348,7 +354,7 @@ public extension SessionThread { variant: variant, creationDateTimestamp: ( values.creationDateTimestamp.valueOrNull ?? - (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + (dependencies.networkOffsetTimestampMs() / 1000) ), shouldBeVisible: LibSession.shouldBeVisible(priority: targetPriority), mutedUntilTimestamp: nil, diff --git a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift index 210d629967..c5b527c49a 100644 --- a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift @@ -127,7 +127,7 @@ public enum AttachmentDownloadJob: JobExecutor { .map { _, data in (data, info.attachment, info.temporaryFileUrl) } case .none: - return try Network + return try Network.FileServer .preparedDownload( url: info.downloadUrl, using: dependencies @@ -144,7 +144,11 @@ public enum AttachmentDownloadJob: JobExecutor { .receive(on: scheduler, using: dependencies) .tryMap { attachment, temporaryFileUrl, data -> Attachment in // Store the encrypted data temporarily - try data.write(to: temporaryFileUrl, options: .atomic) + try dependencies[singleton: .fileManager].write( + data: data, + to: temporaryFileUrl, + options: .atomic + ) // Decrypt the data let plaintext: Data = try { @@ -183,7 +187,7 @@ public enum AttachmentDownloadJob: JobExecutor { let updatedAttachment: Attachment = try attachment .with( state: .downloaded, - creationTimestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000), + creationTimestamp: (dependencies.networkOffsetTimestampMs() / 1000), using: dependencies ) .upserted(db) @@ -223,7 +227,7 @@ public enum AttachmentDownloadJob: JobExecutor { /// If we got a 400 or a 401 then we want to fail the download in a way that has to be manually retried as it's /// likely something else is going on that caused the failure case NetworkError.badRequest, NetworkError.unauthorised, - SnodeAPIError.signatureVerificationFailed: + StorageServerError.signatureVerificationFailed: targetState = .failedDownload permanentFailure = true diff --git a/SessionMessagingKit/Jobs/CheckForAppUpdatesJob.swift b/SessionMessagingKit/Jobs/CheckForAppUpdatesJob.swift index 64f9c9f4c7..7d26151351 100644 --- a/SessionMessagingKit/Jobs/CheckForAppUpdatesJob.swift +++ b/SessionMessagingKit/Jobs/CheckForAppUpdatesJob.swift @@ -47,32 +47,29 @@ public enum CheckForAppUpdatesJob: JobExecutor { return deferred(updatedJob) } - dependencies[singleton: .network] - .checkClientVersion(ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey) - .subscribe(on: scheduler, using: dependencies) - .receive(on: scheduler, using: dependencies) - .sinkUntilComplete( - receiveCompletion: { _ in - var updatedJob: Job = job.with( - failureCount: 0, - nextRunTimestamp: (dependencies.dateNow.timeIntervalSince1970 + updateCheckFrequency) - ) - - dependencies[singleton: .storage].write { db in - try updatedJob.upsert(db) - } + Task { [dependencies] in + let versionInfo: Network.FileServer.AppVersionResponse? = try? await dependencies[singleton: .network] + .checkClientVersion(ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey) + + switch (versionInfo, versionInfo?.prerelease) { + case (.none, _): break + case (.some(let info), .none): + Log.info(.cat, "Latest version: \(info.version) (Current: \(dependencies[cache: .appVersion].versionInfo))") - success(updatedJob, false) - }, - receiveValue: { _, versionInfo in - switch versionInfo.prerelease { - case .none: - Log.info(.cat, "Latest version: \(versionInfo.version) (Current: \(dependencies[cache: .appVersion].versionInfo))") - - case .some(let prerelease): - Log.info(.cat, "Latest version: \(versionInfo.version), pre-release version: \(prerelease.version) (Current: \(dependencies[cache: .appVersion].versionInfo))") - } - } + case (.some(let info), .some(let prerelease)): + Log.info(.cat, "Latest version: \(info.version), pre-release version: \(prerelease.version) (Current: \(dependencies[cache: .appVersion].versionInfo))") + } + + var updatedJob: Job = job.with( + failureCount: 0, + nextRunTimestamp: (dependencies.dateNow.timeIntervalSince1970 + updateCheckFrequency) ) + + try? await dependencies[singleton: .storage].writeAsync { db in + try updatedJob.upsert(db) + } + + success(updatedJob, false) + } } } diff --git a/SessionMessagingKit/Jobs/ConfigMessageReceiveJob.swift b/SessionMessagingKit/Jobs/ConfigMessageReceiveJob.swift index 53cd2a73a5..72d59945e1 100644 --- a/SessionMessagingKit/Jobs/ConfigMessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/ConfigMessageReceiveJob.swift @@ -36,12 +36,21 @@ public enum ConfigMessageReceiveJob: JobExecutor { guard let jobId: Int64 = job.id else { return } dependencies[singleton: .storage].write { db in + let dependantJobIds: Set = try JobDependencies + .select(.jobId) + .filter(JobDependencies.Columns.dependantId == jobId) + .asRequest(of: Int64.self) + .fetchSet(db) + let targetJobIds: Set = try Job + .select(.id) + .filter(dependantJobIds.contains(Job.Columns.id)) + .filter(Job.Columns.variant == Job.Variant.messageReceive) + .asRequest(of: Int64.self) + .fetchSet(db) + try JobDependencies + .filter(targetJobIds.contains(JobDependencies.Columns.jobId)) .filter(JobDependencies.Columns.dependantId == jobId) - .joining( - required: JobDependencies.job - .filter(Job.Columns.variant == Job.Variant.messageReceive) - ) .deleteAll(db) } } @@ -92,13 +101,13 @@ extension ConfigMessageReceiveJob { case data } - public let namespace: Network.SnodeAPI.Namespace + public let namespace: Network.StorageServer.Namespace public let serverHash: String public let serverTimestampMs: Int64 public let data: Data public init( - namespace: Network.SnodeAPI.Namespace, + namespace: Network.StorageServer.Namespace, serverHash: String, serverTimestampMs: Int64, data: Data diff --git a/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift b/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift index f1266f72a1..f03ddd28a8 100644 --- a/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift +++ b/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift @@ -87,41 +87,45 @@ public enum ConfigurationSyncJob: JobExecutor { } let jobStartTimestamp: TimeInterval = dependencies.dateNow.timeIntervalSince1970 - let messageSendTimestamp: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let messageSendTimestamp: Int64 = dependencies.networkOffsetTimestampMs() let additionalTransientData: AdditionalTransientData? = (job.transientData as? AdditionalTransientData) Log.info(.cat, "For \(swarmPublicKey) started with changes: \(pendingPushes.pushData.count), old hashes: \(pendingPushes.obsoleteHashes.count)") - dependencies[singleton: .storage] - .readPublisher { db -> AuthenticationMethod in - try Authentication.with(db, swarmPublicKey: swarmPublicKey, using: dependencies) - } - .tryFlatMap { authMethod -> AnyPublisher<(ResponseInfoType, Network.BatchResponse), Error> in - try Network.SnodeAPI.preparedSequence( + AnyPublisher + .lazy { () -> Network.PreparedRequest in + let authMethod: AuthenticationMethod = try ( + additionalTransientData?.customAuthMethod ?? + Authentication.with( + swarmPublicKey: swarmPublicKey, + using: dependencies + ) + ) + + return try Network.StorageServer.preparedSequence( requests: [] .appending(contentsOf: additionalTransientData?.beforeSequenceRequests) .appending( contentsOf: try pendingPushes.pushData .flatMap { pushData -> [ErasedPreparedRequest] in try pushData.data.map { data -> ErasedPreparedRequest in - try Network.SnodeAPI - .preparedSendMessage( - message: SnodeMessage( - recipient: swarmPublicKey, - data: data, - ttl: pushData.variant.ttl, - timestampMs: UInt64(messageSendTimestamp) - ), - in: pushData.variant.namespace, - authMethod: authMethod, - using: dependencies - ) + try Network.StorageServer.preparedSendMessage( + request: Network.StorageServer.SendMessageRequest( + recipient: swarmPublicKey, + namespace: pushData.variant.namespace, + data: data, + ttl: pushData.variant.ttl, + timestampMs: UInt64(messageSendTimestamp), + authMethod: authMethod + ), + using: dependencies + ) } } ) .appending(try { guard !pendingPushes.obsoleteHashes.isEmpty else { return nil } - return try Network.SnodeAPI.preparedDeleteMessages( + return try Network.StorageServer.preparedDeleteMessages( serverHashes: Array(pendingPushes.obsoleteHashes), requireSuccessfulDeletion: false, authMethod: authMethod, @@ -131,11 +135,11 @@ public enum ConfigurationSyncJob: JobExecutor { .appending(contentsOf: additionalTransientData?.afterSequenceRequests), requireAllBatchResponses: (additionalTransientData?.requireAllBatchResponses == true), swarmPublicKey: swarmPublicKey, - snodeRetrievalRetryCount: 0, // This job has it's own retry mechanism - requestAndPathBuildTimeout: Network.defaultTimeout, + overallTimeout: Network.defaultTimeout, using: dependencies - ).send(using: dependencies) + ) } + .flatMap { request in request.send(using: dependencies) } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) .tryMap { (_: ResponseInfoType, response: Network.BatchResponse) -> [ConfigDump] in @@ -165,10 +169,10 @@ public enum ConfigurationSyncJob: JobExecutor { /// If the request wasn't successful then just ignore it (the next time we sync this config we will try /// to send the changes again) guard - let typedResponse: Network.BatchSubResponse = (subResponse as? Network.BatchSubResponse), + let typedResponse: Network.BatchSubResponse = (subResponse as? Network.BatchSubResponse), 200...299 ~= typedResponse.code, !typedResponse.failedToParseBody, - let sendMessageResponse: SendMessagesResponse = typedResponse.body + let sendMessageResponse: Network.StorageServer.SendMessagesResponse = typedResponse.body else { return (pushData, nil) } return (pushData, sendMessageResponse.hash) @@ -193,37 +197,25 @@ public enum ConfigurationSyncJob: JobExecutor { // If the failure is due to being offline then we should automatically // retry if the connection is re-established - dependencies[cache: .libSessionNetwork].networkStatus - .first() - .sinkUntilComplete( - receiveValue: { status in - switch status { - // If we are currently connected then use the standard - // retry behaviour - case .connected: failure(job, error, false) - - // If not then permanently fail the job and reschedule it - // to run again if we re-establish the connection - default: - failure(job, error, true) - - dependencies[cache: .libSessionNetwork].networkStatus - .filter { $0 == .connected } - .first() - .sinkUntilComplete( - receiveCompletion: { _ in - dependencies[singleton: .storage].writeAsync { db in - ConfigurationSyncJob.enqueue( - db, - swarmPublicKey: swarmPublicKey, - using: dependencies - ) - } - } - ) - } - } - ) + Task { [dependencies] in + // If we are currently connected then use the standard retry behaviour + guard await dependencies.currentNetworkStatus != .connected else { + return failure(job, error, false) + } + + // Otherwise we should permanently fail the job and reschedule it + // to run again if we re-establish the connection + failure(job, error, true) + + try? await dependencies.waitUntilConnected() + try? await dependencies[singleton: .storage].writeAsync { db in + ConfigurationSyncJob.enqueue( + db, + swarmPublicKey: swarmPublicKey, + using: dependencies + ) + } + } } }, receiveValue: { (configDumps: [ConfigDump]) in @@ -334,24 +326,28 @@ extension ConfigurationSyncJob { public let afterSequenceRequests: [any ErasedPreparedRequest] public let requireAllBatchResponses: Bool public let requireAllRequestsSucceed: Bool + public let customAuthMethod: AuthenticationMethod? init?( beforeSequenceRequests: [any ErasedPreparedRequest], afterSequenceRequests: [any ErasedPreparedRequest], requireAllBatchResponses: Bool, - requireAllRequestsSucceed: Bool + requireAllRequestsSucceed: Bool, + customAuthMethod: AuthenticationMethod? ) { guard !beforeSequenceRequests.isEmpty || !afterSequenceRequests.isEmpty || requireAllBatchResponses || - requireAllRequestsSucceed + requireAllRequestsSucceed || + customAuthMethod != nil else { return nil } self.beforeSequenceRequests = beforeSequenceRequests self.afterSequenceRequests = afterSequenceRequests self.requireAllBatchResponses = requireAllBatchResponses self.requireAllRequestsSucceed = requireAllRequestsSucceed + self.customAuthMethod = customAuthMethod } } } @@ -411,6 +407,7 @@ public extension ConfigurationSyncJob { afterSequenceRequests: [any ErasedPreparedRequest] = [], requireAllBatchResponses: Bool = false, requireAllRequestsSucceed: Bool = false, + customAuthMethod: AuthenticationMethod? = nil, using dependencies: Dependencies ) -> AnyPublisher { return Deferred { @@ -425,7 +422,8 @@ public extension ConfigurationSyncJob { beforeSequenceRequests: beforeSequenceRequests, afterSequenceRequests: afterSequenceRequests, requireAllBatchResponses: requireAllBatchResponses, - requireAllRequestsSucceed: requireAllRequestsSucceed + requireAllRequestsSucceed: requireAllRequestsSucceed, + customAuthMethod: customAuthMethod ) ) else { return resolver(Result.failure(NetworkError.parsingFailed)) } diff --git a/SessionMessagingKit/Jobs/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/DisappearingMessagesJob.swift index e6689d73b0..25903b68c3 100644 --- a/SessionMessagingKit/Jobs/DisappearingMessagesJob.swift +++ b/SessionMessagingKit/Jobs/DisappearingMessagesJob.swift @@ -30,7 +30,7 @@ public enum DisappearingMessagesJob: JobExecutor { guard dependencies[cache: .general].userExists else { return success(job, false) } // The 'backgroundTask' gets captured and cleared within the 'completion' block - let timestampNowMs: Double = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let timestampNowMs: Double = dependencies.networkOffsetTimestampMs() var backgroundTask: SessionBackgroundTask? = SessionBackgroundTask(label: #function, using: dependencies) var numDeleted: Int = -1 @@ -68,7 +68,7 @@ public extension DisappearingMessagesJob { static func cleanExpiredMessagesOnResume(using dependencies: Dependencies) { guard dependencies[cache: .general].userExists else { return } - let timestampNowMs: Double = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let timestampNowMs: Double = dependencies.networkOffsetTimestampMs() var numDeleted: Int = -1 dependencies[singleton: .storage].write { db in @@ -104,11 +104,9 @@ public extension DisappearingMessagesJob { return nil } - /// The `expiresStartedAtMs` timestamp is now based on the - /// `dependencies[cache: .snodeAPI].currentOffsetTimestampMs()` - /// value so we need to make sure offset the `nextRunTimestamp` accordingly to - /// ensure it runs at the correct local time - let clockOffsetMs: Int64 = dependencies[cache: .snodeAPI].clockOffsetMs + /// The `expiresStartedAtMs` timestamp is now based on the `dependencies.networkOffsetTimestampMs()` + /// value so we need to make sure offset the `nextRunTimestamp` accordingly to ensure it runs at the correct local time + let clockOffsetMs: Int64 = dependencies[singleton: .network].syncState.networkTimeOffsetMs Log.info(.cat, "Scheduled future message expiration") return try? Job @@ -155,7 +153,7 @@ public extension DisappearingMessagesJob { threadId: threadId, details: GetExpirationJob.Details( expirationInfo: expirationInfo, - startedAtTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + startedAtTimestampMs: dependencies.networkOffsetTimestampMs() ) ), canStartJob: true diff --git a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift index edae3c0108..edb835ecb5 100644 --- a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift +++ b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift @@ -43,7 +43,7 @@ public enum DisplayPictureDownloadJob: JobExecutor { let downloadUrl: URL = URL(string: Network.FileServer.downloadUrlString(for: url, fileId: fileId)) else { throw NetworkError.invalidURL } - return try Network.preparedDownload( + return try Network.FileServer.preparedDownload( url: downloadUrl, using: dependencies ) @@ -64,19 +64,20 @@ public enum DisplayPictureDownloadJob: JobExecutor { } } .tryMap { (preparedDownload: Network.PreparedRequest) -> Network.PreparedRequest<(Data, String, URL?)> in + let downloadUrl: URL? = try? preparedDownload.generateUrl() + guard let filePath: String = try? dependencies[singleton: .displayPictureManager].path( - for: (preparedDownload.destination.url?.absoluteString) - .defaulting(to: preparedDownload.destination.urlPathAndParamsString) + for: (downloadUrl?.absoluteString ?? preparedDownload.path) ) else { throw DisplayPictureError.invalidPath } guard !dependencies[singleton: .fileManager].fileExists(atPath: filePath) else { - throw DisplayPictureError.alreadyDownloaded(preparedDownload.destination.url) + throw DisplayPictureError.alreadyDownloaded(downloadUrl) } return preparedDownload.map { _, data in - (data, filePath, preparedDownload.destination.url) + (data, filePath, downloadUrl) } } .flatMap { $0.send(using: dependencies) } diff --git a/SessionMessagingKit/Jobs/ExpirationUpdateJob.swift b/SessionMessagingKit/Jobs/ExpirationUpdateJob.swift index ef3cdb9d7a..30c259783a 100644 --- a/SessionMessagingKit/Jobs/ExpirationUpdateJob.swift +++ b/SessionMessagingKit/Jobs/ExpirationUpdateJob.swift @@ -24,48 +24,25 @@ public enum ExpirationUpdateJob: JobExecutor { let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData) else { return failure(job, JobRunnerError.missingRequiredDetails, true) } - dependencies[singleton: .storage] - .readPublisher { db in - try Network.SnodeAPI - .preparedUpdateExpiry( - serverHashes: details.serverHashes, - updatedExpiryMs: details.expirationTimestampMs, - shortenOnly: true, - authMethod: try Authentication.with( - db, - swarmPublicKey: dependencies[cache: .general].sessionId.hexString, - using: dependencies - ), + Task { + do { + let response: [String: Network.StorageServer.UpdateExpiryResponseResult] = try await Network.StorageServer.updateExpiry( + serverHashes: details.serverHashes, + updatedExpiryMs: details.expirationTimestampMs, + shortenOnly: true, + authMethod: try Authentication.with( + swarmPublicKey: dependencies[cache: .general].sessionId.hexString, using: dependencies - ) - } - .flatMap { $0.send(using: dependencies) } - .subscribe(on: scheduler, using: dependencies) - .receive(on: scheduler, using: dependencies) - .map { _, response -> [UInt64: [String]] in - guard - let results: [UpdateExpiryResponseResult] = response - .compactMap({ _, value in value.didError ? nil : value }) - .nullIfEmpty, - let unchangedMessages: [UInt64: [String]] = results - .reduce([:], { result, next in result.updated(with: next.unchanged) }) - .groupedByValue() - .nullIfEmpty - else { return [:] } + ), + using: dependencies + ) + let unchangedMessages: [UInt64: [String]] = response + .compactMap { _, value in value.didError ? nil : value } + .reduce([:], { result, next in result.updated(with: next.unchanged) }) + .groupedByValue() - return unchangedMessages - } - .sinkUntilComplete( - receiveCompletion: { result in - switch result { - case .finished: success(job, false) - case .failure(let error): failure(job, error, true) - } - }, - receiveValue: { unchangedMessages in - guard !unchangedMessages.isEmpty else { return } - - dependencies[singleton: .storage].writeAsync { db in + if !unchangedMessages.isEmpty { + try? await dependencies[singleton: .storage].writeAsync { db in unchangedMessages.forEach { updatedExpiry, hashes in hashes.forEach { hash in guard @@ -91,7 +68,17 @@ public enum ExpirationUpdateJob: JobExecutor { } } } - ) + + scheduler.schedule { + success(job, false) + } + } + catch { + scheduler.schedule { + failure(job, error, true) + } + } + } } } diff --git a/SessionMessagingKit/Jobs/GetExpirationJob.swift b/SessionMessagingKit/Jobs/GetExpirationJob.swift index 00be1730b7..ca17050456 100644 --- a/SessionMessagingKit/Jobs/GetExpirationJob.swift +++ b/SessionMessagingKit/Jobs/GetExpirationJob.swift @@ -37,12 +37,11 @@ public enum GetExpirationJob: JobExecutor { return success(job, false) } - dependencies[singleton: .storage] - .readPublisher { db -> Network.PreparedRequest in - try Network.SnodeAPI.preparedGetExpiries( + AnyPublisher + .lazy { + try Network.StorageServer.preparedGetExpiries( of: expirationInfo.map { $0.key }, authMethod: try Authentication.with( - db, swarmPublicKey: dependencies[cache: .general].sessionId.hexString, using: dependencies ), @@ -59,7 +58,7 @@ public enum GetExpirationJob: JobExecutor { case .failure(let error): failure(job, error, true) } }, - receiveValue: { (_: ResponseInfoType, response: GetExpiriesResponse) in + receiveValue: { (_: ResponseInfoType, response: Network.StorageServer.GetExpiriesResponse) in let serverSpecifiedExpirationStartTimesMs: [String: Double] = response.expiries .reduce(into: [:]) { result, next in guard let expiresInSeconds: Double = expirationInfo[next.key] else { return } diff --git a/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift b/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift index e652f75f13..ae3509986f 100644 --- a/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift +++ b/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift @@ -41,12 +41,12 @@ public enum GroupInviteMemberJob: JobExecutor { let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData) else { return failure(job, JobRunnerError.missingRequiredDetails, true) } - let sentTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let sentTimestampMs: Int64 = dependencies.networkOffsetTimestampMs() let adminProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } /// Perform the actual message sending dependencies[singleton: .storage] - .writePublisher { db -> (AuthenticationMethod, AuthenticationMethod) in + .writePublisher { db in _ = try? GroupMember .filter(GroupMember.Columns.groupId == threadId) .filter(GroupMember.Columns.profileId == details.memberSessionIdHexString) @@ -56,18 +56,18 @@ public enum GroupInviteMemberJob: JobExecutor { GroupMember.Columns.roleStatus.set(to: GroupMember.RoleStatus.sending), using: dependencies ) - - return ( - try Authentication.with(db, swarmPublicKey: threadId, using: dependencies), - try Authentication.with( - db, - swarmPublicKey: details.memberSessionIdHexString, - using: dependencies - ) - ) } - .tryFlatMap { groupAuthMethod, memberAuthMethod -> AnyPublisher<(ResponseInfoType, Message), Error> in - try MessageSender.preparedSend( + .tryFlatMap { _ -> AnyPublisher<(ResponseInfoType, Message), Error> in + let groupAuthMethod: AuthenticationMethod = try Authentication.with( + swarmPublicKey: threadId, + using: dependencies + ) + let memberAuthMethod: AuthenticationMethod = try Authentication.with( + swarmPublicKey: details.memberSessionIdHexString, + using: dependencies + ) + + return try MessageSender.preparedSend( message: try GroupUpdateInviteMessage( inviteeSessionIdHexString: details.memberSessionIdHexString, groupSessionId: SessionId(.group, hex: threadId), @@ -144,10 +144,10 @@ public enum GroupInviteMemberJob: JobExecutor { case let senderError as MessageSenderError where !senderError.isRetryable: failure(job, error, true) - case SnodeAPIError.rateLimited: + case StorageServerError.rateLimited: failure(job, error, true) - case SnodeAPIError.clockOutOfSync: + case StorageServerError.clockOutOfSync: Log.error(.cat, "Permanently Failing to send due to clock out of sync issue.") failure(job, error, true) @@ -304,7 +304,7 @@ public extension GroupInviteMemberJob { public extension Cache { static let groupInviteMemberJob: CacheConfig = Dependencies.create( identifier: "groupInviteMemberJob", - createInstance: { dependencies in GroupInviteMemberJob.Cache(using: dependencies) }, + createInstance: { dependencies, _ in GroupInviteMemberJob.Cache(using: dependencies) }, mutableInstance: { $0 }, immutableInstance: { $0 } ) diff --git a/SessionMessagingKit/Jobs/GroupLeavingJob.swift b/SessionMessagingKit/Jobs/GroupLeavingJob.swift index 208072da67..faab92c2bf 100644 --- a/SessionMessagingKit/Jobs/GroupLeavingJob.swift +++ b/SessionMessagingKit/Jobs/GroupLeavingJob.swift @@ -66,10 +66,18 @@ public enum GroupLeavingJob: JobExecutor { return details.behaviour }() + /// There is a rare edge-case where a group could be created locally but the auth data wasn't saved to `libSession` + /// which results in the authentication data being invalid, as a result we want to try to retrieve the auth data regardless + /// of whether we are going to use it as this will throw an `invalidAuthentication` error that would allow deletion + /// even in this invalid state + let authMethod: AuthenticationMethod = try Authentication.with( + swarmPublicKey: threadId, + using: dependencies + ) + switch (finalBehaviour, isAdminUser, (isAdminUser && numAdminUsers == 1)) { case (.leave, _, false): let disappearingConfig: DisappearingMessagesConfiguration? = try? DisappearingMessagesConfiguration.fetchOne(db, id: threadId) - let authMethod: AuthenticationMethod = try Authentication.with(db, swarmPublicKey: threadId, using: dependencies) return .sendLeaveMessage(authMethod, disappearingConfig) @@ -92,7 +100,7 @@ public enum GroupLeavingJob: JobExecutor { .tryFlatMap { requestType -> AnyPublisher in switch requestType { case .sendLeaveMessage(let authMethod, let disappearingConfig): - return try Network.SnodeAPI + return try Network.StorageServer .preparedBatch( requests: [ /// Don't expire the `GroupUpdateMemberLeftMessage` as that's not a UI-based @@ -138,7 +146,7 @@ public enum GroupLeavingJob: JobExecutor { /// If it failed due to one of these errors then clear out any associated data (as the `SessionThread` exists but /// either the data required to send the `MEMBER_LEFT` message doesn't or the user has had their access to the /// group revoked which would leave the user in a state where they can't leave the group) - switch (error as? MessageSenderError, error as? SnodeAPIError, error as? CryptoError) { + switch (error as? MessageSenderError, error as? StorageServerError, error as? CryptoError) { case (.invalidClosedGroupUpdate, _, _), (.noKeyPair, _, _), (.encryptionFailed, _, _), (_, .unauthorised, _), (_, _, .invalidAuthentication): return Just(()).setFailureType(to: Error.self).eraseToAnyPublisher() diff --git a/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift b/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift index d46639ac78..76618e6112 100644 --- a/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift +++ b/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift @@ -52,7 +52,7 @@ public enum GroupPromoteMemberJob: JobExecutor { // The first 32 bytes of a 64 byte ed25519 private key are the seed which can be used // to generate the KeyPair so extract those and send along with the promotion message - let sentTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let sentTimestampMs: Int64 = dependencies.networkOffsetTimestampMs() let message: GroupUpdatePromoteMessage = GroupUpdatePromoteMessage( groupIdentitySeed: groupInfo.groupIdentityPrivateKey.prefix(32), groupName: groupInfo.name, @@ -61,7 +61,7 @@ public enum GroupPromoteMemberJob: JobExecutor { /// Perform the actual message sending dependencies[singleton: .storage] - .writePublisher { db -> AuthenticationMethod in + .writePublisher { db in _ = try? GroupMember .filter(GroupMember.Columns.groupId == threadId) .filter(GroupMember.Columns.profileId == details.memberSessionIdHexString) @@ -71,11 +71,14 @@ public enum GroupPromoteMemberJob: JobExecutor { GroupMember.Columns.roleStatus.set(to: GroupMember.RoleStatus.sending), using: dependencies ) - - return try Authentication.with(db, swarmPublicKey: details.memberSessionIdHexString, using: dependencies) } - .tryFlatMap { authMethod -> AnyPublisher<(ResponseInfoType, Message), Error> in - try MessageSender.preparedSend( + .tryFlatMap { _ -> AnyPublisher<(ResponseInfoType, Message), Error> in + let authMethod: AuthenticationMethod = try Authentication.with( + swarmPublicKey: details.memberSessionIdHexString, + using: dependencies + ) + + return try MessageSender.preparedSend( message: message, to: .contact(publicKey: details.memberSessionIdHexString), namespace: .default, @@ -139,10 +142,10 @@ public enum GroupPromoteMemberJob: JobExecutor { case let senderError as MessageSenderError where !senderError.isRetryable: failure(job, error, true) - case SnodeAPIError.rateLimited: + case StorageServerError.rateLimited: failure(job, error, true) - case SnodeAPIError.clockOutOfSync: + case StorageServerError.clockOutOfSync: Log.error(.cat, "Permanently Failing to send due to clock out of sync issue.") failure(job, error, true) @@ -301,7 +304,7 @@ public extension GroupPromoteMemberJob { public extension Cache { static let groupPromoteMemberJob: CacheConfig = Dependencies.create( identifier: "groupPromoteMemberJob", - createInstance: { dependencies in GroupPromoteMemberJob.Cache(using: dependencies) }, + createInstance: { dependencies, _ in GroupPromoteMemberJob.Cache(using: dependencies) }, mutableInstance: { $0 }, immutableInstance: { $0 } ) diff --git a/SessionMessagingKit/Jobs/MessageSendJob.swift b/SessionMessagingKit/Jobs/MessageSendJob.swift index bbb2b1c7fe..e61397332c 100644 --- a/SessionMessagingKit/Jobs/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/MessageSendJob.swift @@ -86,7 +86,7 @@ public enum MessageSendJob: JobExecutor { /// Retrieve the current attachment state let attachmentState: AttachmentState = dependencies[singleton: .storage] .read { db in try MessageSendJob.fetchAttachmentState(db, interactionId: interactionId) } - .defaulting(to: AttachmentState(error: MessageSenderError.invalidMessage)) + .defaulting(to: AttachmentState(error: StorageError.objectNotFound)) /// If we got an error when trying to retrieve the attachment state then this job is actually invalid so it /// should permanently fail @@ -246,10 +246,10 @@ public enum MessageSendJob: JobExecutor { case (let senderError as MessageSenderError, _) where !senderError.isRetryable: failure(job, error, true) - case (SnodeAPIError.rateLimited, _): + case (StorageServerError.rateLimited, _): failure(job, error, true) - case (SnodeAPIError.clockOutOfSync, _): + case (StorageServerError.clockOutOfSync, _): Log.error(.cat, "\(originalSentTimestampMs != nil ? "Permanently Failing" : "Failing") to send \(messageType) (\(job.id ?? -1)) due to clock out of sync issue.") failure(job, error, (originalSentTimestampMs != nil)) diff --git a/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift b/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift index 0253242bc7..eed08bc2ea 100644 --- a/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift +++ b/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift @@ -84,9 +84,9 @@ public enum ProcessPendingGroupMemberRemovalsJob: JobExecutor { /// member was originally removed whereas the `messageSendTimestamp` is the time it will be uploaded to the swarm let targetChangeTimestampMs: Int64 = ( details.changeTimestampMs ?? - dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + dependencies.networkOffsetTimestampMs() ) - let messageSendTimestamp: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let messageSendTimestamp: Int64 = dependencies.networkOffsetTimestampMs() let memberIdsToRemoveContent: Set = pendingRemovals .filter { _, status -> Bool in status == GROUP_MEMBER_STATUS_REMOVED_MEMBER_AND_MESSAGES } .map { memberId, _ -> String in memberId } @@ -97,7 +97,7 @@ public enum ProcessPendingGroupMemberRemovalsJob: JobExecutor { .tryMap { _ -> Network.PreparedRequest in /// Revoke the members authData from the group so the server rejects API calls from the ex-members (fire-and-forget /// this request, we don't want it to be blocking) - let preparedRevokeSubaccounts: Network.PreparedRequest = try Network.SnodeAPI.preparedRevokeSubaccounts( + let preparedRevokeSubaccounts: Network.PreparedRequest = try Network.StorageServer.preparedRevokeSubaccounts( subaccountsToRevoke: try dependencies.mutate(cache: .libSession) { cache in try Array(pendingRemovals.keys).map { memberId in try dependencies[singleton: .crypto].tryGenerate( @@ -131,18 +131,18 @@ public enum ProcessPendingGroupMemberRemovalsJob: JobExecutor { domain: .kickedMessage ) ) - let preparedGroupDeleteMessage: Network.PreparedRequest = try Network.SnodeAPI + let preparedGroupDeleteMessage: Network.PreparedRequest = try Network.StorageServer .preparedSendMessage( - message: SnodeMessage( + request: Network.StorageServer.SendMessageRequest( recipient: groupSessionId.hexString, + namespace: .revokedRetrievableGroupMessages, data: encryptedDeleteMessageData, ttl: Message().ttl, - timestampMs: UInt64(messageSendTimestamp) - ), - in: .revokedRetrievableGroupMessages, - authMethod: Authentication.groupAdmin( - groupSessionId: groupSessionId, - ed25519SecretKey: Array(groupIdentityPrivateKey) + timestampMs: UInt64(messageSendTimestamp), + authMethod: Authentication.groupAdmin( + groupSessionId: groupSessionId, + ed25519SecretKey: Array(groupIdentityPrivateKey) + ) ), using: dependencies ) @@ -179,12 +179,11 @@ public enum ProcessPendingGroupMemberRemovalsJob: JobExecutor { }() /// Combine the two requests to be sent at the same time - return try Network.SnodeAPI.preparedSequence( + return try Network.StorageServer.preparedSequence( requests: [preparedRevokeSubaccounts, preparedGroupDeleteMessage, preparedMemberContentRemovalMessage] .compactMap { $0 }, requireAllBatchResponses: true, swarmPublicKey: groupSessionId.hexString, - snodeRetrievalRetryCount: 0, // Job has a built-in retry using: dependencies ) } @@ -262,7 +261,7 @@ public enum ProcessPendingGroupMemberRemovalsJob: JobExecutor { ) /// Delete the messages from the swarm so users won't download them again - try? Network.SnodeAPI + try? Network.StorageServer .preparedDeleteMessages( serverHashes: Array(hashes), requireSuccessfulDeletion: false, diff --git a/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift b/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift index e34c48694b..aefa07f279 100644 --- a/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift +++ b/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift @@ -40,143 +40,125 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { .isEmpty else { return deferred(job) } - // The Network.SOGS won't make any API calls if there is no entry for an OpenGroup - // in the database so we need to create a dummy one to retrieve the default room data - let defaultGroupId: String = OpenGroup.idFor(roomToken: "", server: Network.SOGS.defaultServer) - - dependencies[singleton: .storage].write { db in - guard try OpenGroup.exists(db, id: defaultGroupId) == false else { return } - - try OpenGroup( - server: Network.SOGS.defaultServer, - roomToken: "", - publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, - name: "", - userCount: 0, - infoUpdates: 0 - ) - .upserted(db) - } - - /// Try to retrieve the default rooms 8 times - dependencies[singleton: .storage] - .readPublisher { [dependencies] db -> AuthenticationMethod in - try Authentication.with( - db, - server: Network.SOGS.defaultServer, - activeOnly: false, /// The record for the default rooms is inactive - using: dependencies - ) - } - .tryFlatMap { [dependencies] authMethod -> AnyPublisher<(ResponseInfoType, Network.SOGS.CapabilitiesAndRoomsResponse), Error> in - try Network.SOGS.preparedCapabilitiesAndRooms( - authMethod: authMethod, + Task { + do { + /// Don't bother trying to poll if we don't have a network connection, just wait for one to be established + try await dependencies.waitUntilConnected(onWillStartWaiting: { + Log.info(.cat, "Waiting for network to connect.") + }) + guard !Task.isCancelled else { return } + + let request = try Network.SOGS.preparedCapabilitiesAndRooms( + authMethod: Authentication.community( + roomToken: "", + server: Network.SOGS.defaultServer, + publicKey: Network.SOGS.defaultServerPublicKey, + hasCapabilities: false, + supportsBlinding: true + ), skipAuthentication: true, using: dependencies - ).send(using: dependencies) - } - .subscribe(on: scheduler, using: dependencies) - .receive(on: scheduler, using: dependencies) - .retry(8, using: dependencies) - .sinkUntilComplete( - receiveCompletion: { result in - switch result { - case .finished: - Log.info(.cat, "Successfully retrieved default Community rooms") - success(job, false) - - case .failure(let error): - Log.error(.cat, "Failed to get default Community rooms due to error: \(error)") - failure(job, error, false) - } - }, - receiveValue: { info, response in - let defaultRooms: [OpenGroupManager.DefaultRoomInfo]? = dependencies[singleton: .storage].write { db -> [OpenGroupManager.DefaultRoomInfo] in - // Store the capabilities first - OpenGroupManager.handleCapabilities( - db, - capabilities: response.capabilities.data, - on: Network.SOGS.defaultServer - ) - - let existingImageIds: [String: String] = try OpenGroup - .filter(OpenGroup.Columns.server == Network.SOGS.defaultServer) - .filter(OpenGroup.Columns.imageId != nil) - .fetchAll(db) - .reduce(into: [:]) { result, next in result[next.id] = next.imageId } - let result: [OpenGroupManager.DefaultRoomInfo] = try response.rooms.data - .compactMap { room -> OpenGroupManager.DefaultRoomInfo? in - /// Try to insert an inactive version of the OpenGroup (use `insert` rather than - /// `save` as we want it to fail if the room already exists) - do { - return ( - room, - try OpenGroup( - server: Network.SOGS.defaultServer, + ) + let response: Network.SOGS.CapabilitiesAndRoomsResponse = try await request.send(using: dependencies) + guard !Task.isCancelled else { return } + + let defaultRooms: [OpenGroupManager.DefaultRoomInfo]? = try await dependencies[singleton: .storage].writeAsync { db -> [OpenGroupManager.DefaultRoomInfo] in + // Store the capabilities first + dependencies[singleton: .openGroupManager].handleCapabilities( + db, + capabilities: response.capabilities.data, + on: Network.SOGS.defaultServer + ) + + let existingImageIds: [String: String] = try OpenGroup + .filter(OpenGroup.Columns.server == Network.SOGS.defaultServer) + .filter(OpenGroup.Columns.imageId != nil) + .fetchAll(db) + .reduce(into: [:]) { result, next in result[next.id] = next.imageId } + let result: [OpenGroupManager.DefaultRoomInfo] = try response.rooms.data + .compactMap { room -> OpenGroupManager.DefaultRoomInfo? in + /// Try to insert an inactive version of the OpenGroup (use `insert` rather than + /// `save` as we want it to fail if the room already exists) + do { + return ( + room, + try OpenGroup( + server: Network.SOGS.defaultServer, + roomToken: room.token, + publicKey: Network.SOGS.defaultServerPublicKey, + isActive: false, + name: room.name, + roomDescription: room.roomDescription, + imageId: room.imageId, + userCount: room.activeUsers, + infoUpdates: room.infoUpdates + ) + .inserted(db) + ) + } + catch { + return try OpenGroup + .fetchOne( + db, + id: OpenGroup.idFor( roomToken: room.token, - publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, - name: room.name, - roomDescription: room.roomDescription, - imageId: room.imageId, - userCount: room.activeUsers, - infoUpdates: room.infoUpdates + server: Network.SOGS.defaultServer ) - .inserted(db) ) - } - catch { - return try OpenGroup - .fetchOne( - db, - id: OpenGroup.idFor( - roomToken: room.token, - server: Network.SOGS.defaultServer - ) - ) - .map { (room, $0) } - } + .map { (room, $0) } } + } + + /// Schedule the room image download (if it doesn't match out current one) + result.forEach { room, openGroup in + let openGroupId: String = OpenGroup.idFor(roomToken: room.token, server: Network.SOGS.defaultServer) - /// Schedule the room image download (if it doesn't match out current one) - result.forEach { room, openGroup in - let openGroupId: String = OpenGroup.idFor(roomToken: room.token, server: Network.SOGS.defaultServer) - - guard - let imageId: String = room.imageId, - imageId != existingImageIds[openGroupId] || + guard + let imageId: String = room.imageId, + imageId != existingImageIds[openGroupId] || openGroup.displayPictureOriginalUrl == nil - else { return } - - dependencies[singleton: .jobRunner].add( - db, - job: Job( - variant: .displayPictureDownload, - shouldBeUnique: true, - details: DisplayPictureDownloadJob.Details( - target: .community( - imageId: imageId, - roomToken: room.token, - server: Network.SOGS.defaultServer, - skipAuthentication: true - ), - timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) - ) - ), - canStartJob: true - ) - } + else { return } - return result + dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .displayPictureDownload, + shouldBeUnique: true, + details: DisplayPictureDownloadJob.Details( + target: .community( + imageId: imageId, + roomToken: room.token, + server: Network.SOGS.defaultServer, + skipAuthentication: true + ), + timestamp: (dependencies.networkOffsetTimestampMs() / 1000) + ) + ), + canStartJob: true + ) } - /// Update the `openGroupManager` cache to have the default rooms - dependencies.mutate(cache: .openGroupManager) { cache in - cache.setDefaultRoomInfo(defaultRooms ?? []) - } + return result } - ) + + /// Update the `openGroupManager` cache to have the default rooms + await dependencies[singleton: .openGroupManager].setDefaultRoomInfo(defaultRooms ?? []) + Log.info(.cat, "Successfully retrieved default Community rooms") + + scheduler.schedule { + success(job, false) + } + } + catch { + /// We want to fail permanently here, otherwise we would just indefinitely retry (if the user opens the + /// "Join Community" screen that will kick off another job, otherwise this will automatically be rescheduled + /// on launch) + Log.error(.cat, "Failed to get default Community rooms due to error: \(error)") + scheduler.schedule { + failure(job, error, true) + } + } + } } public static func run(using dependencies: Dependencies) { diff --git a/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift index 9196301bfc..b31db68374 100644 --- a/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift @@ -33,10 +33,14 @@ public enum SendReadReceiptsJob: JobExecutor { return success(job, true) } - dependencies[singleton: .storage] - .readPublisher { db in try Authentication.with(db, swarmPublicKey: threadId, using: dependencies) } - .tryFlatMap { authMethod -> AnyPublisher<(ResponseInfoType, Message), Error> in - try MessageSender.preparedSend( + AnyPublisher + .lazy { () -> AnyPublisher<(ResponseInfoType, Message), Error> in + let authMethod: AuthenticationMethod = try Authentication.with( + swarmPublicKey: threadId, + using: dependencies + ) + + return try MessageSender.preparedSend( message: ReadReceipt( timestamps: details.timestampMsValues.map { UInt64($0) } ), diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift index 82cdc5060c..df2adb89f7 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift @@ -289,11 +289,10 @@ internal extension LibSessionCacheType { // send a fire-and-forget API call to delete the messages from the swarm if isAdmin && !messageHashesToDelete.isEmpty { (try? Authentication.with( - db, swarmPublicKey: groupSessionId.hexString, using: dependencies )).map { authMethod in - try? Network.SnodeAPI + try? Network.StorageServer .preparedDeleteMessages( serverHashes: Array(messageHashesToDelete), requireSuccessfulDeletion: false, diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift index ce4d1986dd..24bcd3c4e1 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift @@ -924,7 +924,7 @@ public extension Dependencies { } } - private func set(_ key: Setting.BoolKey, _ value: Bool?) async { + func set(_ key: Setting.BoolKey, _ value: Bool?) async { let targetVariant: ConfigDump.Variant switch key { @@ -943,7 +943,7 @@ public extension Dependencies { } } - private func set(_ key: Setting.EnumKey, _ value: T?) async { + func set(_ key: Setting.EnumKey, _ value: T?) async { let mutation: LibSession.Mutation? = try? await self.mutateAsyncAware(cache: .libSession) { cache in try cache.perform(for: .local) { cache.set(key, value) diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift index 64a1f5260a..9dece8e260 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift @@ -65,7 +65,7 @@ internal extension LibSession { // Prep the relevant details (reduce the members to ensure we don't accidentally insert duplicates) let groupSessionId: SessionId = SessionId(.group, publicKey: groupIdentityKeyPair.publicKey) - let creationTimestamp: TimeInterval = TimeInterval(dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + let creationTimestamp: TimeInterval = TimeInterval(dependencies.networkOffsetTimestampMs() / 1000) let userSessionId: SessionId = dependencies[cache: .general].sessionId let currentUserProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift index c9724f15b1..4c60e4faae 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift @@ -1043,6 +1043,21 @@ public extension LibSession.Cache { return ugroups_group_is_destroyed(&userGroup) } + + func authData(groupSessionId: SessionId) -> GroupAuthData { + var group: ugroups_group_info = ugroups_group_info() + + guard + case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId), + var cGroupId: [CChar] = groupSessionId.hexString.cString(using: .utf8), + user_groups_get_group(conf, &group, &cGroupId) + else { return GroupAuthData(groupIdentityPrivateKey: nil, authData: nil) } + + return GroupAuthData( + groupIdentityPrivateKey: (!group.have_secretkey ? nil : group.get(\.secretkey, nullIfEmpty: true)), + authData: (!group.have_auth_data ? nil : group.get(\.auth_data, nullIfEmpty: true)) + ) + } } // MARK: - Convenience diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index 217af7d5ae..15b001e6e5 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -12,7 +12,7 @@ import SessionUtilitiesKit public extension Cache { static let libSession: CacheConfig = Dependencies.create( identifier: "libSession", - createInstance: { dependencies in NoopLibSessionCache(using: dependencies) }, + createInstance: { dependencies, _ in NoopLibSessionCache(using: dependencies) }, mutableInstance: { $0 }, immutableInstance: { $0 } ) @@ -182,7 +182,7 @@ public extension LibSession { dump: ConfigDump? ) - enum CacheBehaviour { + enum CacheBehaviour: Int, CaseIterable { case skipAutomaticConfigSync case skipGroupAdminCheck } @@ -206,11 +206,11 @@ public extension LibSession { // MARK: - State Management - public func loadState(_ db: ObservingDatabase, requestId: String?) { + public func loadState(_ db: ObservingDatabase, userEd25519SecretKey: [UInt8]) throws { // Ensure we have the ed25519 key and that we haven't already loaded the state before // we continue guard configStore.isEmpty else { - return Log.warn(.libSession, "Ignoring loadState\(requestId.map { " for \($0)" } ?? "") due to existing state") + return Log.warn(.libSession, "Ignoring loadState due to existing state") } /// Retrieve the existing dumps from the database @@ -270,11 +270,11 @@ public extension LibSession { } /// Now that we have fully populated and sorted `configsToLoad` we should load each into memory - configsToLoad.forEach { sessionId, variant, dump in - configStore[sessionId, variant] = try? loadState( + try configsToLoad.forEach { sessionId, variant, dump in + configStore[sessionId, variant] = try loadState( for: variant, sessionId: sessionId, - userEd25519SecretKey: dependencies[cache: .general].ed25519SecretKey, + userEd25519SecretKey: userEd25519SecretKey, groupEd25519SecretKey: groupsByKey[sessionId.hexString]? .groupIdentityPrivateKey .map { Array($0) }, @@ -282,7 +282,7 @@ public extension LibSession { ) } - Log.info(.libSession, "Completed loadState\(requestId.map { " for \($0)" } ?? "")") + Log.info(.libSession, "Completed loadState") } public func loadDefaultStateFor( @@ -950,7 +950,7 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT // MARK: - State Management - func loadState(_ db: ObservingDatabase, requestId: String?) + func loadState(_ db: ObservingDatabase, userEd25519SecretKey: [UInt8]) throws func loadDefaultStateFor( variant: ConfigDump.Variant, sessionId: SessionId, @@ -1113,6 +1113,8 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT func groupIsDestroyed(groupSessionId: SessionId) -> Bool func groupDeleteBefore(groupSessionId: SessionId) -> TimeInterval? func groupDeleteAttachmentsBefore(groupSessionId: SessionId) -> TimeInterval? + + func authData(groupSessionId: SessionId) -> GroupAuthData } public extension LibSessionCacheType { @@ -1167,19 +1169,15 @@ public extension LibSessionCacheType { return try perform(for: variant, sessionId: userSessionId, change: { _ in try change() }) } - func loadState(_ db: ObservingDatabase) { - loadState(db, requestId: nil) - } - - func addEvent(key: ObservableKey, value: AnyHashable?) { + func addEvent(key: ObservableKey, value: T?) { addEvent(ObservedEvent(key: key, value: value)) } - func addEvent(key: Setting.BoolKey, value: AnyHashable?) { + func addEvent(key: Setting.BoolKey, value: T?) { addEvent(ObservedEvent(key: .setting(key), value: value)) } - func addEvent(key: Setting.EnumKey, value: AnyHashable?) { + func addEvent(key: Setting.EnumKey, value: T?) { addEvent(ObservedEvent(key: .setting(key), value: value)) } @@ -1210,7 +1208,7 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { // MARK: - State Management - func loadState(_ db: ObservingDatabase, requestId: String?) {} + func loadState(_ db: ObservingDatabase, userEd25519SecretKey: [UInt8]) throws {} func loadDefaultStateFor( variant: ConfigDump.Variant, sessionId: SessionId, @@ -1383,6 +1381,10 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { func groupIsDestroyed(groupSessionId: SessionId) -> Bool { return false } func groupDeleteBefore(groupSessionId: SessionId) -> TimeInterval? { return nil } func groupDeleteAttachmentsBefore(groupSessionId: SessionId) -> TimeInterval? { return nil } + + func authData(groupSessionId: SessionId) -> GroupAuthData { + return GroupAuthData(groupIdentityPrivateKey: nil, authData: nil) + } } // MARK: - Convenience diff --git a/SessionMessagingKit/LibSession/Types/Config.swift b/SessionMessagingKit/LibSession/Types/Config.swift index 7eb71d798f..5a80a7afaf 100644 --- a/SessionMessagingKit/LibSession/Types/Config.swift +++ b/SessionMessagingKit/LibSession/Types/Config.swift @@ -324,7 +324,7 @@ public extension LibSession { .sorted() if successfulMergeTimestamps.count != messages.count { - Log.warn(.libSession, "Unable to merge \(Network.SnodeAPI.Namespace.configGroupKeys) messages (\(successfulMergeTimestamps.count)/\(messages.count))") + Log.warn(.libSession, "Unable to merge \(Network.StorageServer.Namespace.configGroupKeys) messages (\(successfulMergeTimestamps.count)/\(messages.count))") } return successfulMergeTimestamps.last diff --git a/SessionMessagingKit/LibSession/Types/GroupAuthData.swift b/SessionMessagingKit/LibSession/Types/GroupAuthData.swift new file mode 100644 index 0000000000..cb0dd8bf24 --- /dev/null +++ b/SessionMessagingKit/LibSession/Types/GroupAuthData.swift @@ -0,0 +1,8 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public struct GroupAuthData: Codable { + let groupIdentityPrivateKey: Data? + let authData: Data? +} diff --git a/SessionMessagingKit/LibSession/Types/Mutation.swift b/SessionMessagingKit/LibSession/Types/Mutation.swift index 3929d526d1..822907acdf 100644 --- a/SessionMessagingKit/LibSession/Types/Mutation.swift +++ b/SessionMessagingKit/LibSession/Types/Mutation.swift @@ -2,6 +2,7 @@ import Foundation import GRDB +import SessionNetworkingKit import SessionUtilitiesKit public extension LibSession { @@ -33,7 +34,7 @@ public extension LibSession { config: $0, for: $0.variant, sessionId: sessionId, - timestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + timestampMs: dependencies.networkOffsetTimestampMs() ) } ) diff --git a/SessionMessagingKit/Messages/Message+Destination.swift b/SessionMessagingKit/Messages/Message+Destination.swift index a61a43ec3d..e61776f342 100644 --- a/SessionMessagingKit/Messages/Message+Destination.swift +++ b/SessionMessagingKit/Messages/Message+Destination.swift @@ -39,7 +39,7 @@ public extension Message { } } - public var defaultNamespace: Network.SnodeAPI.Namespace? { + public var defaultNamespace: Network.StorageServer.Namespace? { switch self { case .contact, .syncMessage: return .`default` case .closedGroup(let groupId) where (try? SessionId.Prefix(from: groupId)) == .group: diff --git a/SessionMessagingKit/Messages/Message+DisappearingMessages.swift b/SessionMessagingKit/Messages/Message+DisappearingMessages.swift index 4212f74901..dde2496f6a 100644 --- a/SessionMessagingKit/Messages/Message+DisappearingMessages.swift +++ b/SessionMessagingKit/Messages/Message+DisappearingMessages.swift @@ -40,7 +40,7 @@ extension Message { return nil } - let nowMs: Double = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let nowMs: Double = dependencies.networkOffsetTimestampMs() let serverExpirationTimestampMs: Double = serverExpirationTimestamp * 1000 let expiresInMs: Double = expiresInSeconds * 1000 @@ -86,7 +86,7 @@ extension Message { threadId: threadId, details: GetExpirationJob.Details( expirationInfo: [serverHash: expireInSeconds], - startedAtTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + startedAtTimestampMs: dependencies.networkOffsetTimestampMs() ) ), canStartJob: true diff --git a/SessionMessagingKit/Messages/Message+Origin.swift b/SessionMessagingKit/Messages/Message+Origin.swift index 1c0d5f330a..fadce1efac 100644 --- a/SessionMessagingKit/Messages/Message+Origin.swift +++ b/SessionMessagingKit/Messages/Message+Origin.swift @@ -8,7 +8,7 @@ public extension Message { enum Origin: Codable, Hashable { case swarm( publicKey: String, - namespace: Network.SnodeAPI.Namespace, + namespace: Network.StorageServer.Namespace, serverHash: String, serverTimestampMs: Int64, serverExpirationTimestamp: TimeInterval diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index 2583d84d7c..57191c371d 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -174,7 +174,7 @@ public enum ProcessedMessage { ) case config( publicKey: String, - namespace: Network.SnodeAPI.Namespace, + namespace: Network.StorageServer.Namespace, serverHash: String, serverTimestampMs: Int64, data: Data, @@ -190,7 +190,7 @@ public enum ProcessedMessage { } } - var namespace: Network.SnodeAPI.Namespace { + var namespace: Network.StorageServer.Namespace { switch self { case .standard(_, let threadVariant, _, _, _): switch threadVariant { @@ -510,7 +510,7 @@ public extension Message { ) let count: Int64 = (next.value.you ? next.value.count - 1 : next.value.count) - let timestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let timestampMs: Int64 = dependencies.networkOffsetTimestampMs() let maxLength: Int = shouldAddSelfReaction ? 4 : 5 let desiredReactorIds: [String] = reactors .filter { !currentUserSessionIds.contains($0) } // Remove current user for now, will add back if needed diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index e0b7269ffd..b64a8c5511 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -9,20 +9,9 @@ import SessionNetworkingKit // MARK: - Singleton public extension Singleton { - static let openGroupManager: SingletonConfig = Dependencies.create( + static let openGroupManager: SingletonConfig = Dependencies.create( identifier: "openGroupManager", - createInstance: { dependencies in OpenGroupManager(using: dependencies) } - ) -} - -// MARK: - Cache - -public extension Cache { - static let openGroupManager: CacheConfig = Dependencies.create( - identifier: "openGroupManager", - createInstance: { dependencies in OpenGroupManager.Cache(using: dependencies) }, - mutableInstance: { $0 }, - immutableInstance: { $0 } + createInstance: { dependencies, _ in OpenGroupManager(using: dependencies) } ) } @@ -34,61 +23,49 @@ public extension Log.Category { // MARK: - OpenGroupManager -public final class OpenGroupManager { +public actor OpenGroupManager: OpenGroupManagerType { public typealias DefaultRoomInfo = (room: Network.SOGS.Room, openGroup: OpenGroup) - private let dependencies: Dependencies + nonisolated public let syncState: OpenGroupManagerSyncState + public let dependencies: Dependencies + + nonisolated private let _defaultRooms: CurrentValueAsyncStream<[DefaultRoomInfo]> = CurrentValueAsyncStream([]) + private var _lastSuccessfulCommunityPollTimestamp: TimeInterval? + + public private(set) var pendingChanges: [OpenGroupManager.PendingChange] = [] + nonisolated public var defaultRooms: AsyncStream<[DefaultRoomInfo]> { + return AsyncStream { continuation in + let bridgingTask = Task { + for await element in _defaultRooms.stream { + /// If we don't have any default rooms in memory then we haven't fetched this launch so schedule + /// the `RetrieveDefaultOpenGroupRoomsJob` if one isn't already running + if element.isEmpty { + let dependencies: Dependencies = await self.dependencies + RetrieveDefaultOpenGroupRoomsJob.run(using: dependencies) + } + + continuation.yield(element) + } + + continuation.finish() + } + + continuation.onTermination = { @Sendable _ in + bridgingTask.cancel() + } + } + } // MARK: - Initialization init(using dependencies: Dependencies) { self.dependencies = dependencies - } + self.syncState = OpenGroupManagerSyncState(using: dependencies) + } // MARK: - Adding & Removing - // stringlint:ignore_contents - private static func port(for server: String, serverUrl: URL) -> String { - if let port: Int = serverUrl.port { - return ":\(port)" - } - - let components: [String] = server.components(separatedBy: ":") - - guard - let port: String = components.last, - ( - port != components.first && - !port.starts(with: "//") - ) - else { return "" } - - return ":\(port)" - } - - public static func isSessionRunOpenGroup(server: String) -> Bool { - guard let serverUrl: URL = (URL(string: server.lowercased()) ?? URL(string: "http://\(server.lowercased())")) else { - return false - } - - let serverPort: String = OpenGroupManager.port(for: server, serverUrl: serverUrl) - let serverHost: String = serverUrl.host - .defaulting( - to: server - .lowercased() - .replacingOccurrences(of: serverPort, with: "") - ) - let options: Set = Set([ - Network.SOGS.legacyDefaultServerIP, - Network.SOGS.defaultServer - .replacingOccurrences(of: "http://", with: "") - .replacingOccurrences(of: "https://", with: "") - ]) - - return options.contains(serverHost) - } - - public func hasExistingOpenGroup( + nonisolated public func hasExistingOpenGroup( _ db: ObservingDatabase, roomToken: String, server: String, @@ -125,7 +102,7 @@ public final class OpenGroupManager { } // First check if there is no poller for the specified server - if Set(dependencies[cache: .communityPollers].serversBeingPolled).intersection(serverOptions).isEmpty { + if Set(syncState.dependencies[singleton: .communityPollerManager].syncState.serversBeingPolled).intersection(serverOptions).isEmpty { return false } @@ -142,7 +119,7 @@ public final class OpenGroupManager { return hasExistingThread } - public func add( + nonisolated public func add( _ db: ObservingDatabase, roomToken: String, server: String, @@ -176,7 +153,7 @@ public final class OpenGroupManager { /// handling then we want to wait until it actually has messages before making it visible) shouldBeVisible: (forceVisible ? .setTo(true) : .useExisting) ), - using: dependencies + using: syncState.dependencies ) if (try? OpenGroup.exists(db, id: threadId)) == false { @@ -187,19 +164,21 @@ public final class OpenGroupManager { // Set the group to active and reset the sequenceNumber (handle groups which have // been deactivated) - _ = try? OpenGroup - .filter(id: OpenGroup.idFor(roomToken: roomToken, server: targetServer)) - .updateAllAndConfig( - db, - OpenGroup.Columns.isActive.set(to: true), - OpenGroup.Columns.sequenceNumber.set(to: 0), - using: dependencies - ) + if (try? OpenGroup.select(.isActive).filter(id: threadId).asRequest(of: Bool.self).fetchOne(db)) != true { + _ = try? OpenGroup + .filter(id: OpenGroup.idFor(roomToken: roomToken, server: targetServer)) + .updateAllAndConfig( + db, + OpenGroup.Columns.isActive.set(to: true), + OpenGroup.Columns.sequenceNumber.set(to: 0), + using: syncState.dependencies + ) + } return true } - public func performInitialRequestsAfterAdd( + nonisolated public func performInitialRequestsAfterAdd( queue: DispatchQueue, successfullyAddedGroup: Bool, roomToken: String, @@ -209,8 +188,8 @@ public final class OpenGroupManager { // Only bother performing the initial request if the network isn't suspended guard successfullyAddedGroup, - !dependencies[singleton: .storage].isSuspended, - !dependencies[cache: .libSessionNetwork].isSuspended + !syncState.dependencies[singleton: .storage].isSuspended, + !syncState.dependencies[singleton: .network].syncState.isSuspended else { return Just(()) .setFailureType(to: Error.self) @@ -238,12 +217,14 @@ public final class OpenGroupManager { capabilities: [] /// We won't have `capabilities` before the first request so just hard code ) ), - using: dependencies + using: syncState.dependencies ) } .publisher - .flatMap { [dependencies] in $0.send(using: dependencies) } - .flatMapStorageWritePublisher(using: dependencies) { [dependencies] (db: ObservingDatabase, response: (info: ResponseInfoType, value: Network.SOGS.CapabilitiesAndRoomResponse)) -> Void in + .flatMap { [dependencies = syncState.dependencies] in $0.send(using: dependencies) } + .flatMapStorageWritePublisher(using: syncState.dependencies) { [weak self, dependencies = syncState.dependencies] (db: ObservingDatabase, response: (info: ResponseInfoType, value: Network.SOGS.CapabilitiesAndRoomResponse)) -> Void in + guard let self = self else { throw StorageError.objectNotSaved } + // Add the new open group to libSession try LibSession.add( db, @@ -254,32 +235,31 @@ public final class OpenGroupManager { ) // Store the capabilities first - OpenGroupManager.handleCapabilities( + handleCapabilities( db, capabilities: response.value.capabilities.data, on: targetServer ) // Then the room - try OpenGroupManager.handlePollInfo( + try handlePollInfo( db, pollInfo: Network.SOGS.RoomPollInfo(room: response.value.room.data), publicKey: publicKey, for: roomToken, - on: targetServer, - using: dependencies + on: targetServer ) } .handleEvents( - receiveCompletion: { [dependencies] result in + receiveCompletion: { [communityPollerManager = syncState.dependencies[singleton: .communityPollerManager]] result in switch result { case .finished: - // (Re)start the poller if needed (want to force it to poll immediately in the next - // run loop to avoid a big delay before the next poll) - dependencies.mutate(cache: .communityPollers) { cache in - let poller: CommunityPollerType = cache.getOrCreatePoller(for: server.lowercased()) - poller.stop() - poller.startIfNeeded() + /// (Re)start the poller if needed (want to force it to poll immediately in the next run loop to avoid + /// a big delay before the next poll) + Task { [communityPollerManager] in + let poller = await communityPollerManager.getOrCreatePoller(for: server.lowercased()) + await poller.stop() + await poller.startIfNeeded() } case .failure(let error): Log.error(.openGroup, "Failed to join open group with error: \(error).") @@ -289,7 +269,7 @@ public final class OpenGroupManager { .eraseToAnyPublisher() } - public func delete( + nonisolated public func delete( _ db: ObservingDatabase, openGroupId: String, skipLibSessionUpdate: Bool @@ -317,8 +297,8 @@ public final class OpenGroupManager { .defaulting(to: 1) if numActiveRooms == 1, let server: String = server?.lowercased() { - dependencies.mutate(cache: .communityPollers) { - $0.stopAndRemovePoller(for: server) + Task { [manager = syncState.dependencies[singleton: .communityPollerManager]] in + await manager.stopAndRemovePoller(for: server) } } @@ -331,7 +311,7 @@ public final class OpenGroupManager { db.addConversationEvent(id: openGroupId, type: .deleted) // Remove any dedupe records (we will want to reprocess all OpenGroup messages if they get re-added) - try MessageDeduplication.deleteIfNeeded(db, threadIds: [openGroupId], using: dependencies) + try MessageDeduplication.deleteIfNeeded(db, threadIds: [openGroupId], using: syncState.dependencies) // Remove the open group (no foreign key to the thread so it won't auto-delete) if server?.lowercased() != Network.SOGS.defaultServer.lowercased() { @@ -346,18 +326,44 @@ public final class OpenGroupManager { .updateAllAndConfig( db, OpenGroup.Columns.isActive.set(to: false), - using: dependencies + using: syncState.dependencies ) } if !skipLibSessionUpdate, let server: String = server, let roomToken: String = roomToken { - try LibSession.remove(db, server: server, roomToken: roomToken, using: dependencies) + try LibSession.remove(db, server: server, roomToken: roomToken, using: syncState.dependencies) + } + } + + // MARK: - Default Rooms + + public func setDefaultRoomInfo(_ info: [DefaultRoomInfo]) async { + await _defaultRooms.send(info) + } + + // MARK: - Polling + + public func getLastSuccessfulCommunityPollTimestamp() -> TimeInterval { + if let storedTime: TimeInterval = _lastSuccessfulCommunityPollTimestamp { + return storedTime + } + + guard let lastPoll: Date = dependencies[defaults: .standard, key: .lastOpen] else { + return 0 } + + _lastSuccessfulCommunityPollTimestamp = lastPoll.timeIntervalSince1970 + return lastPoll.timeIntervalSince1970 + } + + public func setLastSuccessfulCommunityPollTimestamp(_ timestamp: TimeInterval) { + dependencies[defaults: .standard, key: .lastOpen] = Date(timeIntervalSince1970: timestamp) + _lastSuccessfulCommunityPollTimestamp = timestamp } // MARK: - Response Processing - internal static func handleCapabilities( + nonisolated public func handleCapabilities( _ db: ObservingDatabase, capabilities: Network.SOGS.CapabilitiesResponse, on server: String @@ -386,13 +392,12 @@ public final class OpenGroupManager { } } - internal static func handlePollInfo( + nonisolated public func handlePollInfo( _ db: ObservingDatabase, pollInfo: Network.SOGS.RoomPollInfo, publicKey maybePublicKey: String?, for roomToken: String, - on server: String, - using dependencies: Dependencies + on server: String ) throws { // Create the open group model and get or create the thread let threadId: String = OpenGroup.idFor(roomToken: roomToken, server: server) @@ -428,7 +433,7 @@ public final class OpenGroupManager { try OpenGroup .filter(id: openGroup.id) - .updateAllAndConfig(db, changes, using: dependencies) + .updateAllAndConfig(db, changes, using: syncState.dependencies) // Update the admin/moderator group members if let roomDetails: Network.SOGS.Room = pollInfo.details { @@ -489,7 +494,7 @@ public final class OpenGroupManager { openGroup.imageId != imageId ) { - dependencies[singleton: .jobRunner].add( + syncState.dependencies[singleton: .jobRunner].add( db, job: Job( variant: .displayPictureDownload, @@ -500,7 +505,7 @@ public final class OpenGroupManager { roomToken: openGroup.roomToken, server: openGroup.server ), - timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + timestamp: (syncState.dependencies.networkOffsetTimestampMs() / 1000) ) ), canStartJob: true @@ -529,12 +534,11 @@ public final class OpenGroupManager { } } - internal static func handleMessages( + nonisolated public func handleMessages( _ db: ObservingDatabase, messages: [Network.SOGS.Message], for roomToken: String, - on server: String, - using dependencies: Dependencies + on server: String ) -> [MessageReceiver.InsertedInteractionInfo?] { guard let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: OpenGroup.idFor(roomToken: roomToken, server: server)) else { Log.error(.openGroup, "Couldn't handle open group messages due to missing group.") @@ -577,13 +581,13 @@ public final class OpenGroupManager { whisperMods: message.whisperMods, whisperTo: message.whisperTo ), - using: dependencies + using: syncState.dependencies ) try MessageDeduplication.insert( db, processedMessage: processedMessage, ignoreDedupeFiles: false, - using: dependencies + using: syncState.dependencies ) switch processedMessage { @@ -598,7 +602,7 @@ public final class OpenGroupManager { serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), suppressNotifications: false, - using: dependencies + using: syncState.dependencies ) ) largestValidSeqNo = max(largestValidSeqNo, message.seqNo) @@ -626,7 +630,7 @@ public final class OpenGroupManager { db, openGroupId: openGroup.id, message: message, - associatedPendingChanges: dependencies[cache: .openGroupManager].pendingChanges + associatedPendingChanges: syncState.pendingChanges .filter { guard $0.server == server && $0.room == roomToken && $0.changeType == .reaction else { return false @@ -637,7 +641,7 @@ public final class OpenGroupManager { } return false }, - using: dependencies + using: syncState.dependencies ) try MessageReceiver.handleOpenGroupReactions( @@ -674,20 +678,20 @@ public final class OpenGroupManager { .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: largestValidSeqNo)) // Update pendingChange cache based on the `largestValidSeqNo` value - dependencies.mutate(cache: .openGroupManager) { - $0.pendingChanges = $0.pendingChanges - .filter { $0.seqNo == nil || $0.seqNo! > largestValidSeqNo } + Task { + await self.setPendingChanges(pendingChanges.filter { + $0.seqNo == nil || $0.seqNo! > largestValidSeqNo + }) } return insertedInteractionInfo } - internal static func handleDirectMessages( + nonisolated public func handleDirectMessages( _ db: ObservingDatabase, messages: [Network.SOGS.DirectMessage], fromOutbox: Bool, - on server: String, - using dependencies: Dependencies + on server: String ) -> [MessageReceiver.InsertedInteractionInfo?] { // Don't need to do anything if we have no messages (it's a valid case) guard !messages.isEmpty else { return [] } @@ -733,13 +737,13 @@ public final class OpenGroupManager { senderId: message.sender, recipientId: message.recipient ), - using: dependencies + using: syncState.dependencies ) try MessageDeduplication.insert( db, processedMessage: processedMessage, ignoreDedupeFiles: false, - using: dependencies + using: syncState.dependencies ) switch processedMessage { @@ -768,7 +772,7 @@ public final class OpenGroupManager { openGroupServer: server.lowercased(), openGroupPublicKey: openGroup.publicKey, isCheckingForOutbox: fromOutbox, - using: dependencies + using: syncState.dependencies ) }() lookupCache[message.recipient] = lookup @@ -798,7 +802,7 @@ public final class OpenGroupManager { serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, associatedWithProto: proto, suppressNotifications: false, - using: dependencies + using: syncState.dependencies ) ) } @@ -824,7 +828,7 @@ public final class OpenGroupManager { // MARK: - Convenience - public func addPendingReaction( + nonisolated public func addPendingReaction( emoji: String, id: Int64, in roomToken: String, @@ -842,31 +846,31 @@ public final class OpenGroupManager { ) ) - dependencies.mutate(cache: .openGroupManager) { - $0.pendingChanges.append(pendingChange) - } - + Task { await self.setPendingChanges(pendingChanges.appending(pendingChange)) } return pendingChange } + private func setPendingChanges(_ pendingChanges: [OpenGroupManager.PendingChange]) { + self.pendingChanges = pendingChanges + self.syncState.update(pendingChanges: pendingChanges) + } + public func updatePendingChange(_ pendingChange: OpenGroupManager.PendingChange, seqNo: Int64?) { - dependencies.mutate(cache: .openGroupManager) { - if let index = $0.pendingChanges.firstIndex(of: pendingChange) { - $0.pendingChanges[index].seqNo = seqNo - } + if let index = pendingChanges.firstIndex(of: pendingChange) { + pendingChanges[index].seqNo = seqNo + syncState.update(pendingChanges: pendingChanges) } } public func removePendingChange(_ pendingChange: OpenGroupManager.PendingChange) { - dependencies.mutate(cache: .openGroupManager) { - if let index = $0.pendingChanges.firstIndex(of: pendingChange) { - $0.pendingChanges.remove(at: index) - } + if let index = pendingChanges.firstIndex(of: pendingChange) { + pendingChanges.remove(at: index) + syncState.update(pendingChanges: pendingChanges) } } /// This method specifies if the given capability is supported on a specified Open Group - public func doesOpenGroupSupport( + nonisolated public func doesOpenGroupSupport( _ db: ObservingDatabase, capability: Capability.Variant, on server: String? @@ -885,7 +889,7 @@ public final class OpenGroupManager { } /// This method specifies if the given publicKey is a moderator or an admin within a specified Open Group - public func isUserModeratorOrAdmin( + nonisolated public func isUserModeratorOrAdmin( _ db: ObservingDatabase, publicKey: String, for roomToken: String?, @@ -904,8 +908,8 @@ public final class OpenGroupManager { possibleKeys = currentUserSessionIds /// Add the users `unblinded` pubkey if we can get it, just for completeness - let userEdKeyPair: KeyPair? = dependencies[singleton: .crypto].generate( - .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) + let userEdKeyPair: KeyPair? = syncState.dependencies[singleton: .crypto].generate( + .ed25519KeyPair(seed: syncState.dependencies[cache: .general].ed25519Seed) ) if let userEdPublicKey: [UInt8] = userEdKeyPair?.publicKey { possibleKeys.insert(SessionId(.unblinded, publicKey: userEdPublicKey).hexString) @@ -920,11 +924,56 @@ public final class OpenGroupManager { } } -// MARK: - Deprecated Conveneince Functions +// MARK: - Helper Functions -public extension OpenGroupManager { +// stringlint:ignore_contents +internal extension OpenGroupManagerType { + nonisolated fileprivate static func port(for server: String, serverUrl: URL) -> String { + if let port: Int = serverUrl.port { + return ":\(port)" + } + + let components: [String] = server.components(separatedBy: ":") + + guard + let port: String = components.last, + ( + port != components.first && + !port.starts(with: "//") + ) + else { return "" } + + return ":\(port)" + } + + nonisolated static func isSessionRunOpenGroup(server: String) -> Bool { + guard let serverUrl: URL = (URL(string: server.lowercased()) ?? URL(string: "http://\(server.lowercased())")) else { + return false + } + + let serverPort: String = Self.port(for: server, serverUrl: serverUrl) + let serverHost: String = serverUrl.host + .defaulting( + to: server + .lowercased() + .replacingOccurrences(of: serverPort, with: "") + ) + let options: Set = Set([ + Network.SOGS.legacyDefaultServerIP, + Network.SOGS.defaultServer + .replacingOccurrences(of: "http://", with: "") + .replacingOccurrences(of: "https://", with: "") + ]) + + return options.contains(serverHost) + } +} + +// MARK: - Deprecated Convenience Functions + +public extension OpenGroupManagerType { @available(*, deprecated, message: "This function should be avoided as it uses a blocking database query to retrieve the result. Use an async method instead.") - func doesOpenGroupSupport( + nonisolated func doesOpenGroupSupport( capability: Capability.Variant, on server: String? ) -> Bool { @@ -932,7 +981,7 @@ public extension OpenGroupManager { var openGroupSupportsCapability: Bool = false let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - dependencies[singleton: .storage].readAsync( + syncState.dependencies[singleton: .storage].readAsync( retrieve: { [weak self] db in self?.doesOpenGroupSupport(db, capability: capability, on: server) }, @@ -949,7 +998,7 @@ public extension OpenGroupManager { } @available(*, deprecated, message: "This function should be avoided as it uses a blocking database query to retrieve the result. Use an async method instead.") - func isUserModeratorOrAdmin( + nonisolated func isUserModeratorOrAdmin( publicKey: String, for roomToken: String?, on server: String?, @@ -959,7 +1008,7 @@ public extension OpenGroupManager { var userIsModeratorOrAdmin: Bool = false let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - dependencies[singleton: .storage].readAsync( + syncState.dependencies[singleton: .storage].readAsync( retrieve: { [weak self] db in self?.isUserModeratorOrAdmin( db, @@ -982,77 +1031,124 @@ public extension OpenGroupManager { } } -// MARK: - OpenGroupManager Cache +// MARK: - OpenGroupManagerSyncState -public extension OpenGroupManager { - class Cache: OGMCacheType { - private let dependencies: Dependencies - private let defaultRoomsSubject: CurrentValueSubject<[DefaultRoomInfo], Error> = CurrentValueSubject([]) - private var _lastSuccessfulCommunityPollTimestamp: TimeInterval? - public var pendingChanges: [OpenGroupManager.PendingChange] = [] - - public var defaultRoomsPublisher: AnyPublisher<[DefaultRoomInfo], Error> { - defaultRoomsSubject - .handleEvents( - receiveSubscription: { [weak defaultRoomsSubject, dependencies] _ in - /// If we don't have any default rooms in memory then we haven't fetched this launch so schedule - /// the `RetrieveDefaultOpenGroupRoomsJob` if one isn't already running - if defaultRoomsSubject?.value.isEmpty == true { - RetrieveDefaultOpenGroupRoomsJob.run(using: dependencies) - } - } - ) - .filter { !$0.isEmpty } - .eraseToAnyPublisher() - } - - // MARK: - Initialization - - init(using dependencies: Dependencies) { - self.dependencies = dependencies - } - - // MARK: - Functions - - public func getLastSuccessfulCommunityPollTimestamp() -> TimeInterval { - if let storedTime: TimeInterval = _lastSuccessfulCommunityPollTimestamp { - return storedTime - } - - guard let lastPoll: Date = dependencies[defaults: .standard, key: .lastOpen] else { - return 0 - } - - _lastSuccessfulCommunityPollTimestamp = lastPoll.timeIntervalSince1970 - return lastPoll.timeIntervalSince1970 - } - - public func setLastSuccessfulCommunityPollTimestamp(_ timestamp: TimeInterval) { - dependencies[defaults: .standard, key: .lastOpen] = Date(timeIntervalSince1970: timestamp) - _lastSuccessfulCommunityPollTimestamp = timestamp - } - - public func setDefaultRoomInfo(_ info: [DefaultRoomInfo]) { - defaultRoomsSubject.send(info) - } +/// We manually handle thread-safety using the `NSLock` so can ensure this is `Sendable` +public final class OpenGroupManagerSyncState: @unchecked Sendable { + private let lock = NSLock() + public let dependencies: Dependencies + private var _pendingChanges: [OpenGroupManager.PendingChange] = [] + public var pendingChanges: [OpenGroupManager.PendingChange] { lock.withLock { _pendingChanges } } + + init( + pendingChanges: [OpenGroupManager.PendingChange] = [], + using dependencies: Dependencies + ) { + self.dependencies = dependencies + self._pendingChanges = pendingChanges + } + + func update(pendingChanges: [OpenGroupManager.PendingChange]) { + lock.withLock { self._pendingChanges = pendingChanges } } } -// MARK: - OGMCacheType +// MARK: - OpenGroupManagerType -/// This is a read-only version of the 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 } +public protocol OpenGroupManagerType: Actor { + @available(*, deprecated, message: "Should try to refactor the code to use proper async/await") + nonisolated var syncState: OpenGroupManagerSyncState { get } + var dependencies: Dependencies { get } + nonisolated var defaultRooms: AsyncStream<[OpenGroupManager.DefaultRoomInfo]> { get } var pendingChanges: [OpenGroupManager.PendingChange] { get } -} - -public protocol OGMCacheType: OGMImmutableCacheType, MutableCacheType { - var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error> { get } - var pendingChanges: [OpenGroupManager.PendingChange] { get set } + // MARK: - Adding & Removing + + nonisolated func hasExistingOpenGroup( + _ db: ObservingDatabase, + roomToken: String, + server: String, + publicKey: String + ) -> Bool + nonisolated func add( + _ db: ObservingDatabase, + roomToken: String, + server: String, + publicKey: String, + forceVisible: Bool + ) -> Bool + nonisolated func performInitialRequestsAfterAdd( + queue: DispatchQueue, + successfullyAddedGroup: Bool, + roomToken: String, + server: String, + publicKey: String + ) -> AnyPublisher + nonisolated func delete( + _ db: ObservingDatabase, + openGroupId: String, + skipLibSessionUpdate: Bool + ) throws + + // MARK: - Default Rooms + + func setDefaultRoomInfo(_ info: [OpenGroupManager.DefaultRoomInfo]) async + + // MARK: - Polling func getLastSuccessfulCommunityPollTimestamp() -> TimeInterval func setLastSuccessfulCommunityPollTimestamp(_ timestamp: TimeInterval) - func setDefaultRoomInfo(_ info: [OpenGroupManager.DefaultRoomInfo]) + + // MARK: - Response Processing + + nonisolated func handleCapabilities( + _ db: ObservingDatabase, + capabilities: Network.SOGS.CapabilitiesResponse, + on server: String + ) + nonisolated func handlePollInfo( + _ db: ObservingDatabase, + pollInfo: Network.SOGS.RoomPollInfo, + publicKey maybePublicKey: String?, + for roomToken: String, + on server: String + ) throws + nonisolated func handleMessages( + _ db: ObservingDatabase, + messages: [Network.SOGS.Message], + for roomToken: String, + on server: String + ) -> [MessageReceiver.InsertedInteractionInfo?] + nonisolated func handleDirectMessages( + _ db: ObservingDatabase, + messages: [Network.SOGS.DirectMessage], + fromOutbox: Bool, + on server: String + ) -> [MessageReceiver.InsertedInteractionInfo?] + + // MARK: - Convenience + + nonisolated func addPendingReaction( + emoji: String, + id: Int64, + in roomToken: String, + on server: String, + type: OpenGroupManager.PendingChange.ReactAction + ) -> OpenGroupManager.PendingChange + func updatePendingChange(_ pendingChange: OpenGroupManager.PendingChange, seqNo: Int64?) + func removePendingChange(_ pendingChange: OpenGroupManager.PendingChange) + + nonisolated func doesOpenGroupSupport( + _ db: ObservingDatabase, + capability: Capability.Variant, + on server: String? + ) -> Bool + nonisolated func isUserModeratorOrAdmin( + _ db: ObservingDatabase, + publicKey: String, + for roomToken: String?, + on server: String?, + currentUserSessionIds: Set + ) -> Bool } diff --git a/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift b/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift index c543b0dd4b..3d9fb63926 100644 --- a/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift +++ b/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift @@ -171,7 +171,7 @@ public final class AttachmentUploader { case .fileServer: return ( attachment, - try Network.preparedUpload(data: finalData, using: dependencies), + try Network.FileServer.preparedUpload(data: finalData, using: dependencies), encryptionKey, digest ) @@ -202,7 +202,7 @@ public final class AttachmentUploader { state: .uploaded, creationTimestamp: ( uploadInfo.attachment.creationTimestamp ?? - (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + (dependencies.networkOffsetTimestampMs() / 1000) ), downloadUrl: { let isPlaceholderUploadUrl: Bool = dependencies[singleton: .attachmentManager] diff --git a/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift b/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift index 15d5488927..dc5658c759 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift @@ -27,13 +27,14 @@ public enum MessageReceiverError: Error, CustomStringConvertible { case missingRequiredAdminPrivileges case deprecatedMessage case failedToProcess + case originalMessageNotFound public var isRetryable: Bool { switch self { case .duplicateMessage, .invalidMessage, .unknownMessage, .unknownEnvelopeType, .invalidSignature, .noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed, .invalidConfigMessageHandling, .outdatedMessage, .ignorableMessage, .ignorableMessageRequestMessage, - .missingRequiredAdminPrivileges, .failedToProcess: + .missingRequiredAdminPrivileges, .failedToProcess, .originalMessageNotFound: return false default: return true @@ -114,6 +115,7 @@ public enum MessageReceiverError: Error, CustomStringConvertible { case .missingRequiredAdminPrivileges: return "Handling this message requires admin privileges which the current user does not have." case .deprecatedMessage: return "This message type has been deprecated." case .failedToProcess: return "Failed to process." + case .originalMessageNotFound: return "Original message not found." } } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift index cdd2921bcf..fba2ac4f25 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift @@ -368,7 +368,7 @@ extension MessageReceiver { let messageSentTimestampMs: Int64 = ( message.sentTimestampMs.map { Int64($0) } ?? - dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + dependencies.networkOffsetTimestampMs() ) let interaction: Interaction = try Interaction( serverHash: message.serverHash, @@ -404,7 +404,7 @@ extension MessageReceiver { message: message, disappearingMessagesConfiguration: try? DisappearingMessagesConfiguration .fetchOne(db, id: threadId), - authMethod: try Authentication.with(db, swarmPublicKey: threadId, using: dependencies), + authMethod: try Authentication.with(swarmPublicKey: threadId, using: dependencies), onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) @@ -476,7 +476,7 @@ extension MessageReceiver { ) let timestampMs: Int64 = ( message.sentTimestampMs.map { Int64($0) } ?? - dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + dependencies.networkOffsetTimestampMs() ) guard let messageInfoData: Data = try? JSONEncoder(using: dependencies).encode(messageInfo) else { diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift index 4c5f597204..7510670e63 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift @@ -25,7 +25,7 @@ extension MessageReceiver { let timestampMs: Int64 = ( message.sentTimestampMs.map { Int64($0) } ?? - dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + dependencies.networkOffsetTimestampMs() ) let wasRead: Bool = dependencies.mutate(cache: .libSession) { cache in diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift index 79ca54ca61..f53336a7d2 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift @@ -307,7 +307,7 @@ extension MessageReceiver { // devices that had the group before they were promoted try SnodeReceivedMessageInfo .filter(SnodeReceivedMessageInfo.Columns.swarmPublicKey == groupSessionId.hexString) - .filter(SnodeReceivedMessageInfo.Columns.namespace == Network.SnodeAPI.Namespace.groupMessages.rawValue) + .filter(SnodeReceivedMessageInfo.Columns.namespace == Network.StorageServer.Namespace.groupMessages.rawValue) .updateAllAndConfig( db, SnodeReceivedMessageInfo.Columns.wasDeletedOrInvalid.set(to: true), @@ -747,13 +747,12 @@ extension MessageReceiver { cache.isAdmin(groupSessionId: groupSessionId) }), let authMethod: AuthenticationMethod = try? Authentication.with( - db, swarmPublicKey: groupSessionId.hexString, using: dependencies ) else { return } - try? Network.SnodeAPI + try? Network.StorageServer .preparedDeleteMessages( serverHashes: Array(hashes), requireSuccessfulDeletion: false, @@ -919,22 +918,19 @@ extension MessageReceiver { case .none: break case .some(let serverHash): db.afterCommit { - dependencies[singleton: .storage] - .readPublisher { db in - try Network.SnodeAPI.preparedDeleteMessages( - serverHashes: [serverHash], - requireSuccessfulDeletion: false, - authMethod: try Authentication.with( - db, - swarmPublicKey: userSessionId.hexString, - using: dependencies - ), - using: dependencies - ) - } - .flatMap { $0.send(using: dependencies) } - .subscribe(on: DispatchQueue.global(qos: .background), using: dependencies) - .sinkUntilComplete() + guard let authMethod: AuthenticationMethod = try? Authentication.with(swarmPublicKey: userSessionId.hexString, using: dependencies) else { + return + } + + try? Network.StorageServer.preparedDeleteMessages( + serverHashes: [serverHash], + requireSuccessfulDeletion: false, + authMethod: authMethod, + using: dependencies + ) + .send(using: dependencies) + .subscribe(on: DispatchQueue.global(qos: .background), using: dependencies) + .sinkUntilComplete() } } @@ -1003,7 +999,7 @@ extension MessageReceiver { db, message: GroupUpdateInviteResponseMessage( isApproved: true, - sentTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + sentTimestampMs: dependencies.networkOffsetTimestampMs() ), interactionId: nil, threadId: groupSessionId.hexString, @@ -1019,7 +1015,7 @@ extension MessageReceiver { variant: .group, values: SessionThread.TargetValues( creationDateTimestamp: .useExistingOrSetTo( - dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000 + dependencies.networkOffsetTimestampMs() / 1000 ), shouldBeVisible: .useExisting ), diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift index 1ad39c5a05..e26c237fd5 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -64,7 +64,7 @@ extension MessageReceiver { .filter(blindedThreadIds.contains(SessionThread.Columns.id)) .select(max(SessionThread.Columns.creationDateTimestamp)) .fetchOne(db)) - .defaulting(to: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000)) + .defaulting(to: (dependencies.networkOffsetTimestampMs() / 1000)) // Prep the unblinded thread let unblindedThread: SessionThread = try SessionThread.upsert( @@ -167,7 +167,7 @@ extension MessageReceiver { variant: .infoMessageRequestAccepted, timestampMs: ( message.sentTimestampMs.map { Int64($0) } ?? - dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + dependencies.networkOffsetTimestampMs() ), using: dependencies ).inserted(db) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift index c10460d2f4..0ac46718fc 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB import SessionNetworkingKit import SessionUtilitiesKit @@ -68,16 +69,17 @@ extension MessageReceiver { switch threadVariant { case .legacyGroup, .group, .community: break case .contact: - dependencies[singleton: .storage] - .readPublisher { db in - try Network.SnodeAPI.preparedDeleteMessages( + AnyPublisher + .lazy { + let authMethod: AuthenticationMethod = try Authentication.with( + swarmPublicKey: dependencies[cache: .general].sessionId.hexString, + using: dependencies + ) + + return try Network.StorageServer.preparedDeleteMessages( serverHashes: Array(hashes), requireSuccessfulDeletion: false, - authMethod: try Authentication.with( - db, - swarmPublicKey: dependencies[cache: .general].sessionId.hexString, - using: dependencies - ), + authMethod: authMethod, using: dependencies ) } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index a5ddfb44bf..977c579f07 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -533,7 +533,7 @@ extension MessageReceiver { .fetchOne(db) guard let interactionId: Int64 = maybeInteractionId else { - throw StorageError.objectNotFound + throw MessageReceiverError.originalMessageNotFound } let sortId = Reaction.getSortId( diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift index 8defb0e98d..9b5b67763c 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift @@ -7,14 +7,14 @@ import SessionUtilitiesKit import SessionNetworkingKit extension MessageSender { - private typealias PreparedGroupData = ( - groupSessionId: SessionId, - groupState: [ConfigDump.Variant: LibSession.Config], - thread: SessionThread, - group: ClosedGroup, - members: [GroupMember], - preparedNotificationsSubscription: Network.PreparedRequest? - ) + private struct PreparedGroupData { + let groupSessionId: SessionId + let identityKeyPair: KeyPair + let groupState: [ConfigDump.Variant: LibSession.Config] + let thread: SessionThread + let group: ClosedGroup + let members: [GroupMember] + } public static func createGroup( name: String, @@ -43,114 +43,98 @@ extension MessageSender { .map { Optional($0) } .eraseToAnyPublisher() } - .flatMap { (displayPictureInfo: DisplayPictureManager.UploadResult?) -> AnyPublisher in - dependencies[singleton: .storage].writePublisher { db -> PreparedGroupData in - /// Create and cache the libSession entries - let createdInfo: LibSession.CreatedGroupInfo = try LibSession.createGroup( - db, - name: name, - description: description, - displayPictureUrl: displayPictureInfo?.downloadUrl, - displayPictureEncryptionKey: displayPictureInfo?.encryptionKey, - members: members, - using: dependencies - ) - - /// Save the relevant objects to the database - let thread: SessionThread = try SessionThread.upsert( - db, - id: createdInfo.group.id, - variant: .group, - values: SessionThread.TargetValues( - creationDateTimestamp: .setTo(createdInfo.group.formationTimestamp), - shouldBeVisible: .setTo(true) - ), - using: dependencies - ) - try createdInfo.group.insert(db) - try createdInfo.members.forEach { try $0.insert(db) } - - /// Add a record of the initial invites going out (default to being read as we don't want the creator of the group - /// to see the "Unread Messages" banner above this control message) - _ = try? Interaction( + .flatMapStorageWritePublisher(using: dependencies) { (db: ObservingDatabase, displayPictureInfo: DisplayPictureManager.UploadResult?) -> PreparedGroupData in + /// Create and cache the libSession entries + let createdInfo: LibSession.CreatedGroupInfo = try LibSession.createGroup( + db, + name: name, + description: description, + displayPictureUrl: displayPictureInfo?.downloadUrl, + displayPictureEncryptionKey: displayPictureInfo?.encryptionKey, + members: members, + using: dependencies + ) + + /// Save the relevant objects to the database + let thread: SessionThread = try SessionThread.upsert( + db, + id: createdInfo.group.id, + variant: .group, + values: SessionThread.TargetValues( + creationDateTimestamp: .setTo(createdInfo.group.formationTimestamp), + shouldBeVisible: .setTo(true) + ), + using: dependencies + ) + try createdInfo.group.insert(db) + try createdInfo.members.forEach { try $0.insert(db) } + + /// Add a record of the initial invites going out (default to being read as we don't want the creator of the group + /// to see the "Unread Messages" banner above this control message) + _ = try? Interaction( + threadId: createdInfo.group.id, + threadVariant: .group, + authorId: userSessionId.hexString, + variant: .infoGroupMembersUpdated, + body: ClosedGroup.MessageInfo + .addedUsers( + hasCurrentUser: false, + names: sortedOtherMembers.map { id, profile in + profile?.displayName(for: .group) ?? + id.truncated() + }, + historyShared: false + ) + .infoString(using: dependencies), + timestampMs: Int64(createdInfo.group.formationTimestamp * 1000), + wasRead: true, + using: dependencies + ).inserted(db) + + /// Schedule the "members added" control message to be sent after the config sync completes + try dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .messageSend, + behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, threadId: createdInfo.group.id, - threadVariant: .group, - authorId: userSessionId.hexString, - variant: .infoGroupMembersUpdated, - body: ClosedGroup.MessageInfo - .addedUsers( - hasCurrentUser: false, - names: sortedOtherMembers.map { id, profile in - profile?.displayName(for: .group) ?? - id.truncated() - }, - historyShared: false - ) - .infoString(using: dependencies), - timestampMs: Int64(createdInfo.group.formationTimestamp * 1000), - wasRead: true, - using: dependencies - ).inserted(db) - - /// Schedule the "members added" control message to be sent after the config sync completes - try dependencies[singleton: .jobRunner].add( - db, - job: Job( - variant: .messageSend, - behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, - threadId: createdInfo.group.id, - details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: createdInfo.group.id), - message: GroupUpdateMemberChangeMessage( - changeType: .added, - memberSessionIds: sortedOtherMembers.map { id, _ in id }, - historyShared: false, - sentTimestampMs: UInt64(createdInfo.group.formationTimestamp * 1000), - authMethod: Authentication.groupAdmin( - groupSessionId: createdInfo.groupSessionId, - ed25519SecretKey: createdInfo.identityKeyPair.secretKey - ), - using: dependencies + details: MessageSendJob.Details( + destination: .closedGroup(groupPublicKey: createdInfo.group.id), + message: GroupUpdateMemberChangeMessage( + changeType: .added, + memberSessionIds: sortedOtherMembers.map { id, _ in id }, + historyShared: false, + sentTimestampMs: UInt64(createdInfo.group.formationTimestamp * 1000), + authMethod: Authentication.groupAdmin( + groupSessionId: createdInfo.groupSessionId, + ed25519SecretKey: createdInfo.identityKeyPair.secretKey ), - requiredConfigSyncVariant: .groupMembers - ) - ), - canStartJob: false - ) - - // Prepare the notification subscription - var preparedNotificationSubscription: Network.PreparedRequest? - - if let token: String = dependencies[defaults: .standard, key: .deviceToken] { - preparedNotificationSubscription = try? Network.PushNotification - .preparedSubscribe( - token: Data(hex: token), - swarms: [( - createdInfo.groupSessionId, - Authentication.groupAdmin( - groupSessionId: createdInfo.groupSessionId, - ed25519SecretKey: createdInfo.identityKeyPair.secretKey - ) - )], using: dependencies - ) - } - - return ( - createdInfo.groupSessionId, - createdInfo.groupState, - thread, - createdInfo.group, - createdInfo.members, - preparedNotificationSubscription - ) - } + ), + requiredConfigSyncVariant: .groupMembers + ) + ), + canStartJob: false + ) + + return PreparedGroupData( + groupSessionId: createdInfo.groupSessionId, + identityKeyPair: createdInfo.identityKeyPair, + groupState: createdInfo.groupState, + thread: thread, + group: createdInfo.group, + members: createdInfo.members + ) } .flatMap { preparedGroupData -> AnyPublisher in ConfigurationSyncJob .run( swarmPublicKey: preparedGroupData.groupSessionId.hexString, requireAllRequestsSucceed: true, + customAuthMethod: Authentication.groupAdmin( + groupSessionId: preparedGroupData.groupSessionId, + ed25519SecretKey: preparedGroupData.identityKeyPair.secretKey + ), using: dependencies ) .flatMap { _ in @@ -192,23 +176,33 @@ extension MessageSender { .eraseToAnyPublisher() } .handleEvents( - receiveOutput: { groupSessionId, _, thread, group, groupMembers, preparedNotificationSubscription in + receiveOutput: { preparedGroupData in let userSessionId: SessionId = dependencies[cache: .general].sessionId // Start polling - dependencies - .mutate(cache: .groupPollers) { $0.getOrCreatePoller(for: thread.id) } - .startIfNeeded() + Task.detached(priority: .userInitiated) { [manager = dependencies[singleton: .groupPollerManager]] in + await manager.getOrCreatePoller(for: preparedGroupData.thread.id).startIfNeeded() + } // Subscribe for push notifications (if PNs are enabled) - preparedNotificationSubscription? - .send(using: dependencies) - .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) - .sinkUntilComplete() + if let token: String = dependencies[defaults: .standard, key: .deviceToken] { + Task.detached(priority: .userInitiated) { [dependencies] in + try? await Network.PushNotification.subscribe( + token: Data(hex: token), + swarmAuthentication: [ + Authentication.groupAdmin( + groupSessionId: preparedGroupData.groupSessionId, + ed25519SecretKey: preparedGroupData.identityKeyPair.secretKey + ) + ], + using: dependencies + ) + } + } dependencies[singleton: .storage].writeAsync { db in // Save jobs for sending group member invitations - groupMembers + preparedGroupData.members .filter { $0.profileId != userSessionId.hexString } .compactMap { member -> (GroupMember, GroupInviteMemberJob.Details)? in // Generate authData for the removed member @@ -216,8 +210,11 @@ extension MessageSender { let memberAuthInfo: Authentication.Info = try? dependencies.mutate(cache: .libSession, { cache in try dependencies[singleton: .crypto].tryGenerate( .memberAuthData( - config: cache.config(for: .groupKeys, sessionId: groupSessionId), - groupSessionId: groupSessionId, + config: cache.config( + for: .groupKeys, + sessionId: preparedGroupData.groupSessionId + ), + groupSessionId: preparedGroupData.groupSessionId, memberId: member.profileId ) ) @@ -235,7 +232,7 @@ extension MessageSender { db, job: Job( variant: .groupInviteMember, - threadId: thread.id, + threadId: preparedGroupData.thread.id, details: jobDetails ), canStartJob: true @@ -244,7 +241,7 @@ extension MessageSender { } } ) - .map { _, _, thread, _, _, _ in thread } + .map { $0.thread } .eraseToAnyPublisher() } @@ -266,7 +263,7 @@ extension MessageSender { else { throw MessageSenderError.invalidClosedGroupUpdate } let userSessionId: SessionId = dependencies[cache: .general].sessionId - let changeTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let changeTimestampMs: Int64 = dependencies.networkOffsetTimestampMs() /// Perform the config changes without triggering a config sync (we will trigger one manually as part of the process) try dependencies.mutate(cache: .libSession) { cache in @@ -373,7 +370,7 @@ extension MessageSender { else { throw MessageSenderError.invalidClosedGroupUpdate } let userSessionId: SessionId = dependencies[cache: .general].sessionId - let changeTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let changeTimestampMs: Int64 = dependencies.networkOffsetTimestampMs() /// Perform the config changes without triggering a config sync (we will trigger one manually as part of the process) try dependencies.mutate(cache: .libSession) { cache in @@ -389,7 +386,7 @@ extension MessageSender { using: dependencies ) - case .groupUpdateTo(let url, let key, let fileName): + case .groupUpdateTo(let url, let key, _): try ClosedGroup .filter(id: groupSessionId) .updateAllAndConfig( @@ -474,7 +471,7 @@ extension MessageSender { .fetchOne(db) else { throw MessageSenderError.invalidClosedGroupUpdate } - let currentOffsetTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let currentOffsetTimestampMs: Int64 = dependencies.networkOffsetTimestampMs() /// Perform the config changes without triggering a config sync (we will trigger one manually as part of the process) try dependencies.mutate(cache: .libSession) { cache in @@ -572,7 +569,7 @@ extension MessageSender { .fetchOne(db) else { throw MessageSenderError.invalidClosedGroupUpdate } - let changeTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let changeTimestampMs: Int64 = dependencies.networkOffsetTimestampMs() var maybeSupplementalKeyRequest: Network.PreparedRequest? /// Perform the config changes without triggering a config sync (we will trigger one manually as part of the process) @@ -598,17 +595,17 @@ extension MessageSender { using: dependencies ) - maybeSupplementalKeyRequest = try Network.SnodeAPI.preparedSendMessage( - message: SnodeMessage( + maybeSupplementalKeyRequest = try Network.StorageServer.preparedSendMessage( + request: Network.StorageServer.SendMessageRequest( recipient: sessionId.hexString, + namespace: .configGroupKeys, data: supplementData, ttl: ConfigDump.Variant.groupKeys.ttl, - timestampMs: UInt64(changeTimestampMs) - ), - in: .configGroupKeys, - authMethod: Authentication.groupAdmin( - groupSessionId: sessionId, - ed25519SecretKey: Array(groupIdentityPrivateKey) + timestampMs: UInt64(changeTimestampMs), + authMethod: Authentication.groupAdmin( + groupSessionId: sessionId, + ed25519SecretKey: Array(groupIdentityPrivateKey) + ) ), using: dependencies ) @@ -682,7 +679,7 @@ extension MessageSender { /// Unrevoke the newly added members just in case they had previously gotten their access to the group /// revoked (fire-and-forget this request, we don't want it to be blocking - if the invited user still can't access /// the group the admin can resend their invitation which will also attempt to unrevoke their subaccount) - let unrevokeRequest: Network.PreparedRequest = try Network.SnodeAPI.preparedUnrevokeSubaccounts( + let unrevokeRequest: Network.PreparedRequest = try Network.StorageServer.preparedUnrevokeSubaccounts( subaccountsToUnrevoke: memberJobData.map { _, _, _, subaccountToken in subaccountToken }, authMethod: Authentication.groupAdmin( groupSessionId: sessionId, @@ -802,7 +799,7 @@ extension MessageSender { .fetchOne(db) else { throw MessageSenderError.invalidClosedGroupUpdate } - let changeTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let changeTimestampMs: Int64 = dependencies.networkOffsetTimestampMs() var maybeSupplementalKeyRequest: Network.PreparedRequest? /// Perform the config changes without triggering a config sync (we will do so manually after the process completes) @@ -861,17 +858,17 @@ extension MessageSender { using: dependencies ) - maybeSupplementalKeyRequest = try Network.SnodeAPI.preparedSendMessage( - message: SnodeMessage( + maybeSupplementalKeyRequest = try Network.StorageServer.preparedSendMessage( + request: Network.StorageServer.SendMessageRequest( recipient: sessionId.hexString, + namespace: .configGroupKeys, data: supplementData, ttl: ConfigDump.Variant.groupKeys.ttl, - timestampMs: UInt64(changeTimestampMs) - ), - in: .configGroupKeys, - authMethod: Authentication.groupAdmin( - groupSessionId: sessionId, - ed25519SecretKey: Array(groupIdentityPrivateKey) + timestampMs: UInt64(changeTimestampMs), + authMethod: Authentication.groupAdmin( + groupSessionId: sessionId, + ed25519SecretKey: Array(groupIdentityPrivateKey) + ) ), using: dependencies ) @@ -907,7 +904,7 @@ extension MessageSender { /// Unrevoke the member just in case they had previously gotten their access to the group revoked and the /// unrevoke request when initially added them failed (fire-and-forget this request, we don't want it to be blocking) - let unrevokeRequest: Network.PreparedRequest = try Network.SnodeAPI + let unrevokeRequest: Network.PreparedRequest = try Network.StorageServer .preparedUnrevokeSubaccounts( subaccountsToUnrevoke: memberInfo.map { token, _ in token }, authMethod: Authentication.groupAdmin( @@ -967,7 +964,7 @@ extension MessageSender { let targetChangeTimestampMs: Int64 = ( changeTimestampMs ?? - dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + dependencies.networkOffsetTimestampMs() ) let userSessionId: SessionId = dependencies[cache: .general].sessionId @@ -1168,7 +1165,7 @@ extension MessageSender { /// that are getting promotions re-sent to them - we only want to send an admin changed message if there /// is a newly promoted member if !isResend && !membersReceivingPromotions.isEmpty { - let changeTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let changeTimestampMs: Int64 = dependencies.networkOffsetTimestampMs() let disappearingConfig: DisappearingMessagesConfiguration? = try? DisappearingMessagesConfiguration.fetchOne(db, id: groupSessionId.hexString) _ = try Interaction( @@ -1273,7 +1270,7 @@ extension MessageSender { authorId: userSessionId.hexString, variant: .infoGroupCurrentUserLeaving, body: "leaving".localized(), - timestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs(), + timestampMs: dependencies.networkOffsetTimestampMs(), using: dependencies ).inserted(db) diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 91ce5e83db..5a4bbb42cc 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -175,13 +175,13 @@ public enum MessageReceiver { let proto: SNProtoContent = try (customProto ?? Result(catching: { try SNProtoContent.parseData(plaintext) }) .onFailure { Log.error(.messageReceiver, "Couldn't parse proto due to error: \($0).") } - .successOrThrow()) + .get()) let message: Message = try (customMessage ?? Message.createMessageFrom(proto, sender: sender, using: dependencies)) message.sender = sender message.serverHash = serverHash message.sentTimestampMs = sentTimestampMs message.sigTimestampMs = (proto.hasSigTimestamp ? proto.sigTimestamp : nil) - message.receivedTimestampMs = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + message.receivedTimestampMs = dependencies.networkOffsetTimestampMs() message.openGroupServerMessageId = openGroupServerMessageId message.openGroupWhisper = openGroupWhisper message.openGroupWhisperMods = openGroupWhisperMods @@ -477,7 +477,7 @@ public enum MessageReceiver { .filter(Interaction.Columns.openGroupServerMessageId == openGroupMessageServerId) .asRequest(of: Info.self) .fetchOne(db) - else { throw MessageReceiverError.invalidMessage } + else { throw MessageReceiverError.originalMessageNotFound } // If the user locally deleted the message then we don't want to process reactions for it guard !interactionInfo.variant.isDeletedMessage else { return } @@ -572,7 +572,7 @@ public enum MessageReceiver { threadVariant: threadVariant, changeTimestampMs: message.sentTimestampMs .map { Int64($0) } - .defaulting(to: dependencies[cache: .snodeAPI].currentOffsetTimestampMs()) + .defaulting(to: dependencies.networkOffsetTimestampMs()) ) switch (conversationInConfig, canPerformConfigChange) { diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index f10e1dca4c..506e06e718 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -214,8 +214,8 @@ extension MessageSender { // from the correct time. var scheduledTimestampForDeletion: Double? { guard interaction.isExpiringMessage else { return nil } - let sentTimestampMs: Double = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - return sentTimestampMs + + return dependencies.networkOffsetTimestampMs() } // Update the interaction so we have the correct `expiresStartedAtMs` value diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 78ed522564..c4f8bdfa08 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -43,7 +43,7 @@ public final class MessageSender { public static func preparedSend( message: Message, to destination: Message.Destination, - namespace: Network.SnodeAPI.Namespace?, + namespace: Network.StorageServer.Namespace?, interactionId: Int64?, attachments: [(attachment: Attachment, fileId: String)]?, authMethod: AuthenticationMethod, @@ -51,7 +51,7 @@ public final class MessageSender { using dependencies: Dependencies ) throws -> Network.PreparedRequest { // Common logic for all destinations - let messageSendTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let messageSendTimestampMs: Int64 = dependencies.networkOffsetTimestampMs() let updatedMessage: Message = message // Set the message 'sentTimestamp' (Visible messages will already have their sent timestamp set) @@ -138,7 +138,7 @@ public final class MessageSender { private static func preparedSendToSnodeDestination( message: Message, to destination: Message.Destination, - namespace: Network.SnodeAPI.Namespace?, + namespace: Network.StorageServer.Namespace?, interactionId: Int64?, attachments: [(attachment: Attachment, fileId: String)]?, messageSendTimestampMs: Int64, @@ -146,7 +146,7 @@ public final class MessageSender { onEvent: ((Event) -> Void)?, using dependencies: Dependencies ) throws -> Network.PreparedRequest { - guard let namespace: Network.SnodeAPI.Namespace = namespace else { + guard let namespace: Network.StorageServer.Namespace = namespace else { throw MessageSenderError.invalidMessage } @@ -186,8 +186,9 @@ public final class MessageSender { case .openGroup, .openGroupInbox: preconditionFailure() } }() - let snodeMessage = SnodeMessage( + let request: Network.StorageServer.SendMessageRequest = Network.StorageServer.SendMessageRequest( recipient: swarmPublicKey, + namespace: namespace, data: try MessageSender.encodeMessageForSending( namespace: namespace, destination: destination, @@ -197,21 +198,22 @@ public final class MessageSender { using: dependencies ), ttl: Message.getSpecifiedTTL(message: message, destination: destination, using: dependencies), - timestampMs: UInt64(messageSendTimestampMs) + /// **Note:** This timestamp is for the request being sent rather than when the message was created so it should always + /// be the current offset timestamp (otherwise the storage server could reject the request for the clock being too far out) + timestampMs: dependencies.networkOffsetTimestampMs(), + authMethod: authMethod ) // Perform any pre-send actions onEvent?(.willSend(message, destination, interactionId: interactionId)) - return try Network.SnodeAPI + return try Network.StorageServer .preparedSendMessage( - message: snodeMessage, - in: namespace, - authMethod: authMethod, + request: request, using: dependencies ) .map { _, response in - let expirationTimestampMs: Int64 = (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + SnodeReceivedMessage.defaultExpirationMs) + let expirationTimestampMs: Int64 = (dependencies.networkOffsetTimestampMs() + Network.StorageServer.Message.defaultExpirationMs) let updatedMessage: Message = message updatedMessage.serverHash = response.hash @@ -373,7 +375,7 @@ public final class MessageSender { // MARK: - Message Wrapping public static func encodeMessageForSending( - namespace: Network.SnodeAPI.Namespace, + namespace: Network.StorageServer.Namespace, destination: Message.Destination, message: Message, attachments: [(attachment: Attachment, fileId: String)]?, @@ -399,7 +401,7 @@ public final class MessageSender { return try Result(proto.serializedData().paddedMessageBody()) .mapError { MessageSenderError.other(nil, "Couldn't serialize proto", $0) } - .successOrThrow() + .get() default: guard @@ -417,7 +419,7 @@ public final class MessageSender { } } .mapError { MessageSenderError.other(nil, "Couldn't serialize proto", $0) } - .successOrThrow() + .get() } }() @@ -433,7 +435,7 @@ public final class MessageSender { ) ) .mapError { MessageSenderError.other(nil, "Couldn't wrap message", $0) } - .successOrThrow() + .get() let ciphertext: Data = try dependencies[singleton: .crypto].tryGenerate( .ciphertextForGroupMessage( @@ -476,7 +478,7 @@ public final class MessageSender { ) ) .mapError { MessageSenderError.other(nil, "Couldn't wrap message", $0) } - .successOrThrow() + .get() /// Community messages should be sent in plaintext case (.openGroup, _): return plaintext @@ -491,7 +493,7 @@ public final class MessageSender { ) ) .mapError { MessageSenderError.other(nil, "Couldn't encrypt message for destination: \(destination)", $0) } - .successOrThrow() + .get() /// Config messages should be sent directly rather than via this method case (.closedGroup(let groupId), _) where (try? SessionId.Prefix(from: groupId)) == .group: diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift index b844d336dc..22adeaa3a1 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift @@ -10,7 +10,7 @@ import SessionUtilitiesKit public extension Singleton { static let notificationsManager: SingletonConfig = Dependencies.create( identifier: "notificationsManager", - createInstance: { dependencies in NoopNotificationsManager(using: dependencies) } + createInstance: { dependencies, _ in NoopNotificationsManager(using: dependencies) } ) } @@ -31,7 +31,7 @@ public protocol NotificationsManagerType { mentionsOnly: Bool, mutedUntil: TimeInterval? ) - func notificationUserInfo(threadId: String, threadVariant: SessionThread.Variant) -> [String: Any] + func notificationUserInfo(threadId: String, threadVariant: SessionThread.Variant) -> [String: AnyHashable] func notificationShouldPlaySound(applicationState: UIApplication.State) -> Bool func notifyForFailedSend( @@ -443,7 +443,7 @@ public struct NoopNotificationsManager: NotificationsManagerType, NoopDependency public func updateSettings(threadId: String, threadVariant: SessionThread.Variant, mentionsOnly: Bool, mutedUntil: TimeInterval?) { } - public func notificationUserInfo(threadId: String, threadVariant: SessionThread.Variant) -> [String: Any] { + public func notificationUserInfo(threadId: String, threadVariant: SessionThread.Variant) -> [String: AnyHashable] { return [:] } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI+SessionMessagingKit.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI+SessionMessagingKit.swift index dda9ad50dd..f9e7e04336 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI+SessionMessagingKit.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI+SessionMessagingKit.swift @@ -11,117 +11,65 @@ public extension Network.PushNotification { token: Data, isForcedUpdate: Bool, using dependencies: Dependencies - ) -> AnyPublisher { + ) async throws { let hexEncodedToken: String = token.toHexString() let oldToken: String? = dependencies[defaults: .standard, key: .deviceToken] let lastUploadTime: Double = dependencies[defaults: .standard, key: .lastDeviceTokenUpload] let now: TimeInterval = dependencies.dateNow.timeIntervalSince1970 guard isForcedUpdate || hexEncodedToken != oldToken || now - lastUploadTime > tokenExpirationInterval else { - Log.info(.pushNotificationAPI, "Device token hasn't changed or expired; no need to re-upload.") - return Just(()) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return Log.info(.pushNotificationAPI, "Device token hasn't changed or expired; no need to re-upload.") } - return dependencies[singleton: .storage] - .readPublisher { db -> Network.PreparedRequest in - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let userAuthMethod: AuthenticationMethod = try Authentication.with( - db, - swarmPublicKey: userSessionId.hexString, - using: dependencies - ) - - return try Network.PushNotification - .preparedSubscribe( - token: token, - swarms: [(userSessionId, userAuthMethod)] - .appending(contentsOf: try ClosedGroup - .select(.threadId) - .filter( - ClosedGroup.Columns.threadId > SessionId.Prefix.group.rawValue && - ClosedGroup.Columns.threadId < SessionId.Prefix.group.endOfRangeString - ) - .filter(ClosedGroup.Columns.shouldPoll) - .asRequest(of: String.self) - .fetchSet(db) - .map { threadId in - ( - SessionId(.group, hex: threadId), - try Authentication.with( - db, - swarmPublicKey: threadId, - using: dependencies - ) - ) - } - ), - using: dependencies - ) - .handleEvents( - receiveOutput: { _, response in - guard response.subResponses.first?.success == true else { return } - - dependencies[defaults: .standard, key: .deviceToken] = hexEncodedToken - dependencies[defaults: .standard, key: .lastDeviceTokenUpload] = now - dependencies[defaults: .standard, key: .isUsingFullAPNs] = true - } - ) - } - .flatMap { $0.send(using: dependencies) } - .map { _ in () } - .eraseToAnyPublisher() + let swarmAuthentication: [AuthenticationMethod] = try await retrieveAllSwarmAuth(using: dependencies) + let response: SubscribeResponse = try await Network.PushNotification.subscribe( + token: token, + swarmAuthentication: swarmAuthentication, + using: dependencies + ) + + /// Only cache the token data If we successfully subscribed for user PNs + if response.subResponses.first?.success == true { + dependencies[defaults: .standard, key: .deviceToken] = hexEncodedToken + dependencies[defaults: .standard, key: .lastDeviceTokenUpload] = now + dependencies[defaults: .standard, key: .isUsingFullAPNs] = true + } } - public static func unsubscribeAll( + static func unsubscribeAll( token: Data, using dependencies: Dependencies - ) -> AnyPublisher { - return dependencies[singleton: .storage] - .readPublisher { db -> Network.PreparedRequest in - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let userAuthMethod: AuthenticationMethod = try Authentication.with( - db, - swarmPublicKey: userSessionId.hexString, - using: dependencies + ) async throws { + let swarmAuthentication: [AuthenticationMethod] = try await retrieveAllSwarmAuth(using: dependencies) + let response: UnsubscribeResponse = try await Network.PushNotification.unsubscribe( + token: token, + swarmAuthentication: swarmAuthentication, + using: dependencies + ) + + /// If we successfully unsubscribed for user PNs then remove the cached token + if response.subResponses.first?.success == true { + dependencies[defaults: .standard, key: .deviceToken] = nil + } + } + + static func retrieveAllSwarmAuth( + using dependencies: Dependencies + ) async throws -> [AuthenticationMethod] { + let userSessionId: SessionId = dependencies[cache: .general].sessionId + let groupIds: Set = try await dependencies[singleton: .storage].readAsync { db in + try ClosedGroup + .select(.threadId) + .filter( + ClosedGroup.Columns.threadId > SessionId.Prefix.group.rawValue && + ClosedGroup.Columns.threadId < SessionId.Prefix.group.endOfRangeString ) - - return try Network.PushNotification - .preparedUnsubscribe( - token: token, - swarms: [(userSessionId, userAuthMethod)] - .appending(contentsOf: (try? ClosedGroup - .select(.threadId) - .filter( - ClosedGroup.Columns.threadId > SessionId.Prefix.group.rawValue && - ClosedGroup.Columns.threadId < SessionId.Prefix.group.endOfRangeString - ) - .asRequest(of: String.self) - .fetchSet(db)) - .defaulting(to: []) - .map { threadId in - ( - SessionId(.group, hex: threadId), - try Authentication.with( - db, - swarmPublicKey: threadId, - using: dependencies - ) - ) - }), - using: dependencies - ) - .handleEvents( - receiveOutput: { _, response in - guard response.subResponses.first?.success == true else { return } - - dependencies[defaults: .standard, key: .deviceToken] = nil - } - ) - } - .flatMap { $0.send(using: dependencies) } - .map { _ in () } - .eraseToAnyPublisher() + .asRequest(of: String.self) + .fetchSet(db) + } + + return try ([userSessionId.hexString] + groupIds).map { + try Authentication.with(swarmPublicKey: $0, using: dependencies) + } } } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationCategory.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationCategory.swift index 3d340c63e7..da03212c1b 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationCategory.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationCategory.swift @@ -4,7 +4,7 @@ import Foundation -public enum NotificationCategory: CaseIterable { +public enum NotificationCategory: Int, CaseIterable, Equatable { case incomingMessage case errorMessage case threadlessErrorMessage diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationContent.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationContent.swift index 2557384fb4..f215a8988b 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationContent.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationContent.swift @@ -3,7 +3,7 @@ import UIKit import UserNotifications -public struct NotificationContent { +public struct NotificationContent: Equatable { public let threadId: String? public let threadVariant: SessionThread.Variant? public let identifier: String @@ -12,7 +12,7 @@ public struct NotificationContent { public let body: String? public let delay: TimeInterval? public let sound: Preferences.Sound - public let userInfo: [AnyHashable: Any] + public let userInfo: [AnyHashable: AnyHashable] public let applicationState: UIApplication.State // MARK: - Init @@ -26,7 +26,7 @@ public struct NotificationContent { body: String? = nil, delay: TimeInterval? = nil, sound: Preferences.Sound = .none, - userInfo: [AnyHashable: Any] = [:], + userInfo: [AnyHashable: AnyHashable] = [:], applicationState: UIApplication.State ) { self.threadId = threadId diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift index 40477f59f3..cec68ba219 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift @@ -8,32 +8,50 @@ import SessionUtilitiesKit // MARK: - Cache -public extension Cache { - static let communityPollers: CacheConfig = Dependencies.create( +public extension Singleton { + static let communityPollerManager: SingletonConfig = Dependencies.create( identifier: "communityPollers", - createInstance: { dependencies in CommunityPoller.Cache(using: dependencies) }, - mutableInstance: { $0 }, - immutableInstance: { $0 } + createInstance: { dependencies, _ in CommunityPollerManager(using: dependencies) } ) } -// MARK: - CommunityPollerType +// MARK: - CommunityPoller Convenience -public protocol CommunityPollerType { - typealias PollResponse = (info: ResponseInfoType, data: Network.BatchResponseMap) - - var isPolling: Bool { get } - var receivedPollResponse: AnyPublisher { get } - - func startIfNeeded() - func stop() +public extension PollerType where PollResponse == CommunityPoller.PollResponse { + init( + pollerName: String, + destination: PollerDestination, + failureCount: Int = 0, + shouldStoreMessages: Bool, + logStartAndStopCalls: Bool, + customAuthMethod: AuthenticationMethod? = nil, + using dependencies: Dependencies + ) { + self.init( + pollerName: pollerName, + destination: destination, + swarmDrainStrategy: .alwaysRandom, + namespaces: [], + failureCount: failureCount, + shouldStoreMessages: shouldStoreMessages, + logStartAndStopCalls: logStartAndStopCalls, + customAuthMethod: customAuthMethod, + key: nil, + using: dependencies + ) + } } // MARK: - CommunityPoller private typealias Capabilities = Network.SOGS.CapabilitiesResponse -public final class CommunityPoller: CommunityPollerType & PollerType { +public actor CommunityPoller: PollerType { + public typealias PollResponse = ( + info: ResponseInfoType, + data: Network.BatchResponseMap + ) + // MARK: - Settings private static let minPollInterval: TimeInterval = 3 @@ -50,294 +68,267 @@ public final class CommunityPoller: CommunityPollerType & PollerType { // MARK: - PollerType public let dependencies: Dependencies - public let pollerQueue: DispatchQueue + public let dependenciesKey: Dependencies.Key? = nil public let pollerName: String - public let pollerDestination: PollerDestination + public let destination: PollerDestination public let logStartAndStopCalls: Bool - public var receivedPollResponse: AnyPublisher { - receivedPollResponseSubject.eraseToAnyPublisher() - } + nonisolated public var receivedPollResponse: AsyncStream { responseStream.stream } - public var isPolling: Bool = false + public var pollTask: Task? public var pollCount: Int = 0 public var failureCount: Int public var lastPollStart: TimeInterval = 0 public var cancellable: AnyCancellable? private let shouldStoreMessages: Bool - private let receivedPollResponseSubject: PassthroughSubject = PassthroughSubject() + nonisolated private let responseStream: CancellationAwareAsyncStream = CancellationAwareAsyncStream() // MARK: - Initialization - required public init( + public init( pollerName: String, - pollerQueue: DispatchQueue, - pollerDestination: PollerDestination, - pollerDrainBehaviour: ThreadSafeObject = .alwaysRandom, - namespaces: [Network.SnodeAPI.Namespace] = [], + destination: PollerDestination, + swarmDrainStrategy: SwarmDrainer.Strategy, + namespaces: [Network.StorageServer.Namespace], failureCount: Int, shouldStoreMessages: Bool, logStartAndStopCalls: Bool, - customAuthMethod: AuthenticationMethod? = nil, + customAuthMethod: AuthenticationMethod?, + key: Dependencies.Key?, using dependencies: Dependencies ) { self.dependencies = dependencies self.pollerName = pollerName - self.pollerQueue = pollerQueue - self.pollerDestination = pollerDestination + self.destination = destination self.failureCount = failureCount self.shouldStoreMessages = shouldStoreMessages self.logStartAndStopCalls = logStartAndStopCalls } - // MARK: - Abstract Methods + deinit { + // Send completion events to the observables + Task { [stream = responseStream] in + await stream.finishCurrentStreams() + } + + pollTask?.cancel() + } + + // MARK: - PollerType - public func nextPollDelay() -> AnyPublisher { + public func nextPollDelay() async -> TimeInterval { // Arbitrary backoff factor... - return Just(min(CommunityPoller.maxPollInterval, CommunityPoller.minPollInterval + pow(2, Double(failureCount)))) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return min( + CommunityPoller.maxPollInterval, + (CommunityPoller.minPollInterval + pow(2, Double(failureCount))) + ) } - public func handlePollError(_ error: Error, _ lastError: Error?) -> PollerErrorResponse { + public func handlePollError(_ error: Error) async { /// 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 /// /// **Note:** To prevent an infinite loop caused by a server-side bug we want to prevent this capabilities request from /// happening multiple times in a row - switch (error.isMissingBlindedAuthError, lastError?.isMissingBlindedAuthError) { - case (true, .none), (true, false): break - default: - /// Save the updated failure count to the database - dependencies[singleton: .storage].writeAsync { [pollerDestination, failureCount] db in - try OpenGroup - .filter(OpenGroup.Columns.server == pollerDestination.target) - .updateAll( - db, - OpenGroup.Columns.pollFailureCount.set(to: failureCount + 1) - ) - } - return .continuePolling + guard error.isMissingBlindedAuthError else { + /// Save the updated failure count to the database + _ = try? await dependencies[singleton: .storage].writeAsync { [destination, failureCount] db in + try OpenGroup + .filter(OpenGroup.Columns.server == destination.target) + .updateAll( + db, + OpenGroup.Columns.pollFailureCount.set(to: failureCount) + ) + } + return + } + + /// Since we have gotten here we should update the SOGS capabilities before triggering the next poll + do { + let authMethod: AuthenticationMethod = try await dependencies[singleton: .storage].readAsync { [destination, dependencies] db -> AuthenticationMethod in + try Authentication.with( + db, + server: destination.target, + forceBlinded: true, + using: dependencies + ) + } + let request: Network.PreparedRequest = try Network.SOGS.preparedCapabilities( + authMethod: authMethod, + using: dependencies + ) + let response: Network.SOGS.CapabilitiesResponse = try await request.send(using: dependencies) + + try await dependencies[singleton: .storage].writeAsync { [destination, manager = dependencies[singleton: .openGroupManager]] db in + manager.handleCapabilities( + db, + capabilities: response, + on: destination.target + ) + } } - //[pollerName, pollerDestination, failureCount, dependencies] - func handleError(_ error: Error) throws -> AnyPublisher { + catch { /// Log the error first Log.error(.poller, "\(pollerName) failed to update capabilities due to error: \(error).") /// If the polling has failed 10+ times then try to prune any invalid rooms that /// aren't visible (they would have been added via config messages and will /// likely always fail but the user has no way to delete them) - guard (failureCount + 1) > CommunityPoller.maxHiddenRoomFailureCount else { + guard failureCount > CommunityPoller.maxHiddenRoomFailureCount else { /// Save the updated failure count to the database - dependencies[singleton: .storage].writeAsync { [pollerDestination, failureCount] db in + _ = try? await dependencies[singleton: .storage].writeAsync { [destination, failureCount] db in try OpenGroup - .filter(OpenGroup.Columns.server == pollerDestination.target) + .filter(OpenGroup.Columns.server == destination.target) .updateAll( db, - OpenGroup.Columns.pollFailureCount.set(to: failureCount + 1) + OpenGroup.Columns.pollFailureCount.set(to: failureCount) ) } - - throw error + return } - return dependencies[singleton: .storage] - .writePublisher { [pollerDestination, failureCount, dependencies] db -> [String] in - /// Save the updated failure count to the database - try OpenGroup - .filter(OpenGroup.Columns.server == pollerDestination.target) - .updateAll( - db, - OpenGroup.Columns.pollFailureCount.set(to: failureCount + 1) - ) - - /// Prune any hidden rooms - let roomIds: Set = try OpenGroup - .filter( - OpenGroup.Columns.server == pollerDestination.target && - OpenGroup.Columns.isActive == true - ) - .select(.roomToken) - .asRequest(of: String.self) - .fetchSet(db) - .map { OpenGroup.idFor(roomToken: $0, server: pollerDestination.target) } - .asSet() - let hiddenRoomIds: Set = try SessionThread - .select(.id) - .filter(ids: roomIds) - .filter( - SessionThread.Columns.shouldBeVisible == false || - SessionThread.Columns.pinnedPriority == LibSession.hiddenPriority - ) - .asRequest(of: String.self) - .fetchSet(db) - - try hiddenRoomIds.forEach { id in - try dependencies[singleton: .openGroupManager].delete( - db, - openGroupId: id, - /// **Note:** We pass `skipLibSessionUpdate` as `true` - /// here because we want to avoid syncing this deletion as the room might - /// not be in an invalid state on other devices - one of the other devices - /// will eventually trigger a new config update which will re-add this room - /// and hopefully at that time it'll work again - skipLibSessionUpdate: true - ) - } + let hiddenRoomIds: [String]? = try? await dependencies[singleton: .storage].writeAsync { [destination, failureCount, dependencies] db -> [String] in + /// Save the updated failure count to the database + try OpenGroup + .filter(OpenGroup.Columns.server == destination.target) + .updateAll( + db, + OpenGroup.Columns.pollFailureCount.set(to: failureCount) + ) + + /// Prune any hidden rooms + let roomIds: Set = try OpenGroup + .filter( + OpenGroup.Columns.server == destination.target && + OpenGroup.Columns.isActive == true + ) + .select(.roomToken) + .asRequest(of: String.self) + .fetchSet(db) + .map { OpenGroup.idFor(roomToken: $0, server: destination.target) } + .asSet() + let hiddenRoomIds: Set = try SessionThread + .select(.id) + .filter(ids: roomIds) + .filter( + SessionThread.Columns.shouldBeVisible == false || + SessionThread.Columns.pinnedPriority == LibSession.hiddenPriority + ) + .asRequest(of: String.self) + .fetchSet(db) - return Array(hiddenRoomIds) + try hiddenRoomIds.forEach { id in + try dependencies[singleton: .openGroupManager].delete( + db, + openGroupId: id, + /// **Note:** We pass `skipLibSessionUpdate` as `true` + /// here because we want to avoid syncing this deletion as the room might + /// not be in an invalid state on other devices - one of the other devices + /// will eventually trigger a new config update which will re-add this room + /// and hopefully at that time it'll work again + skipLibSessionUpdate: true + ) } - .handleEvents( - receiveOutput: { [pollerName, pollerDestination] hiddenRoomIds in - guard !hiddenRoomIds.isEmpty else { return } - - // Add a note to the logs that this happened - let rooms: String = hiddenRoomIds - .sorted() - .compactMap { $0.components(separatedBy: pollerDestination.target).last } - .joined(separator: ", ") - Log.error(.poller, "\(pollerName) failure count surpassed \(CommunityPoller.maxHiddenRoomFailureCount), removed hidden rooms [\(rooms)].") - } - ) - .map { _ in () } - .eraseToAnyPublisher() - } - - /// Since we have gotten here we should update the SOGS capabilities before triggering the next poll - cancellable = dependencies[singleton: .storage] - .readPublisher { [pollerDestination, dependencies] db -> AuthenticationMethod in - try Authentication.with( - db, - server: pollerDestination.target, - forceBlinded: true, - using: dependencies - ) - } - .subscribe(on: pollerQueue, using: dependencies) - .receive(on: pollerQueue, using: dependencies) - .tryMap { [dependencies] authMethod in - try Network.SOGS.preparedCapabilities( - authMethod: authMethod, - using: dependencies - ) - } - .flatMap { [dependencies] in $0.send(using: dependencies) } - .flatMapStorageWritePublisher(using: dependencies) { [pollerDestination] (db: ObservingDatabase, response: (info: ResponseInfoType, data: Network.SOGS.CapabilitiesResponse)) in - OpenGroupManager.handleCapabilities( - db, - capabilities: response.data, - on: pollerDestination.target - ) + + return Array(hiddenRoomIds) } - .tryCatch { try handleError($0) } - .asResult() - .flatMapOptional { [weak self] _ in self?.nextPollDelay() } - .sink( - receiveCompletion: { _ in }, // Never called - receiveValue: { [weak self, pollerQueue, dependencies] nextPollDelay in - let nextPollInterval: TimeUnit = .seconds(nextPollDelay) - - // Schedule the next poll - pollerQueue.asyncAfter(deadline: .now() + .milliseconds(Int(nextPollInterval.timeInterval * 1000)), qos: .default, using: dependencies) { - self?.pollRecursively(error) - } - } - ) - - /// Stop polling at this point (we will resume once the above publisher completes - return .stopPolling + + guard let hiddenRoomIds: [String] = hiddenRoomIds, !hiddenRoomIds.isEmpty else { return } + + /// Add a note to the logs that this happened + let rooms: String = hiddenRoomIds + .sorted() + .compactMap { $0.components(separatedBy: destination.target).last } + .joined(separator: ", ") + Log.error(.poller, "\(pollerName) failure count surpassed \(CommunityPoller.maxHiddenRoomFailureCount), removed hidden rooms [\(rooms)].") + } } // MARK: - Polling public func pollerDidStart() {} + public func pollerReceivedResponse(_ response: PollResponse) async { + await responseStream.send(response) + } + + public func pollerDidStop() { + Task { await responseStream.finishCurrentStreams() } + } + /// Polls based on it's configuration and processes any messages, returning an array of messages that were /// successfully processed /// /// **Note:** The returned messages will have already been processed by the `Poller`, they are only returned /// for cases where we need explicit/custom behaviours to occur (eg. Onboarding) - public func poll(forceSynchronousProcessing: Bool = false) -> AnyPublisher { + public func poll(forceSynchronousProcessing: Bool = false) async throws -> PollResult { typealias PollInfo = ( roomInfo: [Network.SOGS.PollRoomInfo], lastInboxMessageId: Int64, lastOutboxMessageId: Int64, authMethod: AuthenticationMethod ) + typealias APIValue = Network.BatchResponseMap let lastSuccessfulPollTimestamp: TimeInterval = (self.lastPollStart > 0 ? lastPollStart : - dependencies.mutate(cache: .openGroupManager) { cache in - cache.getLastSuccessfulCommunityPollTimestamp() - } + await dependencies[singleton: .openGroupManager].getLastSuccessfulCommunityPollTimestamp() ) - return dependencies[singleton: .storage] - .readPublisher { [pollerDestination, dependencies] db -> PollInfo in - /// **Note:** The `OpenGroup` type converts to lowercase in init - let server: String = pollerDestination.target.lowercased() - let roomInfo: [Network.SOGS.PollRoomInfo] = try OpenGroup - .select(.roomToken, .infoUpdates, .sequenceNumber) + let pollInfo: PollInfo = try await dependencies[singleton: .storage].readAsync { [destination, dependencies] db in + /// **Note:** The `OpenGroup` type converts to lowercase in init + let server: String = destination.target.lowercased() + let roomInfo: [Network.SOGS.PollRoomInfo] = try OpenGroup + .select(.roomToken, .infoUpdates, .sequenceNumber) + .filter(OpenGroup.Columns.server == server) + .filter(OpenGroup.Columns.isActive == true) + .filter(OpenGroup.Columns.roomToken != "") + .asRequest(of: Network.SOGS.PollRoomInfo.self) + .fetchAll(db) + + guard !roomInfo.isEmpty else { throw SOGSError.invalidPoll } + + return ( + roomInfo, + (try? OpenGroup + .select(.inboxLatestMessageId) .filter(OpenGroup.Columns.server == server) - .filter(OpenGroup.Columns.isActive == true) - .filter(OpenGroup.Columns.roomToken != "") - .asRequest(of: Network.SOGS.PollRoomInfo.self) - .fetchAll(db) - - guard !roomInfo.isEmpty else { throw SOGSError.invalidPoll } - - return ( - roomInfo, - (try? OpenGroup - .select(.inboxLatestMessageId) - .filter(OpenGroup.Columns.server == server) - .asRequest(of: Int64.self) - .fetchOne(db)) - .defaulting(to: 0), - (try? OpenGroup - .select(.outboxLatestMessageId) - .filter(OpenGroup.Columns.server == server) - .asRequest(of: Int64.self) - .fetchOne(db)) - .defaulting(to: 0), - try Authentication.with(db, server: server, using: dependencies) - ) - } - .tryFlatMap { [pollCount, dependencies] pollInfo -> AnyPublisher<(ResponseInfoType, Network.BatchResponseMap), Error> in - try Network.SOGS - .preparedPoll( - roomInfo: pollInfo.roomInfo, - lastInboxMessageId: pollInfo.lastInboxMessageId, - lastOutboxMessageId: pollInfo.lastOutboxMessageId, - checkForCommunityMessageRequests: dependencies.mutate(cache: .libSession) { - $0.get(.checkForCommunityMessageRequests) - }, - hasPerformedInitialPoll: (pollCount > 0), - timeSinceLastPoll: (dependencies.dateNow.timeIntervalSince1970 - lastSuccessfulPollTimestamp), - authMethod: pollInfo.authMethod, - using: dependencies - ) - .send(using: dependencies) - } - .flatMapOptional { [weak self, failureCount, dependencies] info, response in - self?.handlePollResponse( - info: info, - response: response, - failureCount: failureCount, - using: dependencies - ) - } - .handleEvents( - receiveOutput: { [weak self, dependencies] _ in - self?.pollCount += 1 - - dependencies.mutate(cache: .openGroupManager) { cache in - cache.setLastSuccessfulCommunityPollTimestamp( - dependencies.dateNow.timeIntervalSince1970 - ) - } - } + .asRequest(of: Int64.self) + .fetchOne(db)) + .defaulting(to: 0), + (try? OpenGroup + .select(.outboxLatestMessageId) + .filter(OpenGroup.Columns.server == server) + .asRequest(of: Int64.self) + .fetchOne(db)) + .defaulting(to: 0), + try Authentication.with(db, server: server, using: dependencies) ) - .eraseToAnyPublisher() + } + let request: Network.PreparedRequest = try Network.SOGS.preparedPoll( + roomInfo: pollInfo.roomInfo, + lastInboxMessageId: pollInfo.lastInboxMessageId, + lastOutboxMessageId: pollInfo.lastOutboxMessageId, + checkForCommunityMessageRequests: dependencies.mutate(cache: .libSession) { + $0.get(.checkForCommunityMessageRequests) + }, + hasPerformedInitialPoll: (pollCount > 0), + timeSinceLastPoll: (dependencies.dateNow.timeIntervalSince1970 - lastSuccessfulPollTimestamp), + authMethod: pollInfo.authMethod, + using: dependencies + ) + let response: (info: ResponseInfoType, value: APIValue) = try await request.send(using: dependencies) + let result: PollResult = try await handlePollResponse( + info: response.info, + response: response.value, + failureCount: failureCount, + using: dependencies + ) + pollCount += 1 + await dependencies[singleton: .openGroupManager].setLastSuccessfulCommunityPollTimestamp( + dependencies.dateNow.timeIntervalSince1970 + ) + + return result } private func handlePollResponse( @@ -345,7 +336,7 @@ public final class CommunityPoller: CommunityPollerType & PollerType { response: Network.BatchResponseMap, failureCount: Int, using dependencies: Dependencies - ) -> AnyPublisher { + ) async throws -> PollResult { var rawMessageCount: Int = 0 let validResponses: [Network.SOGS.Endpoint: Any] = response.data .filter { endpoint, data in @@ -414,9 +405,7 @@ public final class CommunityPoller: CommunityPollerType & PollerType { // If there are no remaining 'validResponses' and there hasn't been a failure then there is // no need to do anything else guard !validResponses.isEmpty || failureCount != 0 else { - return Just(((info, response), rawMessageCount, 0, true)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return PollResult((info, response), rawMessageCount, 0, true) } // Retrieve the current capability & group info to check if anything changed @@ -428,173 +417,161 @@ public final class CommunityPoller: CommunityPollerType & PollerType { default: return nil } } + let currentInfo: (capabilities: Network.SOGS.CapabilitiesResponse, groups: [OpenGroup]) = try await dependencies[singleton: .storage].readAsync { [destination] db in + let allCapabilities: [Capability] = try Capability + .filter(Capability.Columns.openGroupServer == destination.target) + .fetchAll(db) + let capabilities: Network.SOGS.CapabilitiesResponse = Network.SOGS.CapabilitiesResponse( + capabilities: allCapabilities + .filter { !$0.isMissing } + .map { $0.variant.rawValue }, + missing: { + let missingCapabilities: [String] = allCapabilities + .filter { $0.isMissing } + .map { $0.variant.rawValue } + + return (missingCapabilities.isEmpty ? nil : missingCapabilities) + }() + ) + let openGroupIds: [String] = rooms + .map { OpenGroup.idFor(roomToken: $0, server: destination.target) } + let groups: [OpenGroup] = try OpenGroup + .filter(ids: openGroupIds) + .fetchAll(db) + + return (capabilities, groups) + } - return dependencies[singleton: .storage] - .readPublisher { [pollerDestination] db -> (capabilities: Network.SOGS.CapabilitiesResponse, groups: [OpenGroup]) in - let allCapabilities: [Capability] = try Capability - .filter(Capability.Columns.openGroupServer == pollerDestination.target) - .fetchAll(db) - let capabilities: Network.SOGS.CapabilitiesResponse = Network.SOGS.CapabilitiesResponse( - capabilities: allCapabilities - .filter { !$0.isMissing } - .map { $0.variant.rawValue }, - missing: { - let missingCapabilities: [String] = allCapabilities - .filter { $0.isMissing } - .map { $0.variant.rawValue } - - return (missingCapabilities.isEmpty ? nil : missingCapabilities) - }() - ) - let openGroupIds: [String] = rooms - .map { OpenGroup.idFor(roomToken: $0, server: pollerDestination.target) } - let groups: [OpenGroup] = try OpenGroup - .filter(ids: openGroupIds) - .fetchAll(db) - - return (capabilities, groups) - } - .flatMap { [pollerDestination, dependencies] (capabilities: Network.SOGS.CapabilitiesResponse, groups: [OpenGroup]) -> AnyPublisher in - let changedResponses: [Network.SOGS.Endpoint: Any] = validResponses - .filter { endpoint, data in - switch endpoint { - case .capabilities: - guard - let responseData: Network.BatchSubResponse = data as? Network.BatchSubResponse, - let responseBody: Network.SOGS.CapabilitiesResponse = responseData.body - else { return false } - - return (responseBody != capabilities) - - case .roomPollInfo(let roomToken, _): - guard - let responseData: Network.BatchSubResponse = data as? Network.BatchSubResponse, - let responseBody: Network.SOGS.RoomPollInfo = responseData.body - else { return false } - guard let existingOpenGroup: OpenGroup = groups.first(where: { $0.roomToken == roomToken }) else { - return true - } - - return ( - responseBody.activeUsers != existingOpenGroup.userCount || ( - responseBody.details != nil && - responseBody.details?.infoUpdates != existingOpenGroup.infoUpdates - ) || - OpenGroup.Permissions(roomInfo: responseBody) != existingOpenGroup.permissions - ) - - default: return true - } + let changedResponses: [Network.SOGS.Endpoint: Any] = validResponses.filter { endpoint, data in + switch endpoint { + case .capabilities: + guard + let responseData: Network.BatchSubResponse = data as? Network.BatchSubResponse, + let responseBody: Network.SOGS.CapabilitiesResponse = responseData.body + else { return false } + + return (responseBody != currentInfo.capabilities) + + case .roomPollInfo(let roomToken, _): + guard + let responseData: Network.BatchSubResponse = data as? Network.BatchSubResponse, + let responseBody: Network.SOGS.RoomPollInfo = responseData.body + else { return false } + guard let existingOpenGroup: OpenGroup = currentInfo.groups.first(where: { $0.roomToken == roomToken }) else { + return true } + + return ( + responseBody.activeUsers != existingOpenGroup.userCount || ( + responseBody.details != nil && + responseBody.details?.infoUpdates != existingOpenGroup.infoUpdates + ) || + OpenGroup.Permissions(roomInfo: responseBody) != existingOpenGroup.permissions + ) - // If there are no 'changedResponses' and there hasn't been a failure then there is - // no need to do anything else - guard !changedResponses.isEmpty || failureCount != 0 else { - return Just(((info, response), rawMessageCount, 0, true)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - return dependencies[singleton: .storage] - .writePublisher { db -> PollResult in - // Reset the failure count - if failureCount > 0 { - try OpenGroup - .filter(OpenGroup.Columns.server == pollerDestination.target) - .updateAll(db, OpenGroup.Columns.pollFailureCount.set(to: 0)) - } + default: return true + } + } + + // If there are no 'changedResponses' and there hasn't been a failure then there is + // no need to do anything else + guard !changedResponses.isEmpty || failureCount != 0 else { + return PollResult((info, response), rawMessageCount, 0, true) + } + + return try await dependencies[singleton: .storage].writeAsync { [destination, manager = dependencies[singleton: .openGroupManager]] db -> PollResult in + // Reset the failure count + if failureCount > 0 { + try OpenGroup + .filter(OpenGroup.Columns.server == destination.target) + .updateAll(db, OpenGroup.Columns.pollFailureCount.set(to: 0)) + } + + var interactionInfo: [MessageReceiver.InsertedInteractionInfo?] = [] + try changedResponses.forEach { endpoint, data in + switch endpoint { + case .capabilities: + guard + let responseData: Network.BatchSubResponse = data as? Network.BatchSubResponse, + let responseBody: Network.SOGS.CapabilitiesResponse = responseData.body + else { return } + + manager.handleCapabilities( + db, + capabilities: responseBody, + on: destination.target + ) + + case .roomPollInfo(let roomToken, _): + guard + let responseData: Network.BatchSubResponse = data as? Network.BatchSubResponse, + let responseBody: Network.SOGS.RoomPollInfo = responseData.body + else { return } + + try manager.handlePollInfo( + db, + pollInfo: responseBody, + publicKey: nil, + for: roomToken, + on: destination.target + ) + + case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): + guard + let responseData: Network.BatchSubResponse<[Failable]> = data as? Network.BatchSubResponse<[Failable]>, + let responseBody: [Failable] = responseData.body + else { return } + + interactionInfo.append( + contentsOf: manager.handleMessages( + db, + messages: responseBody.compactMap { $0.value }, + for: roomToken, + on: destination.target + ) + ) - var interactionInfo: [MessageReceiver.InsertedInteractionInfo?] = [] - try changedResponses.forEach { endpoint, data in + case .inbox, .inboxSince, .outbox, .outboxSince: + guard + let responseData: Network.BatchSubResponse<[Network.SOGS.DirectMessage]?> = data as? Network.BatchSubResponse<[Network.SOGS.DirectMessage]?>, + !responseData.failedToParseBody + else { return } + + // Double optional because the server can return a `304` with an empty body + let messages: [Network.SOGS.DirectMessage] = ((responseData.body ?? []) ?? []) + let fromOutbox: Bool = { switch endpoint { - case .capabilities: - guard - let responseData: Network.BatchSubResponse = data as? Network.BatchSubResponse, - let responseBody: Network.SOGS.CapabilitiesResponse = responseData.body - else { return } - - OpenGroupManager.handleCapabilities( - db, - capabilities: responseBody, - on: pollerDestination.target - ) - - case .roomPollInfo(let roomToken, _): - guard - let responseData: Network.BatchSubResponse = data as? Network.BatchSubResponse, - let responseBody: Network.SOGS.RoomPollInfo = responseData.body - else { return } - - try OpenGroupManager.handlePollInfo( - db, - pollInfo: responseBody, - publicKey: nil, - for: roomToken, - on: pollerDestination.target, - using: dependencies - ) - - case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): - guard - let responseData: Network.BatchSubResponse<[Failable]> = data as? Network.BatchSubResponse<[Failable]>, - let responseBody: [Failable] = responseData.body - else { return } - - interactionInfo.append( - contentsOf: OpenGroupManager.handleMessages( - db, - messages: responseBody.compactMap { $0.value }, - for: roomToken, - on: pollerDestination.target, - using: dependencies - ) - ) - - case .inbox, .inboxSince, .outbox, .outboxSince: - guard - let responseData: Network.BatchSubResponse<[Network.SOGS.DirectMessage]?> = data as? Network.BatchSubResponse<[Network.SOGS.DirectMessage]?>, - !responseData.failedToParseBody - else { return } - - // Double optional because the server can return a `304` with an empty body - let messages: [Network.SOGS.DirectMessage] = ((responseData.body ?? []) ?? []) - let fromOutbox: Bool = { - switch endpoint { - case .outbox, .outboxSince: return true - default: return false - } - }() - - interactionInfo.append( - contentsOf: OpenGroupManager.handleDirectMessages( - db, - messages: messages, - fromOutbox: fromOutbox, - on: pollerDestination.target, - using: dependencies - ) - ) - - default: break // No custom handling needed + case .outbox, .outboxSince: return true + default: return false } - } + }() - /// Notify about the received message - interactionInfo.forEach { info in - MessageReceiver.prepareNotificationsForInsertedInteractions( + interactionInfo.append( + contentsOf: manager.handleDirectMessages( db, - insertedInteractionInfo: info, - isMessageRequest: false, /// Communities can't be message requests - using: dependencies + messages: messages, + fromOutbox: fromOutbox, + on: destination.target ) - } + ) - /// Assume all messages were handled - return ((info, response), rawMessageCount, rawMessageCount, true) - } - .eraseToAnyPublisher() + default: break // No custom handling needed + } + } + + /// Notify about the received message + interactionInfo.forEach { info in + MessageReceiver.prepareNotificationsForInsertedInteractions( + db, + insertedInteractionInfo: info, + isMessageRequest: false, /// Communities can't be message requests + using: dependencies + ) } - .eraseToAnyPublisher() + + /// Assume all messages were handled + return PollResult((info, response), rawMessageCount, rawMessageCount, true) + } } } @@ -613,7 +590,7 @@ fileprivate extension Error { } } -// MARK: - GroupPoller Cache +// MARK: - CommunityPollerManager public extension CommunityPoller { struct Info: Equatable, FetchableRecord, Decodable, ColumnExpressible { @@ -626,111 +603,122 @@ public extension CommunityPoller { public let server: String public let pollFailureCount: Int64 } +} + +// MARK: - CommunityPollerManager - class Cache: CommunityPollerCacheType { - private let dependencies: Dependencies - private var _pollers: [String: CommunityPoller] = [:] // One for each server - - public var serversBeingPolled: Set { Set(_pollers.keys) } - public var allPollers: [CommunityPollerType] { Array(_pollers.values) } - - // MARK: - Initialization - - public init(using dependencies: Dependencies) { - self.dependencies = dependencies - } - - deinit { - _pollers.forEach { _, poller in poller.stop() } - _pollers.removeAll() - } - - // MARK: - Functions - - public func startAllPollers() { - // On the communityPollerQueue fetch all SOGS and start the pollers - Threading.communityPollerQueue.async(using: dependencies) { [weak self, dependencies] in - dependencies[singleton: .storage].readAsync( - retrieve: { db -> [Info] in - // 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 - try OpenGroup - .select( - OpenGroup.Columns.server, - max(OpenGroup.Columns.pollFailureCount).forKey(Info.Columns.pollFailureCount) - ) - .filter(OpenGroup.Columns.isActive == true) - .filter(OpenGroup.Columns.roomToken != "") - .group(OpenGroup.Columns.server) - .asRequest(of: Info.self) - .fetchAll(db) - }, - completion: { [weak self] result in - switch result { - case .failure: break - case .success(let infos): - Threading.communityPollerQueue.async(using: dependencies) { [weak self] in - infos.forEach { info in - self?.getOrCreatePoller(for: info).startIfNeeded() - } - } - } - } - ) +actor CommunityPollerManager: CommunityPollerManagerType { + private let dependencies: Dependencies + private var pollers: [String: CommunityPoller] = [:] // One for each server + + nonisolated public let syncState: CommunityPollerManagerSyncState = CommunityPollerManagerSyncState() + public var serversBeingPolled: Set { Set(pollers.keys) } + public var allPollers: [any PollerType] { Array(pollers.values) } + + // MARK: - Initialization + + public init(using dependencies: Dependencies) { + self.dependencies = dependencies + } + + deinit { + Task { [pollers] in + for poller in pollers.values { + await poller.stop() } } - - @discardableResult public func getOrCreatePoller(for info: CommunityPoller.Info) -> CommunityPollerType { - guard let poller: CommunityPoller = _pollers[info.server.lowercased()] else { - let poller: CommunityPoller = CommunityPoller( - pollerName: "Community poller for: \(info.server)", // stringlint:ignore - pollerQueue: Threading.communityPollerQueue, - pollerDestination: .server(info.server), - failureCount: Int(info.pollFailureCount), - shouldStoreMessages: true, - logStartAndStopCalls: false, - using: dependencies + } + + // MARK: - Functions + + public func startAllPollers() async { + let communityInfo: [CommunityPoller.Info] = ((try? await dependencies[singleton: .storage].readAsync { db in + // 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 + try OpenGroup + .select( + OpenGroup.Columns.server, + max(OpenGroup.Columns.pollFailureCount).forKey(CommunityPoller.Info.Columns.pollFailureCount) ) - _pollers[info.server.lowercased()] = poller - return poller - } - + .filter(OpenGroup.Columns.isActive == true) + .filter(OpenGroup.Columns.roomToken != "") + .group(OpenGroup.Columns.server) + .asRequest(of: CommunityPoller.Info.self) + .fetchAll(db) + }) ?? []) + + for info in communityInfo { + await getOrCreatePoller(for: info).startIfNeeded() + } + } + + @discardableResult public func getOrCreatePoller(for info: CommunityPoller.Info) async -> any PollerType { + guard let poller: CommunityPoller = pollers[info.server.lowercased()] else { + let poller: CommunityPoller = CommunityPoller( + pollerName: "Community poller for: \(info.server)", // stringlint:ignore + destination: .server(info.server), + failureCount: Int(info.pollFailureCount), + shouldStoreMessages: true, + logStartAndStopCalls: false, + using: dependencies + ) + pollers[info.server.lowercased()] = poller + syncState.update(serversBeingPolled: Set(pollers.keys)) return poller } + + return poller + } - public func stopAndRemovePoller(for server: String) { - _pollers[server.lowercased()]?.stop() - _pollers[server.lowercased()] = nil + public func stopAndRemovePoller(for server: String) async { + await pollers[server.lowercased()]?.stop() + pollers[server.lowercased()] = nil + syncState.update(serversBeingPolled: Set(pollers.keys)) + } + + public func stopAndRemoveAllPollers() async { + for poller in pollers.values { + await poller.stop() } - public func stopAndRemoveAllPollers() { - _pollers.forEach { _, poller in poller.stop() } - _pollers.removeAll() - } + pollers.removeAll() + syncState.update(serversBeingPolled: Set(pollers.keys)) } } -// MARK: - GroupPollerCacheType +/// We manually handle thread-safety using the `NSLock` so can ensure this is `Sendable` +public final class CommunityPollerManagerSyncState: @unchecked Sendable { + private let lock = NSLock() + private var _serversBeingPolled: Set + + public init(serversBeingPolled: Set = []) { + self._serversBeingPolled = serversBeingPolled + } + + public var serversBeingPolled: Set { lock.withLock { _serversBeingPolled } } -/// This is a read-only version of the Cache designed to avoid unintentionally mutating the instance in a non-thread-safe way -public protocol CommunityPollerImmutableCacheType: ImmutableCacheType { - var serversBeingPolled: Set { get } - var allPollers: [CommunityPollerType] { get } + func update(serversBeingPolled: Set) { + lock.withLock { self._serversBeingPolled = serversBeingPolled } + } } -public protocol CommunityPollerCacheType: CommunityPollerImmutableCacheType, MutableCacheType { - var serversBeingPolled: Set { get } - var allPollers: [CommunityPollerType] { get } +// MARK: - CommunityPollerManagerType + +public protocol CommunityPollerManagerType { + @available(*, deprecated, message: "Should try to refactor the code to use proper async/await") + nonisolated var syncState: CommunityPollerManagerSyncState { get } + var serversBeingPolled: Set { get async } + var allPollers: [any PollerType] { get async } - func startAllPollers() - @discardableResult func getOrCreatePoller(for info: CommunityPoller.Info) -> CommunityPollerType - func stopAndRemovePoller(for server: String) - func stopAndRemoveAllPollers() + func startAllPollers() async + @discardableResult func getOrCreatePoller(for info: CommunityPoller.Info) async -> any PollerType + func stopAndRemovePoller(for server: String) async + func stopAndRemoveAllPollers() async } -public extension CommunityPollerCacheType { - @discardableResult func getOrCreatePoller(for server: String) -> CommunityPollerType { - return getOrCreatePoller(for: CommunityPoller.Info(server: server, pollFailureCount: 0)) +public extension CommunityPollerManagerType { + @discardableResult func getOrCreatePoller(for server: String) async -> any PollerType { + return await getOrCreatePoller(for: CommunityPoller.Info(server: server, pollFailureCount: 0)) } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift index 57c63b8be8..5bf0360979 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift @@ -11,19 +11,19 @@ import SessionUtilitiesKit public extension Singleton { static let currentUserPoller: SingletonConfig = Dependencies.create( identifier: "currentUserPoller", - createInstance: { dependencies in + createInstance: { dependencies, key in /// After polling a given snode 6 times we always switch to a new one. /// /// The reason for doing this is that sometimes a snode will be giving us successful responses while /// it isn't actually getting messages from other snodes. return CurrentUserPoller( pollerName: "Main Poller", // stringlint:ignore - pollerQueue: Threading.pollerQueue, - pollerDestination: .swarm(dependencies[cache: .general].sessionId.hexString), - pollerDrainBehaviour: .limitedReuse(count: 6), + destination: .swarm(dependencies[cache: .general].sessionId.hexString), + swarmDrainStrategy: .limitedReuse(count: 6), namespaces: CurrentUserPoller.namespaces, shouldStoreMessages: true, logStartAndStopCalls: true, + key: key, using: dependencies ) } @@ -32,45 +32,93 @@ public extension Singleton { // MARK: - CurrentUserPoller -public final class CurrentUserPoller: SwarmPoller { - public static let namespaces: [Network.SnodeAPI.Namespace] = [ +public final actor CurrentUserPoller: SwarmPollerType { + public static let namespaces: [Network.StorageServer.Namespace] = [ .default, .configUserProfile, .configContacts, .configConvoInfoVolatile, .configUserGroups ] private let pollInterval: TimeInterval = 1.5 private let retryInterval: TimeInterval = 0.25 private let maxRetryInterval: TimeInterval = 15 - // MARK: - Abstract Methods + public let dependencies: Dependencies + public let dependenciesKey: Dependencies.Key? + public let pollerName: String + public let destination: PollerDestination + public let swarmDrainer: SwarmDrainer + public let logStartAndStopCalls: Bool + nonisolated public var receivedPollResponse: AsyncStream { responseStream.stream } + public var pollTask: Task? + public var pollCount: Int = 0 + public var failureCount: Int + public var lastPollStart: TimeInterval = 0 + public var cancellable: AnyCancellable? - override public func nextPollDelay() -> AnyPublisher { - // If there have been no failures then just use the 'minPollInterval' - guard failureCount > 0 else { - return Just(pollInterval) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + public let namespaces: [Network.StorageServer.Namespace] + public let customAuthMethod: AuthenticationMethod? + public let shouldStoreMessages: Bool + nonisolated private let responseStream: CancellationAwareAsyncStream = CancellationAwareAsyncStream() + + // MARK: - Initialization + + public init( + pollerName: String, + destination: PollerDestination, + swarmDrainStrategy: SwarmDrainer.Strategy, + namespaces: [Network.StorageServer.Namespace], + failureCount: Int, + shouldStoreMessages: Bool, + logStartAndStopCalls: Bool, + customAuthMethod: AuthenticationMethod?, + key: Dependencies.Key?, + using dependencies: Dependencies + ) { + self.dependencies = dependencies + self.dependenciesKey = key + self.pollerName = pollerName + self.destination = destination + self.swarmDrainer = SwarmDrainer( + strategy: swarmDrainStrategy, + nextRetrievalAfterDrain: .resetState, + logDetails: SwarmDrainer.LogDetails(cat: .poller, name: pollerName), + using: dependencies + ) + self.namespaces = namespaces + self.failureCount = failureCount + self.customAuthMethod = customAuthMethod + self.shouldStoreMessages = shouldStoreMessages + self.logStartAndStopCalls = logStartAndStopCalls + } + + deinit { + // Send completion events to the observables + Task { [stream = responseStream] in + await stream.finishCurrentStreams() } - // Otherwise use a simple back-off with the 'retryInterval' - let nextDelay: TimeInterval = TimeInterval(retryInterval * (Double(failureCount) * 1.2)) - - return Just(min(maxRetryInterval, nextDelay)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + pollTask?.cancel() } - // stringlint:ignore_contents - override public func handlePollError(_ error: Error, _ lastError: Error?) -> PollerErrorResponse { - if !dependencies[defaults: .appGroup, key: .isMainAppActive] { - // Do nothing when an error gets throws right after returning from the background (happens frequently) - } - else if case .limitedReuse(_, .some(let targetSnode), _, _, _) = pollerDrainBehaviour { - setDrainBehaviour(pollerDrainBehaviour.clearTargetSnode()) - return .continuePollingInfo("Switching from \(targetSnode) to next snode.") - } - else { - return .continuePollingInfo("Had no target snode.") - } + // MARK: - Polling + + public func pollerDidStart() {} + + public func pollerReceivedResponse(_ response: PollResponse) async { + await responseStream.send(response) + } + + public func pollerDidStop() { + Task { await responseStream.finishCurrentStreams() } + } + + // MARK: - PollerType + + public func nextPollDelay() async -> TimeInterval { + // If there have been no failures then just use the 'minPollInterval' + guard failureCount > 0 else { return pollInterval } + + // Otherwise use a simple back-off with the 'retryInterval' + let nextDelay: TimeInterval = TimeInterval(retryInterval * (Double(failureCount) * 1.2)) - return .continuePolling + return min(maxRetryInterval, nextDelay) } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift index 7e816d4a4f..d3b8a0d227 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift @@ -8,22 +8,20 @@ import SessionUtilitiesKit // MARK: - Cache -public extension Cache { - static let groupPollers: CacheConfig = Dependencies.create( - identifier: "groupPollers", - createInstance: { dependencies in GroupPoller.Cache(using: dependencies) }, - mutableInstance: { $0 }, - immutableInstance: { $0 } +public extension Singleton { + static let groupPollerManager: SingletonConfig = Dependencies.create( + identifier: "groupPollerManager", + createInstance: { dependencies, _ in GroupPollerManager(using: dependencies) } ) } // MARK: - GroupPoller -public final class GroupPoller: SwarmPoller { +public actor GroupPoller: SwarmPollerType { private let minPollInterval: Double = 3 private let maxPollInterval: Double = 30 - public static func namespaces(swarmPublicKey: String) -> [Network.SnodeAPI.Namespace] { + public static func namespaces(swarmPublicKey: String) -> [Network.StorageServer.Namespace] { guard (try? SessionId.Prefix(from: swarmPublicKey)) == .group else { return [.legacyClosedGroup] } @@ -37,9 +35,69 @@ public final class GroupPoller: SwarmPoller { ] } - public override func pollerDidStart() { + public let dependencies: Dependencies + public let dependenciesKey: Dependencies.Key? = nil + public let pollerName: String + public let destination: PollerDestination + public let swarmDrainer: SwarmDrainer + public let logStartAndStopCalls: Bool + nonisolated public var receivedPollResponse: AsyncStream { responseStream.stream } + + public var pollTask: Task? + public var pollCount: Int = 0 + public var failureCount: Int + public var lastPollStart: TimeInterval = 0 + public var cancellable: AnyCancellable? + + public let namespaces: [Network.StorageServer.Namespace] + public let customAuthMethod: AuthenticationMethod? + public let shouldStoreMessages: Bool + nonisolated private let responseStream: CancellationAwareAsyncStream = CancellationAwareAsyncStream() + + // MARK: - Initialization + + public init( + pollerName: String, + destination: PollerDestination, + swarmDrainStrategy: SwarmDrainer.Strategy, + namespaces: [Network.StorageServer.Namespace], + failureCount: Int, + shouldStoreMessages: Bool, + logStartAndStopCalls: Bool, + customAuthMethod: AuthenticationMethod?, + key: Dependencies.Key?, + using dependencies: Dependencies + ) { + self.dependencies = dependencies + self.pollerName = pollerName + self.destination = destination + self.swarmDrainer = SwarmDrainer( + strategy: swarmDrainStrategy, + nextRetrievalAfterDrain: .resetState, + logDetails: SwarmDrainer.LogDetails(cat: .poller, name: pollerName), + using: dependencies + ) + self.namespaces = namespaces + self.failureCount = failureCount + self.shouldStoreMessages = shouldStoreMessages + self.logStartAndStopCalls = logStartAndStopCalls + self.customAuthMethod = customAuthMethod + } + + deinit { + // Send completion events to the observables + Task { [stream = responseStream] in + await stream.finishCurrentStreams() + } + + pollTask?.cancel() + } + + // MARK: - Polling + + public func pollerDidStart() { guard - let sessionId: SessionId = try? SessionId(from: pollerDestination.target), + let sessionId: SessionId = try? SessionId(from: destination.target), sessionId.prefix == .group else { return } @@ -50,43 +108,52 @@ public final class GroupPoller: SwarmPoller { /// If the keys generation is greated than `0` then it means we have a valid config so shouldn't continue guard numKeys == 0 else { return } - dependencies[singleton: .storage] - .readPublisher { [pollerDestination] db in + Task { [destination, dependencies] in + let isExpired: Bool? = try await dependencies[singleton: .storage].readAsync { [destination] db in try ClosedGroup - .filter(id: pollerDestination.target) + .filter(id: destination.target) .select(.expired) .asRequest(of: Bool.self) .fetchOne(db) } - .filter { ($0 != true) } - .flatMap { [receivedPollResponse] _ in receivedPollResponse } - .first() - .map { $0.filter { $0.isConfigMessage } } - .filter { !$0.contains(where: { $0.namespace == Network.SnodeAPI.Namespace.configGroupKeys }) } - .sinkUntilComplete( - receiveValue: { [pollerDestination, pollerName, dependencies] configMessages in - Log.error(.poller, "\(pollerName) received no config messages in it's first poll, flagging as expired.") - - dependencies[singleton: .storage].writeAsync { db in - try ClosedGroup - .filter(id: pollerDestination.target) - .updateAllAndConfig( - db, - ClosedGroup.Columns.expired.set(to: true), - using: dependencies - ) - } - } - ) + + /// If we haven't set the `expired` value then we should check the first poll response to see if it's missing the + /// `GroupKeys` config message + guard + isExpired != true, + let response: PollResponse = await receivedPollResponse.first(), + !response.contains(where: { $0.namespace == .configGroupKeys }) + else { return } + + /// There isn't `GroupKeys` config so flag the group as `expired` + Log.error(.poller, "\(pollerName) received no config messages in it's first poll, flagging as expired.") + try await dependencies[singleton: .storage].writeAsync { db in + try ClosedGroup + .filter(id: destination.target) + .updateAllAndConfig( + db, + ClosedGroup.Columns.expired.set(to: true), + using: dependencies + ) + } + } + } + + public func pollerReceivedResponse(_ response: PollResponse) async { + await responseStream.send(response) + } + + public func pollerDidStop() { + Task { await responseStream.finishCurrentStreams() } } - // MARK: - Abstract Methods + // MARK: - PollerType - override public func nextPollDelay() -> AnyPublisher { + public func nextPollDelay() async -> TimeInterval { let lastReadDate: Date = dependencies .mutate(cache: .libSession) { cache in cache.conversationLastRead( - threadId: pollerDestination.target, + threadId: destination.target, threadVariant: .group, openGroupUrlInfo: nil ) @@ -98,131 +165,113 @@ public final class GroupPoller: SwarmPoller { } .defaulting(to: dependencies.dateNow.addingTimeInterval(-5 * 60)) - // 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 - return dependencies[singleton: .storage] - .readPublisher { [pollerDestination] db in - try Interaction - .filter(Interaction.Columns.threadId == pollerDestination.target) - .select(.receivedAtTimestampMs) - .order(Interaction.Columns.timestampMs.desc) - .asRequest(of: Int64.self) - .fetchOne(db) - } - .map { [dependencies] receivedAtTimestampMs -> Date in - guard - let receivedAtTimestampMs: Int64 = receivedAtTimestampMs, - receivedAtTimestampMs > 0 - else { return dependencies.dateNow.addingTimeInterval(-5 * 60) } - - return Date(timeIntervalSince1970: TimeInterval(Double(receivedAtTimestampMs) / 1000)) - } - .map { [maxPollInterval, minPollInterval, dependencies] lastMessageDate in - let timeSinceLastMessage: TimeInterval = dependencies.dateNow - .timeIntervalSince(max(lastMessageDate, lastReadDate)) - let limit: Double = (12 * 60 * 60) - let a: TimeInterval = ((maxPollInterval - minPollInterval) / limit) - let nextPollInterval: TimeInterval = a * min(timeSinceLastMessage, limit) + minPollInterval - - return nextPollInterval - } - .eraseToAnyPublisher() - } - - override public func handlePollError(_ error: Error, _ lastError: Error?) -> PollerErrorResponse { - return .continuePolling + /// 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 receivedAtTimestampMs: Int64? = try? await dependencies[singleton: .storage].readAsync { [destination] db in + try Interaction + .filter(Interaction.Columns.threadId == destination.target) + .select(.receivedAtTimestampMs) + .order(Interaction.Columns.timestampMs.desc) + .asRequest(of: Int64.self) + .fetchOne(db) + } + let lastMessageDate: Date = { + guard + let receivedAtTimestampMs: Int64 = receivedAtTimestampMs, + receivedAtTimestampMs > 0 + else { return dependencies.dateNow.addingTimeInterval(-5 * 60) } + + return Date(timeIntervalSince1970: TimeInterval(Double(receivedAtTimestampMs) / 1000)) + }() + + let timeSinceLastMessage: TimeInterval = dependencies.dateNow + .timeIntervalSince(max(lastMessageDate, lastReadDate)) + let limit: Double = (12 * 60 * 60) + let a: TimeInterval = ((maxPollInterval - minPollInterval) / limit) + let nextPollInterval: TimeInterval = a * min(timeSinceLastMessage, limit) + minPollInterval + + return nextPollInterval } } -// MARK: - GroupPoller Cache +// MARK: - GroupPollerManager -public extension GroupPoller { - class Cache: GroupPollerCacheType { - private let dependencies: Dependencies - private var _pollers: [String: GroupPoller] = [:] // One for each swarm - - // MARK: - Initialization - - public init(using dependencies: Dependencies) { - self.dependencies = dependencies - } - - deinit { - _pollers.forEach { _, poller in poller.stop() } - _pollers.removeAll() - } - - // MARK: - Functions - - public func startAllPollers() { - // On the group poller queue fetch all closed groups which should poll and start the pollers - Threading.groupPollerQueue.async(using: dependencies) { [weak self, dependencies] in - dependencies[singleton: .storage].readAsync( - retrieve: { db -> Set in - try ClosedGroup - .select(.threadId) - .filter(ClosedGroup.Columns.shouldPoll == true) - .filter( - ClosedGroup.Columns.threadId > SessionId.Prefix.group.rawValue && - ClosedGroup.Columns.threadId < SessionId.Prefix.group.endOfRangeString - ) - .asRequest(of: String.self) - .fetchSet(db) - }, - completion: { [weak self] result in - switch result { - case .failure: break - case .success(let publicKeys): - Threading.groupPollerQueue.async(using: dependencies) { [weak self] in - publicKeys.forEach { swarmPublicKey in - self?.getOrCreatePoller(for: swarmPublicKey).startIfNeeded() - } - } - } - } - ) +public actor GroupPollerManager: GroupPollerManagerType { + private let dependencies: Dependencies + private var pollers: [String: GroupPoller] = [:] // One for each swarm + + // MARK: - Initialization + + public init(using dependencies: Dependencies) { + self.dependencies = dependencies + } + + deinit { + Task { [pollers] in + for poller in pollers.values { + await poller.stop() } } - - @discardableResult public func getOrCreatePoller(for swarmPublicKey: String) -> SwarmPollerType { - guard let poller: GroupPoller = _pollers[swarmPublicKey.lowercased()] else { - let poller: GroupPoller = GroupPoller( - pollerName: "Closed group poller with public key: \(swarmPublicKey)", // stringlint:ignore - pollerQueue: Threading.groupPollerQueue, - pollerDestination: .swarm(swarmPublicKey), - pollerDrainBehaviour: .alwaysRandom, - namespaces: GroupPoller.namespaces(swarmPublicKey: swarmPublicKey), - shouldStoreMessages: true, - logStartAndStopCalls: false, - using: dependencies + } + + // MARK: - Functions + + public func startAllPollers() async { + let groupPublicKeys: Set = ((try? await dependencies[singleton: .storage].readAsync { db in + try ClosedGroup + .select(.threadId) + .filter(ClosedGroup.Columns.shouldPoll == true) + .filter( + ClosedGroup.Columns.threadId > SessionId.Prefix.group.rawValue && + ClosedGroup.Columns.threadId < SessionId.Prefix.group.endOfRangeString ) - _pollers[swarmPublicKey.lowercased()] = poller - return poller - } - + .asRequest(of: String.self) + .fetchSet(db) + }) ?? []) + + for swarmPublicKey in groupPublicKeys { + await getOrCreatePoller(for: swarmPublicKey).startIfNeeded() + } + } + + @discardableResult public func getOrCreatePoller(for swarmPublicKey: String) async -> any PollerType { + guard let poller: GroupPoller = pollers[swarmPublicKey.lowercased()] else { + let poller: GroupPoller = GroupPoller( + pollerName: "Closed group poller with public key: \(swarmPublicKey)", // stringlint:ignore + destination: .swarm(swarmPublicKey), + swarmDrainStrategy: .alwaysRandom, + namespaces: GroupPoller.namespaces(swarmPublicKey: swarmPublicKey), + shouldStoreMessages: true, + logStartAndStopCalls: false, + key: nil, + using: dependencies + ) + pollers[swarmPublicKey.lowercased()] = poller return poller } - public func stopAndRemovePoller(for swarmPublicKey: String) { - _pollers[swarmPublicKey.lowercased()]?.stop() - _pollers[swarmPublicKey.lowercased()] = nil + return poller + } + + public func stopAndRemovePoller(for swarmPublicKey: String) async { + await pollers[swarmPublicKey.lowercased()]?.stop() + pollers[swarmPublicKey.lowercased()] = nil + } + + public func stopAndRemoveAllPollers() async { + for poller in pollers.values { + await poller.stop() } - public func stopAndRemoveAllPollers() { - _pollers.forEach { _, poller in poller.stop() } - _pollers.removeAll() - } + pollers.removeAll() } } -// MARK: - GroupPollerCacheType - -/// This is a read-only version of the Cache designed to avoid unintentionally mutating the instance in a non-thread-safe way -public protocol GroupPollerImmutableCacheType: ImmutableCacheType {} +// MARK: - GroupPollerManagerType -public protocol GroupPollerCacheType: GroupPollerImmutableCacheType, MutableCacheType { - func startAllPollers() - @discardableResult func getOrCreatePoller(for swarmPublicKey: String) -> SwarmPollerType - func stopAndRemovePoller(for swarmPublicKey: String) - func stopAndRemoveAllPollers() +public protocol GroupPollerManagerType { + func startAllPollers() async + @discardableResult func getOrCreatePoller(for swarmPublicKey: String) async -> any PollerType + func stopAndRemovePoller(for swarmPublicKey: String) async + func stopAndRemoveAllPollers() async } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/PollerType.swift b/SessionMessagingKit/Sending & Receiving/Pollers/PollerType.swift index c3e16e5fc5..0ea29b281b 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/PollerType.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/PollerType.swift @@ -15,7 +15,7 @@ public extension Log.Category { // MARK: - PollerDestination -public enum PollerDestination { +public enum PollerDestination: Sendable, Equatable { case swarm(String) case server(String) @@ -26,182 +26,226 @@ public enum PollerDestination { } } -// MARK: - PollerErrorResponse +// MARK: - PollResult -public enum PollerErrorResponse { - case stopPolling - case continuePolling - case continuePollingInfo(String) +public struct PollResult { + public let response: R + public let rawMessageCount: Int + public let validMessageCount: Int + public let hadValidHashUpdate: Bool + + public init( + _ response: R, + _ rawMessageCount: Int = 0, + _ validMessageCount: Int = 0, + _ hadValidHashUpdate: Bool = false + ) { + self.response = response + self.rawMessageCount = rawMessageCount + self.validMessageCount = validMessageCount + self.hadValidHashUpdate = hadValidHashUpdate + } } // MARK: - PollerType -public protocol PollerType: AnyObject { +public protocol PollerType: Actor { associatedtype PollResponse - typealias PollResult = ( - response: PollResponse, - rawMessageCount: Int, - validMessageCount: Int, - hadValidHashUpdate: Bool - ) - var dependencies: Dependencies { get } - var pollerQueue: DispatchQueue { get } + var dependenciesKey: Dependencies.Key? { get } var pollerName: String { get } - var pollerDestination: PollerDestination { get } + var destination: PollerDestination { get } var logStartAndStopCalls: Bool { get } - var receivedPollResponse: AnyPublisher { get } + nonisolated var receivedPollResponse: AsyncStream { get } - var isPolling: Bool { get set } var pollCount: Int { get set } var failureCount: Int { get set } var lastPollStart: TimeInterval { get set } - var cancellable: AnyCancellable? { get set } + var pollTask: Task? { get set } init( pollerName: String, - pollerQueue: DispatchQueue, - pollerDestination: PollerDestination, - pollerDrainBehaviour: ThreadSafeObject, - namespaces: [Network.SnodeAPI.Namespace], + destination: PollerDestination, + swarmDrainStrategy: SwarmDrainer.Strategy, + namespaces: [Network.StorageServer.Namespace], failureCount: Int, shouldStoreMessages: Bool, logStartAndStopCalls: Bool, customAuthMethod: AuthenticationMethod?, + key: Dependencies.Key?, using dependencies: Dependencies ) - func startIfNeeded(forceStartInBackground: Bool) + func startIfNeeded(forceStartInBackground: Bool) async func stop() func pollerDidStart() - func poll(forceSynchronousProcessing: Bool) -> AnyPublisher - func nextPollDelay() -> AnyPublisher - func handlePollError(_ error: Error, _ lastError: Error?) -> PollerErrorResponse + func pollerReceivedResponse(_ response: PollResponse) async + func pollerDidStop() + func poll(forceSynchronousProcessing: Bool) async throws -> PollResult + func pollFromBackground() async throws -> PollResult + func nextPollDelay() async -> TimeInterval + func handlePollError(_ error: Error) async } // MARK: - Default Implementations public extension PollerType { - func startIfNeeded() { startIfNeeded(forceStartInBackground: false) } + func startIfNeeded() async { await startIfNeeded(forceStartInBackground: false) } - func startIfNeeded(forceStartInBackground: Bool) { - Task { @MainActor [weak self, pollerName, pollerQueue, appContext = dependencies[singleton: .appContext], dependencies] in - guard - forceStartInBackground || - appContext.isMainAppAndActive - else { return Log.info(.poller, "Ignoring call to start \(pollerName) due to not being active.") } + func startIfNeeded(forceStartInBackground: Bool) async { + var canStartWhenInactive: Bool = forceStartInBackground + + if !canStartWhenInactive { + canStartWhenInactive = await dependencies[singleton: .appContext].isMainAppAndActive + } + + guard canStartWhenInactive else { + return Log.info(.poller, "Ignoring call to start \(pollerName) due to not being active.") + } + + guard pollTask == nil else { return } + + pollTask = Task { [weak self] in + guard let self = self else { return } - pollerQueue.async(using: dependencies) { [weak self] in - guard self?.isPolling != true else { return } - - // Might be a race condition that the setUpPolling finishes too soon, - // 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 = true - self?.pollRecursively(nil) - - if self?.logStartAndStopCalls == true { - Log.info(.poller, "Started \(pollerName).") + do { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { try await self.pollRecursively() } + group.addTask { try await self.listenForReplacement() } + + /// Wait until one of the groups completes or errors + for try await _ in group {} } - - self?.pollerDidStart() } + catch { await stop() } + } + + if logStartAndStopCalls { + Log.info(.poller, "Started \(pollerName).") } + + pollerDidStart() } func stop() { - pollerQueue.async(using: dependencies) { [weak self, pollerName] in - self?.isPolling = false - self?.cancellable?.cancel() - - if self?.logStartAndStopCalls == true { - Log.info(.poller, "Stopped \(pollerName).") - } + pollTask?.cancel() + pollTask = nil + + if logStartAndStopCalls { + Log.info(.poller, "Stopped \(pollerName).") } + + pollerDidStop() } - internal func pollRecursively(_ lastError: Error?) { - guard isPolling else { return } - guard - !dependencies[singleton: .storage].isSuspended && - !dependencies[cache: .libSessionNetwork].isSuspended - else { - let suspendedDependency: String = { - guard !dependencies[singleton: .storage].isSuspended else { - return "storage" - } - - return "network" - }() - Log.warn(.poller, "Stopped \(pollerName) due to \(suspendedDependency) being suspended.") - self.stop() - return - } + private func pollRecursively() async throws { + typealias TimeInfo = ( + duration: TimeUnit, + nextPollDelay: TimeInterval, + nextPollInterval: TimeUnit + ) - self.lastPollStart = dependencies.dateNow.timeIntervalSince1970 + /// Don't bother trying to poll if we don't have a network connection, just wait for one to be established + try await dependencies.waitUntilConnected(onWillStartWaiting: { [pollerName] in + Log.info(.poller, "\(pollerName) waiting for network to connect before starting to poll.") + }) - cancellable = poll(forceSynchronousProcessing: false) - .subscribe(on: pollerQueue, using: dependencies) - .receive(on: pollerQueue, using: dependencies) - .asResult() - .flatMapOptional { [weak self] value in self?.nextPollDelay().map { (value, $0) } } - .sink( - receiveCompletion: { _ in }, // Never called - receiveValue: { [weak self, pollerName, pollerQueue, lastPollStart, failureCount, dependencies] result, nextPollDelay in - // If the polling has been cancelled then don't continue - guard self?.isPolling == true else { return } - - let endTime: TimeInterval = dependencies.dateNow.timeIntervalSince1970 - let duration: TimeUnit = .seconds(endTime - lastPollStart) - let nextPollInterval: TimeUnit = .seconds(nextPollDelay) - var errorFromPoll: Error? - - // Log information about the poll - switch result { - case .failure(let error): - // Increment the failure count - self?.failureCount = (failureCount + 1) - errorFromPoll = error - - // Determine if the error should stop us from polling anymore - switch self?.handlePollError(error, lastError) { - case .stopPolling: return - case .continuePollingInfo(let info): - Log.error(.poller, "\(pollerName) failed to process any messages after \(duration, unit: .s) due to error: \(error). \(info). Setting failure count to \(failureCount). Next poll in \(nextPollInterval, unit: .s).") - - case .continuePolling, .none: - Log.error(.poller, "\(pollerName) failed to process any messages after \(duration, unit: .s) due to error: \(error). Setting failure count to \(failureCount). Next poll in \(nextPollInterval, unit: .s).") - } - - case .success(let response): - // Reset the failure count - self?.failureCount = 0 - - switch (response.rawMessageCount, response.validMessageCount, response.hadValidHashUpdate) { - case (0, _, _): - Log.info(.poller, "Received no new messages in \(pollerName) after \(duration, unit: .s). Next poll in \(nextPollInterval, unit: .s).") - - case (_, 0, false): - Log.info(.poller, "Received \(response.rawMessageCount) new message(s) in \(pollerName) after \(duration, unit: .s), all duplicates - marked the hash we polled with as invalid. Next poll in \(nextPollInterval, unit: .s).") - - default: - Log.info(.poller, "Received \(response.validMessageCount) new message(s) in \(pollerName) after \(duration, unit: .s) (duplicates: \(response.rawMessageCount - response.validMessageCount)). Next poll in \(nextPollInterval, unit: .s).") - } + /// Now that we have a connection just poll indefinitely + while true { + try Task.checkCancellation() + + guard + !dependencies[singleton: .storage].isSuspended, + await dependencies[singleton: .network].isSuspended == false + else { + let suspendedDependency: String = { + guard !dependencies[singleton: .storage].isSuspended else { + return "storage" } - // Schedule the next poll - pollerQueue.asyncAfter(deadline: .now() + .milliseconds(Int(nextPollInterval.timeInterval * 1000)), qos: .default, using: dependencies) { - self?.pollRecursively(errorFromPoll) - } + return "network" + }() + Log.warn(.poller, "Stopped \(pollerName) due to \(suspendedDependency) being suspended.") + stop() + return + } + + lastPollStart = dependencies.dateNow.timeIntervalSince1970 + let getTimeInfo: (TimeInterval, Dependencies) async throws -> TimeInfo = { lastPollStart, dependencies in + let endTime: TimeInterval = dependencies.dateNow.timeIntervalSince1970 + let duration: TimeUnit = .seconds(endTime - lastPollStart) + let nextPollDelay: TimeInterval = await self.nextPollDelay() + let nextPollInterval: TimeUnit = .seconds(nextPollDelay) + + return (duration, nextPollDelay, nextPollInterval) + } + var timeInfo: TimeInfo = try await getTimeInfo(lastPollStart, dependencies) + + do { + let result: PollResult = try await poll(forceSynchronousProcessing: false) + try Task.checkCancellation() + + /// Notify any observers that we got a result + await pollerReceivedResponse(result.response) + + /// Reset the failure count + failureCount = 0 + timeInfo = try await getTimeInfo(lastPollStart, dependencies) + + /// Log the poll result + switch (result.rawMessageCount, result.validMessageCount, result.hadValidHashUpdate) { + case (0, _, _): + Log.info(.poller, "Received no new messages in \(pollerName) after \(timeInfo.duration, unit: .s). Next poll in \(timeInfo.nextPollInterval, unit: .s).") + + case (_, 0, false): + Log.info(.poller, "Received \(result.rawMessageCount) new message(s) in \(pollerName) after \(timeInfo.duration, unit: .s), all duplicates - marked the hash we polled with as invalid. Next poll in \(timeInfo.nextPollInterval, unit: .s).") + + default: + Log.info(.poller, "Received \(result.validMessageCount) new message(s) in \(pollerName) after \(timeInfo.duration, unit: .s) (duplicates: \(result.rawMessageCount - result.validMessageCount)). Next poll in \(timeInfo.nextPollInterval, unit: .s).") + } + } + catch is CancellationError { + /// If we were cancelled then we don't want to continue + break + } + catch { + try Task.checkCancellation() + + /// Increment the failure count and log the error + failureCount = failureCount + 1 + timeInfo = try await getTimeInfo(lastPollStart, dependencies) + Log.error(.poller, "\(pollerName) failed to process any messages after \(timeInfo.duration, unit: .s) due to error: \(error). Setting failure count to \(failureCount). Next poll in \(timeInfo.nextPollInterval, unit: .s).") + + /// Perform any custom error handling + await handlePollError(error) + } + + /// Sleep until the next poll + try await Task.sleep(for: .milliseconds(Int(timeInfo.nextPollDelay * 1000))) + } + } + + private func listenForReplacement() async throws { + guard let key: Dependencies.Key = dependenciesKey else { return } + + if #available(iOS 16.0, *) { + for await changedValue in dependencies.stream(key: key, of: (any PollerType).self) { + if ObjectIdentifier(changedValue as AnyObject) != ObjectIdentifier(self as AnyObject) { + Log.info(.poller, "\(pollerName) has been replaced in dependencies, shutting down old instance.") + pollTask?.cancel() + break } - ) + } + } } /// This doesn't do anything functional _but_ does mean if we get a crash from the `BackgroundPoller` we can better distinguish /// it from a crash from a foreground poll - func pollFromBackground() -> AnyPublisher { - return poll(forceSynchronousProcessing: true) + func pollFromBackground() async throws -> PollResult { + return try await poll(forceSynchronousProcessing: true) } + + func handlePollError(_ error: Error) async {} } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift index ab0851838b..0088042945 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift @@ -8,221 +8,174 @@ import SessionUtilitiesKit // MARK: - SwarmPollerType -public protocol SwarmPollerType { - typealias PollResponse = [ProcessedMessage] +public protocol SwarmPollerType: PollerType where PollResponse == SwarmPoller.PollResponse { + var swarmDrainer: SwarmDrainer { get } + var namespaces: [Network.StorageServer.Namespace] { get } + var customAuthMethod: AuthenticationMethod? { get } + var shouldStoreMessages: Bool { get } - var receivedPollResponse: AnyPublisher { get } - - func startIfNeeded() - func stop() + init( + pollerName: String, + destination: PollerDestination, + swarmDrainStrategy: SwarmDrainer.Strategy, + namespaces: [Network.StorageServer.Namespace], + failureCount: Int, + shouldStoreMessages: Bool, + logStartAndStopCalls: Bool, + customAuthMethod: AuthenticationMethod?, + key: Dependencies.Key?, + using dependencies: Dependencies + ) } -// MARK: - SwarmPoller +// MARK: - SwarmPollerType Convenience -public class SwarmPoller: SwarmPollerType & PollerType { - public enum PollSource: Equatable { - case snode(LibSession.Snode) - case pushNotification - } - - public let dependencies: Dependencies - public let pollerQueue: DispatchQueue - public let pollerName: String - public let pollerDestination: PollerDestination - @ThreadSafeObject public var pollerDrainBehaviour: SwarmDrainBehaviour - public let logStartAndStopCalls: Bool - public var receivedPollResponse: AnyPublisher { - receivedPollResponseSubject.eraseToAnyPublisher() - } - - public var isPolling: Bool = false - public var pollCount: Int = 0 - public var failureCount: Int - public var lastPollStart: TimeInterval = 0 - public var cancellable: AnyCancellable? - - private let namespaces: [Network.SnodeAPI.Namespace] - private let customAuthMethod: AuthenticationMethod? - private let shouldStoreMessages: Bool - private let receivedPollResponseSubject: PassthroughSubject = PassthroughSubject() - - // MARK: - Initialization - - required public init( +extension SwarmPollerType { + public init( pollerName: String, - pollerQueue: DispatchQueue, - pollerDestination: PollerDestination, - pollerDrainBehaviour: ThreadSafeObject, - namespaces: [Network.SnodeAPI.Namespace], + destination: PollerDestination, + swarmDrainStrategy: SwarmDrainer.Strategy, + namespaces: [Network.StorageServer.Namespace], failureCount: Int = 0, shouldStoreMessages: Bool, logStartAndStopCalls: Bool, - customAuthMethod: AuthenticationMethod? = nil, + key: Dependencies.Key?, using dependencies: Dependencies ) { - self.dependencies = dependencies - self.pollerName = pollerName - self.pollerQueue = pollerQueue - self.pollerDestination = pollerDestination - self._pollerDrainBehaviour = pollerDrainBehaviour - self.namespaces = namespaces - self.failureCount = failureCount - self.customAuthMethod = customAuthMethod - self.shouldStoreMessages = shouldStoreMessages - self.logStartAndStopCalls = logStartAndStopCalls - } - - // MARK: - Abstract Methods - - /// Calculate the delay which should occur before the next poll - public func nextPollDelay() -> AnyPublisher { - preconditionFailure("abstract class - override in subclass") - } - - /// Perform and logic which should occur when the poll errors, will stop polling if `false` is returned - public func handlePollError(_ error: Error, _ lastError: Error?) -> PollerErrorResponse { - preconditionFailure("abstract class - override in subclass") + self.init( + pollerName: pollerName, + destination: destination, + swarmDrainStrategy: swarmDrainStrategy, + namespaces: namespaces, + failureCount: failureCount, + shouldStoreMessages: shouldStoreMessages, + logStartAndStopCalls: logStartAndStopCalls, + customAuthMethod: nil, + key: key, + using: dependencies + ) } - // MARK: - Internal Functions - - internal func setDrainBehaviour(_ behaviour: SwarmDrainBehaviour) { - _pollerDrainBehaviour.set(to: behaviour) - } - - // MARK: - Polling - - public func pollerDidStart() {} - /// Polls based on it's configuration and processes any messages, returning an array of messages that were /// successfully processed /// /// **Note:** The returned messages will have already been processed by the `Poller`, they are only returned /// for cases where we need explicit/custom behaviours to occur (eg. Onboarding) - public func poll(forceSynchronousProcessing: Bool) -> AnyPublisher { - let pollerQueue: DispatchQueue = self.pollerQueue + public func poll(forceSynchronousProcessing: Bool) async throws -> PollResult { + /// Select the node to poll + let swarm: Set = try await dependencies[singleton: .network] + .getSwarm(for: destination.target) + await swarmDrainer.updateSwarmIfNeeded(swarm) + let snode: LibSession.Snode = try await swarmDrainer.selectNextNode() + + /// Fetch the messages (refreshing the current config hashes) + let authMethod: AuthenticationMethod = try (customAuthMethod ?? Authentication.with( + swarmPublicKey: destination.target, + using: dependencies + )) let activeHashes: [String] = dependencies.mutate(cache: .libSession) { cache in - cache.activeHashes(for: pollerDestination.target) + cache.activeHashes(for: destination.target) } + let lastHashes: [Network.StorageServer.Namespace: String] = try await dependencies[singleton: .storage].readAsync { [namespaces, dependencies] db in + try namespaces.reduce(into: [:]) { result, namespace in + result[namespace] = try SnodeReceivedMessageInfo.fetchLastNotExpired( + db, + for: snode, + namespace: namespace, + swarmPublicKey: try authMethod.swarmPublicKey, + using: dependencies + )?.hash + } + } + let response: Network.StorageServer.PollResponse = try await Network.StorageServer.poll( + namespaces: namespaces, + lastHashes: lastHashes, + refreshingConfigHashes: activeHashes, + from: snode, + authMethod: authMethod, + using: dependencies + ) - /// Fetch the messages - return dependencies[singleton: .network] - .getSwarm(for: pollerDestination.target) - .tryFlatMapWithRandomSnode(drainBehaviour: _pollerDrainBehaviour, using: dependencies) { [pollerDestination, customAuthMethod, namespaces, dependencies] snode -> AnyPublisher<(LibSession.Snode, Network.PreparedRequest), Error> in - dependencies[singleton: .storage].readPublisher { db -> (LibSession.Snode, Network.PreparedRequest) in - let authMethod: AuthenticationMethod = try (customAuthMethod ?? Authentication.with( - db, - swarmPublicKey: pollerDestination.target, - using: dependencies - )) + /// Get all of the messages and sort them by their required `processingOrder` + typealias MessageData = (namespace: Network.StorageServer.Namespace, messages: [Network.StorageServer.Message], lastHash: String?) + let sortedMessages: [MessageData] = response + .compactMap { namespace, result -> MessageData? in + (result.data?.messages).map { (namespace, $0, result.data?.lastHash) } + } + .sorted { lhs, rhs in lhs.namespace.processingOrder < rhs.namespace.processingOrder } + let rawMessageCount: Int = sortedMessages.map { $0.messages.count }.reduce(0, +) + + /// No need to do anything if there are no messages + guard rawMessageCount > 0 else { + return PollResult([]) + } + + /// Process the response + let processedResponse: (configMessageJobs: [Job], standardMessageJobs: [Job], pollResult: PollResult) = try await dependencies[singleton: .storage].writeAsync { [destination, shouldStoreMessages, dependencies] db in + SwarmPoller.processPollResponse( + db, + cat: .poller, + source: .snode(snode), + swarmPublicKey: destination.target, + shouldStoreMessages: shouldStoreMessages, + ignoreDedupeFiles: false, + forceSynchronousProcessing: forceSynchronousProcessing, + sortedMessages: sortedMessages, + using: dependencies + ) + } + + /// If we don't want to forcible process the response synchronously then just finish immediately + guard forceSynchronousProcessing else { return processedResponse.pollResult } + + /// We want to try to handle the receive jobs immediately in the background + await withThrowingTaskGroup { [dependencies] group in + processedResponse.configMessageJobs.forEach { job in + group.addTask { [dependencies] in + /// **Note:** In the background we just want jobs to fail silently + let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - return ( - snode, - try Network.SnodeAPI.preparedPoll( - db, - namespaces: namespaces, - refreshingConfigHashes: activeHashes, - from: snode, - authMethod: authMethod, - using: dependencies - ) + // FIXME: Rework this once jobs are async/await + ConfigMessageReceiveJob.run( + job, + scheduler: Threading.pollerQueue, + success: { _, _ in semaphore.signal() }, + failure: { _, _, _ in semaphore.signal() }, + deferred: { _ in semaphore.signal() }, + using: dependencies ) } } - .flatMap { [dependencies] snode, request in - request.send(using: dependencies) - .map { _, response in (snode, response) } - } - .flatMapStorageWritePublisher(using: dependencies, updates: { [pollerDestination, shouldStoreMessages, forceSynchronousProcessing, dependencies] db, info -> ([Job], [Job], PollResult) in - let (snode, namespacedResults): (LibSession.Snode, Network.SnodeAPI.PollResponse) = info - - /// Get all of the messages and sort them by their required `processingOrder` - typealias MessageData = (namespace: Network.SnodeAPI.Namespace, messages: [SnodeReceivedMessage], lastHash: String?) - let sortedMessages: [MessageData] = namespacedResults - .compactMap { namespace, result -> MessageData? in - (result.data?.messages).map { (namespace, $0, result.data?.lastHash) } - } - .sorted { lhs, rhs in lhs.namespace.processingOrder < rhs.namespace.processingOrder } - let rawMessageCount: Int = sortedMessages.map { $0.messages.count }.reduce(0, +) - - /// No need to do anything if there are no messages - guard rawMessageCount > 0 else { - return ([], [], ([], 0, 0, false)) - } - - return SwarmPoller.processPollResponse( - db, - cat: .poller, - source: .snode(snode), - swarmPublicKey: pollerDestination.target, - shouldStoreMessages: shouldStoreMessages, - ignoreDedupeFiles: false, - forceSynchronousProcessing: forceSynchronousProcessing, - sortedMessages: sortedMessages, - using: dependencies - ) - }) - .flatMap { [dependencies] (configMessageJobs, standardMessageJobs, pollResult) -> AnyPublisher in - // If we don't want to forcible process the response synchronously then just finish immediately - guard forceSynchronousProcessing else { - return Just(pollResult) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - // We want to try to handle the receive jobs immediately in the background - return Publishers - .MergeMany( - configMessageJobs.map { job -> AnyPublisher in - Deferred { - Future { resolver in - // Note: In the background we just want jobs to fail silently - ConfigMessageReceiveJob.run( - job, - scheduler: pollerQueue, - success: { _, _ in resolver(Result.success(())) }, - failure: { _, _, _ in resolver(Result.success(())) }, - deferred: { _ in resolver(Result.success(())) }, - using: dependencies - ) - } - } - .eraseToAnyPublisher() - } + } + await withThrowingTaskGroup { [dependencies] group in + processedResponse.standardMessageJobs.forEach { job in + group.addTask { [dependencies] in + /// **Note:** In the background we just want jobs to fail silently + let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + + // FIXME: Rework this once jobs are async/await + MessageReceiveJob.run( + job, + scheduler: Threading.pollerQueue, + success: { _, _ in semaphore.signal() }, + failure: { _, _, _ in semaphore.signal() }, + deferred: { _ in semaphore.signal() }, + using: dependencies ) - .collect() - .flatMap { _ in - Publishers - .MergeMany( - standardMessageJobs.map { job -> AnyPublisher in - Deferred { - Future { resolver in - // Note: In the background we just want jobs to fail silently - MessageReceiveJob.run( - job, - scheduler: pollerQueue, - success: { _, _ in resolver(Result.success(())) }, - failure: { _, _, _ in resolver(Result.success(())) }, - deferred: { _ in resolver(Result.success(())) }, - using: dependencies - ) - } - } - .eraseToAnyPublisher() - } - ) - .collect() - } - .map { _ in pollResult } - .eraseToAnyPublisher() - } - .handleEvents( - receiveOutput: { [weak self] (pollResult: PollResult) in - /// Notify any observers that we got a result - self?.receivedPollResponseSubject.send(pollResult.response) } - ) - .eraseToAnyPublisher() + } + } + + return processedResponse.pollResult + } +} + +public enum SwarmPoller { + public typealias PollResponse = [ProcessedMessage] + + public enum PollSource: Equatable { + case snode(LibSession.Snode) + case pushNotification } @discardableResult public static func processPollResponse( @@ -233,14 +186,14 @@ public class SwarmPoller: SwarmPollerType & PollerType { shouldStoreMessages: Bool, ignoreDedupeFiles: Bool, forceSynchronousProcessing: Bool, - sortedMessages: [(namespace: Network.SnodeAPI.Namespace, messages: [SnodeReceivedMessage], lastHash: String?)], + sortedMessages: [(namespace: Network.StorageServer.Namespace, messages: [Network.StorageServer.Message], lastHash: String?)], using dependencies: Dependencies - ) -> ([Job], [Job], PollResult) { + ) -> ([Job], [Job], PollResult) { /// No need to do anything if there are no messages let rawMessageCount: Int = sortedMessages.map { $0.messages.count }.reduce(0, +) guard rawMessageCount > 0 else { - return ([], [], ([], 0, 0, false)) + return ([], [], PollResult([])) } /// Otherwise process the messages and add them to the queue for handling @@ -275,7 +228,7 @@ public class SwarmPoller: SwarmPollerType & PollerType { }() guard lastHashes.isEmpty || Set(lastHashes) == lastHashesAfterFetch else { - return ([], [], ([], 0, 0, false)) + return ([], [], PollResult([])) } /// Since the hashes are still accurate we can now process the messages @@ -398,7 +351,7 @@ public class SwarmPoller: SwarmPollerType & PollerType { /// If we don't want to store the messages then no need to continue (don't want to create message receive jobs or mess with cached hashes) guard shouldStoreMessages && !forceSynchronousProcessing else { finalProcessedMessages += allProcessedMessages - return ([], [], (finalProcessedMessages, rawMessageCount, messageCount, hadValidHashUpdate)) + return ([], [], PollResult(finalProcessedMessages, rawMessageCount, messageCount, hadValidHashUpdate)) } /// Add a job to process the config messages first @@ -491,6 +444,6 @@ public class SwarmPoller: SwarmPollerType & PollerType { catch { Log.error(cat, "Failed to handle potential invalid/deleted hashes due to error: \(error).") } } - return (configMessageJobs, standardMessageJobs, (finalProcessedMessages, rawMessageCount, messageCount, hadValidHashUpdate)) + return (configMessageJobs, standardMessageJobs, PollResult(finalProcessedMessages, rawMessageCount, messageCount, hadValidHashUpdate)) } } diff --git a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift index 3486db29e6..5a9e2dba3b 100644 --- a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift +++ b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift @@ -10,7 +10,7 @@ import SessionNetworkingKit public extension Singleton { static let typingIndicators: SingletonConfig = Dependencies.create( identifier: "typingIndicators", - createInstance: { dependencies in TypingIndicators(using: dependencies) } + createInstance: { dependencies, _ in TypingIndicators(using: dependencies) } ) } @@ -60,11 +60,12 @@ public actor TypingIndicators { }) else { return } + let currentTimestampMs: Int64 = await dependencies.networkOffsetTimestampMs() let newIndicator: Indicator = Indicator( threadId: threadId, threadVariant: threadVariant, direction: direction, - timestampMs: (timestampMs ?? dependencies[cache: .snodeAPI].currentOffsetTimestampMs()) + timestampMs: (timestampMs ?? currentTimestampMs) ) switch direction { diff --git a/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift b/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift index 67fd9318a2..f992d7746c 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift @@ -352,7 +352,7 @@ public extension MessageViewModel.DeletionBehaviours { } }, completion: { result in - deletionBehaviours = try? result.successOrThrow() + deletionBehaviours = try? result.get() semaphore.signal() } ) @@ -416,7 +416,7 @@ public extension MessageViewModel.DeletionBehaviours { .chunked(by: Network.BatchRequest.childRequestLimit) .map { unsendRequestChunk in .preparedRequest( - try Network.SnodeAPI.preparedBatch( + try Network.StorageServer.preparedBatch( requests: unsendRequestChunk, requireAllBatchResponses: false, swarmPublicKey: threadData.threadId, @@ -427,11 +427,10 @@ public extension MessageViewModel.DeletionBehaviours { ) .appending(serverHashes.isEmpty ? nil : .preparedRequest( - try Network.SnodeAPI.preparedDeleteMessages( + try Network.StorageServer.preparedDeleteMessages( serverHashes: Array(serverHashes), requireSuccessfulDeletion: false, authMethod: try Authentication.with( - db, swarmPublicKey: threadData.currentUserSessionId, using: dependencies ), @@ -497,7 +496,7 @@ public extension MessageViewModel.DeletionBehaviours { .chunked(by: Network.BatchRequest.childRequestLimit) .map { unsendRequestChunk in .preparedRequest( - try Network.SnodeAPI.preparedBatch( + try Network.StorageServer.preparedBatch( requests: unsendRequestChunk, requireAllBatchResponses: false, swarmPublicKey: threadData.threadId, @@ -537,7 +536,7 @@ public extension MessageViewModel.DeletionBehaviours { message: GroupUpdateDeleteMemberContentMessage( memberSessionIds: [], messageHashes: Array(serverHashes), - sentTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs(), + sentTimestampMs: dependencies.networkOffsetTimestampMs(), authMethod: nil, using: dependencies ), @@ -593,7 +592,7 @@ public extension MessageViewModel.DeletionBehaviours { message: GroupUpdateDeleteMemberContentMessage( memberSessionIds: [], messageHashes: Array(serverHashes), - sentTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs(), + sentTimestampMs: dependencies.networkOffsetTimestampMs(), authMethod: Authentication.groupAdmin( groupSessionId: SessionId(.group, hex: threadData.threadId), ed25519SecretKey: ed25519SecretKey @@ -617,7 +616,7 @@ public extension MessageViewModel.DeletionBehaviours { ) ) .appending(serverHashes.isEmpty ? nil : - .preparedRequest(try Network.SnodeAPI + .preparedRequest(try Network.StorageServer .preparedDeleteMessages( serverHashes: Array(serverHashes), requireSuccessfulDeletion: false, diff --git a/SessionMessagingKit/Utilities/AppReadiness.swift b/SessionMessagingKit/Utilities/AppReadiness.swift index 3de1754239..907131de0f 100644 --- a/SessionMessagingKit/Utilities/AppReadiness.swift +++ b/SessionMessagingKit/Utilities/AppReadiness.swift @@ -8,7 +8,7 @@ import SessionUtilitiesKit public extension Singleton { static let appReadiness: SingletonConfig = Dependencies.create( identifier: "appReadiness", - createInstance: { _ in AppReadiness() } + createInstance: { _, _ in AppReadiness() } ) } diff --git a/SessionMessagingKit/Utilities/AppSetup.swift b/SessionMessagingKit/Utilities/AppSetup.swift new file mode 100644 index 0000000000..742c8ea778 --- /dev/null +++ b/SessionMessagingKit/Utilities/AppSetup.swift @@ -0,0 +1,109 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import AVFoundation +import GRDB +import SessionUIKit +import SessionNetworkingKit +import SessionUtilitiesKit + +public enum AppSetup { + public static func performSetup(using dependencies: Dependencies) async throws { + /// Order matters here. + /// + /// All of these "singletons" should have any dependencies used in their + /// initializers injected. + dependencies[singleton: .backgroundTaskManager].startObservingNotifications() + + /// Attachments can be stored to NSTemporaryDirectory() + /// If you receive a media message while the device is locked, the download will fail if + /// the temporary directory is NSFileProtectionComplete + try? dependencies[singleton: .fileManager].protectFileOrFolder( + at: NSTemporaryDirectory(), + fileProtectionType: .completeUntilFirstUserAuthentication + ) + + SessionEnvironment.shared = SessionEnvironment( + audioSession: OWSAudioSession(), + proximityMonitoringManager: OWSProximityMonitoringManagerImpl(using: dependencies), + windowManager: OWSWindowManager(default: ()) + ) + + dependencies.warm(cache: .appVersion) + dependencies.warm(singleton: .network) + + /// Configure the different targets + SNUtilitiesKit.configure( + networkMaxFileSize: Network.maxFileSize, + maxValidImageDimention: ImageDataManager.DataSource.maxValidDimension, + using: dependencies + ) + SNMessagingKit.configure(using: dependencies) + } + + public static func performDatabaseMigrations( + using dependencies: Dependencies, + migrationProgressChanged: ((CGFloat, TimeInterval) -> ())? = nil + ) async throws { + /// Run the migrations + try await dependencies[singleton: .storage].perform( + migrations: SNMessagingKit.migrations, + onProgressUpdate: migrationProgressChanged + ) + } + + public static func postMigrationSetup(using dependencies: Dependencies) async throws { + /// Now that the migrations are complete there are a few more states which need to be setup + typealias UserInfo = ( + sessionId: SessionId, + ed25519SecretKey: [UInt8], + dumpSessionIds: Set, + unreadCount: Int? + ) + let userInfo: UserInfo? = try? await dependencies[singleton: .storage].readAsync { db -> UserInfo? in + guard let ed25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { + return nil + } + + /// Cache the users session id so we don't need to fetch it from the database every time + dependencies.mutate(cache: .general) { + $0.setSecretKey(ed25519SecretKey: ed25519KeyPair.secretKey) + } + + /// Load the `libSession` state into memory + let userSessionId: SessionId = dependencies[cache: .general].sessionId + let cache: LibSession.Cache = LibSession.Cache( + userSessionId: userSessionId, + using: dependencies + ) + try? cache.loadState(db, userEd25519SecretKey: ed25519KeyPair.secretKey) + dependencies.set(cache: .libSession, to: cache) + + return ( + userSessionId, + ed25519KeyPair.secretKey, + cache.allDumpSessionIds, + try? Interaction.fetchAppBadgeUnreadCount(db, using: dependencies) + ) + } + + /// Save the `UserMetadata` and replicate `ConfigDump` data if needed + if let userInfo: UserInfo = userInfo { + try? dependencies[singleton: .extensionHelper].saveUserMetadata( + sessionId: userInfo.sessionId, + ed25519SecretKey: userInfo.ed25519SecretKey, + unreadCount: userInfo.unreadCount + ) + + Task.detached(priority: .medium) { + await dependencies[singleton: .extensionHelper].replicateAllConfigDumpsIfNeeded( + userSessionId: userInfo.sessionId, + allDumpSessionIds: userInfo.dumpSessionIds + ) + } + } + + /// Ensure any recurring jobs are properly scheduled + dependencies[singleton: .jobRunner].scheduleRecurringJobsIfNeeded() + } +} diff --git a/SessionMessagingKit/Utilities/AttachmentManager.swift b/SessionMessagingKit/Utilities/AttachmentManager.swift index 394cb1fef0..cbcd33f1e9 100644 --- a/SessionMessagingKit/Utilities/AttachmentManager.swift +++ b/SessionMessagingKit/Utilities/AttachmentManager.swift @@ -15,7 +15,7 @@ import SessionUtilitiesKit public extension Singleton { static let attachmentManager: SingletonConfig = Dependencies.create( identifier: "attachmentManager", - createInstance: { dependencies in AttachmentManager(using: dependencies) } + createInstance: { dependencies, _ in AttachmentManager(using: dependencies) } ) } @@ -171,13 +171,17 @@ public final class AttachmentManager: Sendable, ThumbnailManager { public func existingThumbnailImage(url: URL, size: ImageDataManager.ThumbnailSize) -> UIImage? { guard let thumbnailUrl: URL = try? thumbnailUrl(for: url, size: size) else { return nil } - return UIImage(contentsOfFile: thumbnailUrl.path) + return dependencies[singleton: .fileManager].imageContents(atPath: thumbnailUrl.path) } public func saveThumbnail(data: Data, size: ImageDataManager.ThumbnailSize, url: URL) { guard let thumbnailUrl: URL = try? thumbnailUrl(for: url, size: size) else { return } - try? data.write(to: thumbnailUrl) + try? dependencies[singleton: .fileManager].write( + data: data, + to: thumbnailUrl, + options: .atomic + ) } // MARK: - Validity diff --git a/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift index d51fb9fd78..17a869456a 100644 --- a/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift @@ -82,11 +82,6 @@ public extension Authentication { // MARK: - Convenience -fileprivate struct GroupAuthData: Codable, FetchableRecord { - let groupIdentityPrivateKey: Data? - let authData: Data? -} - public extension Authentication.community { init(info: LibSession.OpenGroupCapabilityInfo, forceBlinded: Bool = false) { self.init( @@ -143,12 +138,11 @@ public extension Authentication { return Authentication.community(info: info, forceBlinded: forceBlinded) - default: return try Authentication.with(db, swarmPublicKey: threadId, using: dependencies) + default: return try Authentication.with(swarmPublicKey: threadId, using: dependencies) } } static func with( - _ db: ObservingDatabase, swarmPublicKey: String, using dependencies: Dependencies ) throws -> AuthenticationMethod { @@ -158,7 +152,7 @@ public extension Authentication { let userEdKeyPair: KeyPair = dependencies[singleton: .crypto].generate( .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) ) - else { throw SnodeAPIError.noKeyPair } + else { throw CryptoError.keyGenerationFailed } return Authentication.standard( sessionId: sessionId, @@ -167,20 +161,18 @@ public extension Authentication { ) case .some(let sessionId) where sessionId.prefix == .group: - let authData: GroupAuthData? = try? ClosedGroup - .filter(id: swarmPublicKey) - .select(.authData, .groupIdentityPrivateKey) - .asRequest(of: GroupAuthData.self) - .fetchOne(db) + let authData: GroupAuthData = dependencies.mutate(cache: .libSession) { libSession in + libSession.authData(groupSessionId: SessionId(.group, hex: swarmPublicKey)) + } - switch (authData?.groupIdentityPrivateKey, authData?.authData) { - case (.some(let privateKey), _): + switch (authData.groupIdentityPrivateKey, authData.authData) { + case (.some(let privateKey), _) where !privateKey.isEmpty: return Authentication.groupAdmin( groupSessionId: sessionId, ed25519SecretKey: Array(privateKey) ) - case (_, .some(let authData)): + case (_, .some(let authData)) where !authData.isEmpty: return Authentication.groupMember( groupSessionId: sessionId, authData: authData diff --git a/SessionMessagingKit/Utilities/DeviceSleepManager.swift b/SessionMessagingKit/Utilities/DeviceSleepManager.swift index bb5539072a..c408103ec6 100644 --- a/SessionMessagingKit/Utilities/DeviceSleepManager.swift +++ b/SessionMessagingKit/Utilities/DeviceSleepManager.swift @@ -10,7 +10,7 @@ import SessionUtilitiesKit public extension Singleton { static let deviceSleepManager: SingletonConfig = Dependencies.create( identifier: "deviceSleepManager", - createInstance: { dependencies in DeviceSleepManager(using: dependencies) } + createInstance: { dependencies, _ in DeviceSleepManager(using: dependencies) } ) } diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index 6663fcf0f8..8168939f42 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -12,7 +12,7 @@ import SessionUtilitiesKit public extension Singleton { static let displayPictureManager: SingletonConfig = Dependencies.create( identifier: "displayPictureManager", - createInstance: { dependencies in DisplayPictureManager(using: dependencies) } + createInstance: { dependencies, _ in DisplayPictureManager(using: dependencies) } ) } @@ -261,7 +261,13 @@ public class DisplayPictureManager { let temporaryFilePath: String = dependencies[singleton: .fileManager].temporaryFilePath(fileExtension: fileExtension) // Write the avatar to disk - do { try finalImageData.write(to: URL(fileURLWithPath: temporaryFilePath), options: [.atomic]) } + do { + try dependencies[singleton: .fileManager].write( + data: finalImageData, + to: URL(fileURLWithPath: temporaryFilePath), + options: .atomic + ) + } catch { Log.error(.displayPictureManager, "Updating service with profile failed.") throw DisplayPictureError.writeFailed @@ -279,9 +285,9 @@ public class DisplayPictureManager { // Upload the avatar to the FileServer guard - let preparedUpload: Network.PreparedRequest = try? Network.preparedUpload( + let preparedUpload: Network.PreparedRequest = try? Network.FileServer.preparedUpload( data: encryptedData, - requestAndPathBuildTimeout: Network.fileUploadTimeout, + overallTimeout: Network.fileUploadTimeout, using: dependencies ) else { @@ -366,7 +372,7 @@ public extension DisplayPictureManager { public extension Cache { static let displayPicture: CacheConfig = Dependencies.create( identifier: "displayPicture", - createInstance: { _ in DisplayPictureManager.Cache() }, + createInstance: { _, _ in DisplayPictureManager.Cache() }, mutableInstance: { $0 }, immutableInstance: { $0 } ) diff --git a/SessionMessagingKit/Utilities/ExtensionHelper.swift b/SessionMessagingKit/Utilities/ExtensionHelper.swift index 0075b91d78..3a681085f8 100644 --- a/SessionMessagingKit/Utilities/ExtensionHelper.swift +++ b/SessionMessagingKit/Utilities/ExtensionHelper.swift @@ -10,7 +10,7 @@ public extension Singleton { static let extensionHelper: SingletonConfig = Dependencies.create( identifier: "extensionHelper", // TODO: [Database Relocation] Might be good to add a mechanism to check if we can access the AppGroup and, if not, create a NoopExtensionHelper (to better support side-loading the app) - createInstance: { dependencies in ExtensionHelper(using: dependencies) } + createInstance: { dependencies, _ in ExtensionHelper(using: dependencies) } ) } @@ -44,7 +44,6 @@ public class ExtensionHelper: ExtensionHelperType { // stringlint:ignore_stop private let dependencies: Dependencies - private lazy var messagesLoadedStream: CurrentValueAsyncStream = CurrentValueAsyncStream(false) // MARK: - Initialization @@ -337,7 +336,7 @@ public class ExtensionHelper: ExtensionHelperType { public func replicateAllConfigDumpsIfNeeded( userSessionId: SessionId, allDumpSessionIds: Set - ) { + ) async { struct ReplicatedDumpInfo { struct DumpState { let variant: ConfigDump.Variant @@ -402,37 +401,29 @@ public class ExtensionHelper: ExtensionHelperType { let fetchTimestamp: TimeInterval = dependencies.dateNow.timeIntervalSince1970 let missingDumpIds: Set = Set(missingReplicatedDumpInfo.map { $0.sessionId.hexString }) - dependencies[singleton: .storage].readAsync( - retrieve: { db in - try ConfigDump - .filter(missingDumpIds.contains(ConfigDump.Columns.publicKey)) - .fetchAll(db) - }, - completion: { [weak self] result in - guard - let self = self, - let dumps: [ConfigDump] = try? result.successOrThrow() - else { return } - - /// Persist each dump to disk (if there isn't already one there, or it was updated before the dump was fetched from - /// the database) - /// - /// **Note:** Because it's likely that this function runs in the background it's possible that another thread could trigger - /// a config update which would result in the dump getting replicated - if that occurs then we don't want to override what - /// is likely a newer dump, but do need to replace what might be an invalid dump file (hence the timestamp check) - dumps.forEach { dump in - let dumpLastUpdated: TimeInterval = self.lastUpdatedTimestamp( - for: dump.sessionId, - variant: dump.variant - ) - - self.replicate( - dump: dump, - replaceExisting: (dumpLastUpdated < fetchTimestamp) - ) - } - } - ) + let dumps: [ConfigDump] = ((try? await dependencies[singleton: .storage].readAsync { db in + try ConfigDump + .filter(missingDumpIds.contains(ConfigDump.Columns.publicKey)) + .fetchAll(db) + }) ?? []) + + /// Persist each dump to disk (if there isn't already one there, or it was updated before the dump was fetched from + /// the database) + /// + /// **Note:** Because it's likely that this function runs in the background it's possible that another thread could trigger + /// a config update which would result in the dump getting replicated - if that occurs then we don't want to override what + /// is likely a newer dump, but do need to replace what might be an invalid dump file (hence the timestamp check) + dumps.forEach { dump in + let dumpLastUpdated: TimeInterval = lastUpdatedTimestamp( + for: dump.sessionId, + variant: dump.variant + ) + + replicate( + dump: dump, + replaceExisting: (dumpLastUpdated < fetchTimestamp) + ) + } } public func refreshDumpModifiedDate(sessionId: SessionId, variant: ConfigDump.Variant) { @@ -691,13 +682,13 @@ public class ExtensionHelper: ExtensionHelperType { } public func saveMessage( - _ message: SnodeReceivedMessage?, + _ message: Network.StorageServer.Message?, threadId: String, isUnread: Bool, isMessageRequest: Bool ) throws { guard - let message: SnodeReceivedMessage = message, + let message: Network.StorageServer.Message = message, let messageAsData: Data = try? JSONEncoder(using: dependencies).encode(message), let targetPath: String = { switch (message.namespace.isConfigNamespace, isUnread) { @@ -727,18 +718,10 @@ public class ExtensionHelper: ExtensionHelperType { try write(data: messageAsData, to: targetPath) } - public func willLoadMessages() { - /// We want to synchronously reset the `messagesLoadedStream` value to `false` - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - Task { - await messagesLoadedStream.send(false) - semaphore.signal() - } - semaphore.wait() - } - public func loadMessages() async throws { - typealias MessageData = (namespace: Network.SnodeAPI.Namespace, messages: [SnodeReceivedMessage], lastHash: String?) + typealias MessageData = (namespace: Network.StorageServer.Namespace, messages: [Network.StorageServer.Message], lastHash: String?) + + try Task.checkCancellation() /// Retrieve all conversation file paths /// @@ -763,8 +746,14 @@ public class ExtensionHelper: ExtensionHelperType { try await dependencies[singleton: .storage].writeAsync { [weak self, dependencies] db in guard let this = self else { return } + /// Stop processing if the task got cancelled + guard !Task.isCancelled else { return } + /// Process each conversation individually conversationHashes.forEach { conversationHash in + /// Stop processing if the task got cancelled + guard !Task.isCancelled else { return } + /// Retrieve and process any config messages /// /// For config message changes we want to load in every config for a conversation and process them all at once @@ -781,15 +770,15 @@ public class ExtensionHelper: ExtensionHelperType { do { let sortedMessages: [MessageData] = try configMessageHashes - .reduce([Network.SnodeAPI.Namespace: [SnodeReceivedMessage]]()) { (result: [Network.SnodeAPI.Namespace: [SnodeReceivedMessage]], hash: String) in + .reduce([Network.StorageServer.Namespace: [Network.StorageServer.Message]]()) { (result: [Network.StorageServer.Namespace: [Network.StorageServer.Message]], hash: String) in let path: String = URL(fileURLWithPath: this.conversationsPath) .appendingPathComponent(conversationHash) .appendingPathComponent(this.conversationConfigDir) .appendingPathComponent(hash) .path let plaintext: Data = try this.read(from: path) - let message: SnodeReceivedMessage = try JSONDecoder(using: dependencies) - .decode(SnodeReceivedMessage.self, from: plaintext) + let message: Network.StorageServer.Message = try JSONDecoder(using: dependencies) + .decode(Network.StorageServer.Message.self, from: plaintext) return result.appending(message, toArrayOn: message.namespace) } @@ -866,11 +855,11 @@ public class ExtensionHelper: ExtensionHelperType { ) let sortedMessages: [MessageData] = allMessagePaths - .reduce([Network.SnodeAPI.Namespace: [SnodeReceivedMessage]]()) { (result: [Network.SnodeAPI.Namespace: [SnodeReceivedMessage]], path: String) in + .reduce([Network.StorageServer.Namespace: [Network.StorageServer.Message]]()) { (result: [Network.StorageServer.Namespace: [Network.StorageServer.Message]], path: String) in do { let plaintext: Data = try this.read(from: path) - let message: SnodeReceivedMessage = try JSONDecoder(using: dependencies) - .decode(SnodeReceivedMessage.self, from: plaintext) + let message: Network.StorageServer.Message = try JSONDecoder(using: dependencies) + .decode(Network.StorageServer.Message.self, from: plaintext) return result.appending(message, toArrayOn: message.namespace) } @@ -926,14 +915,12 @@ public class ExtensionHelper: ExtensionHelperType { } Log.info(.cat, "Finished: Successfully processed \(successStandardCount)/\(successStandardCount + failureStandardCount) standard messages, \(successConfigCount)/\(failureConfigCount) config messages.") - await messagesLoadedStream.send(true) } @discardableResult public func waitUntilMessagesAreLoaded(timeout: DispatchTimeInterval) async -> Bool { return await withThrowingTaskGroup(of: Bool.self) { [weak self] group in group.addTask { - guard await self?.messagesLoadedStream.currentValue != true else { return true } - _ = await self?.messagesLoadedStream.stream.first { $0 == true } + try? await self?.loadMessages() return true } group.addTask { @@ -1011,7 +998,7 @@ public protocol ExtensionHelperType { func lastUpdatedTimestamp(for sessionId: SessionId, variant: ConfigDump.Variant) -> TimeInterval func replicate(dump: ConfigDump?, replaceExisting: Bool) - func replicateAllConfigDumpsIfNeeded(userSessionId: SessionId, allDumpSessionIds: Set) + func replicateAllConfigDumpsIfNeeded(userSessionId: SessionId, allDumpSessionIds: Set) async func refreshDumpModifiedDate(sessionId: SessionId, variant: ConfigDump.Variant) func loadUserConfigState( into cache: LibSessionCacheType, @@ -1036,12 +1023,11 @@ public protocol ExtensionHelperType { func unreadMessageCount() -> Int? func saveMessage( - _ message: SnodeReceivedMessage?, + _ message: Network.StorageServer.Message?, threadId: String, isUnread: Bool, isMessageRequest: Bool ) throws - func willLoadMessages() func loadMessages() async throws @discardableResult func waitUntilMessagesAreLoaded(timeout: DispatchTimeInterval) async -> Bool } diff --git a/SessionMessagingKit/Utilities/ImageDataManager+Singleton.swift b/SessionMessagingKit/Utilities/ImageDataManager+Singleton.swift index b9e1c8f207..7f7993ba35 100644 --- a/SessionMessagingKit/Utilities/ImageDataManager+Singleton.swift +++ b/SessionMessagingKit/Utilities/ImageDataManager+Singleton.swift @@ -9,6 +9,6 @@ import SessionUtilitiesKit public extension Singleton { static let imageDataManager: SingletonConfig = Dependencies.create( identifier: "imageDataManager", - createInstance: { _ in ImageDataManager() } + createInstance: { _, _ in ImageDataManager() } ) } diff --git a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift index 969a79da3b..01b9897ee6 100644 --- a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift @@ -51,6 +51,7 @@ public extension ObservableKey { static func conversationDeleted(_ id: String) -> ObservableKey { ObservableKey("conversationDeleted-\(id)", .conversationDeleted) } + static let anyConversationDeleted: ObservableKey = "anyConversationDeleted" // MARK: - Messages @@ -240,7 +241,9 @@ public extension ObservingDatabase { switch type { case .created: addEvent(ObservedEvent(key: .conversationCreated, value: event)) case .updated: addEvent(ObservedEvent(key: .conversationUpdated(id), value: event)) - case .deleted: addEvent(ObservedEvent(key: .conversationDeleted(id), value: event)) + case .deleted: + addEvent(ObservedEvent(key: .conversationDeleted(id), value: event)) + addEvent(ObservedEvent(key: .anyConversationDeleted, value: event)) } } } diff --git a/SessionMessagingKit/Utilities/Preferences+Sound.swift b/SessionMessagingKit/Utilities/Preferences+Sound.swift index 566d82414d..5576572a90 100644 --- a/SessionMessagingKit/Utilities/Preferences+Sound.swift +++ b/SessionMessagingKit/Utilities/Preferences+Sound.swift @@ -15,7 +15,7 @@ private extension Log.Category { // MARK: - Preferences public extension Preferences { - enum Sound: Int, Sendable, Codable, Differentiable, ThreadSafeType { + enum Sound: Int, Sendable, Codable, CaseIterable, Equatable, Differentiable, ThreadSafeType { public static var defaultiOSIncomingRingtone: Sound = .opening public static var defaultNotificationSound: Sound = .note diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 3fd1d96279..9ecd18d950 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -107,7 +107,7 @@ public extension KeyValueStore.IntKey { } public enum Preferences { - public struct NotificationSettings { + public struct NotificationSettings: Equatable { public let previewType: Preferences.NotificationPreviewType public let sound: Preferences.Sound public let mentionsOnly: Bool diff --git a/SessionMessagingKit/Utilities/SNProtoEnvelope+Conversion.swift b/SessionMessagingKit/Utilities/SNProtoEnvelope+Conversion.swift index ed46bc066c..43589a5aec 100644 --- a/SessionMessagingKit/Utilities/SNProtoEnvelope+Conversion.swift +++ b/SessionMessagingKit/Utilities/SNProtoEnvelope+Conversion.swift @@ -5,7 +5,7 @@ import SessionNetworkingKit import SessionUtilitiesKit public extension SNProtoEnvelope { - static func from(_ message: SnodeReceivedMessage) -> SNProtoEnvelope? { + static func from(_ message: Network.StorageServer.Message) -> SNProtoEnvelope? { guard let result = try? MessageWrapper.unwrap(data: message.data) else { Log.error(.messageReceiver, "Failed to unwrap data for message: \(String(reflecting: message)).") return nil diff --git a/SessionMessagingKit/Utilities/SessionProState.swift b/SessionMessagingKit/Utilities/SessionProState.swift index feb5e96ffe..373fde0d47 100644 --- a/SessionMessagingKit/Utilities/SessionProState.swift +++ b/SessionMessagingKit/Utilities/SessionProState.swift @@ -9,7 +9,7 @@ import Combine public extension Singleton { static let sessionProState: SingletonConfig = Dependencies.create( identifier: "sessionProState", - createInstance: { dependencies in SessionProState(using: dependencies) } + createInstance: { dependencies, _ in SessionProState(using: dependencies) } ) } diff --git a/SessionMessagingKit/Utilities/Threading+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/Threading+SessionMessagingKit.swift index 56f0f67cbd..41c26e5ee8 100644 --- a/SessionMessagingKit/Utilities/Threading+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/Threading+SessionMessagingKit.swift @@ -3,6 +3,4 @@ import SessionUtilitiesKit public extension Threading { static let pollerQueue = DispatchQueue(label: "SessionMessagingKit.pollerQueue") - static let groupPollerQueue = DispatchQueue(label: "SessionMessagingKit.groupPollerQueue") - static let communityPollerQueue = DispatchQueue(label: "SessionMessagingKit.communityPollerQueue") } diff --git a/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift b/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift index 7de5f146de..754ee651d2 100644 --- a/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift +++ b/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift @@ -2,24 +2,27 @@ import Foundation import SessionUtilitiesKit +import TestUtilities import Quick import Nimble @testable import SessionMessagingKit -class CryptoSMKSpec: QuickSpec { +class CryptoSMKSpec: AsyncSpec { override class func spec() { // MARK: Configuration @TestState var dependencies: TestDependencies! = TestDependencies() - @TestState(singleton: .crypto, in: dependencies) var crypto: Crypto! = Crypto(using: dependencies) - @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( - initialSetup: { cache in - cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) - cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) - } - ) + @TestState var crypto: Crypto! = Crypto(using: dependencies) + @TestState var mockGeneralCache: MockGeneralCache! = .create(using: dependencies) + + beforeEach { + dependencies.set(singleton: .crypto, to: crypto) + + try await mockGeneralCache.defaultInitialSetup() + dependencies.set(cache: .general, to: mockGeneralCache) + } // MARK: - Crypto for SessionMessagingKit describe("Crypto for SessionMessagingKit") { @@ -84,7 +87,7 @@ class CryptoSMKSpec: QuickSpec { // MARK: ---- throws an error if there is no ed25519 keyPair it("throws an error if there is no ed25519 keyPair") { - mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) + try await mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) expect { try crypto.tryGenerate( @@ -119,7 +122,7 @@ class CryptoSMKSpec: QuickSpec { // MARK: ---- throws an error if there is no ed25519 keyPair it("throws an error if there is no ed25519 keyPair") { - mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) + try await mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) expect { try crypto.tryGenerate( diff --git a/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift b/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift index 22fb802b43..d7ffdc98b7 100644 --- a/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift +++ b/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift @@ -4,6 +4,7 @@ import Foundation import GRDB import SessionNetworkingKit import SessionUtilitiesKit +import TestUtilities import Quick import Nimble @@ -16,28 +17,11 @@ class MessageDeduplicationSpec: AsyncSpec { @TestState var dependencies: TestDependencies! = TestDependencies() - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + @TestState var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), - migrations: SNMessagingKit.migrations, using: dependencies ) - @TestState(singleton: .extensionHelper, in: dependencies) var mockExtensionHelper: MockExtensionHelper! = MockExtensionHelper( - initialSetup: { helper in - helper.when { $0.deleteCache() }.thenReturn(()) - helper - .when { $0.dedupeRecordExists(threadId: .any, uniqueIdentifier: .any) } - .thenReturn(false) - helper - .when { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) } - .thenReturn(()) - helper - .when { try $0.removeDedupeRecord(threadId: .any, uniqueIdentifier: .any) } - .thenReturn(()) - helper - .when { try $0.upsertLastClearedRecord(threadId: .any) } - .thenReturn(()) - } - ) + @TestState var mockExtensionHelper: MockExtensionHelper! = .create(using: dependencies) @TestState var mockMessage: Message! = { let result: ReadReceipt = ReadReceipt(timestamps: [1]) result.sentTimestampMs = 12345678901234 @@ -45,6 +29,26 @@ class MessageDeduplicationSpec: AsyncSpec { return result }() + beforeEach { + try await mockStorage.perform(migrations: SNMessagingKit.migrations) + dependencies.set(singleton: .storage, to: mockStorage) + + try await mockExtensionHelper.when { $0.deleteCache() }.thenReturn(()) + try await mockExtensionHelper + .when { $0.dedupeRecordExists(threadId: .any, uniqueIdentifier: .any) } + .thenReturn(false) + try await mockExtensionHelper + .when { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) } + .thenReturn(()) + try await mockExtensionHelper + .when { try $0.removeDedupeRecord(threadId: .any, uniqueIdentifier: .any) } + .thenReturn(()) + try await mockExtensionHelper + .when { try $0.upsertLastClearedRecord(threadId: .any) } + .thenReturn(()) + dependencies.set(singleton: .extensionHelper, to: mockExtensionHelper) + } + // MARK: - MessageDeduplication - Inserting describe("MessageDeduplication") { // MARK: -- when inserting @@ -66,7 +70,7 @@ class MessageDeduplicationSpec: AsyncSpec { }.toNot(throwError()) } - let expectedTimestamp: Int64 = (1234567890 + ((SnodeReceivedMessage.serverClockToleranceMs * 2) / 1000)) + let expectedTimestamp: Int64 = (1234567890 + ((Network.StorageServer.Message.serverClockToleranceMs * 2) / 1000)) let records: [MessageDeduplication]? = mockStorage .read { db in try MessageDeduplication.fetchAll(db) } expect(records?.count).to(equal(1)) @@ -74,9 +78,9 @@ class MessageDeduplicationSpec: AsyncSpec { expect(records?.first?.uniqueIdentifier).to(equal("testId")) expect(records?.first?.expirationTimestampSeconds).to(equal(expectedTimestamp)) expect(records?.first?.shouldDeleteWhenDeletingThread).to(beFalse()) - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") - }) + await mockExtensionHelper + .verify { try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") } + .wasCalled(exactly: 1) } // MARK: ---- checks that it is not a duplicate record @@ -96,9 +100,9 @@ class MessageDeduplicationSpec: AsyncSpec { }.toNot(throwError()) } - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.dedupeRecordExists(threadId: "testThreadId", uniqueIdentifier: "testId") - }) + await mockExtensionHelper + .verify { $0.dedupeRecordExists(threadId: "testThreadId", uniqueIdentifier: "testId") } + .wasCalled(exactly: 1) } // MARK: ---- creates a legacy record if needed @@ -119,9 +123,9 @@ class MessageDeduplicationSpec: AsyncSpec { }.toNot(throwError()) } - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.dedupeRecordExists(threadId: "testThreadId", uniqueIdentifier: "testLegacyId") - }) + await mockExtensionHelper + .verify { $0.dedupeRecordExists(threadId: "testThreadId", uniqueIdentifier: "testLegacyId") } + .wasCalled(exactly: 1) } // MARK: ---- sets the shouldDeleteWhenDeletingThread flag correctly @@ -309,15 +313,17 @@ class MessageDeduplicationSpec: AsyncSpec { ) }.toNot(throwError()) } - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") - }) - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.createDedupeRecord( - threadId: "testThreadId", - uniqueIdentifier: "LegacyRecord-1-12345678901234" - ) - }) + await mockExtensionHelper + .verify { try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") } + .wasCalled(exactly: 1) + await mockExtensionHelper + .verify { + try $0.createDedupeRecord( + threadId: "testThreadId", + uniqueIdentifier: "LegacyRecord-1-12345678901234" + ) + } + .wasCalled(exactly: 1) } // MARK: ---- does not create records for config ProcessedMessages @@ -343,14 +349,14 @@ class MessageDeduplicationSpec: AsyncSpec { let records: [MessageDeduplication]? = mockStorage .read { db in try MessageDeduplication.fetchAll(db) } expect(records).to(beEmpty()) - expect(mockExtensionHelper).toNot(call { - try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") - }) + await mockExtensionHelper + .verify { try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") } + .wasNotCalled() } // MARK: ---- throws if the message is a duplicate it("throws if the message is a duplicate") { - mockExtensionHelper + try await mockExtensionHelper .when { $0.dedupeRecordExists(threadId: .any, uniqueIdentifier: .any) } .thenReturn(true) @@ -373,7 +379,7 @@ class MessageDeduplicationSpec: AsyncSpec { // MARK: ---- throws if the message is a legacy duplicate it("throws if the message is a legacy duplicate") { - mockExtensionHelper + try await mockExtensionHelper .when { $0.dedupeRecordExists( threadId: "testThreadId", @@ -381,7 +387,7 @@ class MessageDeduplicationSpec: AsyncSpec { ) } .thenReturn(false) - mockExtensionHelper + try await mockExtensionHelper .when { $0.dedupeRecordExists( threadId: "testThreadId", @@ -409,7 +415,7 @@ class MessageDeduplicationSpec: AsyncSpec { // MARK: ---- throws if it fails to create the dedupe file it("throws if it fails to create the dedupe file") { - mockExtensionHelper + try await mockExtensionHelper .when { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) } .thenThrow(TestError.mock) @@ -432,7 +438,7 @@ class MessageDeduplicationSpec: AsyncSpec { // MARK: ---- throws if it fails to create the legacy dedupe file it("throws if it fails to create the legacy dedupe file") { - mockExtensionHelper + try await mockExtensionHelper .when { try $0.createDedupeRecord( threadId: "testThreadId", @@ -488,9 +494,11 @@ class MessageDeduplicationSpec: AsyncSpec { expect(records?.first?.uniqueIdentifier).to(equal("12345-preOffer")) expect(records?.first?.expirationTimestampSeconds).to(equal(1234567891)) expect(records?.first?.shouldDeleteWhenDeletingThread).to(beFalse()) - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "12345-preOffer") - }) + await mockExtensionHelper + .verify { + try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "12345-preOffer") + } + .wasCalled(exactly: 1) } // MARK: ---- inserts a generic record correctly @@ -520,9 +528,11 @@ class MessageDeduplicationSpec: AsyncSpec { expect(records?.first?.uniqueIdentifier).to(equal("12345")) expect(records?.first?.expirationTimestampSeconds).to(equal(1234567891)) expect(records?.first?.shouldDeleteWhenDeletingThread).to(beFalse()) - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "12345") - }) + await mockExtensionHelper + .verify { + try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "12345") + } + .wasCalled(exactly: 1) } // MARK: ---- does nothing if no call message is provided @@ -543,9 +553,9 @@ class MessageDeduplicationSpec: AsyncSpec { let records: [MessageDeduplication]? = mockStorage .read { db in try MessageDeduplication.fetchAll(db) } expect(records?.count).to(equal(0)) - expect(mockExtensionHelper).toNot(call { - try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) - }) + await mockExtensionHelper + .verify { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) } + .wasNotCalled() } } } @@ -578,9 +588,11 @@ class MessageDeduplicationSpec: AsyncSpec { await expect(mockStorage .read { db in try MessageDeduplication.fetchAll(db) }) .toEventually(beEmpty()) - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.removeDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") - }) + await mockExtensionHelper + .verify { + try $0.removeDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") + } + .wasCalled(exactly: 1) } // MARK: ---- upserts the last cleared record @@ -607,9 +619,9 @@ class MessageDeduplicationSpec: AsyncSpec { await expect(mockStorage .read { db in try MessageDeduplication.fetchAll(db) }) .toEventually(beEmpty()) - await expect(mockExtensionHelper).toEventually(call(.exactly(times: 1), matchingParameters: .all) { - try $0.upsertLastClearedRecord(threadId: "testThreadId") - }) + await mockExtensionHelper + .verify { try $0.upsertLastClearedRecord(threadId: "testThreadId") } + .wasCalled(exactly: 1) } // MARK: ---- deletes multiple records @@ -642,12 +654,12 @@ class MessageDeduplicationSpec: AsyncSpec { await expect(mockStorage .read { db in try MessageDeduplication.fetchAll(db) }) .toEventually(beEmpty()) - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.removeDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") - }) - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.removeDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId2") - }) + await mockExtensionHelper + .verify { try $0.removeDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") } + .wasCalled(exactly: 1) + await mockExtensionHelper + .verify { try $0.removeDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId2") } + .wasCalled(exactly: 1) } // MARK: ---- leaves unrelated records @@ -684,9 +696,11 @@ class MessageDeduplicationSpec: AsyncSpec { expect((records?.map { $0.threadId }).map { Set($0) }).to(equal(["testThreadId2"])) expect((records?.map { $0.uniqueIdentifier }).map { Set($0) }) .to(equal(["testId2"])) - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.removeDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") - }) + await mockExtensionHelper + .verify { + try $0.removeDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") + } + .wasCalled(exactly: 1) } // MARK: ---- leaves records which should not be deleted alongside the thread @@ -715,9 +729,9 @@ class MessageDeduplicationSpec: AsyncSpec { expect((records?.map { $0.threadId }).map { Set($0) }).to(equal(["testThreadId"])) expect((records?.map { $0.uniqueIdentifier }).map { Set($0) }) .to(equal(["testId"])) - expect(mockExtensionHelper).toNot(call { - try $0.removeDedupeRecord(threadId: .any, uniqueIdentifier: .any) - }) + await mockExtensionHelper + .verify { try $0.removeDedupeRecord(threadId: .any, uniqueIdentifier: .any) } + .wasNotCalled() } // MARK: ---- resets the expiration timestamp when failing to delete the file @@ -730,7 +744,7 @@ class MessageDeduplicationSpec: AsyncSpec { shouldDeleteWhenDeletingThread: true ).insert(db) } - mockExtensionHelper + try await mockExtensionHelper .when { try $0.removeDedupeRecord(threadId: .any, uniqueIdentifier: .any) } .thenThrow(TestError.mock) @@ -753,9 +767,11 @@ class MessageDeduplicationSpec: AsyncSpec { .to(equal(["testId"])) expect((records?.map { $0.expirationTimestampSeconds }).map { Set($0) }) .to(equal([0])) - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.removeDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") - }) + await mockExtensionHelper + .verify { + try $0.removeDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") + } + .wasCalled(exactly: 1) } } } @@ -773,9 +789,9 @@ class MessageDeduplicationSpec: AsyncSpec { using: dependencies ) }.toNot(throwError()) - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") - }) + await mockExtensionHelper + .verify { try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") } + .wasCalled(exactly: 1) } // MARK: ---- creates both the main file and a legacy file when needed @@ -788,15 +804,17 @@ class MessageDeduplicationSpec: AsyncSpec { using: dependencies ) }.toNot(throwError()) - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") - }) - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.createDedupeRecord( - threadId: "testThreadId", - uniqueIdentifier: "testLegacyId" - ) - }) + await mockExtensionHelper + .verify { try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") } + .wasCalled(exactly: 1) + await mockExtensionHelper + .verify { + try $0.createDedupeRecord( + threadId: "testThreadId", + uniqueIdentifier: "testLegacyId" + ) + } + .wasCalled(exactly: 1) } // MARK: ---- creates a file from a ProcessedMessage @@ -819,14 +837,14 @@ class MessageDeduplicationSpec: AsyncSpec { using: dependencies ) }.toNot(throwError()) - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") - }) + await mockExtensionHelper + .verify { try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") } + .wasCalled(exactly: 1) } // MARK: ---- throws when it fails to create the file it("throws when it fails to create the file") { - mockExtensionHelper + try await mockExtensionHelper .when { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) } .thenThrow(TestError.mock) @@ -841,7 +859,7 @@ class MessageDeduplicationSpec: AsyncSpec { // MARK: ---- throws when it fails to create the legacy file it("throws when it fails to create the legacy file") { - mockExtensionHelper + try await mockExtensionHelper .when { try $0.createDedupeRecord( threadId: "testThreadId", @@ -849,7 +867,7 @@ class MessageDeduplicationSpec: AsyncSpec { ) } .thenReturn(()) - mockExtensionHelper + try await mockExtensionHelper .when { try $0.createDedupeRecord( threadId: "testThreadId", @@ -866,15 +884,17 @@ class MessageDeduplicationSpec: AsyncSpec { using: dependencies ) }.to(throwError(TestError.mock)) - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") - }) - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.createDedupeRecord( - threadId: "testThreadId", - uniqueIdentifier: "testLegacyId" - ) - }) + await mockExtensionHelper + .verify { try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") } + .wasCalled(exactly: 1) + await mockExtensionHelper + .verify { + try $0.createDedupeRecord( + threadId: "testThreadId", + uniqueIdentifier: "testLegacyId" + ) + } + .wasCalled(exactly: 1) } } @@ -895,9 +915,11 @@ class MessageDeduplicationSpec: AsyncSpec { ) }.toNot(throwError()) - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "12345-preOffer") - }) + await mockExtensionHelper + .verify { + try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "12345-preOffer") + } + .wasCalled(exactly: 1) } // MARK: ---- creates a generic file correctly @@ -915,9 +937,9 @@ class MessageDeduplicationSpec: AsyncSpec { ) }.toNot(throwError()) - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "12345") - }) + await mockExtensionHelper + .verify { try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "12345") } + .wasCalled(exactly: 1) } // MARK: ---- creates a files for the correct call message kinds @@ -925,8 +947,8 @@ class MessageDeduplicationSpec: AsyncSpec { var resultIdentifiers: [String] = [] var resultKinds: [CallMessage.Kind] = [] - CallMessage.Kind.allCases.forEach { kind in - mockExtensionHelper + for kind in CallMessage.Kind.allCases { + try await mockExtensionHelper .when { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) } .then { args in guard let identifier: String = args[test: 1] as? String else { return } @@ -959,7 +981,7 @@ class MessageDeduplicationSpec: AsyncSpec { var resultIdentifiers: [String] = [] var resultStates: [CallMessage.MessageInfo.State] = [] - CallMessage.MessageInfo.State.allCases.forEach { state in + for state in CallMessage.MessageInfo.State.allCases { let message: CallMessage = CallMessage( uuid: "12345", kind: .answer, @@ -967,7 +989,7 @@ class MessageDeduplicationSpec: AsyncSpec { sentTimestampMs: 1234567890 ) message.state = state - mockExtensionHelper + try await mockExtensionHelper .when { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) } .then { args in guard let identifier: String = args[test: 1] as? String else { return } @@ -1000,9 +1022,9 @@ class MessageDeduplicationSpec: AsyncSpec { ) }.toNot(throwError()) - expect(mockExtensionHelper).toNot(call { - try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) - }) + await mockExtensionHelper + .verify { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) } + .wasNotCalled() } } } @@ -1058,7 +1080,7 @@ class MessageDeduplicationSpec: AsyncSpec { // MARK: ---- throws when the message is a duplicate it("throws when the message is a duplicate") { - mockExtensionHelper + try await mockExtensionHelper .when { $0.dedupeRecordExists(threadId: .any, uniqueIdentifier: .any) } .thenReturn(true) @@ -1073,7 +1095,7 @@ class MessageDeduplicationSpec: AsyncSpec { // MARK: ---- throws when the message is a legacy duplicate it("throws when the message is a legacy duplicate") { - mockExtensionHelper + try await mockExtensionHelper .when { $0.dedupeRecordExists( threadId: "testThreadId", @@ -1081,7 +1103,7 @@ class MessageDeduplicationSpec: AsyncSpec { ) } .thenReturn(false) - mockExtensionHelper + try await mockExtensionHelper .when { $0.dedupeRecordExists( threadId: "testThreadId", @@ -1098,12 +1120,12 @@ class MessageDeduplicationSpec: AsyncSpec { using: dependencies ) }.to(throwError(MessageReceiverError.duplicateMessage)) - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.dedupeRecordExists(threadId: "testThreadId", uniqueIdentifier: "testId") - }) - expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.dedupeRecordExists(threadId: "testThreadId", uniqueIdentifier: "testLegacyId") - }) + await mockExtensionHelper + .verify { $0.dedupeRecordExists(threadId: "testThreadId", uniqueIdentifier: "testId") } + .wasCalled(exactly: 1) + await mockExtensionHelper + .verify { $0.dedupeRecordExists(threadId: "testThreadId", uniqueIdentifier: "testLegacyId") } + .wasCalled(exactly: 1) } } @@ -1138,7 +1160,7 @@ class MessageDeduplicationSpec: AsyncSpec { // MARK: ---- throws when the call message is a duplicate it("throws when the call message is a duplicate") { - mockExtensionHelper + try await mockExtensionHelper .when { $0.dedupeRecordExists(threadId: .any, uniqueIdentifier: .any) } .thenReturn(true) diff --git a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift index 8cdfca3459..320ecaac33 100644 --- a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift @@ -2,6 +2,7 @@ import Foundation import GRDB +import TestUtilities import Quick import Nimble @@ -10,7 +11,7 @@ import Nimble @testable import SessionMessagingKit @testable import SessionUtilitiesKit -class DisplayPictureDownloadJobSpec: QuickSpec { +class DisplayPictureDownloadJobSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -22,19 +23,10 @@ class DisplayPictureDownloadJobSpec: QuickSpec { dependencies.forceSynchronous = true dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) } - @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( - initialSetup: { $0.defaultInitialSetup() } - ) - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + @TestState var mockLibSessionCache: MockLibSessionCache! = .create(using: dependencies) + @TestState var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), - migrations: SNMessagingKit.migrations, - using: dependencies, - initialData: { db in - try Identity(variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey)).insert(db) - try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).insert(db) - try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) - try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) - } + using: dependencies ) @TestState var imageData: Data! = Data( hex: "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c" + @@ -48,56 +40,73 @@ class DisplayPictureDownloadJobSpec: QuickSpec { "673120e153a5cb6b869380744d493068ebc418266d6596d728cfc60b30662a089376" + "f2761e3bb6ee837a26b24b5" ) - @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( - initialSetup: { network in - network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn(MockNetwork.response(data: encryptedData)) - } - ) - @TestState(singleton: .fileManager, in: dependencies) var mockFileManager: MockFileManager! = MockFileManager( - initialSetup: { $0.defaultInitialSetup() } - ) - @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( - initialSetup: { crypto in - crypto.when { $0.generate(.uuid()) }.thenReturn(UUID(uuidString: "00000000-0000-0000-0000-000000001234")) - crypto - .when { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } - .thenReturn(imageData) - crypto.when { $0.generate(.hash(message: .any, length: .any)) }.thenReturn("TestHash".bytes) - crypto - .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } - .thenReturn( - KeyPair( - publicKey: Data(hex: TestConstants.publicKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ) - ) - crypto - .when { $0.generate(.randomBytes(16)) } - .thenReturn(Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!.bytes) - crypto - .when { $0.generate(.signatureBlind15(message: .any, serverPublicKey: .any, ed25519SecretKey: .any)) } - .thenReturn("TestSogsSignature".bytes) - } - ) + @TestState var mockNetwork: MockNetwork! = .create(using: dependencies) + @TestState var mockFileManager: MockFileManager! = .create(using: dependencies) + @TestState var mockCrypto: MockCrypto! = .create(using: dependencies) + @TestState var mockImageDataManager: MockImageDataManager! = .create(using: dependencies) + @TestState var mockGeneralCache: MockGeneralCache! = .create(using: dependencies) - @TestState(singleton: .imageDataManager, in: dependencies) var mockImageDataManager: MockImageDataManager! = MockImageDataManager( - initialSetup: { imageDataManager in - imageDataManager - .when { await $0.load(.any) } - .thenReturn(nil) - } - ) - @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( - initialSetup: { cache in - cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) - cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) - cache - .when { $0.ed25519Seed } - .thenReturn(Array(Array(Data(hex: TestConstants.edSecretKey)).prefix(upTo: 32))) + beforeEach { + try await mockGeneralCache.defaultInitialSetup() + dependencies.set(cache: .general, to: mockGeneralCache) + + try await mockLibSessionCache.defaultInitialSetup() + dependencies.set(cache: .libSession, to: mockLibSessionCache) + + try await mockFileManager.defaultInitialSetup() + dependencies.set(singleton: .fileManager, to: mockFileManager) + + try await mockStorage.perform(migrations: SNMessagingKit.migrations) + try await mockStorage.writeAsync { db in + try Identity(variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey)).insert(db) + try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).insert(db) + try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) + try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) } - ) + dependencies.set(singleton: .storage, to: mockStorage) + + try await mockCrypto.when { $0.generate(.uuid()) }.thenReturn(UUID(uuidString: "00000000-0000-0000-0000-000000001234")) + try await mockCrypto + .when { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } + .thenReturn(imageData) + try await mockCrypto.when { $0.generate(.hash(message: .any, length: .any)) }.thenReturn("TestHash".bytes) + try await mockCrypto + .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } + .thenReturn( + KeyPair( + publicKey: Data(hex: TestConstants.publicKey).bytes, + secretKey: Data(hex: TestConstants.edSecretKey).bytes + ) + ) + try await mockCrypto + .when { $0.generate(.randomBytes(16)) } + .thenReturn(Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!.bytes) + try await mockCrypto + .when { $0.generate(.signatureBlind15(message: .any, serverPublicKey: .any, ed25519SecretKey: .any)) } + .thenReturn("TestSogsSignature".bytes) + dependencies.set(singleton: .crypto, to: mockCrypto) + + try await mockNetwork.defaultInitialSetup(using: dependencies) + await mockNetwork.removeRequestMocks() + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } + .thenReturn(MockNetwork.response(data: encryptedData)) + dependencies.set(singleton: .network, to: mockNetwork) + + try await mockImageDataManager + .when { await $0.load(.any) } + .thenReturn(nil) + dependencies.set(singleton: .imageDataManager, to: mockImageDataManager) + } // MARK: - a DisplayPictureDownloadJob describe("a DisplayPictureDownloadJob") { @@ -489,7 +498,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { timestamp: 0 ) ) - let expectedRequest: Network.PreparedRequest = try Network.preparedDownload( + let expectedRequest: Network.PreparedRequest = try Network.FileServer.preparedDownload( url: URL(string: "http://filev2.getsession.org/file/1234")!, using: dependencies ) @@ -503,15 +512,18 @@ class DisplayPictureDownloadJobSpec: QuickSpec { using: dependencies ) - expect(mockNetwork) - .to(call(.exactly(times: 1), matchingParameters: .all) { network in - network.send( - expectedRequest.body, - to: expectedRequest.destination, + await mockNetwork + .verify { + $0.send( + endpoint: expectedRequest.endpoint, + destination: expectedRequest.destination, + body: expectedRequest.body, + category: .download, requestTimeout: expectedRequest.requestTimeout, - requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout + overallTimeout: expectedRequest.overallTimeout ) - }) + } + .wasCalled(exactly: 1) } // MARK: -- generates a SOGS download request correctly @@ -566,15 +578,18 @@ class DisplayPictureDownloadJobSpec: QuickSpec { using: dependencies ) - expect(mockNetwork) - .to(call(.exactly(times: 1), matchingParameters: .all) { network in - network.send( - expectedRequest.body, - to: expectedRequest.destination, + await mockNetwork + .verify { + $0.send( + endpoint: expectedRequest.endpoint, + destination: expectedRequest.destination, + body: expectedRequest.body, + category: .download, requestTimeout: expectedRequest.requestTimeout, - requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout + overallTimeout: expectedRequest.overallTimeout ) - }) + } + .wasCalled(exactly: 1) } // MARK: -- checking if a downloaded display picture is valid @@ -618,18 +633,19 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: ---- when it fails to decrypt the data context("when it fails to decrypt the data") { beforeEach { - mockCrypto + try await mockCrypto .when { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } .thenReturn(nil) } // MARK: ------ does not save the picture it("does not save the picture") { - expect(mockFileManager) - .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { - await $0.load(.any) - }) + await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any, attributes: .any) } + .wasNotCalled() + await mockImageDataManager + .verify { await $0.load(.any) } + .wasNotCalled(timeout: .milliseconds(100)) expect(mockStorage.read { db in try Profile.fetchOne(db) }).to(equal(profile)) } } @@ -637,18 +653,19 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: ---- when it decrypts invalid image data context("when it decrypts invalid image data") { beforeEach { - mockCrypto + try await mockCrypto .when { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } .thenReturn(Data([1, 2, 3])) } // MARK: ------ does not save the picture it("does not save the picture") { - expect(mockFileManager) - .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { - await $0.load(.any) - }) + await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any, attributes: .any) } + .wasNotCalled() + await mockImageDataManager + .verify { await $0.load(.any) } + .wasNotCalled(timeout: .milliseconds(100)) expect(mockStorage.read { db in try Profile.fetchOne(db) }).to(equal(profile)) } } @@ -656,40 +673,38 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: ---- when it fails to write to disk context("when it fails to write to disk") { beforeEach { - mockFileManager + try await mockFileManager .when { $0.createFile(atPath: .any, contents: .any, attributes: .any) } .thenReturn(false) } // MARK: ------ does not save the picture it("does not save the picture") { - expect(mockImageDataManager).toNotEventually(call { - await $0.load(.any) - }) + await mockImageDataManager + .verify { await $0.load(.any) } + .wasNotCalled(timeout: .milliseconds(100)) expect(mockStorage.read { db in try Profile.fetchOne(db) }).to(equal(profile)) } } // MARK: ---- writes the file to disk it("writes the file to disk") { - expect(mockFileManager) - .to(call(.exactly(times: 1), matchingParameters: .all) { mockFileManager in - mockFileManager.createFile( + await mockFileManager + .verify { + $0.createFile( atPath: "/test/DisplayPictures/5465737448617368", contents: imageData, attributes: nil ) - }) + } + .wasCalled(exactly: 1) } // MARK: ---- adds the image data to the displayPicture cache it("adds the image data to the displayPicture cache") { - expect(mockImageDataManager) - .toEventually(call(.exactly(times: 1), matchingParameters: .all) { - await $0.load( - .url(URL(fileURLWithPath: "/test/DisplayPictures/5465737448617368")) - ) - }) + await mockImageDataManager + .verify { await $0.load(.url(URL(fileURLWithPath: "/test/DisplayPictures/5465737448617368"))) } + .wasCalled(exactly: 1, timeout: .milliseconds(100)) } // MARK: ---- successfully completes the job @@ -733,15 +748,15 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: -------- does not save the picture it("does not save the picture") { - expect(mockCrypto) - .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) - }) - expect(mockFileManager) - .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { - await $0.load(.any) - }) + await mockCrypto + .verify { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } + .wasNotCalled() + await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any, attributes: .any) } + .wasNotCalled() + await mockImageDataManager + .verify { await $0.load(.any) } + .wasNotCalled(timeout: .milliseconds(100)) expect(mockStorage.read { db in try Profile.fetchOne(db) }).to(beNil()) } } @@ -761,15 +776,15 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: -------- does not save the picture it("does not save the picture") { - expect(mockCrypto) - .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) - }) - expect(mockFileManager) - .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { - await $0.load(.any) - }) + await mockCrypto + .verify { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } + .wasNotCalled() + await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any, attributes: .any) } + .wasNotCalled() + await mockImageDataManager + .verify { await $0.load(.any) } + .wasNotCalled(timeout: .milliseconds(100)) expect(mockStorage.read { db in try Profile.fetchOne(db) }) .toNot(equal( Profile( @@ -798,15 +813,15 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: -------- does not save the picture it("does not save the picture") { - expect(mockCrypto) - .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) - }) - expect(mockFileManager) - .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { - await $0.load(.any) - }) + await mockCrypto + .verify { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } + .wasNotCalled() + await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any, attributes: .any) } + .wasNotCalled() + await mockImageDataManager + .verify { await $0.load(.any) } + .wasNotCalled(timeout: .milliseconds(100)) expect(mockStorage.read { db in try Profile.fetchOne(db) }) .toNot(equal( Profile( @@ -834,24 +849,25 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: -------- saves the picture it("saves the picture") { - expect(mockCrypto) - .to(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) - }) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.createFile( - atPath: "/test/DisplayPictures/5465737448617368", - contents: imageData, - attributes: nil - ) - }) - - expect(mockImageDataManager) - .toEventually(call(.exactly(times: 1), matchingParameters: .all) { + await mockCrypto + .verify { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } + .wasCalled(exactly: 1) + await mockFileManager + .verify { + $0.createFile( + atPath: "/test/DisplayPictures/5465737448617368", + contents: imageData, + attributes: nil + ) + } + .wasCalled(exactly: 1) + await mockImageDataManager + .verify { await $0.load( .url(URL(fileURLWithPath: "/test/DisplayPictures/5465737448617368")) ) - }) + } + .wasCalled(exactly: 1, timeout: .milliseconds(100)) expect(mockStorage.read { db in try Profile.fetchOne(db) }) .to(equal( Profile( @@ -931,15 +947,15 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: -------- does not save the picture it("does not save the picture") { - expect(mockCrypto) - .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) - }) - expect(mockFileManager) - .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { - await $0.load(.any) - }) + await mockCrypto + .verify { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } + .wasNotCalled() + await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any, attributes: .any) } + .wasNotCalled() + await mockImageDataManager + .verify { await $0.load(.any) } + .wasNotCalled(timeout: .milliseconds(100)) expect(mockStorage.read { db in try ClosedGroup.fetchOne(db) }).to(beNil()) } } @@ -958,15 +974,15 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: -------- does not save the picture it("does not save the picture") { - expect(mockCrypto) - .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) - }) - expect(mockFileManager) - .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { - await $0.load(.any) - }) + await mockCrypto + .verify { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } + .wasNotCalled() + await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any, attributes: .any) } + .wasNotCalled() + await mockImageDataManager + .verify { await $0.load(.any) } + .wasNotCalled(timeout: .milliseconds(100)) expect(mockStorage.read { db in try ClosedGroup.fetchOne(db) }) .toNot(equal( ClosedGroup( @@ -999,15 +1015,15 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: -------- does not save the picture it("does not save the picture") { - expect(mockCrypto) - .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) - }) - expect(mockFileManager) - .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { - await $0.load(.any) - }) + await mockCrypto + .verify { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } + .wasNotCalled() + await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any, attributes: .any) } + .wasNotCalled() + await mockImageDataManager + .verify { await $0.load(.any) } + .wasNotCalled(timeout: .milliseconds(100)) expect(mockStorage.read { db in try ClosedGroup.fetchOne(db) }) .toNot(equal( ClosedGroup( @@ -1088,8 +1104,17 @@ class DisplayPictureDownloadJobSpec: QuickSpec { ) // SOGS doesn't encrypt it's images so replace the encrypted mock response - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn(MockNetwork.response(data: imageData)) } @@ -1101,9 +1126,12 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: -------- does not save the picture it("does not save the picture") { - expect(mockFileManager) - .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { await $0.load(.any) }) + await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any, attributes: .any) } + .wasNotCalled() + await mockImageDataManager + .verify { await $0.load(.any) } + .wasNotCalled(timeout: .milliseconds(100)) expect(mockStorage.read { db in try OpenGroup.fetchOne(db) }).to(beNil()) } } @@ -1122,9 +1150,12 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: -------- does not save the picture it("does not save the picture") { - expect(mockFileManager) - .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { await $0.load(.any) }) + await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any, attributes: .any) } + .wasNotCalled() + await mockImageDataManager + .verify { await $0.load(.any) } + .wasNotCalled(timeout: .milliseconds(100)) expect(mockStorage.read { db in try OpenGroup.fetchOne(db) }) .toNot(equal( OpenGroup( @@ -1155,19 +1186,22 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: -------- saves the picture it("saves the picture") { - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.createFile( - atPath: "/test/DisplayPictures/5465737448617368", - contents: imageData, - attributes: nil - ) - }) - expect(mockImageDataManager) - .toEventually(call(.exactly(times: 1), matchingParameters: .all) { + await mockFileManager + .verify { + $0.createFile( + atPath: "/test/DisplayPictures/5465737448617368", + contents: imageData, + attributes: nil + ) + } + .wasCalled(exactly: 1) + await mockImageDataManager + .verify { await $0.load( .url(URL(fileURLWithPath: "/test/DisplayPictures/5465737448617368")) ) - }) + } + .wasCalled(exactly: 1, timeout: .milliseconds(100)) expect(mockStorage.read { db in try OpenGroup.fetchOne(db) }) .to(equal( OpenGroup( diff --git a/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift b/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift index e549e2c221..d33619b3d3 100644 --- a/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift @@ -2,6 +2,7 @@ import Foundation import GRDB +import TestUtilities import Quick import Nimble @@ -9,11 +10,7 @@ import Nimble @testable import SessionMessagingKit @testable import SessionUtilitiesKit -extension Job: @retroactive MutableIdentifiable { - public mutating func setId(_ id: Int64?) { self.id = id } -} - -class MessageSendJobSpec: QuickSpec { +class MessageSendJobSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -30,14 +27,19 @@ class MessageSendJobSpec: QuickSpec { @TestState var dependencies: TestDependencies! = TestDependencies { dependencies in dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) } - @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( - initialSetup: { $0.defaultInitialSetup() } - ) - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + @TestState var mockLibSessionCache: MockLibSessionCache! = .create(using: dependencies) + @TestState var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), - migrations: SNMessagingKit.migrations, - using: dependencies, - initialData: { db in + using: dependencies + ) + @TestState var mockJobRunner: MockJobRunner! = .create(using: dependencies) + + beforeEach { + try await mockLibSessionCache.defaultInitialSetup() + dependencies.set(cache: .libSession, to: mockLibSessionCache) + + try await mockStorage.perform(migrations: SNMessagingKit.migrations) + try await mockStorage.writeAsync { db in try SessionThread.upsert( db, id: "Test1", @@ -50,30 +52,29 @@ class MessageSendJobSpec: QuickSpec { using: dependencies ) } - ) - @TestState(singleton: .jobRunner, in: dependencies) var mockJobRunner: MockJobRunner! = MockJobRunner( - initialSetup: { jobRunner in - jobRunner - .when { - $0.jobInfoFor( - jobs: nil, - state: .running, - variant: .attachmentUpload - ) - } - .thenReturn([:]) - jobRunner - .when { $0.insert(.any, job: .any, before: .any) } - .then { args, untrackedArgs in - let db: ObservingDatabase = untrackedArgs[0] as! ObservingDatabase - var job: Job = args[0] as! Job - job.id = 1000 - - try! job.insert(db) - } - .thenReturn((1000, Job(variant: .messageSend))) - } - ) + dependencies.set(singleton: .storage, to: mockStorage) + + try await mockJobRunner + .when { + $0.jobInfoFor( + jobs: nil, + state: .anyState, + variant: .attachmentUpload + ) + } + .thenReturn([:]) + try await mockJobRunner + .when { $0.insert(.any, job: .any, before: .any) } + .then { args in + let db: ObservingDatabase = args[0] as! ObservingDatabase + var job: Job = args[1] as! Job + job.id = 1000 + + try! job.insert(db) + } + .thenReturn((1000, Job(variant: .messageSend))) + dependencies.set(singleton: .jobRunner, to: mockJobRunner) + } // MARK: - a MessageSendJob describe("a MessageSendJob") { @@ -202,7 +203,8 @@ class MessageSendJobSpec: QuickSpec { mockStorage.write { db in try interaction.insert(db) - try job.insert(db, withRowId: 54321) + job.id = 54321 + try job.insert(db) } } @@ -284,7 +286,10 @@ class MessageSendJobSpec: QuickSpec { ) ) ) - mockStorage.write { db in try job.insert(db, withRowId: 54321) } + mockStorage.write { db in + job.id = 54321 + try job.insert(db) + } var error: Error? = nil var permanentFailure: Bool = false @@ -322,7 +327,7 @@ class MessageSendJobSpec: QuickSpec { // 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 await mockStorage.writeAsync { db in try attachment.with(state: .failedDownload, using: dependencies).upsert(db) } @@ -348,7 +353,7 @@ class MessageSendJobSpec: QuickSpec { // MARK: ------ with a pending upload context("with a pending upload") { beforeEach { - mockStorage.write { db in + try await mockStorage.writeAsync { db in try attachment.with(state: .uploading, using: dependencies).upsert(db) } } @@ -357,10 +362,6 @@ class MessageSendJobSpec: QuickSpec { 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, using: dependencies).upsert(db) - } - MessageSendJob.run( job, scheduler: DispatchQueue.main, @@ -377,7 +378,7 @@ class MessageSendJobSpec: QuickSpec { 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 await mockStorage.writeAsync { db in try attachment .with( state: .uploaded, @@ -396,12 +397,12 @@ class MessageSendJobSpec: QuickSpec { using: dependencies ) - expect(didDefer).to(beTrue()) + await expect(didDefer).toEventually(beTrue()) } // MARK: -------- inserts an attachment upload job before the message send job it("inserts an attachment upload job before the message send job") { - mockJobRunner + try await mockJobRunner .when { $0.jobInfoFor( jobs: nil, @@ -420,8 +421,8 @@ class MessageSendJobSpec: QuickSpec { using: dependencies ) - expect(mockJobRunner) - .to(call(.exactly(times: 1), matchingParameters: .all) { + await mockJobRunner + .verify { $0.insert( .any, job: Job( @@ -438,7 +439,8 @@ class MessageSendJobSpec: QuickSpec { ), before: job ) - }) + } + .wasCalled(exactly: 1, timeout: .milliseconds(100)) } // MARK: -------- creates a dependency between the new job and the existing one @@ -452,8 +454,8 @@ class MessageSendJobSpec: QuickSpec { using: dependencies ) - expect(mockStorage.read { db in try JobDependencies.fetchOne(db) }) - .to(equal(JobDependencies(jobId: 54321, dependantId: 1000))) + await expect(mockStorage.read { db in try JobDependencies.fetchOne(db) }) + .toEventually(equal(JobDependencies(jobId: 54321, dependantId: 1000))) } } } diff --git a/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift index 42ff8e59cc..034b207928 100644 --- a/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift @@ -2,6 +2,7 @@ import Foundation import GRDB +import TestUtilities import Quick import Nimble @@ -10,7 +11,7 @@ import Nimble @testable import SessionMessagingKit @testable import SessionUtilitiesKit -class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { +class RetrieveDefaultOpenGroupRoomsJobSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -18,95 +19,104 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { dependencies.forceSynchronous = true dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) } - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + @TestState var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), - migrations: SNMessagingKit.migrations, - using: dependencies, - initialData: { db in + using: dependencies + ) + @TestState var mockUserDefaults: MockUserDefaults! = .create(using: dependencies) + @TestState var mockNetwork: MockNetwork! = .create(using: dependencies) + @TestState var mockJobRunner: MockJobRunner! = .create(using: dependencies) + @TestState var mockOpenGroupManager: MockOpenGroupManager! = .create(using: dependencies) + @TestState var mockGeneralCache: MockGeneralCache! = .create(using: dependencies) + @TestState var job: Job! = Job(variant: .retrieveDefaultOpenGroupRooms) + @TestState var error: Error? = nil + @TestState var permanentFailure: Bool! = false + @TestState var wasDeferred: Bool! = false + + beforeEach { + try await mockGeneralCache.defaultInitialSetup() + dependencies.set(cache: .general, to: mockGeneralCache) + + try await mockOpenGroupManager.defaultInitialSetup() + dependencies.set(singleton: .openGroupManager, to: mockOpenGroupManager) + + try await mockStorage.perform(migrations: SNMessagingKit.migrations) + try await mockStorage.writeAsync { db in try Identity(variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey)).insert(db) try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).insert(db) try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) } - ) - @TestState(defaults: .appGroup, in: dependencies) var mockUserDefaults: MockUserDefaults! = MockUserDefaults( - initialSetup: { defaults in - defaults.when { $0.bool(forKey: .any) }.thenReturn(true) - } - ) - @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( - initialSetup: { network in - network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn( - MockNetwork.batchResponseData( - with: [ - ( - Network.SOGS.Endpoint.capabilities, - Network.SOGS.CapabilitiesResponse( - capabilities: [ - Capability.Variant.blind.rawValue, - Capability.Variant.reactions.rawValue - ] - ).batchSubResponse() - ), - ( - Network.SOGS.Endpoint.rooms, - [ - Network.SOGS.Room.mock.with( - token: "testRoom", - name: "TestRoomName" - ), - Network.SOGS.Room.mock.with( - token: "testRoom2", - name: "TestRoomName2", - infoUpdates: 12, - imageId: "12" - ) - ].batchSubResponse() - ) - ] - ) + dependencies.set(singleton: .storage, to: mockStorage) + + try await mockUserDefaults.defaultInitialSetup() + try await mockUserDefaults.when { $0.bool(forKey: .any) }.thenReturn(true) + dependencies.set(defaults: .appGroup, to: mockUserDefaults) + + try await mockNetwork.defaultInitialSetup(using: dependencies) + await mockNetwork.removeRequestMocks() + try await mockNetwork + .when { + try await $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any ) - } - ) - @TestState(singleton: .jobRunner, in: dependencies) var mockJobRunner: MockJobRunner! = MockJobRunner( - initialSetup: { jobRunner in - jobRunner - .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } - .thenReturn(nil) - jobRunner - .when { $0.upsert(.any, job: .any, canStartJob: .any) } - .thenReturn(nil) - jobRunner - .when { $0.jobInfoFor(jobs: .any, state: .any, variant: .any) } - .thenReturn([:]) - } - ) - @TestState(cache: .openGroupManager, in: dependencies) var mockOGMCache: MockOGMCache! = MockOGMCache( - initialSetup: { cache in - cache.when { $0.setDefaultRoomInfo(.any) }.thenReturn(()) - } - ) - @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( - initialSetup: { cache in - cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) - cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) - cache - .when { $0.ed25519Seed } - .thenReturn(Array(Array(Data(hex: TestConstants.edSecretKey)).prefix(upTo: 32))) - } - ) - @TestState var job: Job! = Job(variant: .retrieveDefaultOpenGroupRooms) - @TestState var error: Error? = nil - @TestState var permanentFailure: Bool! = false - @TestState var wasDeferred: Bool! = false + } + .thenReturn( + MockNetwork.batchResponseData( + with: [ + ( + Network.SOGS.Endpoint.capabilities, + Network.SOGS.CapabilitiesResponse( + capabilities: [ + Capability.Variant.blind.rawValue, + Capability.Variant.reactions.rawValue + ] + ).batchSubResponse() + ), + ( + Network.SOGS.Endpoint.rooms, + [ + Network.SOGS.Room.mock.with( + token: "testRoom", + name: "TestRoomName" + ), + Network.SOGS.Room.mock.with( + token: "testRoom2", + name: "TestRoomName2", + infoUpdates: 12, + imageId: "12" + ) + ].batchSubResponse() + ) + ] + ) + ) + dependencies.set(singleton: .network, to: mockNetwork) + + try await mockJobRunner + .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } + .thenReturn(nil) + try await mockJobRunner + .when { $0.upsert(.any, job: .any, canStartJob: .any) } + .thenReturn(nil) + try await mockJobRunner + .when { $0.jobInfoFor(jobs: .any, state: .any, variant: .any) } + .thenReturn([:]) + dependencies.set(singleton: .jobRunner, to: mockJobRunner) + } // MARK: - a RetrieveDefaultOpenGroupRoomsJob describe("a RetrieveDefaultOpenGroupRoomsJob") { // MARK: -- defers the job if the main app is not running it("defers the job if the main app is not running") { - mockUserDefaults.when { $0.bool(forKey: UserDefaults.BoolKey.isMainAppActive.rawValue) }.thenReturn(false) + try await mockUserDefaults + .when { $0.bool(forKey: UserDefaults.BoolKey.isMainAppActive.rawValue) } + .thenReturn(false) RetrieveDefaultOpenGroupRoomsJob.run( job, @@ -122,7 +132,9 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { // MARK: -- does not defer the job when the main app is running it("does not defer the job when the main app is running") { - mockUserDefaults.when { $0.bool(forKey: UserDefaults.BoolKey.isMainAppActive.rawValue) }.thenReturn(true) + try await mockUserDefaults + .when { $0.bool(forKey: UserDefaults.BoolKey.isMainAppActive.rawValue) } + .thenReturn(true) RetrieveDefaultOpenGroupRoomsJob.run( job, @@ -138,7 +150,7 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { // MARK: -- defers the job if there is an existing job running it("defers the job if there is an existing job running") { - mockJobRunner + try await mockJobRunner .when { $0.jobInfoFor(jobs: .any, state: .running, variant: .retrieveDefaultOpenGroupRooms) } .thenReturn([ 101: JobRunner.JobInfo( @@ -163,7 +175,7 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { // MARK: -- does not defer the job when there is no existing job it("does not defer the job when there is no existing job") { - mockJobRunner + try await mockJobRunner .when { $0.jobInfoFor(jobs: .any, state: .running, variant: .retrieveDefaultOpenGroupRooms) } .thenReturn([:]) @@ -179,11 +191,20 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { expect(wasDeferred).to(beFalse()) } - // MARK: -- creates an inactive entry in the database if one does not exist - it("creates an inactive entry in the database if one does not exist") { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn(MockNetwork.errorResponse()) + // MARK: -- does not add any rooms to the database when the request fails + it("does not add any rooms to the database when the request fails") { + try await mockNetwork + .when { + try await $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } + .thenThrow(TestError.mock) RetrieveDefaultOpenGroupRoomsJob.run( job, @@ -194,20 +215,26 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) - let openGroups: [OpenGroup]? = mockStorage.read { db in try OpenGroup.fetchAll(db) } - expect(openGroups?.count).to(equal(1)) - expect(openGroups?.map { $0.server }).to(equal([Network.SOGS.defaultServer])) - expect(openGroups?.map { $0.roomToken }).to(equal([""])) - expect(openGroups?.map { $0.publicKey }).to(equal([Network.SOGS.defaultServerPublicKey])) - expect(openGroups?.map { $0.isActive }).to(equal([false])) - expect(openGroups?.map { $0.name }).to(equal([""])) + let openGroups: [OpenGroup]? = await expect { mockStorage.read { db in try OpenGroup.fetchAll(db) } } + .toEventuallyNot(beNil()) + .retrieveValue() + expect(openGroups).to(beEmpty()) } - // MARK: -- does not create a new entry if one already exists - it("does not create a new entry if one already exists") { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn(MockNetwork.errorResponse()) + // MARK: -- does not impact existing database entries + it("does not impact existing database entries") { + try await mockNetwork + .when { + try await $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } + .thenThrow(TestError.mock) mockStorage.write { db in try OpenGroup( @@ -231,7 +258,9 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) - let openGroups: [OpenGroup]? = mockStorage.read { db in try OpenGroup.fetchAll(db) } + let openGroups: [OpenGroup]? = await expect { mockStorage.read { db in try OpenGroup.fetchAll(db) } } + .toEventuallyNot(beNil()) + .retrieveValue() expect(openGroups?.count).to(equal(1)) expect(openGroups?.map { $0.server }).to(equal([Network.SOGS.defaultServer])) expect(openGroups?.map { $0.roomToken }).to(equal([""])) @@ -254,21 +283,6 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { ) .insert(db) } - let expectedRequest: Network.PreparedRequest! = mockStorage.read { db in - try Network.SOGS.preparedCapabilitiesAndRooms( - authMethod: Authentication.community( - info: LibSession.OpenGroupCapabilityInfo( - roomToken: "", - server: Network.SOGS.defaultServer, - publicKey: Network.SOGS.defaultServerPublicKey, - capabilities: [] - ), - forceBlinded: false - ), - skipAuthentication: true, - using: dependencies - ) - } RetrieveDefaultOpenGroupRoomsJob.run( job, scheduler: DispatchQueue.main, @@ -278,23 +292,72 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) - expect(mockNetwork) - .to(call { network in - network.send( - expectedRequest.body, - to: expectedRequest.destination, - requestTimeout: expectedRequest.requestTimeout, - requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout + await mockNetwork + .verify { + try await $0.send( + endpoint: Network.SOGS.Endpoint.sequence, + destination: .server( + method: .post, + server: Network.SOGS.defaultServer, + queryParameters: [:], + headers: [:], + x25519PublicKey: Network.SOGS.defaultServerPublicKey + ), + body: try JSONEncoder(using: dependencies).encode( + Network.BatchRequest(requests: [ + try Network.PreparedRequest( + request: Request( + endpoint: .capabilities, + authMethod: Authentication.community( + roomToken: "", + server: Network.SOGS.defaultServer, + publicKey: Network.SOGS.defaultServerPublicKey, + hasCapabilities: false, + supportsBlinding: true, + forceBlinded: false + ) + ), + responseType: Network.SOGS.CapabilitiesResponse.self, + using: dependencies + ), + try Network.PreparedRequest<[Network.SOGS.Room]>( + request: Request( + endpoint: .rooms, + authMethod: Authentication.community( + roomToken: "", + server: Network.SOGS.defaultServer, + publicKey: Network.SOGS.defaultServerPublicKey, + hasCapabilities: false, + supportsBlinding: true, + forceBlinded: false + ) + ), + responseType: [Network.SOGS.Room].self, + using: dependencies + ) + ]) + ), + category: .standard, + requestTimeout: Network.defaultTimeout, + overallTimeout: nil ) - }) - - expect(expectedRequest?.headers).to(beEmpty()) + } + .wasCalled(exactly: 1, timeout: .milliseconds(100)) } - // MARK: -- will retry 8 times before it fails - it("will retry 8 times before it fails") { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + // MARK: -- permanently fails if it gets an error + it("permanently fails if it gets an error") { + try await mockNetwork + .when { + try await $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn(MockNetwork.nullResponse()) RetrieveDefaultOpenGroupRoomsJob.run( @@ -309,15 +372,24 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) + await mockNetwork + .verify { + try await $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } + .wasCalled(exactly: 1, timeout: .milliseconds(100)) expect(error).to(matchError(NetworkError.parsingFailed)) - expect(mockNetwork) // First attempt + 8 retries - .to(call(.exactly(times: 9)) { network in - network.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) - }) + expect(permanentFailure).to(beTrue()) } - // MARK: -- stores the updated capabilities - it("stores the updated capabilities") { + // MARK: -- handles the updated capabilities + it("handles the updated capabilities") { RetrieveDefaultOpenGroupRoomsJob.run( job, scheduler: DispatchQueue.main, @@ -327,12 +399,21 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) - let capabilities: [Capability]? = mockStorage.read { db in try Capability.fetchAll(db) } - expect(capabilities?.count).to(equal(2)) - expect(capabilities?.map { $0.openGroupServer }) - .to(equal([Network.SOGS.defaultServer, Network.SOGS.defaultServer])) - expect(capabilities?.map { $0.variant }).to(equal([.blind, .reactions])) - expect(capabilities?.map { $0.isMissing }).to(equal([false, false])) + await mockOpenGroupManager + .verify { + $0.handleCapabilities( + .any, + capabilities: Network.SOGS.CapabilitiesResponse( + capabilities: [ + Capability.Variant.blind.rawValue, + Capability.Variant.reactions.rawValue + ], + missing: nil + ), + on: Network.SOGS.defaultServer + ) + } + .wasCalled(exactly: 1, timeout: .milliseconds(100)) } // MARK: -- inserts the returned rooms @@ -346,19 +427,20 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) - let openGroups: [OpenGroup]? = mockStorage.read { db in try OpenGroup.fetchAll(db) } - expect(openGroups?.count).to(equal(3)) // 1 for the entry used to fetch the default rooms + let openGroups: [OpenGroup]? = await expect { mockStorage.read { db in try OpenGroup.fetchAll(db) } } + .toEventuallyNot(beNil()) + .retrieveValue() + expect(openGroups?.count).to(equal(2)) expect(openGroups?.map { $0.server }) - .to(equal([Network.SOGS.defaultServer, Network.SOGS.defaultServer, Network.SOGS.defaultServer])) - expect(openGroups?.map { $0.roomToken }).to(equal(["", "testRoom", "testRoom2"])) + .to(equal([Network.SOGS.defaultServer, Network.SOGS.defaultServer])) + expect(openGroups?.map { $0.roomToken }).to(equal(["testRoom", "testRoom2"])) expect(openGroups?.map { $0.publicKey }) .to(equal([ - Network.SOGS.defaultServerPublicKey, Network.SOGS.defaultServerPublicKey, Network.SOGS.defaultServerPublicKey ])) - expect(openGroups?.map { $0.isActive }).to(equal([false, false, false])) - expect(openGroups?.map { $0.name }).to(equal(["", "TestRoomName", "TestRoomName2"])) + expect(openGroups?.map { $0.isActive }).to(equal([false, false])) + expect(openGroups?.map { $0.name }).to(equal(["TestRoomName", "TestRoomName2"])) } // MARK: -- does not override existing rooms that were returned @@ -375,8 +457,17 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { ) .insert(db) } - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + try await mockNetwork + .when { + try await $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn( MockNetwork.batchResponseData( with: [ @@ -410,15 +501,15 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) - let openGroups: [OpenGroup]? = mockStorage.read { db in try OpenGroup.fetchAll(db) } - expect(openGroups?.count).to(equal(2)) // 1 for the entry used to fetch the default rooms - expect(openGroups?.map { $0.server }) - .to(equal([Network.SOGS.defaultServer, Network.SOGS.defaultServer])) - expect(openGroups?.map { $0.roomToken }.sorted()).to(equal(["", "testRoom"])) - expect(openGroups?.map { $0.publicKey }) - .to(equal([Network.SOGS.defaultServerPublicKey, Network.SOGS.defaultServerPublicKey])) - expect(openGroups?.map { $0.isActive }).to(equal([false, false])) - expect(openGroups?.map { $0.name }.sorted()).to(equal(["", "TestExisting"])) + let openGroups: [OpenGroup]? = await expect { mockStorage.read { db in try OpenGroup.fetchAll(db) } } + .toEventuallyNot(beNil()) + .retrieveValue() + expect(openGroups?.count).to(equal(1)) + expect(openGroups?.map { $0.server }).to(equal([Network.SOGS.defaultServer])) + expect(openGroups?.map { $0.roomToken }).to(equal(["testRoom"])) + expect(openGroups?.map { $0.publicKey }).to(equal([Network.SOGS.defaultServerPublicKey])) + expect(openGroups?.map { $0.isActive }).to(equal([false])) + expect(openGroups?.map { $0.name }.sorted()).to(equal(["TestExisting"])) } // MARK: -- schedules a display picture download @@ -432,8 +523,8 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) - expect(mockJobRunner) - .to(call(matchingParameters: .all) { + await mockJobRunner + .verify { $0.add( .any, job: Job( @@ -452,7 +543,8 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { dependantJob: nil, canStartJob: true ) - }) + } + .wasCalled(exactly: 1, timeout: .milliseconds(100)) } // MARK: -- schedules a display picture download if the imageId has changed @@ -480,8 +572,8 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) - expect(mockJobRunner) - .to(call(matchingParameters: .all) { + await mockJobRunner + .verify { $0.add( .any, job: Job( @@ -500,13 +592,23 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { dependantJob: nil, canStartJob: true ) - }) + } + .wasCalled(exactly: 1, timeout: .milliseconds(100)) } // MARK: -- does not schedule a display picture download if there is no imageId it("does not schedule a display picture download if there is no imageId") { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + try await mockNetwork + .when { + try await $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn( MockNetwork.batchResponseData( with: [ @@ -545,8 +647,9 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) - expect(mockJobRunner) - .toNot(call { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) }) + await mockJobRunner + .verify { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } + .wasNotCalled() } // MARK: -- does not schedule a display picture download if the imageId matches and the image has already been downloaded @@ -575,8 +678,9 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) - expect(mockJobRunner) - .toNot(call { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) }) + await mockJobRunner + .verify { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } + .wasNotCalled() } // MARK: -- updates the cache with the default rooms @@ -590,9 +694,9 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) - expect(mockOGMCache) - .toNot(call(matchingParameters: .all) { - $0.setDefaultRoomInfo([ + await mockOpenGroupManager + .verify { + await $0.setDefaultRoomInfo([ ( room: Network.SOGS.Room.mock.with( token: "testRoom", @@ -627,7 +731,8 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { ) ) ]) - }) + } + .wasNotCalled() } } } diff --git a/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift index 209274b6fd..d70214a825 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift @@ -5,6 +5,7 @@ import GRDB import SessionUtil import SessionUtilitiesKit import SessionNetworkingKit +import TestUtilities import Quick import Nimble @@ -12,7 +13,7 @@ import Nimble @testable import SessionNetworkingKit @testable import SessionMessagingKit -class LibSessionGroupInfoSpec: QuickSpec { +class LibSessionGroupInfoSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -20,46 +21,44 @@ class LibSessionGroupInfoSpec: QuickSpec { dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) dependencies.forceSynchronous = true } - @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( - initialSetup: { cache in - cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) - cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) - } - ) - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + @TestState var mockGeneralCache: MockGeneralCache! = .create(using: dependencies) + @TestState var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), - migrations: SNMessagingKit.migrations, - using: dependencies, - initialData: { db in + using: dependencies + ) + @TestState var mockNetwork: MockNetwork! = .create(using: dependencies) + @TestState var mockJobRunner: MockJobRunner! = .create(using: dependencies) + @TestState var createGroupOutput: LibSession.CreatedGroupInfo! + @TestState var mockLibSessionCache: MockLibSessionCache! = .create(using: dependencies) + + beforeEach { + try await mockGeneralCache.defaultInitialSetup() + dependencies.set(cache: .general, to: mockGeneralCache) + + try await mockNetwork.defaultInitialSetup(using: dependencies) + await mockNetwork.removeRequestMocks() + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } + .thenReturn(MockNetwork.response(data: Data([1, 2, 3]))) + dependencies.set(singleton: .network, to: mockNetwork) + + try await mockStorage.perform(migrations: SNMessagingKit.migrations) + try await mockStorage.writeAsync { db in try Identity(variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey)).insert(db) try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).insert(db) try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) - } - ) - @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( - initialSetup: { network in - network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn(MockNetwork.response(data: Data([1, 2, 3]))) - } - ) - @TestState(singleton: .jobRunner, in: dependencies) var mockJobRunner: MockJobRunner! = MockJobRunner( - initialSetup: { jobRunner in - jobRunner - .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } - .thenReturn(nil) - jobRunner - .when { $0.upsert(.any, job: .any, canStartJob: .any) } - .thenReturn(nil) - jobRunner - .when { $0.jobInfoFor(jobs: .any, state: .any, variant: .any) } - .thenReturn([:]) - } - ) - @TestState var createGroupOutput: LibSession.CreatedGroupInfo! = { - mockStorage.write { db in - try LibSession.createGroup( + + createGroupOutput = try LibSession.createGroup( db, name: "TestGroup", description: nil, @@ -69,24 +68,34 @@ class LibSessionGroupInfoSpec: QuickSpec { using: dependencies ) } - }() - @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( - initialSetup: { cache in - var conf: UnsafeMutablePointer! - var secretKey: [UInt8] = Array(Data(hex: TestConstants.edSecretKey)) - _ = user_groups_init(&conf, &secretKey, nil, 0, nil) - - cache.defaultInitialSetup( - configs: [ - .userGroups: .userGroups(conf), - .groupInfo: createGroupOutput.groupState[.groupInfo], - .groupMembers: createGroupOutput.groupState[.groupMembers], - .groupKeys: createGroupOutput.groupState[.groupKeys] - ] - ) - cache.when { $0.configNeedsDump(.any) }.thenReturn(true) - } - ) + dependencies.set(singleton: .storage, to: mockStorage) + + try await mockJobRunner + .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } + .thenReturn(nil) + try await mockJobRunner + .when { $0.upsert(.any, job: .any, canStartJob: .any) } + .thenReturn(nil) + try await mockJobRunner + .when { $0.jobInfoFor(jobs: .any, state: .any, variant: .any) } + .thenReturn([:]) + dependencies.set(singleton: .jobRunner, to: mockJobRunner) + + var conf: UnsafeMutablePointer! + var secretKey: [UInt8] = Array(Data(hex: TestConstants.edSecretKey)) + _ = user_groups_init(&conf, &secretKey, nil, 0, nil) + + try await mockLibSessionCache.defaultInitialSetup( + configs: [ + .userGroups: .userGroups(conf), + .groupInfo: createGroupOutput.groupState[.groupInfo], + .groupMembers: createGroupOutput.groupState[.groupMembers], + .groupKeys: createGroupOutput.groupState[.groupKeys] + ] + ) + try await mockLibSessionCache.when { $0.configNeedsDump(.any) }.thenReturn(true) + dependencies.set(cache: .libSession, to: mockLibSessionCache) + } // MARK: - LibSessionGroupInfo describe("LibSessionGroupInfo") { @@ -120,7 +129,7 @@ class LibSessionGroupInfoSpec: QuickSpec { // MARK: ---- does nothing if there are no changes it("does nothing if there are no changes") { - mockLibSessionCache.when { $0.configNeedsDump(.any) }.thenReturn(false) + try await mockLibSessionCache.when { $0.configNeedsDump(.any) }.thenReturn(false) mockStorage.write { db in try mockLibSessionCache.handleGroupInfoUpdate( @@ -221,9 +230,9 @@ class LibSessionGroupInfoSpec: QuickSpec { // MARK: ---- updates the formation timestamp if it is later than the current value it("updates the formation timestamp if it is later than the current value") { - // Note: the 'formationTimestamp' stores the "joinedAt" date so we on'y update it if it's later - // than the current value (as we don't want to replace the record of when the current user joined - // the group with when the group was originally created) + /// **Note:** the `formationTimestamp` stores the "joinedAt" date so we only update it if it's later than + /// the current value (as we don't want to replace the record of when the current user joined the group with + /// when the group was originally created) mockStorage.write { db in try ClosedGroup.updateAll(db, ClosedGroup.Columns.formationTimestamp.set(to: 50000)) } createGroupOutput.groupState[.groupInfo]?.conf.map { groups_info_set_created($0, 54321) } let originalGroup: ClosedGroup? = mockStorage.read { db in @@ -293,9 +302,9 @@ class LibSessionGroupInfoSpec: QuickSpec { ) } - expect(mockJobRunner) - .to(call(.exactly(times: 1), matchingParameters: .all) { jobRunner in - jobRunner.add( + await mockJobRunner + .verify { + $0.add( .any, job: Job( variant: .displayPictureDownload, @@ -317,7 +326,8 @@ class LibSessionGroupInfoSpec: QuickSpec { ), canStartJob: true ) - }) + } + .wasCalled(exactly: 1) } } @@ -618,9 +628,9 @@ class LibSessionGroupInfoSpec: QuickSpec { ) } - expect(mockJobRunner) - .to(call(.exactly(times: 1), matchingParameters: .all) { jobRunner in - jobRunner.add( + await mockJobRunner + .verify { + $0.add( .any, job: Job( variant: .garbageCollection, @@ -634,7 +644,8 @@ class LibSessionGroupInfoSpec: QuickSpec { ), canStartJob: true ) - }) + } + .wasCalled(exactly: 1) } // MARK: ------ does not delete messages with attachments after the timestamp @@ -835,6 +846,14 @@ class LibSessionGroupInfoSpec: QuickSpec { // MARK: ---- deletes from the server after deleting messages before a given timestamp it("deletes from the server after deleting messages before a given timestamp") { + try await mockLibSessionCache + .when { $0.authData(groupSessionId: .any) } + .thenReturn( + GroupAuthData( + groupIdentityPrivateKey: Data(createGroupOutput.identityKeyPair.secretKey), + authData: nil + ) + ) mockStorage.write { db in try SessionThread.upsert( db, @@ -882,24 +901,27 @@ class LibSessionGroupInfoSpec: QuickSpec { ) } - let expectedRequest: Network.PreparedRequest<[String: Bool]> = try Network.SnodeAPI.preparedDeleteMessages( - serverHashes: ["1234"], - requireSuccessfulDeletion: false, - authMethod: Authentication.groupAdmin( - groupSessionId: createGroupOutput.groupSessionId, - ed25519SecretKey: createGroupOutput.identityKeyPair.secretKey - ), - using: dependencies - ) - expect(mockNetwork) - .to(call(.exactly(times: 1), matchingParameters: .all) { network in - network.send( - expectedRequest.body, - to: expectedRequest.destination, - requestTimeout: expectedRequest.requestTimeout, - requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout + await mockNetwork + .verify { + $0.send( + endpoint: Network.StorageServer.Endpoint.deleteMessages, + destination: .randomSnode(swarmPublicKey: createGroupOutput.groupSessionId.hexString), + body: try! JSONEncoder(using: dependencies).encode( + Network.StorageServer.DeleteMessagesRequest( + messageHashes: ["1234"], + requireSuccessfulDeletion: false, + authMethod: Authentication.groupAdmin( + groupSessionId: createGroupOutput.groupSessionId, + ed25519SecretKey: createGroupOutput.identityKeyPair.secretKey + ) + ) + ), + category: .standard, + requestTimeout: Network.defaultTimeout, + overallTimeout: nil ) - }) + } + .wasCalled(exactly: 1) } // MARK: ---- does not delete from the server if there is no server hash @@ -956,10 +978,18 @@ class LibSessionGroupInfoSpec: QuickSpec { } expect(result?.count).to(equal(1)) expect(result?.map { $0.variant }).to(equal([.standardIncomingDeleted])) - expect(mockNetwork) - .toNot(call { network in - network.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) - }) + await mockNetwork + .verify { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } + .wasNotCalled() } } } diff --git a/SessionMessagingKitTests/LibSession/LibSessionGroupMembersSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionGroupMembersSpec.swift index 43f6c045de..df40ffb2b3 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionGroupMembersSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionGroupMembersSpec.swift @@ -4,6 +4,7 @@ import Foundation import GRDB import SessionUtil import SessionUtilitiesKit +import TestUtilities import Quick import Nimble @@ -11,7 +12,7 @@ import Nimble @testable import SessionNetworkingKit @testable import SessionMessagingKit -class LibSessionGroupMembersSpec: QuickSpec { +class LibSessionGroupMembersSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -19,72 +20,80 @@ class LibSessionGroupMembersSpec: QuickSpec { dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) dependencies.forceSynchronous = true } - @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( - initialSetup: { cache in - cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) - cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) - } - ) - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + @TestState var mockGeneralCache: MockGeneralCache! = .create(using: dependencies) + @TestState var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), - migrations: SNMessagingKit.migrations, - using: dependencies, - initialData: { db in + using: dependencies + ) + @TestState var mockNetwork: MockNetwork! = .create(using: dependencies) + @TestState var mockJobRunner: MockJobRunner! = .create(using: dependencies) + @TestState var createGroupOutput: LibSession.CreatedGroupInfo! + @TestState var mockLibSessionCache: MockLibSessionCache! = .create(using: dependencies) + + beforeEach { + try await mockGeneralCache.defaultInitialSetup() + dependencies.set(cache: .general, to: mockGeneralCache) + + try await mockNetwork.defaultInitialSetup(using: dependencies) + await mockNetwork.removeRequestMocks() + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } + .thenReturn(MockNetwork.response(data: Data([1, 2, 3]))) + dependencies.set(singleton: .network, to: mockNetwork) + + try await mockStorage.perform(migrations: SNMessagingKit.migrations) + try await mockStorage.writeAsync { db in try Identity(variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey)).insert(db) try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).insert(db) try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) - } - ) - @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( - initialSetup: { network in - network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn(MockNetwork.response(data: Data([1, 2, 3]))) - } - ) - @TestState(singleton: .jobRunner, in: dependencies) var mockJobRunner: MockJobRunner! = MockJobRunner( - initialSetup: { jobRunner in - jobRunner - .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } - .thenReturn(nil) - jobRunner - .when { $0.upsert(.any, job: .any, canStartJob: .any) } - .thenReturn(nil) - jobRunner - .when { $0.jobInfoFor(jobs: .any, state: .any, variant: .any) } - .thenReturn([:]) - } - ) - @TestState var createGroupOutput: LibSession.CreatedGroupInfo! = { - mockStorage.write { db in - try LibSession.createGroup( - db, - name: "TestGroup", - description: nil, - displayPictureUrl: nil, - displayPictureEncryptionKey: nil, - members: [], - using: dependencies - ) - } - }() - @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( - initialSetup: { cache in - var conf: UnsafeMutablePointer! - var secretKey: [UInt8] = Array(Data(hex: TestConstants.edSecretKey)) - _ = user_groups_init(&conf, &secretKey, nil, 0, nil) - cache.defaultInitialSetup( - configs: [ - .userGroups: .userGroups(conf), - .groupInfo: createGroupOutput.groupState[.groupInfo], - .groupMembers: createGroupOutput.groupState[.groupMembers], - .groupKeys: createGroupOutput.groupState[.groupKeys] - ] + createGroupOutput = try LibSession.createGroup( + db, + name: "TestGroup", + description: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + members: [], + using: dependencies ) } - ) + dependencies.set(singleton: .storage, to: mockStorage) + + try await mockJobRunner + .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } + .thenReturn(nil) + try await mockJobRunner + .when { $0.upsert(.any, job: .any, canStartJob: .any) } + .thenReturn(nil) + try await mockJobRunner + .when { $0.jobInfoFor(jobs: .any, state: .any, variant: .any) } + .thenReturn([:]) + dependencies.set(singleton: .jobRunner, to: mockJobRunner) + + var conf: UnsafeMutablePointer! + var secretKey: [UInt8] = Array(Data(hex: TestConstants.edSecretKey)) + _ = user_groups_init(&conf, &secretKey, nil, 0, nil) + + try await mockLibSessionCache.defaultInitialSetup( + configs: [ + .userGroups: .userGroups(conf), + .groupInfo: createGroupOutput.groupState[.groupInfo], + .groupMembers: createGroupOutput.groupState[.groupMembers], + .groupKeys: createGroupOutput.groupState[.groupKeys] + ] + ) + dependencies.set(cache: .libSession, to: mockLibSessionCache) + } // MARK: - LibSessionGroupMembers describe("LibSessionGroupMembers") { @@ -107,7 +116,7 @@ class LibSessionGroupMembersSpec: QuickSpec { try createGroupOutput.group.insert(db) try createGroupOutput.members.forEach { try $0.insert(db) } } - mockLibSessionCache.when { $0.configNeedsDump(.any) }.thenReturn(true) + try await mockLibSessionCache.when { $0.configNeedsDump(.any) }.thenReturn(true) createGroupOutput.groupState[.groupMembers]?.conf.map { var cMemberId: [CChar] = "05\(TestConstants.publicKey)".cString(using: .utf8)! var member: config_group_member = config_group_member() @@ -122,7 +131,7 @@ class LibSessionGroupMembersSpec: QuickSpec { // MARK: ---- does nothing if there are no changes it("does nothing if there are no changes") { - mockLibSessionCache.when { $0.configNeedsDump(.any) }.thenReturn(false) + try await mockLibSessionCache.when { $0.configNeedsDump(.any) }.thenReturn(false) mockStorage.write { db in try mockLibSessionCache.handleGroupMembersUpdate( diff --git a/SessionMessagingKitTests/LibSession/LibSessionSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionSpec.swift index cb8ff17dd8..70f43a6224 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionSpec.swift @@ -4,6 +4,7 @@ import Foundation import GRDB import SessionUtil import SessionUtilitiesKit +import TestUtilities import Quick import Nimble @@ -11,7 +12,7 @@ import Nimble @testable import SessionNetworkingKit @testable import SessionMessagingKit -class LibSessionSpec: QuickSpec { +class LibSessionSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -19,57 +20,61 @@ class LibSessionSpec: QuickSpec { dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) dependencies.forceSynchronous = true } - @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( - initialSetup: { cache in - cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) - cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) - } - ) - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + @TestState var mockGeneralCache: MockGeneralCache! = .create(using: dependencies) + @TestState var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), - migrations: SNMessagingKit.migrations, - using: dependencies, - initialData: { db in + using: dependencies + ) + @TestState var mockNetwork: MockNetwork! = .create(using: dependencies) + @TestState var mockCrypto: MockCrypto! = .create(using: dependencies) + @TestState var createGroupOutput: LibSession.CreatedGroupInfo! + @TestState var mockLibSessionCache: MockLibSessionCache! = .create(using: dependencies) + @TestState var userGroupsConfig: LibSession.Config! + + beforeEach { + try await mockGeneralCache.defaultInitialSetup() + dependencies.set(cache: .general, to: mockGeneralCache) + + try await mockNetwork.defaultInitialSetup(using: dependencies) + dependencies.set(singleton: .network, to: mockNetwork) + + try await mockCrypto + .when { $0.generate(.ed25519KeyPair()) } + .thenReturn( + KeyPair( + publicKey: Array(Data(hex: "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece")), + secretKey: Array(Data( + hex: "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210" + + "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece" + )) + ) + ) + try await mockCrypto + .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .thenReturn( + KeyPair( + publicKey: Array(Data(hex: "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece")), + secretKey: Array(Data( + hex: "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210" + + "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece" + )) + ) + ) + try await mockCrypto + .when { try $0.tryGenerate(.signature(message: .any, ed25519SecretKey: .any)) } + .thenReturn( + Authentication.Signature.standard(signature: Array("TestSignature".data(using: .utf8)!)) + ) + dependencies.set(singleton: .crypto, to: mockCrypto) + + try await mockStorage.perform(migrations: SNMessagingKit.migrations) + try await mockStorage.writeAsync { db in try Identity(variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey)).insert(db) try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).insert(db) try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) - } - ) - @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( - initialSetup: { crypto in - crypto - .when { $0.generate(.ed25519KeyPair()) } - .thenReturn( - KeyPair( - publicKey: Array(Data(hex: "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece")), - secretKey: Array(Data( - hex: "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210" + - "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece" - )) - ) - ) - crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } - .thenReturn( - KeyPair( - publicKey: Array(Data(hex: "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece")), - secretKey: Array(Data( - hex: "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210" + - "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece" - )) - ) - ) - crypto - .when { try $0.tryGenerate(.signature(message: .any, ed25519SecretKey: .any)) } - .thenReturn( - Authentication.Signature.standard(signature: Array("TestSignature".data(using: .utf8)!)) - ) - } - ) - @TestState var createGroupOutput: LibSession.CreatedGroupInfo! = { - mockStorage.write { db in - try LibSession.createGroup( + + createGroupOutput = try LibSession.createGroup( db, name: "TestGroup", description: nil, @@ -79,24 +84,22 @@ class LibSessionSpec: QuickSpec { using: dependencies ) } - }() - @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( - initialSetup: { cache in - var conf: UnsafeMutablePointer! - var secretKey: [UInt8] = Array(Data(hex: TestConstants.edSecretKey)) - _ = user_groups_init(&conf, &secretKey, nil, 0, nil) - - cache.defaultInitialSetup( - configs: [ - .userGroups: .userGroups(conf), - .groupInfo: createGroupOutput.groupState[.groupInfo], - .groupMembers: createGroupOutput.groupState[.groupMembers], - .groupKeys: createGroupOutput.groupState[.groupKeys] - ] - ) - } - ) - @TestState var userGroupsConfig: LibSession.Config! + dependencies.set(singleton: .storage, to: mockStorage) + + var conf: UnsafeMutablePointer! + var secretKey: [UInt8] = Array(Data(hex: TestConstants.edSecretKey)) + _ = user_groups_init(&conf, &secretKey, nil, 0, nil) + + try await mockLibSessionCache.defaultInitialSetup( + configs: [ + .userGroups: .userGroups(conf), + .groupInfo: createGroupOutput.groupState[.groupInfo], + .groupMembers: createGroupOutput.groupState[.groupMembers], + .groupKeys: createGroupOutput.groupState[.groupKeys] + ] + ) + dependencies.set(cache: .libSession, to: mockLibSessionCache) + } // MARK: - LibSession describe("LibSession") { @@ -315,7 +318,7 @@ class LibSessionSpec: QuickSpec { _ = user_groups_init(&userGroupsConf, &secretKey, nil, 0, nil) userGroupsConfig = .userGroups(userGroupsConf) - mockLibSessionCache + try await mockLibSessionCache .when { $0.config(for: .userGroups, sessionId: .any) } .thenReturn(userGroupsConfig) } @@ -323,7 +326,7 @@ class LibSessionSpec: QuickSpec { // MARK: ---- throws when there is no user ed25519 keyPair it("throws when there is no user ed25519 keyPair") { var resultError: Error? = nil - mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) + try await mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) mockStorage.write { db in do { _ = try LibSession.createGroup( @@ -346,7 +349,7 @@ class LibSessionSpec: QuickSpec { it("throws when it fails to generate a new identity ed25519 keyPair") { var resultError: Error? = nil - mockCrypto.when { $0.generate(.ed25519KeyPair()) }.thenReturn(nil) + try await mockCrypto.when { $0.generate(.ed25519KeyPair()) }.thenReturn(nil) mockStorage.write { db in do { @@ -560,11 +563,11 @@ class LibSessionSpec: QuickSpec { ) } - expect(mockLibSessionCache).to(call(.exactly(times: 3)) { - $0.setConfig(for: .any, sessionId: .any, to: .any) - }) - expect(mockLibSessionCache) - .to(call(matchingParameters: .atLeast(2)) { + await mockLibSessionCache + .verify { $0.setConfig(for: .any, sessionId: .any, to: .any) } + .wasCalled(exactly: 3) + await mockLibSessionCache + .verify { $0.setConfig( for: .groupInfo, sessionId: SessionId( @@ -573,9 +576,10 @@ class LibSessionSpec: QuickSpec { ), to: .any ) - }) - expect(mockLibSessionCache) - .to(call(matchingParameters: .atLeast(2)) { + } + .wasCalled(exactly: 1) + await mockLibSessionCache + .verify { $0.setConfig( for: .groupMembers, sessionId: SessionId( @@ -584,9 +588,10 @@ class LibSessionSpec: QuickSpec { ), to: .any ) - }) - expect(mockLibSessionCache) - .to(call(matchingParameters: .atLeast(2)) { + } + .wasCalled(exactly: 1) + await mockLibSessionCache + .verify { $0.setConfig( for: .groupKeys, sessionId: SessionId( @@ -595,15 +600,16 @@ class LibSessionSpec: QuickSpec { ), to: .any ) - }) + } + .wasCalled(exactly: 1) } } // MARK: -- when saving a created a group context("when saving a created a group") { beforeEach { - mockLibSessionCache.when { $0.configNeedsDump(.any) }.thenReturn(true) - mockLibSessionCache + try await mockLibSessionCache.when { $0.configNeedsDump(.any) }.thenReturn(true) + try await mockLibSessionCache .when { try $0.createDump(config: .any, for: .any, sessionId: .any, timestampMs: .any) } .then { args in mockStorage.write { db in @@ -682,15 +688,16 @@ class LibSessionSpec: QuickSpec { ) } - expect(mockLibSessionCache) - .to(call(.exactly(times: 1), matchingParameters: .all) { + await mockLibSessionCache + .verify { try $0.performAndPushChange( .any, for: .userGroups, sessionId: SessionId(.standard, hex: TestConstants.publicKey), change: { _ in } ) - }) + } + .wasCalled(exactly: 1) } } } diff --git a/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift index 56ab349c08..7cc9090dd2 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift @@ -10,10 +10,9 @@ import Nimble @testable import SessionMessagingKit -class LibSessionUtilSpec: QuickSpec { +class LibSessionUtilSpec: AsyncSpec { static let maxMessageSizeBytes: Int = 76800 // Storage server's limit, should match `config.hpp` in libSession - // FIXME: Would be good to move the identity generation into the libSession-util instead of using Sodium separately static let userSeed: Data = Data(hex: "0123456789abcdef0123456789abcdef") static let seed: Data = Data( hex: "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210" @@ -882,12 +881,20 @@ fileprivate extension LibSessionUtilSpec { expect(pushData5.pointee.seqno).to(equal(3)) expect(pushData6.pointee.seqno).to(equal(3)) - // They should have resolved the conflict to the same thing: - expect(String(cString: user_profile_get_name(conf)!)).to(equal("Nibbler")) - expect(String(cString: user_profile_get_name(conf2)!)).to(equal("Nibbler")) - // (Note that they could have also both resolved to "Raz" here, but the hash of the serialized - // message just happens to have a higher hash -- and thus gets priority -- for this particular - // test). + // They should have resolved the conflict to the same thing - since the configs set + // a timestamp to the current time when modifying the profile (and we don't have a + // mechanism via the C API to set it directly, we can do this directly in the C++ but + // not here) we don't actually know whether "Nibbler" or "Raz" will win here so instead + // the best we can do is insure they match each other, and that they match one of the options + let confNamePtr: UnsafePointer? = user_profile_get_name(conf) + let conf2NamePtr: UnsafePointer? = user_profile_get_name(conf2) + try require(confNamePtr).toNot(beNil()) + try require(conf2NamePtr).toNot(beNil()) + let confName: String = String(cString: confNamePtr!) + let conf2Name: String = String(cString: conf2NamePtr!) + expect(Set(["Nibbler", "Raz"])).to(contain(confName)) + expect(Set(["Nibbler", "Raz"])).to(contain(conf2Name)) + expect(confName).to(equal(conf2Name)) // Since only one of them set a profile pic there should be no conflict there: let pic3: user_profile_pic = user_profile_get_pic(conf) diff --git a/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupSpec.swift b/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupSpec.swift index 7b04fa35a1..c2ef78e099 100644 --- a/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupSpec.swift @@ -2,24 +2,27 @@ import Foundation import SessionUtilitiesKit +import TestUtilities import Quick import Nimble @testable import SessionMessagingKit -class CryptoOpenGroupSpec: QuickSpec { +class CryptoOpenGroupSpec: AsyncSpec { override class func spec() { // MARK: Configuration @TestState var dependencies: TestDependencies! = TestDependencies() - @TestState(singleton: .crypto, in: dependencies) var crypto: Crypto! = Crypto(using: dependencies) - @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( - initialSetup: { cache in - cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) - cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) - } - ) + @TestState var crypto: Crypto! = Crypto(using: dependencies) + @TestState var mockGeneralCache: MockGeneralCache! = .create(using: dependencies) + + beforeEach { + dependencies.set(singleton: .crypto, to: crypto) + + try await mockGeneralCache.defaultInitialSetup() + dependencies.set(cache: .general, to: mockGeneralCache) + } // MARK: - Crypto for Open Group describe("Crypto for Open Group") { @@ -84,7 +87,7 @@ class CryptoOpenGroupSpec: QuickSpec { // MARK: ---- throws an error if there is no ed25519 keyPair it("throws an error if there is no ed25519 keyPair") { - mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) + try await mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) expect { try crypto.tryGenerate( @@ -139,7 +142,7 @@ class CryptoOpenGroupSpec: QuickSpec { // MARK: ---- throws an error if there is no ed25519 keyPair it("throws an error if there is no ed25519 keyPair") { - mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) + try await mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) expect { try crypto.tryGenerate( diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index f0933e8d92..a8c7055f38 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -4,7 +4,9 @@ import UIKit import Combine import GRDB import SessionUtil +import SessionNetworkingKit import SessionUtilitiesKit +import TestUtilities import Quick import Nimble @@ -12,7 +14,7 @@ import Nimble @testable import SessionMessagingKit @testable import SessionNetworkingKit -class OpenGroupManagerSpec: QuickSpec { +class OpenGroupManagerSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -108,11 +110,43 @@ class OpenGroupManagerSpec: QuickSpec { base64EncodedMessage: try! proto.build().serializedData().base64EncodedString() ) }() - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + @TestState var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), - migrations: SNMessagingKit.migrations, - using: dependencies, - initialData: { db in + using: dependencies + ) + @TestState var mockJobRunner: MockJobRunner! = .create(using: dependencies) + @TestState var mockNetwork: MockNetwork! = .create(using: dependencies) + @TestState var mockCrypto: MockCrypto! = .create(using: dependencies) + @TestState var mockUserDefaults: MockUserDefaults! = .create(using: dependencies) + @TestState var mockAppGroupDefaults: MockUserDefaults! = .create(using: dependencies) + @TestState var mockGeneralCache: MockGeneralCache! = .create(using: dependencies) + @TestState var mockLibSessionCache: MockLibSessionCache! = .create(using: dependencies) + @TestState var mockPoller: MockPoller! = .create(using: dependencies) + @TestState var mockCommunityPollerManager: MockCommunityPollerManager! = .create(using: dependencies) + @TestState var mockKeychain: MockKeychain! = .create(using: dependencies) + @TestState var mockFileManager: MockFileManager! = .create(using: dependencies) + @TestState var userGroupsConf: UnsafeMutablePointer! + @TestState var userGroupsInitResult: Int32! = { + var secretKey: [UInt8] = Array(Data(hex: TestConstants.edSecretKey)) + + return user_groups_init(&userGroupsConf, &secretKey, nil, 0, nil) + }() + @TestState var disposables: [AnyCancellable]! = [] + + @TestState var openGroupManager: OpenGroupManager! = OpenGroupManager(using: dependencies) + + beforeEach { + try await mockGeneralCache.defaultInitialSetup() + dependencies.set(cache: .general, to: mockGeneralCache) + + try await mockLibSessionCache.defaultInitialSetup() + dependencies.set(cache: .libSession, to: mockLibSessionCache) + + try await mockFileManager.defaultInitialSetup() + dependencies.set(singleton: .fileManager, to: mockFileManager) + + try await mockStorage.perform(migrations: SNMessagingKit.migrations) + try await mockStorage.writeAsync { db in try Identity(variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey)).insert(db) try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).insert(db) try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) @@ -122,145 +156,100 @@ class OpenGroupManagerSpec: QuickSpec { try testOpenGroup.insert(db) try Capability(openGroupServer: testOpenGroup.server, variant: .sogs, isMissing: false).insert(db) } - ) - @TestState(singleton: .jobRunner, in: dependencies) var mockJobRunner: MockJobRunner! = MockJobRunner( - initialSetup: { jobRunner in - jobRunner - .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } - .thenReturn(nil) - jobRunner - .when { $0.upsert(.any, job: .any, canStartJob: .any) } - .thenReturn(nil) - jobRunner - .when { $0.jobInfoFor(jobs: .any, state: .any, variant: .any) } - .thenReturn([:]) - } - ) - @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( - initialSetup: { network in - network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn(MockNetwork.errorResponse()) - } - ) - @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( - initialSetup: { crypto in - crypto.when { $0.generate(.hash(message: .any, length: .any)) }.thenReturn([]) - crypto - .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } - .thenReturn( - KeyPair( - publicKey: Data(hex: TestConstants.publicKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ) + dependencies.set(singleton: .storage, to: mockStorage) + + try await mockCrypto.when { $0.generate(.hash(message: .any, length: .any)) }.thenReturn([]) + try await mockCrypto + .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } + .thenReturn( + KeyPair( + publicKey: Data(hex: TestConstants.publicKey).bytes, + secretKey: Data(hex: TestConstants.edSecretKey).bytes ) - crypto - .when { $0.generate(.blinded25KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } - .thenReturn( - KeyPair( - publicKey: Data(hex: TestConstants.publicKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ) + ) + try await mockCrypto + .when { $0.generate(.blinded25KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } + .thenReturn( + KeyPair( + publicKey: Data(hex: TestConstants.publicKey).bytes, + secretKey: Data(hex: TestConstants.edSecretKey).bytes ) - crypto - .when { $0.generate(.signatureBlind15(message: .any, serverPublicKey: .any, ed25519SecretKey: .any)) } - .thenReturn("TestSogsSignature".bytes) - crypto - .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } - .thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes)) - crypto - .when { $0.generate(.randomBytes(16)) } - .thenReturn(Array(Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!)) - crypto - .when { $0.generate(.randomBytes(24)) } - .thenReturn(Array(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!)) - crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } - .thenReturn( - KeyPair( - publicKey: Array(Data(hex: TestConstants.edPublicKey)), - secretKey: Array(Data(hex: TestConstants.edSecretKey)) - ) + ) + try await mockCrypto + .when { $0.generate(.signatureBlind15(message: .any, serverPublicKey: .any, ed25519SecretKey: .any)) } + .thenReturn("TestSogsSignature".bytes) + try await mockCrypto + .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } + .thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes)) + try await mockCrypto + .when { $0.generate(.randomBytes(16)) } + .thenReturn(Array(Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!)) + try await mockCrypto + .when { $0.generate(.randomBytes(24)) } + .thenReturn(Array(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!)) + try await mockCrypto + .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .thenReturn( + KeyPair( + publicKey: Array(Data(hex: TestConstants.edPublicKey)), + secretKey: Array(Data(hex: TestConstants.edSecretKey)) ) - crypto - .when { $0.generate(.ciphertextWithXChaCha20(plaintext: .any, encKey: .any)) } - .thenReturn(Data([1, 2, 3])) - } - ) - @TestState(defaults: .standard, in: dependencies) var mockUserDefaults: MockUserDefaults! = MockUserDefaults( - initialSetup: { defaults in - defaults.when { $0.integer(forKey: .any) }.thenReturn(0) - defaults.when { $0.set(.any, forKey: .any) }.thenReturn(()) - } - ) - @TestState(defaults: .appGroup, in: dependencies) var mockAppGroupDefaults: MockUserDefaults! = MockUserDefaults( - initialSetup: { defaults in - defaults.when { $0.bool(forKey: .any) }.thenReturn(false) - } - ) - @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( - initialSetup: { cache in - cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) - cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) - cache - .when { $0.ed25519Seed } - .thenReturn(Array(Array(Data(hex: TestConstants.edSecretKey)).prefix(upTo: 32))) - } - ) - @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( - initialSetup: { $0.defaultInitialSetup() } - ) - @TestState(cache: .openGroupManager, in: dependencies) var mockOGMCache: MockOGMCache! = MockOGMCache( - initialSetup: { cache in - cache.when { $0.pendingChanges }.thenReturn([]) - cache.when { $0.pendingChanges = .any }.thenReturn(()) - cache.when { $0.getLastSuccessfulCommunityPollTimestamp() }.thenReturn(0) - cache.when { $0.setDefaultRoomInfo(.any) }.thenReturn(()) - } - ) - @TestState var mockPoller: MockCommunityPoller! = MockCommunityPoller( - initialSetup: { poller in - poller.when { $0.startIfNeeded() }.thenReturn(()) - poller.when { $0.stop() }.thenReturn(()) - } - ) - @TestState(cache: .communityPollers, in: dependencies) var mockCommunityPollerCache: MockCommunityPollerCache! = MockCommunityPollerCache( - initialSetup: { cache in - cache.when { $0.serversBeingPolled }.thenReturn([]) - cache.when { $0.startAllPollers() }.thenReturn(()) - cache.when { $0.getOrCreatePoller(for: .any) }.thenReturn(mockPoller) - cache.when { $0.stopAndRemovePoller(for: .any) }.thenReturn(()) - cache.when { $0.stopAndRemoveAllPollers() }.thenReturn(()) - } - ) - @TestState(singleton: .keychain, in: dependencies) var mockKeychain: MockKeychain! = MockKeychain( - initialSetup: { keychain in - keychain - .when { - try $0.getOrGenerateEncryptionKey( - forKey: .any, - length: .any, - cat: .any, - legacyKey: .any, - legacyService: .any - ) - } - .thenReturn(Data([1, 2, 3])) - } - ) - @TestState(singleton: .fileManager, in: dependencies) var mockFileManager: MockFileManager! = MockFileManager( - initialSetup: { $0.defaultInitialSetup() } - ) - @TestState var userGroupsConf: UnsafeMutablePointer! - @TestState var userGroupsInitResult: Int32! = { - var secretKey: [UInt8] = Array(Data(hex: TestConstants.edSecretKey)) + ) + try await mockCrypto + .when { $0.generate(.ciphertextWithXChaCha20(plaintext: .any, encKey: .any)) } + .thenReturn(Data([1, 2, 3])) + dependencies.set(singleton: .crypto, to: mockCrypto) - return user_groups_init(&userGroupsConf, &secretKey, nil, 0, nil) - }() - @TestState var disposables: [AnyCancellable]! = [] - - @TestState var cache: OpenGroupManager.Cache! = OpenGroupManager.Cache(using: dependencies) - @TestState var openGroupManager: OpenGroupManager! = OpenGroupManager(using: dependencies) + try await mockPoller.when { await $0.startIfNeeded() }.thenReturn(()) + try await mockPoller.when { await $0.stop() }.thenReturn(()) + + try await mockCommunityPollerManager.when { await $0.serversBeingPolled }.thenReturn([]) + try await mockCommunityPollerManager.when { await $0.startAllPollers() }.thenReturn(()) + try await mockCommunityPollerManager + .when { await $0.getOrCreatePoller(for: .any) } + .thenReturn(mockPoller) + try await mockCommunityPollerManager.when { await $0.stopAndRemovePoller(for: .any) }.thenReturn(()) + try await mockCommunityPollerManager.when { await $0.stopAndRemoveAllPollers() }.thenReturn(()) + try await mockCommunityPollerManager + .when { $0.syncState } + .thenReturn(CommunityPollerManagerSyncState()) + dependencies.set(singleton: .communityPollerManager, to: mockCommunityPollerManager) + + try await mockKeychain + .when { + try $0.getOrGenerateEncryptionKey( + forKey: .any, + length: .any, + cat: .any, + legacyKey: .any, + legacyService: .any + ) + } + .thenReturn(Data([1, 2, 3])) + dependencies.set(singleton: .keychain, to: mockKeychain) + + try await mockUserDefaults.defaultInitialSetup() + try await mockUserDefaults.when { $0.integer(forKey: .any) }.thenReturn(0) + dependencies.set(defaults: .standard, to: mockUserDefaults) + + try await mockAppGroupDefaults.defaultInitialSetup() + try await mockAppGroupDefaults.when { $0.bool(forKey: .any) }.thenReturn(false) + dependencies.set(defaults: .appGroup, to: mockAppGroupDefaults) + + try await mockJobRunner + .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } + .thenReturn(nil) + try await mockJobRunner + .when { $0.upsert(.any, job: .any, canStartJob: .any) } + .thenReturn(nil) + try await mockJobRunner + .when { $0.jobInfoFor(jobs: .any, state: .any, variant: .any) } + .thenReturn([:]) + dependencies.set(singleton: .jobRunner, to: mockJobRunner) + + try await mockNetwork.defaultInitialSetup(using: dependencies) + dependencies.set(singleton: .network, to: mockNetwork) + } // MARK: - an OpenGroupManager describe("an OpenGroupManager") { @@ -268,67 +257,58 @@ class OpenGroupManagerSpec: QuickSpec { _ = userGroupsInitResult } - // MARK: -- cache data - context("cache data") { - // MARK: ---- defaults the time since last open to zero - it("defaults the time since last open to zero") { - mockUserDefaults - .when { (defaults: inout any UserDefaultsType) -> Any? in - defaults.object(forKey: UserDefaults.DateKey.lastOpen.rawValue) - } - .thenReturn(nil) - - expect(cache.getLastSuccessfulCommunityPollTimestamp()).to(equal(0)) - } + // MARK: -- defaults the time since last open to zero + it("defaults the time since last open to zero") { + try await mockUserDefaults + .when { $0.object(forKey: UserDefaults.DateKey.lastOpen.rawValue) } + .thenReturn(nil) - // MARK: ---- returns the time since the last poll - it("returns the time since the last poll") { - mockUserDefaults - .when { (defaults: inout any UserDefaultsType) -> Any? in - defaults.object(forKey: UserDefaults.DateKey.lastOpen.rawValue) - } - .thenReturn(Date(timeIntervalSince1970: 1234567880)) - dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) - - expect(cache.getLastSuccessfulCommunityPollTimestamp()) - .to(equal(1234567880)) - } + await expect { await openGroupManager.getLastSuccessfulCommunityPollTimestamp() } + .toEventually(equal(0)) + } + + // MARK: -- returns the time since the last poll + it("returns the time since the last poll") { + try await mockUserDefaults + .when { $0.object(forKey: UserDefaults.DateKey.lastOpen.rawValue) } + .thenReturn(Date(timeIntervalSince1970: 1234567880)) + dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) - // MARK: ---- caches the time since the last poll in memory - it("caches the time since the last poll in memory") { - mockUserDefaults - .when { (defaults: inout any UserDefaultsType) -> Any? in - defaults.object(forKey: UserDefaults.DateKey.lastOpen.rawValue) - } - .thenReturn(Date(timeIntervalSince1970: 1234567770)) - dependencies.dateNow = Date(timeIntervalSince1970: 1234567780) - - expect(cache.getLastSuccessfulCommunityPollTimestamp()) - .to(equal(1234567770)) - - mockUserDefaults - .when { (defaults: inout any UserDefaultsType) -> Any? in - defaults.object(forKey: UserDefaults.DateKey.lastOpen.rawValue) - } - .thenReturn(Date(timeIntervalSince1970: 1234567890)) - - // Cached value shouldn't have been updated - expect(cache.getLastSuccessfulCommunityPollTimestamp()) - .to(equal(1234567770)) - } + await expect { await openGroupManager.getLastSuccessfulCommunityPollTimestamp() } + .toEventually(equal(1234567880)) + } + + // MARK: -- caches the time since the last poll in memory + it("caches the time since the last poll in memory") { + try await mockUserDefaults + .when { $0.object(forKey: UserDefaults.DateKey.lastOpen.rawValue) } + .thenReturn(Date(timeIntervalSince1970: 1234567770)) + dependencies.dateNow = Date(timeIntervalSince1970: 1234567780) - // MARK: ---- updates the time since the last poll in user defaults - it("updates the time since the last poll in user defaults") { - cache.setLastSuccessfulCommunityPollTimestamp(12345) - - expect(mockUserDefaults) - .to(call(matchingParameters: .all) { - $0.set( - Date(timeIntervalSince1970: 12345), - forKey: UserDefaults.DateKey.lastOpen.rawValue - ) - }) - } + await expect { await openGroupManager.getLastSuccessfulCommunityPollTimestamp() } + .toEventually(equal(1234567770)) + + try await mockUserDefaults + .when { $0.object(forKey: UserDefaults.DateKey.lastOpen.rawValue) } + .thenReturn(Date(timeIntervalSince1970: 1234567890)) + + // Cached value shouldn't have been updated + await expect { await openGroupManager.getLastSuccessfulCommunityPollTimestamp() } + .toEventually(equal(1234567770)) + } + + // MARK: -- updates the time since the last poll in user defaults + it("updates the time since the last poll in user defaults") { + await openGroupManager.setLastSuccessfulCommunityPollTimestamp(12345) + + await mockUserDefaults + .verify { + $0.set( + Date(timeIntervalSince1970: 12345), + forKey: UserDefaults.DateKey.lastOpen.rawValue + ) + } + .wasCalled(exactly: 1) } // MARK: -- when checking if an open group is run by session @@ -405,7 +385,11 @@ class OpenGroupManagerSpec: QuickSpec { // 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") { beforeEach { - mockCommunityPollerCache.when { $0.serversBeingPolled }.thenReturn(["http://127.0.0.1"]) + try await mockCommunityPollerManager + .when { $0.syncState } + .thenReturn(CommunityPollerManagerSyncState( + serversBeingPolled: ["http://127.0.0.1"] + )) } // MARK: ------ for the no-scheme variant @@ -548,7 +532,11 @@ class OpenGroupManagerSpec: QuickSpec { context("when given the legacy DNS host and there is a cached poller for the default server") { // MARK: ------ returns true it("returns true") { - mockCommunityPollerCache.when { $0.serversBeingPolled }.thenReturn(["http://116.203.70.33"]) + try await mockCommunityPollerManager + .when { $0.syncState } + .thenReturn(CommunityPollerManagerSyncState( + serversBeingPolled: ["http://116.203.70.33"] + )) mockStorage.write { db in try SessionThread( id: OpenGroup.idFor(roomToken: "testRoom", server: "http://116.203.70.33"), @@ -580,7 +568,11 @@ class OpenGroupManagerSpec: QuickSpec { context("when given the default server and there is a cached poller for the legacy DNS host") { // MARK: ------ returns true it("returns true") { - mockCommunityPollerCache.when { $0.serversBeingPolled }.thenReturn(["http://open.getsession.org"]) + try await mockCommunityPollerManager + .when { $0.syncState } + .thenReturn(CommunityPollerManagerSyncState( + serversBeingPolled: ["http://open.getsession.org"] + )) mockStorage.write { db in try SessionThread( id: OpenGroup.idFor(roomToken: "testRoom", server: "http://open.getsession.org"), @@ -624,7 +616,9 @@ class OpenGroupManagerSpec: QuickSpec { // 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") { - mockCommunityPollerCache.when { $0.serversBeingPolled }.thenReturn([]) + try await mockCommunityPollerManager + .when { await $0.serversBeingPolled } + .thenReturn([]) expect( mockStorage.read { db -> Bool in @@ -667,14 +661,21 @@ class OpenGroupManagerSpec: QuickSpec { try OpenGroup.deleteAll(db) } - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockCapabilitiesAndRoomResponse) - mockUserDefaults - .when { (defaults: inout any UserDefaultsType) -> Any? in - defaults.object(forKey: UserDefaults.DateKey.lastOpen.rawValue) - } + try await mockUserDefaults + .when { $0.object(forKey: UserDefaults.DateKey.lastOpen.rawValue) } .thenReturn(Date(timeIntervalSince1970: 1234567890)) } @@ -735,29 +736,34 @@ class OpenGroupManagerSpec: QuickSpec { } .sinkAndStore(in: &disposables) - expect(mockCommunityPollerCache) - .to(call(matchingParameters: .all) { - $0.getOrCreatePoller( + await mockCommunityPollerManager + .verify { + await $0.getOrCreatePoller( for: CommunityPoller.Info( server: "http://127.0.0.1", pollFailureCount: 0 ) ) - }) - expect(mockPoller).to(call { $0.startIfNeeded() }) + } + .wasCalled(exactly: 1, timeout: .milliseconds(100)) + await mockPoller.verify { await $0.startIfNeeded() }.wasCalled() } // MARK: ---- an existing room context("an existing room") { beforeEach { - mockCommunityPollerCache.when { $0.serversBeingPolled }.thenReturn(["http://127.0.0.1"]) + try await mockCommunityPollerManager + .when { $0.syncState } + .thenReturn(CommunityPollerManagerSyncState( + serversBeingPolled: ["http://127.0.0.1"] + )) mockStorage.write { db in try testOpenGroup.insert(db) } } - // MARK: ------ does not reset the sequence number or update the public key - it("does not reset the sequence number or update the public key") { + // MARK: ------ does not reset the sequence number + it("does not reset the sequence number") { mockStorage .writePublisher { db -> Bool in openGroupManager.add( @@ -783,36 +789,35 @@ class OpenGroupManagerSpec: QuickSpec { } .sinkAndStore(in: &disposables) - expect( + await expect( mockStorage.read { db in try OpenGroup .select(.sequenceNumber) .asRequest(of: Int64.self) .fetchOne(db) } - ).to(equal(5)) - expect( - mockStorage.read { db in - try OpenGroup - .select(.publicKey) - .asRequest(of: String.self) - .fetchOne(db) - } - ).to(equal(TestConstants.publicKey)) + ).toEventually(equal(5)) } } // MARK: ---- with an invalid response context("with an invalid response") { beforeEach { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn(MockNetwork.response(data: Data())) - mockUserDefaults - .when { (defaults: inout any UserDefaultsType) -> Any? in - defaults.object(forKey: UserDefaults.DateKey.lastOpen.rawValue) - } + try await mockUserDefaults + .when { $0.object(forKey: UserDefaults.DateKey.lastOpen.rawValue) } .thenReturn(Date(timeIntervalSince1970: 1234567890)) } @@ -906,8 +911,9 @@ class OpenGroupManagerSpec: QuickSpec { ) } - expect(mockCommunityPollerCache) - .to(call(matchingParameters: .all) { $0.stopAndRemovePoller(for: "http://127.0.0.1") }) + await mockCommunityPollerManager + .verify { await $0.stopAndRemovePoller(for: "http://127.0.0.1") } + .wasCalled() } // MARK: ------ removes the open group @@ -1040,15 +1046,14 @@ class OpenGroupManagerSpec: QuickSpec { context("when handling capabilities") { beforeEach { mockStorage.write { db in - OpenGroupManager - .handleCapabilities( - db, - capabilities: Network.SOGS.CapabilitiesResponse( - capabilities: ["sogs"], - missing: [] - ), - on: "http://127.0.0.1" - ) + openGroupManager.handleCapabilities( + db, + capabilities: Network.SOGS.CapabilitiesResponse( + capabilities: ["sogs"], + missing: [] + ), + on: "http://127.0.0.1" + ) } } @@ -1075,13 +1080,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- saves the updated open group it("saves the updated open group") { mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try openGroupManager.handlePollInfo( db, pollInfo: testPollInfo, publicKey: TestConstants.publicKey, for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -1098,18 +1102,17 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- does not schedule the displayPictureDownload job if there is no image it("does not schedule the displayPictureDownload job if there is no image") { mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try openGroupManager.handlePollInfo( db, pollInfo: testPollInfo, publicKey: TestConstants.publicKey, for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } - expect(mockJobRunner) - .toNot(call(matchingParameters: .all) { + await mockJobRunner + .verify { $0.add( .any, job: Job( @@ -1127,7 +1130,8 @@ class OpenGroupManagerSpec: QuickSpec { dependantJob: nil, canStartJob: true ) - }) + } + .wasNotCalled() } // MARK: ---- schedules the displayPictureDownload job if there is an image @@ -1147,18 +1151,17 @@ class OpenGroupManagerSpec: QuickSpec { } mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try openGroupManager.handlePollInfo( db, pollInfo: testPollInfo, publicKey: TestConstants.publicKey, for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } - expect(mockJobRunner) - .to(call(matchingParameters: .all) { + await mockJobRunner + .verify { $0.add( .any, job: Job( @@ -1176,7 +1179,8 @@ class OpenGroupManagerSpec: QuickSpec { dependantJob: nil, canStartJob: true ) - }) + } + .wasCalled(exactly: 1) } // MARK: ---- and updating the moderator list @@ -1195,13 +1199,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try openGroupManager.handlePollInfo( db, pollInfo: testPollInfo, publicKey: TestConstants.publicKey, for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -1242,13 +1245,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try openGroupManager.handlePollInfo( db, pollInfo: testPollInfo, publicKey: TestConstants.publicKey, for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -1283,13 +1285,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try openGroupManager.handlePollInfo( db, pollInfo: testPollInfo, publicKey: TestConstants.publicKey, for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -1314,13 +1315,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try openGroupManager.handlePollInfo( db, pollInfo: testPollInfo, publicKey: TestConstants.publicKey, for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -1361,13 +1361,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try openGroupManager.handlePollInfo( db, pollInfo: testPollInfo, publicKey: TestConstants.publicKey, for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -1403,13 +1402,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try openGroupManager.handlePollInfo( db, pollInfo: testPollInfo, publicKey: TestConstants.publicKey, for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -1427,13 +1425,12 @@ class OpenGroupManagerSpec: QuickSpec { } mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try openGroupManager.handlePollInfo( db, pollInfo: testPollInfo, publicKey: TestConstants.publicKey, for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -1446,13 +1443,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ saves the open group with the existing public key it("saves the open group with the existing public key") { mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try openGroupManager.handlePollInfo( db, pollInfo: testPollInfo, publicKey: nil, for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -1489,13 +1485,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try openGroupManager.handlePollInfo( db, pollInfo: testPollInfo, publicKey: TestConstants.publicKey, for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -1507,8 +1502,8 @@ class OpenGroupManagerSpec: QuickSpec { .fetchOne(db) } ).to(equal("10")) - expect(mockJobRunner) - .to(call(.exactly(times: 1), matchingParameters: .all) { + await mockJobRunner + .verify { $0.add( .any, job: Job( @@ -1526,7 +1521,8 @@ class OpenGroupManagerSpec: QuickSpec { dependantJob: nil, canStartJob: true ) - }) + } + .wasCalled(exactly: 1) } // MARK: ------ uses the existing room image id if none is provided @@ -1553,13 +1549,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try openGroupManager.handlePollInfo( db, pollInfo: testPollInfo, publicKey: TestConstants.publicKey, for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -1579,7 +1574,9 @@ class OpenGroupManagerSpec: QuickSpec { .fetchOne(db) } ).toNot(beNil()) - expect(mockJobRunner).toNot(call { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) }) + await mockJobRunner + .verify { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } + .wasNotCalled() } // MARK: ------ uses the new room image id if there is an existing one @@ -1611,13 +1608,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try openGroupManager.handlePollInfo( db, pollInfo: testPollInfo, publicKey: TestConstants.publicKey, for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -1637,8 +1633,8 @@ class OpenGroupManagerSpec: QuickSpec { .fetchOne(db) } ).toNot(beNil()) - expect(mockJobRunner) - .to(call(.exactly(times: 1), matchingParameters: .all) { + await mockJobRunner + .verify { $0.add( .any, job: Job( @@ -1656,19 +1652,19 @@ class OpenGroupManagerSpec: QuickSpec { dependantJob: nil, canStartJob: true ) - }) + } + .wasCalled(exactly: 1) } // MARK: ------ does nothing if there is no room image it("does nothing if there is no room image") { mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try openGroupManager.handlePollInfo( db, pollInfo: testPollInfo, publicKey: TestConstants.publicKey, for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -1700,7 +1696,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( + openGroupManager.handleMessages( db, messages: [ Network.SOGS.Message( @@ -1719,8 +1715,7 @@ class OpenGroupManagerSpec: QuickSpec { ) ], for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -1737,12 +1732,11 @@ class OpenGroupManagerSpec: QuickSpec { // 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( + openGroupManager.handleMessages( db, messages: [], for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -1763,7 +1757,7 @@ class OpenGroupManagerSpec: QuickSpec { } mockStorage.write { db in - OpenGroupManager.handleMessages( + openGroupManager.handleMessages( db, messages: [ Network.SOGS.Message( @@ -1782,8 +1776,7 @@ class OpenGroupManagerSpec: QuickSpec { ) ], for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -1797,7 +1790,7 @@ class OpenGroupManagerSpec: QuickSpec { } mockStorage.write { db in - OpenGroupManager.handleMessages( + openGroupManager.handleMessages( db, messages: [ Network.SOGS.Message( @@ -1816,8 +1809,7 @@ class OpenGroupManagerSpec: QuickSpec { ) ], for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -1827,12 +1819,11 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- processes a message with valid data it("processes a message with valid data") { mockStorage.write { db in - OpenGroupManager.handleMessages( + openGroupManager.handleMessages( db, messages: [testMessage], for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -1842,7 +1833,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- processes valid messages when combined with invalid ones it("processes valid messages when combined with invalid ones") { mockStorage.write { db in - OpenGroupManager.handleMessages( + openGroupManager.handleMessages( db, messages: [ Network.SOGS.Message( @@ -1862,8 +1853,7 @@ class OpenGroupManagerSpec: QuickSpec { testMessage, ], for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -1883,7 +1873,7 @@ class OpenGroupManagerSpec: QuickSpec { } mockStorage.write { db in - OpenGroupManager.handleMessages( + openGroupManager.handleMessages( db, messages: [ Network.SOGS.Message( @@ -1902,8 +1892,7 @@ class OpenGroupManagerSpec: QuickSpec { ) ], for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -1913,7 +1902,7 @@ class OpenGroupManagerSpec: QuickSpec { // 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( + openGroupManager.handleMessages( db, messages: [ Network.SOGS.Message( @@ -1932,8 +1921,7 @@ class OpenGroupManagerSpec: QuickSpec { ) ], for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -1945,7 +1933,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: -- when handling direct messages context("when handling direct messages") { beforeEach { - mockCrypto + try await mockCrypto .when { $0.generate( .plaintextWithSessionBlindingProtocol( @@ -1962,7 +1950,7 @@ class OpenGroupManagerSpec: QuickSpec { Data([UInt8](repeating: 0, count: 32)), senderSessionIdHex: "05\(TestConstants.publicKey)" )) - mockCrypto + try await mockCrypto .when { $0.generate(.x25519(ed25519Pubkey: .any)) } .thenReturn(Data(hex: TestConstants.publicKey).bytes) } @@ -1970,12 +1958,11 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- does nothing if there are no messages it("does nothing if there are no messages") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + openGroupManager.handleDirectMessages( db, messages: [], fromOutbox: false, - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -2004,12 +1991,11 @@ class OpenGroupManagerSpec: QuickSpec { } mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + openGroupManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: false, - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -2043,12 +2029,11 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + openGroupManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: false, - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -2058,7 +2043,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- for the inbox context("for the inbox") { beforeEach { - mockCrypto + try await mockCrypto .when { $0.verify(.sessionId(.any, matchesBlindedId: .any, serverPublicKey: .any)) } .thenReturn(false) } @@ -2066,12 +2051,11 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ updates the inbox latest message id it("updates the inbox latest message id") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + openGroupManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: false, - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -2087,7 +2071,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ ignores a message with invalid data it("ignores a message with invalid data") { - mockCrypto + try await mockCrypto .when { $0.generate( .plaintextWithSessionBlindingProtocol( @@ -2104,12 +2088,11 @@ class OpenGroupManagerSpec: QuickSpec { )) mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + openGroupManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: false, - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -2119,12 +2102,11 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ processes a message with valid data it("processes a message with valid data") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + openGroupManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: false, - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -2134,7 +2116,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ processes valid messages when combined with invalid ones it("processes valid messages when combined with invalid ones") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + openGroupManager.handleDirectMessages( db, messages: [ Network.SOGS.DirectMessage( @@ -2148,8 +2130,7 @@ class OpenGroupManagerSpec: QuickSpec { testDirectMessage ], fromOutbox: false, - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -2160,7 +2141,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- for the outbox context("for the outbox") { beforeEach { - mockCrypto + try await mockCrypto .when { $0.verify(.sessionId(.any, matchesBlindedId: .any, serverPublicKey: .any)) } .thenReturn(false) } @@ -2168,12 +2149,11 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ updates the outbox latest message id it("updates the outbox latest message id") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + openGroupManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: true, - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -2199,12 +2179,11 @@ class OpenGroupManagerSpec: QuickSpec { } mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + openGroupManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: true, - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -2215,12 +2194,11 @@ class OpenGroupManagerSpec: QuickSpec { // 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( + openGroupManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: true, - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -2243,7 +2221,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ ignores a message with invalid data it("ignores a message with invalid data") { - mockCrypto + try await mockCrypto .when { $0.generate( .plaintextWithSessionBlindingProtocol( @@ -2260,12 +2238,11 @@ class OpenGroupManagerSpec: QuickSpec { )) mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + openGroupManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: true, - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -2275,12 +2252,11 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ processes a message with valid data it("processes a message with valid data") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + openGroupManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: true, - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -2290,7 +2266,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ processes valid messages when combined with invalid ones it("processes valid messages when combined with invalid ones") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + openGroupManager.handleDirectMessages( db, messages: [ Network.SOGS.DirectMessage( @@ -2304,8 +2280,7 @@ class OpenGroupManagerSpec: QuickSpec { testDirectMessage ], fromOutbox: true, - on: "http://127.0.0.1", - using: dependencies + on: "http://127.0.0.1" ) } @@ -2502,7 +2477,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ generates and unblinded key if the key belongs to the current user it("generates and unblinded key if the key belongs to the current user") { - mockGeneralCache.when { $0.ed25519Seed }.thenReturn([4, 5, 6]) + try await mockGeneralCache.when { $0.ed25519Seed }.thenReturn([4, 5, 6]) mockStorage.read { db in openGroupManager.isUserModeratorOrAdmin( db, @@ -2513,18 +2488,18 @@ class OpenGroupManagerSpec: QuickSpec { ) } - expect(mockCrypto).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.generate(.ed25519KeyPair(seed: [4, 5, 6])) - }) + await mockCrypto + .verify { $0.generate(.ed25519KeyPair(seed: [4, 5, 6])) } + .wasCalled(exactly: 1) } } } - // MARK: -- when accessing the default rooms publisher - context("when accessing the default rooms publisher") { + // MARK: -- when accessing the default rooms stream + context("when accessing the default rooms stream") { // MARK: ---- starts a job to retrieve the default rooms if we have none it("starts a job to retrieve the default rooms if we have none") { - mockAppGroupDefaults + try await mockAppGroupDefaults .when { $0.bool(forKey: UserDefaults.BoolKey.isMainAppActive.rawValue) } .thenReturn(true) mockStorage.write { db in @@ -2539,41 +2514,81 @@ class OpenGroupManagerSpec: QuickSpec { ) .insert(db) } - let expectedRequest: Network.PreparedRequest! = mockStorage.read { db in - try Network.SOGS.preparedCapabilitiesAndRooms( - authMethod: Authentication.community( - info: LibSession.OpenGroupCapabilityInfo( - roomToken: "", + _ = await openGroupManager.defaultRooms.first() + + await mockNetwork + .verify { + try await $0.send( + endpoint: Network.SOGS.Endpoint.sequence, + destination: .server( + method: .post, server: Network.SOGS.defaultServer, - publicKey: Network.SOGS.defaultServerPublicKey, - capabilities: [] + queryParameters: [:], + headers: [:], + x25519PublicKey: Network.SOGS.defaultServerPublicKey ), - forceBlinded: false - ), - using: dependencies - ) - } - cache.defaultRoomsPublisher.sinkUntilComplete() - - expect(mockNetwork) - .to(call { network in - network.send( - expectedRequest.body, - to: expectedRequest.destination, - requestTimeout: expectedRequest.requestTimeout, - requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout + body: try JSONEncoder(using: dependencies).encode( + Network.BatchRequest(requests: [ + try Network.PreparedRequest( + request: Request( + endpoint: .capabilities, + authMethod: Authentication.community( + roomToken: "", + server: Network.SOGS.defaultServer, + publicKey: Network.SOGS.defaultServerPublicKey, + hasCapabilities: false, + supportsBlinding: true, + forceBlinded: false + ) + ), + responseType: Network.SOGS.CapabilitiesResponse.self, + using: dependencies + ), + try Network.PreparedRequest<[Network.SOGS.Room]>( + request: Request( + endpoint: .rooms, + authMethod: Authentication.community( + roomToken: "", + server: Network.SOGS.defaultServer, + publicKey: Network.SOGS.defaultServerPublicKey, + hasCapabilities: false, + supportsBlinding: true, + forceBlinded: false + ) + ), + responseType: [Network.SOGS.Room].self, + using: dependencies + ) + ]) + ), + category: .standard, + requestTimeout: Network.defaultTimeout, + overallTimeout: nil ) - }) + } + .wasCalled(exactly: 1, timeout: .milliseconds(100)) } // MARK: ---- does not start a job to retrieve the default rooms if we already have rooms it("does not start a job to retrieve the default rooms if we already have rooms") { - mockAppGroupDefaults.when { $0.bool(forKey: UserDefaults.BoolKey.isMainAppActive.rawValue) }.thenReturn(true) - cache.setDefaultRoomInfo([(room: Network.SOGS.Room.mock, openGroup: OpenGroup.mock)]) - cache.defaultRoomsPublisher.sinkUntilComplete() - - expect(mockNetwork) - .toNot(call { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) }) + try await mockAppGroupDefaults + .when { $0.bool(forKey: UserDefaults.BoolKey.isMainAppActive.rawValue) } + .thenReturn(true) + await openGroupManager.setDefaultRoomInfo([(room: Network.SOGS.Room.mock, openGroup: OpenGroup.mock)]) + _ = await openGroupManager.defaultRooms.first() + + await mockNetwork + .verify { + try await $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } + .wasNotCalled() } } } @@ -2650,8 +2665,17 @@ extension Network.SOGS.RoomPollInfo { // MARK: - Mock Types -extension OpenGroup: Mocked { - static var mock: OpenGroup = OpenGroup( +extension OpenGroup: @retroactive Mocked { + public static var any: OpenGroup = OpenGroup( + server: .any, + roomToken: .any, + publicKey: .any, + isActive: .any, + name: .any, + userCount: .any, + infoUpdates: .any + ) + public static var mock: OpenGroup = OpenGroup( server: "testserver", roomToken: "testRoom", publicKey: TestConstants.serverPublicKey, diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift index e42508adee..3f6f448b30 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift @@ -8,343 +8,18 @@ import Nimble import SessionUtil import SessionUtilitiesKit import SessionUIKit +import TestUtilities @testable import SessionNetworkingKit @testable import SessionMessagingKit -class MessageReceiverGroupsSpec: QuickSpec { +class MessageReceiverGroupsSpec: AsyncSpec { override class func spec() { - // MARK: Configuration + @TestState var fixture: MessageReceiverGroupsTestFixture! - let groupSeed: Data = Data(hex: "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210") - @TestState var groupKeyPair: KeyPair! = Crypto(using: .any).generate(.ed25519KeyPair(seed: Array(groupSeed))) - @TestState var groupId: SessionId! = SessionId(.group, hex: "03cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece") - @TestState var groupSecretKey: Data! = Data(hex: - "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210" + - "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece" - ) - @TestState var dependencies: TestDependencies! = TestDependencies { dependencies in - dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) - dependencies.forceSynchronous = true + beforeEach { + fixture = try await MessageReceiverGroupsTestFixture.create() } - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( - customWriter: try! DatabaseQueue(), - migrations: SNMessagingKit.migrations, - using: dependencies, - initialData: { db in - try Identity(variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey)).insert(db) - try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).insert(db) - try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) - try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) - - try Profile( - id: "05\(TestConstants.publicKey)", - name: "TestCurrentUser" - ).insert(db) - } - ) - @TestState(defaults: .standard, in: dependencies) var mockUserDefaults: MockUserDefaults! = MockUserDefaults( - initialSetup: { userDefaults in - userDefaults.when { $0.string(forKey: .any) }.thenReturn(nil) - } - ) - @TestState(singleton: .jobRunner, in: dependencies) var mockJobRunner: MockJobRunner! = MockJobRunner( - initialSetup: { jobRunner in - jobRunner - .when { $0.jobInfoFor(jobs: .any, state: .any, variant: .any) } - .thenReturn([:]) - jobRunner - .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } - .thenReturn(nil) - jobRunner - .when { $0.upsert(.any, job: .any, canStartJob: .any) } - .thenReturn(nil) - jobRunner - .when { $0.manuallyTriggerResult(.any, result: .any) } - .thenReturn(()) - } - ) - @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( - initialSetup: { network in - network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn(MockNetwork.response(with: FileUploadResponse(id: "1"))) - network - .when { $0.getSwarm(for: .any) } - .thenReturn([ - LibSession.Snode( - ip: "1.1.1.1", - quicPort: 1, - ed25519PubkeyHex: TestConstants.edPublicKey - ), - LibSession.Snode( - ip: "1.1.1.1", - quicPort: 2, - ed25519PubkeyHex: TestConstants.edPublicKey - ), - LibSession.Snode( - ip: "1.1.1.1", - quicPort: 3, - ed25519PubkeyHex: TestConstants.edPublicKey - ) - ]) - } - ) - @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( - initialSetup: { crypto in - crypto - .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } - .thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes)) - crypto - .when { $0.generate(.signatureSubaccount(config: .any, verificationBytes: .any, memberAuthData: .any)) } - .thenReturn(Authentication.Signature.subaccount( - subaccount: "TestSubAccount".bytes, - subaccountSig: "TestSubAccountSignature".bytes, - signature: "TestSignature".bytes - )) - crypto - .when { $0.verify(.signature(message: .any, publicKey: .any, signature: .any)) } - .thenReturn(true) - crypto.when { $0.generate(.ed25519KeyPair(seed: .any)) }.thenReturn(groupKeyPair) - crypto - .when { $0.verify(.memberAuthData(groupSessionId: .any, ed25519SecretKey: .any, memberAuthData: .any)) } - .thenReturn(true) - crypto - .when { $0.generate(.hash(message: .any, key: .any, length: .any)) } - .thenReturn("TestHash".bytes) - } - ) - @TestState(singleton: .keychain, in: dependencies) var mockKeychain: MockKeychain! = MockKeychain( - initialSetup: { keychain in - keychain - .when { - try $0.migrateLegacyKeyIfNeeded( - legacyKey: .any, - legacyService: .any, - toKey: .pushNotificationEncryptionKey - ) - } - .thenReturn(()) - keychain - .when { - try $0.getOrGenerateEncryptionKey( - forKey: .any, - length: .any, - cat: .any, - legacyKey: .any, - legacyService: .any - ) - } - .thenReturn(Data([1, 2, 3])) - keychain - .when { try $0.data(forKey: .pushNotificationEncryptionKey) } - .thenReturn(Data((0..! - _ = user_groups_init(&conf, &secretKey, nil, 0, nil) - - return .userGroups(conf) - }() - @TestState var convoInfoVolatileConfig: LibSession.Config! = { - var conf: UnsafeMutablePointer! - _ = convo_info_volatile_init(&conf, &secretKey, nil, 0, nil) - - return .convoInfoVolatile(conf) - }() - @TestState var groupInfoConf: UnsafeMutablePointer! = { - var conf: UnsafeMutablePointer! - _ = groups_info_init(&conf, &groupEdPK, &groupEdSK, nil, 0, nil) - - return conf - }() - @TestState var groupMembersConf: UnsafeMutablePointer! = { - var conf: UnsafeMutablePointer! - _ = groups_members_init(&conf, &groupEdPK, &groupEdSK, nil, 0, nil) - - return conf - }() - @TestState var groupKeysConf: UnsafeMutablePointer! = { - var conf: UnsafeMutablePointer! - _ = groups_keys_init(&conf, &secretKey, &groupEdPK, &groupEdSK, groupInfoConf, groupMembersConf, nil, 0, nil) - - return conf - }() - @TestState var groupInfoConfig: LibSession.Config! = .groupInfo(groupInfoConf) - @TestState var groupMembersConfig: LibSession.Config! = .groupMembers(groupMembersConf) - @TestState var groupKeysConfig: LibSession.Config! = .groupKeys( - groupKeysConf, - info: groupInfoConf, - members: groupMembersConf - ) - @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( - initialSetup: { - $0.defaultInitialSetup( - configs: [ - .userGroups: userGroupsConfig, - .convoInfoVolatile: convoInfoVolatileConfig, - .groupInfo: groupInfoConfig, - .groupMembers: groupMembersConfig, - .groupKeys: groupKeysConfig - ] - ) - } - ) - @TestState(cache: .snodeAPI, in: dependencies) var mockSnodeAPICache: MockSnodeAPICache! = MockSnodeAPICache( - initialSetup: { $0.defaultInitialSetup() } - ) - @TestState var mockSwarmPoller: MockSwarmPoller! = MockSwarmPoller( - initialSetup: { cache in - cache.when { $0.startIfNeeded() }.thenReturn(()) - cache.when { $0.receivedPollResponse }.thenReturn(Just([]).eraseToAnyPublisher()) - } - ) - @TestState(cache: .groupPollers, in: dependencies) var mockGroupPollersCache: MockGroupPollerCache! = MockGroupPollerCache( - initialSetup: { cache in - cache.when { $0.startAllPollers() }.thenReturn(()) - cache.when { $0.getOrCreatePoller(for: .any) }.thenReturn(mockSwarmPoller) - cache.when { $0.stopAndRemovePoller(for: .any) }.thenReturn(()) - cache.when { $0.stopAndRemoveAllPollers() }.thenReturn(()) - } - ) - @TestState(singleton: .notificationsManager, in: dependencies) var mockNotificationsManager: MockNotificationsManager! = MockNotificationsManager( - initialSetup: { $0.defaultInitialSetup() } - ) - @TestState(singleton: .appContext, in: dependencies) var mockAppContext: MockAppContext! = MockAppContext( - initialSetup: { appContext in - appContext.when { $0.isMainApp }.thenReturn(false) - } - ) - @TestState(singleton: .extensionHelper, in: dependencies) var mockExtensionHelper: MockExtensionHelper! = MockExtensionHelper( - initialSetup: { extensionHelper in - extensionHelper - .when { try $0.removeDedupeRecord(threadId: .any, uniqueIdentifier: .any) } - .thenReturn(()) - extensionHelper - .when { try $0.upsertLastClearedRecord(threadId: .any) } - .thenReturn(()) - } - ) - - // MARK: -- Messages - @TestState var inviteMessage: GroupUpdateInviteMessage! = { - let result: GroupUpdateInviteMessage = GroupUpdateInviteMessage( - inviteeSessionIdHexString: "TestId", - groupSessionId: groupId, - groupName: "TestGroup", - memberAuthData: Data([1, 2, 3]), - profile: nil, - adminSignature: .standard(signature: "TestSignature".bytes) - ) - result.sender = "051111111111111111111111111111111111111111111111111111111111111111" - result.sentTimestampMs = 1234567890000 - - return result - }() - @TestState var promoteMessage: GroupUpdatePromoteMessage! = { - let result: GroupUpdatePromoteMessage = GroupUpdatePromoteMessage( - groupIdentitySeed: groupSeed, - groupName: "TestGroup", - sentTimestampMs: 1234567890000 - ) - result.sender = "051111111111111111111111111111111111111111111111111111111111111111" - - return result - }() - @TestState var infoChangedMessage: GroupUpdateInfoChangeMessage! = { - let result: GroupUpdateInfoChangeMessage = GroupUpdateInfoChangeMessage( - changeType: .name, - updatedName: "TestGroup Rename", - updatedExpiration: nil, - adminSignature: .standard(signature: "TestSignature".bytes) - ) - result.sender = "051111111111111111111111111111111111111111111111111111111111111111" - result.sentTimestampMs = 1234567800000 - - return result - }() - @TestState var memberChangedMessage: GroupUpdateMemberChangeMessage! = { - let result: GroupUpdateMemberChangeMessage = GroupUpdateMemberChangeMessage( - changeType: .added, - memberSessionIds: ["051111111111111111111111111111111111111111111111111111111111111112"], - historyShared: false, - adminSignature: .standard(signature: "TestSignature".bytes) - ) - result.sender = "051111111111111111111111111111111111111111111111111111111111111111" - result.sentTimestampMs = 1234567800000 - - return result - }() - @TestState var memberLeftMessage: GroupUpdateMemberLeftMessage! = { - let result: GroupUpdateMemberLeftMessage = GroupUpdateMemberLeftMessage() - result.sender = "051111111111111111111111111111111111111111111111111111111111111112" - result.sentTimestampMs = 1234567800000 - - return result - }() - @TestState var memberLeftNotificationMessage: GroupUpdateMemberLeftNotificationMessage! = { - let result: GroupUpdateMemberLeftNotificationMessage = GroupUpdateMemberLeftNotificationMessage() - result.sender = "051111111111111111111111111111111111111111111111111111111111111112" - result.sentTimestampMs = 1234567800000 - - return result - }() - @TestState var inviteResponseMessage: GroupUpdateInviteResponseMessage! = { - let result: GroupUpdateInviteResponseMessage = GroupUpdateInviteResponseMessage( - isApproved: true, - profile: VisibleMessage.VMProfile(displayName: "TestOtherMember"), - sentTimestampMs: 1234567800000 - ) - result.sender = "051111111111111111111111111111111111111111111111111111111111111112" - - return result - }() - @TestState var deleteMessage: Data! = try! LibSessionMessage.groupKicked( - memberId: "05\(TestConstants.publicKey)", - groupKeysGen: 1 - ).1 - @TestState var deleteContentMessage: GroupUpdateDeleteMemberContentMessage! = { - let result: GroupUpdateDeleteMemberContentMessage = GroupUpdateDeleteMemberContentMessage( - memberSessionIds: ["051111111111111111111111111111111111111111111111111111111111111112"], - messageHashes: [], - adminSignature: .standard(signature: "TestSignature".bytes) - ) - result.sender = "051111111111111111111111111111111111111111111111111111111111111112" - result.sentTimestampMs = 1234567800000 - - return result - }() - @TestState var visibleMessageProto: SNProtoContent! = { - let proto = SNProtoContent.builder() - proto.setSigTimestamp((1234568890 - (60 * 10)) * 1000) - - let dataMessage = SNProtoDataMessage.builder() - dataMessage.setBody("Test") - proto.setDataMessage(try! dataMessage.build()) - return try? proto.build() - }() - @TestState var visibleMessage: VisibleMessage! = { - let result = VisibleMessage( - sender: "051111111111111111111111111111111111111111111111111111111111111112", - sentTimestampMs: ((1234568890 - (60 * 10)) * 1000), - text: "Test" - ) - result.receivedTimestampMs = (1234568890 * 1000) - return result - }() // MARK: - a MessageReceiver dealing with Groups describe("a MessageReceiver dealing with Groups") { @@ -352,23 +27,23 @@ class MessageReceiverGroupsSpec: QuickSpec { context("when receiving a group invitation") { // MARK: ---- throws if the admin signature fails to verify it("throws if the admin signature fails to verify") { - mockCrypto + try await fixture.mockCrypto .when { $0.verify(.signature(message: .any, publicKey: .any, signature: .any)) } .thenReturn(false) - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: inviteMessage, + message: fixture.inviteMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let threads: [SessionThread]? = mockStorage.read { db in try SessionThread.fetchAll(db) } + let threads: [SessionThread]? = fixture.mockStorage.read { db in try SessionThread.fetchAll(db) } expect(threads).to(beEmpty()) } @@ -376,21 +51,21 @@ class MessageReceiverGroupsSpec: QuickSpec { context("with profile information") { // MARK: ------ updates the profile name it("updates the profile name") { - inviteMessage.profile = VisibleMessage.VMProfile(displayName: "TestName") + fixture.inviteMessage.profile = VisibleMessage.VMProfile(displayName: "TestName") - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: inviteMessage, + message: fixture.inviteMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let profiles: [Profile]? = mockStorage.read { db in try Profile.fetchAll(db) } + let profiles: [Profile]? = fixture.mockStorage.read { db in try Profile.fetchAll(db) } expect(profiles?.map { $0.name }.sorted()).to(equal(["TestCurrentUser", "TestName"])) } @@ -398,29 +73,29 @@ class MessageReceiverGroupsSpec: QuickSpec { context("with a profile picture") { // MARK: ------ schedules and starts a displayPictureDownload job if running the main app it("schedules and starts a displayPictureDownload job if running the main app") { - mockAppContext.when { $0.isMainApp }.thenReturn(true) + try await fixture.mockAppContext.when { $0.isMainApp }.thenReturn(true) - inviteMessage.profile = VisibleMessage.VMProfile( + fixture.inviteMessage.profile = VisibleMessage.VMProfile( displayName: "TestName", profileKey: Data((0.. = mockStorage.write { db in + fixture.mockStorage.write { db in _ = try SessionThread.upsert( db, - id: groupId.hexString, + id: fixture.groupId.hexString, variant: .group, values: SessionThread.TargetValues( creationDateTimestamp: .setTo(0), shouldBeVisible: .useExisting ), - using: dependencies + using: fixture.dependencies ) try ClosedGroup( - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, name: "Test", formationTimestamp: 0, shouldPoll: nil, - groupIdentityPrivateKey: groupSecretKey, + groupIdentityPrivateKey: fixture.groupSecretKey, invited: nil ).upsert(db) - let result = try Network.PushNotification.preparedSubscribe( - token: Data([5, 4, 3, 2, 1]), - swarms: [ - ( - groupId, - Authentication.groupAdmin( - groupSessionId: groupId, - ed25519SecretKey: Array(groupSecretKey) - ) - ) - ], - using: dependencies - ) // Remove the debug group so it can be created during the actual test - try ClosedGroup.filter(id: groupId.hexString).deleteAll(db) - try SessionThread.filter(id: groupId.hexString).deleteAll(db) - - return result + try ClosedGroup.filter(id: fixture.groupId.hexString).deleteAll(db) + try SessionThread.filter(id: fixture.groupId.hexString).deleteAll(db) }! - mockUserDefaults + try await fixture.mockUserDefaults .when { $0.bool(forKey: UserDefaults.BoolKey.isUsingFullAPNs.rawValue) } .thenReturn(false) - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: inviteMessage, + message: fixture.inviteMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - expect(mockNetwork) - .toNot(call { network in - network.send( - expectedRequest.body, - to: expectedRequest.destination, - requestTimeout: expectedRequest.requestTimeout, - requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout + await fixture.mockNetwork + .verify { + $0.send( + endpoint: Network.PushNotification.Endpoint.subscribe, + destination: .server( + method: .post, + server: Network.PushNotification.server, + queryParameters: [:], + headers: [:], + x25519PublicKey: Network.PushNotification.serverPublicKey + ), + body: try! JSONEncoder(using: fixture.dependencies).encode( + Network.PushNotification.SubscribeRequest( + subscriptions: [ + Network.PushNotification.SubscribeRequest.Subscription( + namespaces: [ + .groupMessages, + .configGroupKeys, + .configGroupInfo, + .configGroupMembers, + .revokedRetrievableGroupMessages + ], + includeMessageData: true, + serviceInfo: Network.PushNotification.ServiceInfo( + token: Data([5, 4, 3, 2, 1]).toHexString() + ), + notificationsEncryptionKey: Data([1, 2, 3]), + authMethod: try! Authentication.with( + swarmPublicKey: fixture.groupId.hexString, + using: fixture.dependencies + ), + timestamp: 1234567890 + ) + ] + ) + ), + category: .standard, + requestTimeout: Network.defaultTimeout, + overallTimeout: nil ) - }) + } + .wasNotCalled() } } // MARK: ------ and push notifications are enabled context("and push notifications are enabled") { beforeEach { - mockUserDefaults + try await fixture.mockUserDefaults .when { $0.string(forKey: UserDefaults.StringKey.deviceToken.rawValue) } .thenReturn(Data([5, 4, 3, 2, 1]).toHexString()) - mockUserDefaults + try await fixture.mockUserDefaults .when { $0.bool(forKey: UserDefaults.BoolKey.isUsingFullAPNs.rawValue) } .thenReturn(true) } // MARK: -------- subscribes for push notifications it("subscribes for push notifications") { - let expectedRequest: Network.PreparedRequest = mockStorage.write { db in + fixture.mockStorage.write { db in _ = try SessionThread.upsert( db, - id: groupId.hexString, + id: fixture.groupId.hexString, variant: .group, values: SessionThread.TargetValues( creationDateTimestamp: .setTo(0), shouldBeVisible: .useExisting ), - using: dependencies + using: fixture.dependencies ) try ClosedGroup( - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, name: "Test", formationTimestamp: 0, shouldPoll: nil, - authData: inviteMessage.memberAuthData, + authData: fixture.inviteMessage.memberAuthData, invited: nil ).upsert(db) - let result = try Network.PushNotification.preparedSubscribe( - token: Data(hex: Data([5, 4, 3, 2, 1]).toHexString()), - swarms: [ - ( - groupId, - Authentication.groupMember( - groupSessionId: groupId, - authData: inviteMessage.memberAuthData - ) - ) - ], - using: dependencies - ) // Remove the debug group so it can be created during the actual test - try ClosedGroup.filter(id: groupId.hexString).deleteAll(db) - try SessionThread.filter(id: groupId.hexString).deleteAll(db) - - return result + try ClosedGroup.filter(id: fixture.groupId.hexString).deleteAll(db) + try SessionThread.filter(id: fixture.groupId.hexString).deleteAll(db) }! - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: inviteMessage, + message: fixture.inviteMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - expect(mockNetwork) - .to(call(.exactly(times: 1), matchingParameters: .all) { network in - network.send( - expectedRequest.body, - to: expectedRequest.destination, - requestTimeout: expectedRequest.requestTimeout, - requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout + await fixture.mockNetwork + .verify { + $0.send( + endpoint: Network.PushNotification.Endpoint.subscribe, + destination: .server( + method: .post, + server: Network.PushNotification.server, + queryParameters: [:], + headers: [:], + x25519PublicKey: Network.PushNotification.serverPublicKey + ), + body: try! JSONEncoder(using: fixture.dependencies).encode( + Network.PushNotification.SubscribeRequest( + subscriptions: [ + Network.PushNotification.SubscribeRequest.Subscription( + namespaces: [ + .groupMessages, + .configGroupKeys, + .configGroupInfo, + .configGroupMembers, + .revokedRetrievableGroupMessages + ], + includeMessageData: true, + serviceInfo: Network.PushNotification.ServiceInfo( + token: Data([5, 4, 3, 2, 1]).toHexString() + ), + notificationsEncryptionKey: Data([1, 2, 3]), + authMethod: try! Authentication.with( + swarmPublicKey: fixture.groupId.hexString, + using: fixture.dependencies + ), + timestamp: 1234567890 + ) + ] + ) + ), + category: .standard, + requestTimeout: Network.defaultTimeout, + overallTimeout: nil ) - }) + } + .wasCalled(exactly: Network.PushNotification.maxRetryCount + 1, timeout: .milliseconds(100)) } } } // MARK: ---- adds the invited control message if the thread does not exist it("adds the invited control message if the thread does not exist") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: inviteMessage, + message: fixture.inviteMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interactions: [Interaction]? = mockStorage.read { db in try Interaction.fetchAll(db) } + let interactions: [Interaction]? = fixture.mockStorage.read { db in try Interaction.fetchAll(db) } expect(interactions?.count).to(equal(1)) expect(interactions?.first?.body) .to(equal("{\"invited\":{\"_0\":\"0511...1111\",\"_1\":\"TestGroup\"}}")) @@ -1004,32 +719,32 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- does not add the invited control message if the thread already exists it("does not add the invited control message if the thread already exists") { - mockStorage.write { db in + fixture.mockStorage.write { db in try SessionThread.upsert( db, - id: groupId.hexString, + id: fixture.groupId.hexString, variant: .group, values: SessionThread.TargetValues( creationDateTimestamp: .setTo(1234567890), shouldBeVisible: .setTo(true) ), - using: dependencies + using: fixture.dependencies ) } - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: inviteMessage, + message: fixture.inviteMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interactions: [Interaction]? = mockStorage.read { db in try Interaction.fetchAll(db) } + let interactions: [Interaction]? = fixture.mockStorage.read { db in try Interaction.fetchAll(db) } expect(interactions?.count).to(equal(0)) } } @@ -1041,11 +756,11 @@ class MessageReceiverGroupsSpec: QuickSpec { beforeEach { var cMemberId: [CChar] = "05\(TestConstants.publicKey)".cString(using: .utf8)! var member: config_group_member = config_group_member() - _ = groups_members_get_or_construct(groupMembersConf, &member, &cMemberId) + _ = groups_members_get_or_construct(fixture.groupMembersConfig.conf, &member, &cMemberId) member.set(\.name, to: "TestName") - groups_members_set(groupMembersConf, &member) + groups_members_set(fixture.groupMembersConfig.conf, &member) - mockStorage.write { db in + fixture.mockStorage.write { db in try Contact( id: "051111111111111111111111111111111111111111111111111111111111111111", isTrusted: true, @@ -1058,17 +773,17 @@ class MessageReceiverGroupsSpec: QuickSpec { ).insert(db) try SessionThread.upsert( db, - id: groupId.hexString, + id: fixture.groupId.hexString, variant: .group, values: SessionThread.TargetValues( creationDateTimestamp: .setTo(1234567890), shouldBeVisible: .setTo(true) ), - using: dependencies + using: fixture.dependencies ) try ClosedGroup( - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, name: "TestGroup", formationTimestamp: 1234567890, shouldPoll: true, @@ -1081,18 +796,18 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- fails if it cannot convert the group seed to a groupIdentityKeyPair it("fails if it cannot convert the group seed to a groupIdentityKeyPair") { - mockCrypto.when { $0.generate(.ed25519KeyPair(seed: .any)) }.thenReturn(nil) + try await fixture.mockCrypto.when { $0.generate(.ed25519KeyPair(seed: .any)) }.thenReturn(nil) - mockStorage.write { db in + fixture.mockStorage.write { db in result = Result(catching: { try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: promoteMessage, + message: fixture.promoteMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) }) } @@ -1102,35 +817,36 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- updates the GROUP_KEYS state correctly it("updates the GROUP_KEYS state correctly") { - mockCrypto + try await fixture.mockCrypto .when { $0.generate(.ed25519KeyPair(seed: .any)) } .thenReturn(KeyPair(publicKey: [1, 2, 3], secretKey: [4, 5, 6])) - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: promoteMessage, + message: fixture.promoteMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - - expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.loadAdminKey( - groupIdentitySeed: groupSeed, - groupSessionId: SessionId(.group, publicKey: [1, 2, 3]) - ) - }) + await fixture.mockLibSessionCache + .verify { + try $0.loadAdminKey( + groupIdentitySeed: fixture.groupSeed, + groupSessionId: SessionId(.group, publicKey: [1, 2, 3]) + ) + } + .wasCalled(exactly: 1) } // MARK: ---- replaces the memberAuthData with the admin key in the database it("replaces the memberAuthData with the admin key in the database") { - mockStorage.write { db in + fixture.mockStorage.write { db in try ClosedGroup( - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, name: "TestGroup", formationTimestamp: 1234567890, shouldPoll: true, @@ -1140,21 +856,21 @@ class MessageReceiverGroupsSpec: QuickSpec { ).upsert(db) } - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: promoteMessage, + message: fixture.promoteMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let groups: [ClosedGroup]? = mockStorage.read { db in try ClosedGroup.fetchAll(db) } + let groups: [ClosedGroup]? = fixture.mockStorage.read { db in try ClosedGroup.fetchAll(db) } expect(groups?.count).to(equal(1)) - expect(groups?.first?.groupIdentityPrivateKey).to(equal(Data(groupKeyPair.secretKey))) + expect(groups?.first?.groupIdentityPrivateKey).to(equal(Data(fixture.groupKeyPair.secretKey))) expect(groups?.first?.authData).to(beNil()) } } @@ -1162,34 +878,34 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: -- when receiving an info changed message context("when receiving an info changed message") { beforeEach { - mockStorage.write { db in + fixture.mockStorage.write { db in try SessionThread.upsert( db, - id: groupId.hexString, + id: fixture.groupId.hexString, variant: .group, values: SessionThread.TargetValues( creationDateTimestamp: .setTo(1234567890), shouldBeVisible: .setTo(true) ), - using: dependencies + using: fixture.dependencies ) } } // MARK: ---- throws if there is no sender it("throws if there is no sender") { - infoChangedMessage.sender = nil + fixture.infoChangedMessage.sender = nil - mockStorage.write { db in + fixture.mockStorage.write { db in expect { try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: infoChangedMessage, + message: fixture.infoChangedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) } @@ -1197,18 +913,18 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- throws if there is no timestamp it("throws if there is no timestamp") { - infoChangedMessage.sentTimestampMs = nil + fixture.infoChangedMessage.sentTimestampMs = nil - mockStorage.write { db in + fixture.mockStorage.write { db in expect { try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: infoChangedMessage, + message: fixture.infoChangedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) } @@ -1216,20 +932,20 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- throws if the admin signature fails to verify it("throws if the admin signature fails to verify") { - mockCrypto + try await fixture.mockCrypto .when { $0.verify(.signature(message: .any, publicKey: .any, signature: .any)) } .thenReturn(false) - mockStorage.write { db in + fixture.mockStorage.write { db in expect { try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: infoChangedMessage, + message: fixture.infoChangedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) } @@ -1239,24 +955,24 @@ class MessageReceiverGroupsSpec: QuickSpec { context("for a name change") { // MARK: ------ creates the correct control message it("creates the correct control message") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: infoChangedMessage, + message: fixture.infoChangedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interaction: Interaction? = mockStorage.read { db in try Interaction.fetchOne(db) } + let interaction: Interaction? = fixture.mockStorage.read { db in try Interaction.fetchOne(db) } expect(interaction?.timestampMs).to(equal(1234567800000)) expect(interaction?.body).to(equal( ClosedGroup.MessageInfo .updatedName("TestGroup Rename") - .infoString(using: dependencies) + .infoString(using: fixture.dependencies) )) } } @@ -1264,36 +980,36 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- for a display picture change context("for a display picture change") { beforeEach { - infoChangedMessage = GroupUpdateInfoChangeMessage( + fixture.infoChangedMessage = GroupUpdateInfoChangeMessage( changeType: .avatar, updatedName: nil, updatedExpiration: nil, adminSignature: .standard(signature: "TestSignature".bytes) ) - infoChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" - infoChangedMessage.sentTimestampMs = 1234567800000 + fixture.infoChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" + fixture.infoChangedMessage.sentTimestampMs = 1234567800000 } // MARK: ------ creates the correct control message it("creates the correct control message") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: infoChangedMessage, + message: fixture.infoChangedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interaction: Interaction? = mockStorage.read { db in try Interaction.fetchOne(db) } + let interaction: Interaction? = fixture.mockStorage.read { db in try Interaction.fetchOne(db) } expect(interaction?.timestampMs).to(equal(1234567800000)) expect(interaction?.body).to(equal( ClosedGroup.MessageInfo .updatedDisplayPicture - .infoString(using: dependencies) + .infoString(using: fixture.dependencies) )) } } @@ -1301,42 +1017,42 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- for a disappearing message setting change context("for a disappearing message setting change") { beforeEach { - infoChangedMessage = GroupUpdateInfoChangeMessage( + fixture.infoChangedMessage = GroupUpdateInfoChangeMessage( changeType: .disappearingMessages, updatedName: nil, updatedExpiration: 3600, adminSignature: .standard(signature: "TestSignature".bytes) ) - infoChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" - infoChangedMessage.sentTimestampMs = 1234567800000 + fixture.infoChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" + fixture.infoChangedMessage.sentTimestampMs = 1234567800000 } // MARK: ------ creates the correct control message it("creates the correct control message") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: infoChangedMessage, + message: fixture.infoChangedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interaction: Interaction? = mockStorage.read { db in try Interaction.fetchOne(db) } + let interaction: Interaction? = fixture.mockStorage.read { db in try Interaction.fetchOne(db) } expect(interaction?.timestampMs).to(equal(1234567800000)) expect(interaction?.body).to(equal( DisappearingMessagesConfiguration( - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, isEnabled: true, durationSeconds: 3600, type: .disappearAfterSend ).messageInfoString( threadVariant: .group, - senderName: infoChangedMessage.sender, - using: dependencies + senderName: fixture.infoChangedMessage.sender, + using: fixture.dependencies ) )) expect(interaction?.expiresInSeconds).to(beNil()) @@ -1347,34 +1063,34 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: -- when receiving a member changed message context("when receiving a member changed message") { beforeEach { - mockStorage.write { db in + fixture.mockStorage.write { db in try SessionThread.upsert( db, - id: groupId.hexString, + id: fixture.groupId.hexString, variant: .group, values: SessionThread.TargetValues( creationDateTimestamp: .setTo(1234567890), shouldBeVisible: .setTo(true) ), - using: dependencies + using: fixture.dependencies ) } } // MARK: ---- throws if there is no sender it("throws if there is no sender") { - memberChangedMessage.sender = nil + fixture.memberChangedMessage.sender = nil - mockStorage.write { db in + fixture.mockStorage.write { db in expect { try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberChangedMessage, + message: fixture.memberChangedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) } @@ -1382,18 +1098,18 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- throws if there is no timestamp it("throws if there is no timestamp") { - memberChangedMessage.sentTimestampMs = nil + fixture.memberChangedMessage.sentTimestampMs = nil - mockStorage.write { db in + fixture.mockStorage.write { db in expect { try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberChangedMessage, + message: fixture.memberChangedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) } @@ -1401,20 +1117,20 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- throws if the admin signature fails to verify it("throws if the admin signature fails to verify") { - mockCrypto + try await fixture.mockCrypto .when { $0.verify(.signature(message: .any, publicKey: .any, signature: .any)) } .thenReturn(false) - mockStorage.write { db in + fixture.mockStorage.write { db in expect { try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberChangedMessage, + message: fixture.memberChangedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) } @@ -1422,31 +1138,31 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- correctly retrieves the member name if present it("correctly retrieves the member name if present") { - mockStorage.write { db in + fixture.mockStorage.write { db in try Profile( id: "051111111111111111111111111111111111111111111111111111111111111112", name: "TestOtherProfile" ).insert(db) } - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberChangedMessage, + message: fixture.memberChangedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interaction: Interaction? = mockStorage.read { db in try Interaction.fetchOne(db) } + let interaction: Interaction? = fixture.mockStorage.read { db in try Interaction.fetchOne(db) } expect(interaction?.timestampMs).to(equal(1234567800000)) expect(interaction?.body).to(equal( ClosedGroup.MessageInfo .addedUsers(hasCurrentUser: false, names: ["TestOtherProfile"], historyShared: false) - .infoString(using: dependencies) + .infoString(using: fixture.dependencies) )) } @@ -1454,7 +1170,7 @@ class MessageReceiverGroupsSpec: QuickSpec { context("for adding members") { // MARK: ------ creates the correct control message for a single member it("creates the correct control message for a single member") { - memberChangedMessage = GroupUpdateMemberChangeMessage( + fixture.memberChangedMessage = GroupUpdateMemberChangeMessage( changeType: .added, memberSessionIds: [ "051111111111111111111111111111111111111111111111111111111111111112" @@ -1462,22 +1178,22 @@ class MessageReceiverGroupsSpec: QuickSpec { historyShared: false, adminSignature: .standard(signature: "TestSignature".bytes) ) - memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" - memberChangedMessage.sentTimestampMs = 1234567800000 + fixture.memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" + fixture.memberChangedMessage.sentTimestampMs = 1234567800000 - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberChangedMessage, + message: fixture.memberChangedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interaction: Interaction? = mockStorage.read { db in try Interaction.fetchOne(db) } + let interaction: Interaction? = fixture.mockStorage.read { db in try Interaction.fetchOne(db) } expect(interaction?.timestampMs).to(equal(1234567800000)) expect(interaction?.body).to(equal( ClosedGroup.MessageInfo @@ -1486,13 +1202,13 @@ class MessageReceiverGroupsSpec: QuickSpec { names: ["0511...1112"], historyShared: false ) - .infoString(using: dependencies) + .infoString(using: fixture.dependencies) )) } // MARK: ------ creates the correct control message for two members it("creates the correct control message for two members") { - memberChangedMessage = GroupUpdateMemberChangeMessage( + fixture.memberChangedMessage = GroupUpdateMemberChangeMessage( changeType: .added, memberSessionIds: [ "051111111111111111111111111111111111111111111111111111111111111112", @@ -1501,22 +1217,22 @@ class MessageReceiverGroupsSpec: QuickSpec { historyShared: false, adminSignature: .standard(signature: "TestSignature".bytes) ) - memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" - memberChangedMessage.sentTimestampMs = 1234567800000 + fixture.memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" + fixture.memberChangedMessage.sentTimestampMs = 1234567800000 - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberChangedMessage, + message: fixture.memberChangedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interaction: Interaction? = mockStorage.read { db in try Interaction.fetchOne(db) } + let interaction: Interaction? = fixture.mockStorage.read { db in try Interaction.fetchOne(db) } expect(interaction?.timestampMs).to(equal(1234567800000)) expect(interaction?.body).to(equal( ClosedGroup.MessageInfo @@ -1525,13 +1241,13 @@ class MessageReceiverGroupsSpec: QuickSpec { names: ["0511...1112", "0511...1113"], historyShared: false ) - .infoString(using: dependencies) + .infoString(using: fixture.dependencies) )) } // MARK: ------ creates the correct control message for many members it("creates the correct control message for many members") { - memberChangedMessage = GroupUpdateMemberChangeMessage( + fixture.memberChangedMessage = GroupUpdateMemberChangeMessage( changeType: .added, memberSessionIds: [ "051111111111111111111111111111111111111111111111111111111111111112", @@ -1541,22 +1257,22 @@ class MessageReceiverGroupsSpec: QuickSpec { historyShared: false, adminSignature: .standard(signature: "TestSignature".bytes) ) - memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" - memberChangedMessage.sentTimestampMs = 1234567800000 + fixture.memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" + fixture.memberChangedMessage.sentTimestampMs = 1234567800000 - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberChangedMessage, + message: fixture.memberChangedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interaction: Interaction? = mockStorage.read { db in try Interaction.fetchOne(db) } + let interaction: Interaction? = fixture.mockStorage.read { db in try Interaction.fetchOne(db) } expect(interaction?.timestampMs).to(equal(1234567800000)) expect(interaction?.body).to(equal( ClosedGroup.MessageInfo @@ -1565,7 +1281,7 @@ class MessageReceiverGroupsSpec: QuickSpec { names: ["0511...1112", "0511...1113", "0511...1114"], historyShared: false ) - .infoString(using: dependencies) + .infoString(using: fixture.dependencies) )) } } @@ -1574,7 +1290,7 @@ class MessageReceiverGroupsSpec: QuickSpec { context("for removing members") { // MARK: ------ creates the correct control message for a single member it("creates the correct control message for a single member") { - memberChangedMessage = GroupUpdateMemberChangeMessage( + fixture.memberChangedMessage = GroupUpdateMemberChangeMessage( changeType: .removed, memberSessionIds: [ "051111111111111111111111111111111111111111111111111111111111111112" @@ -1582,33 +1298,33 @@ class MessageReceiverGroupsSpec: QuickSpec { historyShared: false, adminSignature: .standard(signature: "TestSignature".bytes) ) - memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" - memberChangedMessage.sentTimestampMs = 1234567800000 + fixture.memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" + fixture.memberChangedMessage.sentTimestampMs = 1234567800000 - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberChangedMessage, + message: fixture.memberChangedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interaction: Interaction? = mockStorage.read { db in try Interaction.fetchOne(db) } + let interaction: Interaction? = fixture.mockStorage.read { db in try Interaction.fetchOne(db) } expect(interaction?.timestampMs).to(equal(1234567800000)) expect(interaction?.body).to(equal( ClosedGroup.MessageInfo .removedUsers(hasCurrentUser: false, names: ["0511...1112"]) - .infoString(using: dependencies) + .infoString(using: fixture.dependencies) )) } // MARK: ------ creates the correct control message for two members it("creates the correct control message for two members") { - memberChangedMessage = GroupUpdateMemberChangeMessage( + fixture.memberChangedMessage = GroupUpdateMemberChangeMessage( changeType: .removed, memberSessionIds: [ "051111111111111111111111111111111111111111111111111111111111111112", @@ -1617,33 +1333,33 @@ class MessageReceiverGroupsSpec: QuickSpec { historyShared: false, adminSignature: .standard(signature: "TestSignature".bytes) ) - memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" - memberChangedMessage.sentTimestampMs = 1234567800000 + fixture.memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" + fixture.memberChangedMessage.sentTimestampMs = 1234567800000 - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberChangedMessage, + message: fixture.memberChangedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interaction: Interaction? = mockStorage.read { db in try Interaction.fetchOne(db) } + let interaction: Interaction? = fixture.mockStorage.read { db in try Interaction.fetchOne(db) } expect(interaction?.timestampMs).to(equal(1234567800000)) expect(interaction?.body).to(equal( ClosedGroup.MessageInfo .removedUsers(hasCurrentUser: false, names: ["0511...1112", "0511...1113"]) - .infoString(using: dependencies) + .infoString(using: fixture.dependencies) )) } // MARK: ------ creates the correct control message for many members it("creates the correct control message for many members") { - memberChangedMessage = GroupUpdateMemberChangeMessage( + fixture.memberChangedMessage = GroupUpdateMemberChangeMessage( changeType: .removed, memberSessionIds: [ "051111111111111111111111111111111111111111111111111111111111111112", @@ -1653,27 +1369,27 @@ class MessageReceiverGroupsSpec: QuickSpec { historyShared: false, adminSignature: .standard(signature: "TestSignature".bytes) ) - memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" - memberChangedMessage.sentTimestampMs = 1234567800000 + fixture.memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" + fixture.memberChangedMessage.sentTimestampMs = 1234567800000 - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberChangedMessage, + message: fixture.memberChangedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interaction: Interaction? = mockStorage.read { db in try Interaction.fetchOne(db) } + let interaction: Interaction? = fixture.mockStorage.read { db in try Interaction.fetchOne(db) } expect(interaction?.timestampMs).to(equal(1234567800000)) expect(interaction?.body).to(equal( ClosedGroup.MessageInfo .removedUsers(hasCurrentUser: false, names: ["0511...1112", "0511...1113", "0511...1114"]) - .infoString(using: dependencies) + .infoString(using: fixture.dependencies) )) } } @@ -1682,7 +1398,7 @@ class MessageReceiverGroupsSpec: QuickSpec { context("for promoting members") { // MARK: ------ creates the correct control message for a single member it("creates the correct control message for a single member") { - memberChangedMessage = GroupUpdateMemberChangeMessage( + fixture.memberChangedMessage = GroupUpdateMemberChangeMessage( changeType: .promoted, memberSessionIds: [ "051111111111111111111111111111111111111111111111111111111111111112" @@ -1690,33 +1406,33 @@ class MessageReceiverGroupsSpec: QuickSpec { historyShared: false, adminSignature: .standard(signature: "TestSignature".bytes) ) - memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" - memberChangedMessage.sentTimestampMs = 1234567800000 + fixture.memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" + fixture.memberChangedMessage.sentTimestampMs = 1234567800000 - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberChangedMessage, + message: fixture.memberChangedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interaction: Interaction? = mockStorage.read { db in try Interaction.fetchOne(db) } + let interaction: Interaction? = fixture.mockStorage.read { db in try Interaction.fetchOne(db) } expect(interaction?.timestampMs).to(equal(1234567800000)) expect(interaction?.body).to(equal( ClosedGroup.MessageInfo .promotedUsers(hasCurrentUser: false, names: ["0511...1112"]) - .infoString(using: dependencies) + .infoString(using: fixture.dependencies) )) } // MARK: ------ creates the correct control message for two members it("creates the correct control message for two members") { - memberChangedMessage = GroupUpdateMemberChangeMessage( + fixture.memberChangedMessage = GroupUpdateMemberChangeMessage( changeType: .promoted, memberSessionIds: [ "051111111111111111111111111111111111111111111111111111111111111112", @@ -1725,33 +1441,33 @@ class MessageReceiverGroupsSpec: QuickSpec { historyShared: false, adminSignature: .standard(signature: "TestSignature".bytes) ) - memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" - memberChangedMessage.sentTimestampMs = 1234567800000 + fixture.memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" + fixture.memberChangedMessage.sentTimestampMs = 1234567800000 - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberChangedMessage, + message: fixture.memberChangedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interaction: Interaction? = mockStorage.read { db in try Interaction.fetchOne(db) } + let interaction: Interaction? = fixture.mockStorage.read { db in try Interaction.fetchOne(db) } expect(interaction?.timestampMs).to(equal(1234567800000)) expect(interaction?.body).to(equal( ClosedGroup.MessageInfo .promotedUsers(hasCurrentUser: false, names: ["0511...1112", "0511...1113"]) - .infoString(using: dependencies) + .infoString(using: fixture.dependencies) )) } // MARK: ------ creates the correct control message for many members it("creates the correct control message for many members") { - memberChangedMessage = GroupUpdateMemberChangeMessage( + fixture.memberChangedMessage = GroupUpdateMemberChangeMessage( changeType: .promoted, memberSessionIds: [ "051111111111111111111111111111111111111111111111111111111111111112", @@ -1761,27 +1477,27 @@ class MessageReceiverGroupsSpec: QuickSpec { historyShared: false, adminSignature: .standard(signature: "TestSignature".bytes) ) - memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" - memberChangedMessage.sentTimestampMs = 1234567800000 + fixture.memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" + fixture.memberChangedMessage.sentTimestampMs = 1234567800000 - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberChangedMessage, + message: fixture.memberChangedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interaction: Interaction? = mockStorage.read { db in try Interaction.fetchOne(db) } + let interaction: Interaction? = fixture.mockStorage.read { db in try Interaction.fetchOne(db) } expect(interaction?.timestampMs).to(equal(1234567800000)) expect(interaction?.body).to(equal( ClosedGroup.MessageInfo .promotedUsers(hasCurrentUser: false, names: ["0511...1112", "0511...1113", "0511...1114"]) - .infoString(using: dependencies) + .infoString(using: fixture.dependencies) )) } } @@ -1790,52 +1506,52 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: -- when receiving a member left message context("when receiving a member left message") { beforeEach { - mockStorage.write { db in + fixture.mockStorage.write { db in try SessionThread.upsert( db, - id: groupId.hexString, + id: fixture.groupId.hexString, variant: .group, values: SessionThread.TargetValues( creationDateTimestamp: .setTo(1234567890), shouldBeVisible: .setTo(true) ), - using: dependencies + using: fixture.dependencies ) } } // MARK: ---- does not create a control message it("does not create a control message") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberLeftMessage, + message: fixture.memberLeftMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interactions: [Interaction]? = mockStorage.read { db in try Interaction.fetchAll(db) } + let interactions: [Interaction]? = fixture.mockStorage.read { db in try Interaction.fetchAll(db) } expect(interactions).to(beEmpty()) } // MARK: ---- throws if there is no sender it("throws if there is no sender") { - memberLeftMessage.sender = nil + fixture.memberLeftMessage.sender = nil - mockStorage.write { db in + fixture.mockStorage.write { db in expect { try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberLeftMessage, + message: fixture.memberLeftMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) } @@ -1843,18 +1559,18 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- throws if there is no timestamp it("throws if there is no timestamp") { - memberLeftMessage.sentTimestampMs = nil + fixture.memberLeftMessage.sentTimestampMs = nil - mockStorage.write { db in + fixture.mockStorage.write { db in expect { try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberLeftMessage, + message: fixture.memberLeftMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) } @@ -1866,23 +1582,23 @@ class MessageReceiverGroupsSpec: QuickSpec { // Only update members if they already exist in the group var cMemberId: [CChar] = "051111111111111111111111111111111111111111111111111111111111111112".cString(using: .utf8)! var groupMember: config_group_member = config_group_member() - _ = groups_members_get_or_construct(groupMembersConf, &groupMember, &cMemberId) + _ = groups_members_get_or_construct(fixture.groupMembersConfig.conf, &groupMember, &cMemberId) groupMember.set(\.name, to: "TestOtherName") - groups_members_set(groupMembersConf, &groupMember) + groups_members_set(fixture.groupMembersConfig.conf, &groupMember) - mockStorage.write { db in + fixture.mockStorage.write { db in try ClosedGroup( - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, name: "TestGroup", formationTimestamp: 1234567890, shouldPoll: true, - groupIdentityPrivateKey: groupSecretKey, + groupIdentityPrivateKey: fixture.groupSecretKey, authData: nil, invited: false ).upsert(db) try GroupMember( - groupId: groupId.hexString, + groupId: fixture.groupId.hexString, profileId: "051111111111111111111111111111111111111111111111111111111111111112", role: .standard, roleStatus: .accepted, @@ -1893,97 +1609,98 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ------ flags the member for removal keeping their messages it("flags the member for removal keeping their messages") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberLeftMessage, + message: fixture.memberLeftMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } var cMemberId: [CChar] = "051111111111111111111111111111111111111111111111111111111111111112".cString(using: .utf8)! var groupMember: config_group_member = config_group_member() - expect(groups_members_get(groupMembersConf, &groupMember, &cMemberId)).to(beTrue()) + expect(groups_members_get(fixture.groupMembersConfig.conf, &groupMember, &cMemberId)).to(beTrue()) expect(groupMember.removed).to(equal(1)) } // MARK: ------ flags the GroupMember as pending removal it("flags the GroupMember as pending removal") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberLeftMessage, + message: fixture.memberLeftMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let members: [GroupMember]? = mockStorage.read { db in try GroupMember.fetchAll(db) } + let members: [GroupMember]? = fixture.mockStorage.read { db in try GroupMember.fetchAll(db) } expect(members?.count).to(equal(1)) expect(members?.first?.roleStatus).to(equal(.pendingRemoval)) } // MARK: ------ schedules a job to process the pending removal it("schedules a job to process the pending removal") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberLeftMessage, + message: fixture.memberLeftMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - expect(mockJobRunner) - .to(call(matchingParameters: .all) { + await fixture.mockJobRunner + .verify { $0.add( .any, job: Job( variant: .processPendingGroupMemberRemovals, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, details: ProcessPendingGroupMemberRemovalsJob.Details( changeTimestampMs: 1234567800000 ) ), canStartJob: true ) - }) + } + .wasCalled(exactly: 1) } // MARK: ------ does not schedule a member change control message to be sent it("does not schedule a member change control message to be sent") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberLeftMessage, + message: fixture.memberLeftMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - expect(mockJobRunner) - .toNot(call(.exactly(times: 1), matchingParameters: .all) { + await fixture.mockJobRunner + .verify { $0.add( .any, job: Job( variant: .messageSend, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, interactionId: nil, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: groupId.hexString), + destination: .closedGroup(groupPublicKey: fixture.groupId.hexString), message: try! GroupUpdateMemberChangeMessage( changeType: .removed, memberSessionIds: [ @@ -1992,16 +1709,17 @@ class MessageReceiverGroupsSpec: QuickSpec { historyShared: false, sentTimestampMs: 1234567800000, authMethod: Authentication.groupAdmin( - groupSessionId: groupId, - ed25519SecretKey: Array(groupSecretKey) + groupSessionId: fixture.groupId, + ed25519SecretKey: Array(fixture.groupSecretKey) ), - using: dependencies + using: fixture.dependencies ) ) ), canStartJob: true ) - }) + } + .wasNotCalled() } } } @@ -2009,70 +1727,70 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: -- when receiving a member left notification message context("when receiving a member left notification message") { beforeEach { - mockStorage.write { db in + fixture.mockStorage.write { db in try SessionThread.upsert( db, - id: groupId.hexString, + id: fixture.groupId.hexString, variant: .group, values: SessionThread.TargetValues( creationDateTimestamp: .setTo(1234567890), shouldBeVisible: .setTo(true) ), - using: dependencies + using: fixture.dependencies ) } } // MARK: ---- creates the correct control message it("creates the correct control message") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberLeftNotificationMessage, + message: fixture.memberLeftNotificationMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interaction: Interaction? = mockStorage.read { db in try Interaction.fetchOne(db) } + let interaction: Interaction? = fixture.mockStorage.read { db in try Interaction.fetchOne(db) } expect(interaction?.timestampMs).to(equal(1234567800000)) expect(interaction?.body).to(equal( ClosedGroup.MessageInfo .memberLeft(wasCurrentUser: false, name: "0511...1112") - .infoString(using: dependencies) + .infoString(using: fixture.dependencies) )) } // MARK: ---- correctly retrieves the member name if present it("correctly retrieves the member name if present") { - mockStorage.write { db in + fixture.mockStorage.write { db in try Profile( id: "051111111111111111111111111111111111111111111111111111111111111112", name: "TestOtherProfile" ).insert(db) } - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: memberLeftNotificationMessage, + message: fixture.memberLeftNotificationMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interaction: Interaction? = mockStorage.read { db in try Interaction.fetchOne(db) } + let interaction: Interaction? = fixture.mockStorage.read { db in try Interaction.fetchOne(db) } expect(interaction?.timestampMs).to(equal(1234567800000)) expect(interaction?.body).to(equal( ClosedGroup.MessageInfo .memberLeft(wasCurrentUser: false, name: "TestOtherProfile") - .infoString(using: dependencies) + .infoString(using: fixture.dependencies) )) } } @@ -2080,34 +1798,34 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: -- when receiving an invite response message context("when receiving an invite response message") { beforeEach { - mockStorage.write { db in + fixture.mockStorage.write { db in try SessionThread.upsert( db, - id: groupId.hexString, + id: fixture.groupId.hexString, variant: .group, values: SessionThread.TargetValues( creationDateTimestamp: .setTo(1234567890), shouldBeVisible: .setTo(true) ), - using: dependencies + using: fixture.dependencies ) } } // MARK: ---- throws if there is no sender it("throws if there is no sender") { - inviteResponseMessage.sender = nil + fixture.inviteResponseMessage.sender = nil - mockStorage.write { db in + fixture.mockStorage.write { db in expect { try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: inviteResponseMessage, + message: fixture.inviteResponseMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) } @@ -2115,18 +1833,18 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- throws if there is no timestamp it("throws if there is no timestamp") { - inviteResponseMessage.sentTimestampMs = nil + fixture.inviteResponseMessage.sentTimestampMs = nil - mockStorage.write { db in + fixture.mockStorage.write { db in expect { try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: inviteResponseMessage, + message: fixture.inviteResponseMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) } @@ -2134,19 +1852,19 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- updates the profile information in the database if provided it("updates the profile information in the database if provided") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: inviteResponseMessage, + message: fixture.inviteResponseMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let profiles: [Profile]? = mockStorage.read { db in try Profile.fetchAll(db) } + let profiles: [Profile]? = fixture.mockStorage.read { db in try Profile.fetchAll(db) } expect(profiles?.map { $0.id }).to(equal([ "05\(TestConstants.publicKey)", "051111111111111111111111111111111111111111111111111111111111111112" @@ -2160,18 +1878,18 @@ class MessageReceiverGroupsSpec: QuickSpec { // Only update members if they already exist in the group var cMemberId: [CChar] = "051111111111111111111111111111111111111111111111111111111111111112".cString(using: .utf8)! var groupMember: config_group_member = config_group_member() - _ = groups_members_get_or_construct(groupMembersConf, &groupMember, &cMemberId) + _ = groups_members_get_or_construct(fixture.groupMembersConfig.conf, &groupMember, &cMemberId) groupMember.set(\.name, to: "TestOtherMember") groupMember.invited = 1 - groups_members_set(groupMembersConf, &groupMember) + groups_members_set(fixture.groupMembersConfig.conf, &groupMember) - mockStorage.write { db in + fixture.mockStorage.write { db in try ClosedGroup( - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, name: "TestGroup", formationTimestamp: 1234567890, shouldPoll: true, - groupIdentityPrivateKey: groupSecretKey, + groupIdentityPrivateKey: fixture.groupSecretKey, authData: nil, invited: false ).upsert(db) @@ -2180,9 +1898,9 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ------ updates a pending member entry to an accepted member it("updates a pending member entry to an accepted member") { - mockStorage.write { db in + fixture.mockStorage.write { db in try GroupMember( - groupId: groupId.hexString, + groupId: fixture.groupId.hexString, profileId: "051111111111111111111111111111111111111111111111111111111111111112", role: .standard, roleStatus: .pending, @@ -2190,19 +1908,19 @@ class MessageReceiverGroupsSpec: QuickSpec { ).upsert(db) } - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: inviteResponseMessage, + message: fixture.inviteResponseMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let members: [GroupMember]? = mockStorage.read { db in try GroupMember.fetchAll(db) } + let members: [GroupMember]? = fixture.mockStorage.read { db in try GroupMember.fetchAll(db) } expect(members?.count).to(equal(1)) expect(members?.first?.profileId).to(equal( "051111111111111111111111111111111111111111111111111111111111111112" @@ -2212,7 +1930,7 @@ class MessageReceiverGroupsSpec: QuickSpec { var cMemberId: [CChar] = "051111111111111111111111111111111111111111111111111111111111111112".cString(using: .utf8)! var groupMember: config_group_member = config_group_member() - expect(groups_members_get(groupMembersConf, &groupMember, &cMemberId)).to(beTrue()) + expect(groups_members_get(fixture.groupMembersConfig.conf, &groupMember, &cMemberId)).to(beTrue()) expect(groupMember.invited).to(equal(0)) } @@ -2220,13 +1938,13 @@ class MessageReceiverGroupsSpec: QuickSpec { it("updates a failed member entry to an accepted member") { var cMemberId1: [CChar] = "051111111111111111111111111111111111111111111111111111111111111112".cString(using: .utf8)! var groupMember1: config_group_member = config_group_member() - _ = groups_members_get(groupMembersConf, &groupMember1, &cMemberId1) + _ = groups_members_get(fixture.groupMembersConfig.conf, &groupMember1, &cMemberId1) groupMember1.invited = 2 - groups_members_set(groupMembersConf, &groupMember1) + groups_members_set(fixture.groupMembersConfig.conf, &groupMember1) - mockStorage.write { db in + fixture.mockStorage.write { db in try GroupMember( - groupId: groupId.hexString, + groupId: fixture.groupId.hexString, profileId: "051111111111111111111111111111111111111111111111111111111111111112", role: .standard, roleStatus: .failed, @@ -2234,19 +1952,19 @@ class MessageReceiverGroupsSpec: QuickSpec { ).upsert(db) } - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: inviteResponseMessage, + message: fixture.inviteResponseMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let members: [GroupMember]? = mockStorage.read { db in try GroupMember.fetchAll(db) } + let members: [GroupMember]? = fixture.mockStorage.read { db in try GroupMember.fetchAll(db) } expect(members?.count).to(equal(1)) expect(members?.first?.profileId).to(equal( "051111111111111111111111111111111111111111111111111111111111111112" @@ -2256,55 +1974,55 @@ class MessageReceiverGroupsSpec: QuickSpec { var cMemberId: [CChar] = "051111111111111111111111111111111111111111111111111111111111111112".cString(using: .utf8)! var groupMember: config_group_member = config_group_member() - expect(groups_members_get(groupMembersConf, &groupMember, &cMemberId)).to(beTrue()) + expect(groups_members_get(fixture.groupMembersConfig.conf, &groupMember, &cMemberId)).to(beTrue()) expect(groupMember.invited).to(equal(0)) } // MARK: ------ updates the entry in libSession directly if there is no database value it("updates the entry in libSession directly if there is no database value") { - mockStorage.write { db in + fixture.mockStorage.write { db in _ = try GroupMember.deleteAll(db) } - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: inviteResponseMessage, + message: fixture.inviteResponseMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } var cMemberId: [CChar] = "051111111111111111111111111111111111111111111111111111111111111112".cString(using: .utf8)! var groupMember: config_group_member = config_group_member() - expect(groups_members_get(groupMembersConf, &groupMember, &cMemberId)).to(beTrue()) + expect(groups_members_get(fixture.groupMembersConfig.conf, &groupMember, &cMemberId)).to(beTrue()) expect(groupMember.invited).to(equal(0)) } // MARK: ---- updates the config member entry with profile information if provided it("updates the config member entry with profile information if provided") { - mockStorage.write { db in + fixture.mockStorage.write { db in _ = try GroupMember.deleteAll(db) } - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: inviteResponseMessage, + message: fixture.inviteResponseMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } var cMemberId: [CChar] = "051111111111111111111111111111111111111111111111111111111111111112".cString(using: .utf8)! var groupMember: config_group_member = config_group_member() - expect(groups_members_get(groupMembersConf, &groupMember, &cMemberId)).to(beTrue()) + expect(groups_members_get(fixture.groupMembersConfig.conf, &groupMember, &cMemberId)).to(beTrue()) expect(groupMember.get(\.name)).to(equal("TestOtherMember")) } } @@ -2313,23 +2031,23 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: -- when receiving a delete content message context("when receiving a delete content message") { beforeEach { - mockStorage.write { db in + fixture.mockStorage.write { db in try SessionThread.upsert( db, - id: groupId.hexString, + id: fixture.groupId.hexString, variant: .group, values: SessionThread.TargetValues( creationDateTimestamp: .setTo(1234567890), shouldBeVisible: .setTo(true) ), - using: dependencies + using: fixture.dependencies ) _ = try Interaction( id: 1, serverHash: "TestMessageHash1", messageUuid: nil, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, authorId: "051111111111111111111111111111111111111111111111111111111111111111", variant: .standardIncoming, body: "Test1", @@ -2354,7 +2072,7 @@ class MessageReceiverGroupsSpec: QuickSpec { id: 2, serverHash: "TestMessageHash2", messageUuid: nil, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, authorId: "051111111111111111111111111111111111111111111111111111111111111111", variant: .standardIncoming, body: "Test2", @@ -2379,7 +2097,7 @@ class MessageReceiverGroupsSpec: QuickSpec { id: 3, serverHash: "TestMessageHash3", messageUuid: nil, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, authorId: "051111111111111111111111111111111111111111111111111111111111111112", variant: .standardIncoming, body: "Test3", @@ -2404,7 +2122,7 @@ class MessageReceiverGroupsSpec: QuickSpec { id: 4, serverHash: "TestMessageHash4", messageUuid: nil, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, authorId: "051111111111111111111111111111111111111111111111111111111111111112", variant: .standardIncoming, body: "Test4", @@ -2429,23 +2147,23 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- throws if there is no sender and no admin signature it("throws if there is no sender and no admin signature") { - deleteContentMessage = GroupUpdateDeleteMemberContentMessage( + fixture.deleteContentMessage = GroupUpdateDeleteMemberContentMessage( memberSessionIds: ["051111111111111111111111111111111111111111111111111111111111111112"], messageHashes: [], adminSignature: nil ) - deleteContentMessage.sentTimestampMs = 1234567800000 + fixture.deleteContentMessage.sentTimestampMs = 1234567800000 - mockStorage.write { db in + fixture.mockStorage.write { db in expect { try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: deleteContentMessage, + message: fixture.deleteContentMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) } @@ -2453,18 +2171,18 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- throws if there is no timestamp it("throws if there is no timestamp") { - deleteContentMessage.sentTimestampMs = nil + fixture.deleteContentMessage.sentTimestampMs = nil - mockStorage.write { db in + fixture.mockStorage.write { db in expect { try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: deleteContentMessage, + message: fixture.deleteContentMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) } @@ -2472,20 +2190,20 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- throws if the admin signature fails to verify it("throws if the admin signature fails to verify") { - mockCrypto + try await fixture.mockCrypto .when { $0.verify(.signature(message: .any, publicKey: .any, signature: .any)) } .thenReturn(false) - mockStorage.write { db in + fixture.mockStorage.write { db in expect { try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: deleteContentMessage, + message: fixture.deleteContentMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) } @@ -2495,83 +2213,83 @@ class MessageReceiverGroupsSpec: QuickSpec { context("and there is no admin signature") { // MARK: ------ removes content for specific messages from the database it("removes content for specific messages from the database") { - deleteContentMessage = GroupUpdateDeleteMemberContentMessage( + fixture.deleteContentMessage = GroupUpdateDeleteMemberContentMessage( memberSessionIds: [], messageHashes: ["TestMessageHash3"], adminSignature: nil ) - deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111112" - deleteContentMessage.sentTimestampMs = 1234567800000 + fixture.deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111112" + fixture.deleteContentMessage.sentTimestampMs = 1234567800000 - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: deleteContentMessage, + message: fixture.deleteContentMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interactions: [Interaction]? = mockStorage.read { db in try Interaction.fetchAll(db) } + let interactions: [Interaction]? = fixture.mockStorage.read { db in try Interaction.fetchAll(db) } expect(interactions?.count).to(equal(4)) // Message isn't deleted, just content expect(interactions?.map { $0.body }).toNot(contain("Test3")) } // MARK: ------ removes content for all messages from the sender from the database it("removes content for all messages from the sender from the database") { - deleteContentMessage = GroupUpdateDeleteMemberContentMessage( + fixture.deleteContentMessage = GroupUpdateDeleteMemberContentMessage( memberSessionIds: [ "051111111111111111111111111111111111111111111111111111111111111112" ], messageHashes: [], adminSignature: nil ) - deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111112" - deleteContentMessage.sentTimestampMs = 1234567800000 + fixture.deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111112" + fixture.deleteContentMessage.sentTimestampMs = 1234567800000 - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: deleteContentMessage, + message: fixture.deleteContentMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interactions: [Interaction]? = mockStorage.read { db in try Interaction.fetchAll(db) } + let interactions: [Interaction]? = fixture.mockStorage.read { db in try Interaction.fetchAll(db) } expect(interactions?.count).to(equal(4)) // Message isn't deleted, just content expect(interactions?.map { $0.body }).toNot(contain("Test3")) } // MARK: ------ ignores messages not sent by the sender it("ignores messages not sent by the sender") { - deleteContentMessage = GroupUpdateDeleteMemberContentMessage( + fixture.deleteContentMessage = GroupUpdateDeleteMemberContentMessage( memberSessionIds: [], messageHashes: ["TestMessageHash1", "TestMessageHash3"], adminSignature: nil ) - deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111112" - deleteContentMessage.sentTimestampMs = 1234567800000 + fixture.deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111112" + fixture.deleteContentMessage.sentTimestampMs = 1234567800000 - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: deleteContentMessage, + message: fixture.deleteContentMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interactions: [Interaction]? = mockStorage.read { db in try Interaction.fetchAll(db) } + let interactions: [Interaction]? = fixture.mockStorage.read { db in try Interaction.fetchAll(db) } expect(interactions?.count).to(equal(4)) expect(interactions?.map { $0.serverHash }).to(equal([ "TestMessageHash1", "TestMessageHash2", "TestMessageHash3", "TestMessageHash4" @@ -2598,27 +2316,27 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ------ ignores messages sent after the delete content message was sent it("ignores messages sent after the delete content message was sent") { - deleteContentMessage = GroupUpdateDeleteMemberContentMessage( + fixture.deleteContentMessage = GroupUpdateDeleteMemberContentMessage( memberSessionIds: [], messageHashes: ["TestMessageHash3", "TestMessageHash4"], adminSignature: nil ) - deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111112" - deleteContentMessage.sentTimestampMs = 1234567800000 + fixture.deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111112" + fixture.deleteContentMessage.sentTimestampMs = 1234567800000 - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: deleteContentMessage, + message: fixture.deleteContentMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interactions: [Interaction]? = mockStorage.read { db in try Interaction.fetchAll(db) } + let interactions: [Interaction]? = fixture.mockStorage.read { db in try Interaction.fetchAll(db) } expect(interactions?.count).to(equal(4)) expect(interactions?.map { $0.serverHash }).to(equal([ "TestMessageHash1", "TestMessageHash2", "TestMessageHash3", "TestMessageHash4" @@ -2648,119 +2366,119 @@ class MessageReceiverGroupsSpec: QuickSpec { context("and there is no admin signature") { // MARK: ------ removes content for specific messages from the database it("removes content for specific messages from the database") { - deleteContentMessage = GroupUpdateDeleteMemberContentMessage( + fixture.deleteContentMessage = GroupUpdateDeleteMemberContentMessage( memberSessionIds: [], messageHashes: ["TestMessageHash3"], adminSignature: .standard(signature: "TestSignature".bytes) ) - deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111112" - deleteContentMessage.sentTimestampMs = 1234567800000 + fixture.deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111112" + fixture.deleteContentMessage.sentTimestampMs = 1234567800000 - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: deleteContentMessage, + message: fixture.deleteContentMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interactions: [Interaction]? = mockStorage.read { db in try Interaction.fetchAll(db) } + let interactions: [Interaction]? = fixture.mockStorage.read { db in try Interaction.fetchAll(db) } expect(interactions?.count).to(equal(4)) expect(interactions?.map { $0.body }).toNot(contain("Test3")) } // MARK: ------ removes content for all messages for a given id from the database it("removes content for all messages for a given id from the database") { - deleteContentMessage = GroupUpdateDeleteMemberContentMessage( + fixture.deleteContentMessage = GroupUpdateDeleteMemberContentMessage( memberSessionIds: [ "051111111111111111111111111111111111111111111111111111111111111112" ], messageHashes: [], adminSignature: .standard(signature: "TestSignature".bytes) ) - deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111112" - deleteContentMessage.sentTimestampMs = 1234567800000 + fixture.deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111112" + fixture.deleteContentMessage.sentTimestampMs = 1234567800000 - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: deleteContentMessage, + message: fixture.deleteContentMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interactions: [Interaction]? = mockStorage.read { db in try Interaction.fetchAll(db) } + let interactions: [Interaction]? = fixture.mockStorage.read { db in try Interaction.fetchAll(db) } expect(interactions?.count).to(equal(4)) expect(interactions?.map { $0.body }).toNot(contain("Test3")) } // MARK: ------ removes content for specific messages sent from a user that is not the sender from the database it("removes content for specific messages sent from a user that is not the sender from the database") { - deleteContentMessage = GroupUpdateDeleteMemberContentMessage( + fixture.deleteContentMessage = GroupUpdateDeleteMemberContentMessage( memberSessionIds: [], messageHashes: ["TestMessageHash3"], adminSignature: .standard(signature: "TestSignature".bytes) ) - deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" - deleteContentMessage.sentTimestampMs = 1234567800000 + fixture.deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" + fixture.deleteContentMessage.sentTimestampMs = 1234567800000 - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: deleteContentMessage, + message: fixture.deleteContentMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interactions: [Interaction]? = mockStorage.read { db in try Interaction.fetchAll(db) } + let interactions: [Interaction]? = fixture.mockStorage.read { db in try Interaction.fetchAll(db) } expect(interactions?.count).to(equal(4)) expect(interactions?.map { $0.body }).toNot(contain("Test3")) } // MARK: ------ removes content for all messages for a given id that is not the sender from the database it("removes content for all messages for a given id that is not the sender from the database") { - deleteContentMessage = GroupUpdateDeleteMemberContentMessage( + fixture.deleteContentMessage = GroupUpdateDeleteMemberContentMessage( memberSessionIds: [ "051111111111111111111111111111111111111111111111111111111111111112" ], messageHashes: [], adminSignature: .standard(signature: "TestSignature".bytes) ) - deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" - deleteContentMessage.sentTimestampMs = 1234567800000 + fixture.deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" + fixture.deleteContentMessage.sentTimestampMs = 1234567800000 - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: deleteContentMessage, + message: fixture.deleteContentMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interactions: [Interaction]? = mockStorage.read { db in try Interaction.fetchAll(db) } + let interactions: [Interaction]? = fixture.mockStorage.read { db in try Interaction.fetchAll(db) } expect(interactions?.count).to(equal(4)) expect(interactions?.map { $0.body }).toNot(contain("Test3")) } // MARK: ------ ignores messages sent after the delete content message was sent it("ignores messages sent after the delete content message was sent") { - deleteContentMessage = GroupUpdateDeleteMemberContentMessage( + fixture.deleteContentMessage = GroupUpdateDeleteMemberContentMessage( memberSessionIds: [ "051111111111111111111111111111111111111111111111111111111111111111", "051111111111111111111111111111111111111111111111111111111111111112" @@ -2768,22 +2486,22 @@ class MessageReceiverGroupsSpec: QuickSpec { messageHashes: [], adminSignature: .standard(signature: "TestSignature".bytes) ) - deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" - deleteContentMessage.sentTimestampMs = 1234567800000 + fixture.deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" + fixture.deleteContentMessage.sentTimestampMs = 1234567800000 - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: deleteContentMessage, + message: fixture.deleteContentMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let interactions: [Interaction]? = mockStorage.read { db in try Interaction.fetchAll(db) } + let interactions: [Interaction]? = fixture.mockStorage.read { db in try Interaction.fetchAll(db) } expect(interactions?.count).to(equal(4)) expect(interactions?.map { $0.serverHash }).to(equal([ "TestMessageHash1", "TestMessageHash2", "TestMessageHash3", "TestMessageHash4" @@ -2812,13 +2530,13 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- and the current user is an admin context("and the current user is an admin") { beforeEach { - mockStorage.write { db in + fixture.mockStorage.write { db in try ClosedGroup( - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, name: "TestGroup", formationTimestamp: 1234567890, shouldPoll: true, - groupIdentityPrivateKey: groupSecretKey, + groupIdentityPrivateKey: fixture.groupSecretKey, authData: nil, invited: false ).upsert(db) @@ -2827,77 +2545,94 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ------ deletes the messages from the swarm it("deletes the messages from the swarm") { - deleteContentMessage = GroupUpdateDeleteMemberContentMessage( + fixture.deleteContentMessage = GroupUpdateDeleteMemberContentMessage( memberSessionIds: [], messageHashes: ["TestMessageHash3"], adminSignature: nil ) - deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111112" - deleteContentMessage.sentTimestampMs = 1234567800000 + fixture.deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111112" + fixture.deleteContentMessage.sentTimestampMs = 1234567800000 - let preparedRequest: Network.PreparedRequest<[String: Bool]> = try! Network.SnodeAPI + let preparedRequest: Network.PreparedRequest<[String: Bool]> = try! Network.StorageServer .preparedDeleteMessages( serverHashes: ["TestMessageHash3"], requireSuccessfulDeletion: false, authMethod: Authentication.groupAdmin( - groupSessionId: groupId, - ed25519SecretKey: Array(groupSecretKey) + groupSessionId: fixture.groupId, + ed25519SecretKey: Array(fixture.groupSecretKey) ), - using: dependencies + using: fixture.dependencies ) - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: deleteContentMessage, + message: fixture.deleteContentMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - expect(mockNetwork) - .to(call(.exactly(times: 1), matchingParameters: .all) { network in - network.send( - preparedRequest.body, - to: preparedRequest.destination, + await fixture.mockNetwork + .verify { + $0.send( + endpoint: Network.StorageServer.Endpoint.deleteMessages, + destination: preparedRequest.destination, + body: preparedRequest.body, + category: .standard, requestTimeout: preparedRequest.requestTimeout, - requestAndPathBuildTimeout: preparedRequest.requestAndPathBuildTimeout + overallTimeout: preparedRequest.overallTimeout ) - }) + } + .wasCalled(exactly: 1) } } // MARK: ---- and the current user is not an admin context("and the current user is not an admin") { + beforeEach { + try await fixture.mockLibSessionCache + .when { $0.isAdmin(groupSessionId: .any) } + .thenReturn(false) + } + // MARK: ------ does not delete the messages from the swarm it("does not delete the messages from the swarm") { - deleteContentMessage = GroupUpdateDeleteMemberContentMessage( + fixture.deleteContentMessage = GroupUpdateDeleteMemberContentMessage( memberSessionIds: [], messageHashes: ["TestMessageHash3"], adminSignature: nil ) - deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111112" - deleteContentMessage.sentTimestampMs = 1234567800000 + fixture.deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111112" + fixture.deleteContentMessage.sentTimestampMs = 1234567800000 - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: deleteContentMessage, + message: fixture.deleteContentMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - expect(mockNetwork) - .toNot(call { network in - network.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) - }) + await fixture.mockNetwork + .verify { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } + .wasNotCalled() } } } @@ -2905,36 +2640,36 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: -- when receiving a delete message context("when receiving a delete message") { beforeEach { - var cGroupId: [CChar] = groupId.hexString.cString(using: .utf8)! + var cGroupId: [CChar] = fixture.groupId.hexString.cString(using: .utf8)! var userGroup: ugroups_group_info = ugroups_group_info() - user_groups_get_or_construct_group(userGroupsConfig.conf, &userGroup, &cGroupId) + user_groups_get_or_construct_group(fixture.userGroupsConfig.conf, &userGroup, &cGroupId) userGroup.set(\.name, to: "TestName") - user_groups_set_group(userGroupsConfig.conf, &userGroup) + user_groups_set_group(fixture.userGroupsConfig.conf, &userGroup) // Rekey a couple of times to increase the key generation to 1 var fakeHash1: [CChar] = "fakehash1".cString(using: .utf8)! var fakeHash2: [CChar] = "fakehash2".cString(using: .utf8)! var pushResult: UnsafePointer? = nil var pushResultLen: Int = 0 - _ = groups_keys_rekey(groupKeysConf, groupInfoConf, groupMembersConf, &pushResult, &pushResultLen) - _ = groups_keys_load_message(groupKeysConf, &fakeHash1, pushResult, pushResultLen, 1234567890, groupInfoConf, groupMembersConf) - _ = groups_keys_rekey(groupKeysConf, groupInfoConf, groupMembersConf, &pushResult, &pushResultLen) - _ = groups_keys_load_message(groupKeysConf, &fakeHash2, pushResult, pushResultLen, 1234567890, groupInfoConf, groupMembersConf) + _ = groups_keys_rekey(fixture.groupKeysConfig.keysConf, fixture.groupInfoConfig.conf, fixture.groupMembersConfig.conf, &pushResult, &pushResultLen) + _ = groups_keys_load_message(fixture.groupKeysConfig.keysConf, &fakeHash1, pushResult, pushResultLen, 1234567890, fixture.groupInfoConfig.conf, fixture.groupMembersConfig.conf) + _ = groups_keys_rekey(fixture.groupKeysConfig.keysConf, fixture.groupInfoConfig.conf, fixture.groupMembersConfig.conf, &pushResult, &pushResultLen) + _ = groups_keys_load_message(fixture.groupKeysConfig.keysConf, &fakeHash2, pushResult, pushResultLen, 1234567890, fixture.groupInfoConfig.conf, fixture.groupMembersConfig.conf) - mockStorage.write { db in + fixture.mockStorage.write { db in try SessionThread.upsert( db, - id: groupId.hexString, + id: fixture.groupId.hexString, variant: .group, values: SessionThread.TargetValues( creationDateTimestamp: .setTo(1234567890), shouldBeVisible: .setTo(true) ), - using: dependencies + using: fixture.dependencies ) try ClosedGroup( - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, name: "TestGroup", formationTimestamp: 1234567890, shouldPoll: true, @@ -2944,7 +2679,7 @@ class MessageReceiverGroupsSpec: QuickSpec { ).upsert(db) try GroupMember( - groupId: groupId.hexString, + groupId: fixture.groupId.hexString, profileId: "05\(TestConstants.publicKey)", role: .standard, roleStatus: .accepted, @@ -2955,7 +2690,7 @@ class MessageReceiverGroupsSpec: QuickSpec { id: 1, serverHash: nil, messageUuid: nil, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, authorId: "051111111111111111111111111111111111111111111111111111111111111111", variant: .standardIncoming, body: "Test", @@ -2978,21 +2713,21 @@ class MessageReceiverGroupsSpec: QuickSpec { try ConfigDump( variant: .groupKeys, - sessionId: groupId.hexString, + sessionId: fixture.groupId.hexString, data: Data([1, 2, 3]), timestampMs: 1234567890 ).insert(db) try ConfigDump( variant: .groupInfo, - sessionId: groupId.hexString, + sessionId: fixture.groupId.hexString, data: Data([1, 2, 3]), timestampMs: 1234567890 ).insert(db) try ConfigDump( variant: .groupMembers, - sessionId: groupId.hexString, + sessionId: fixture.groupId.hexString, data: Data([1, 2, 3]), timestampMs: 1234567890 ).insert(db) @@ -3001,37 +2736,37 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- deletes any interactions from the conversation it("deletes any interactions from the conversation") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupDelete( db, - groupSessionId: groupId, - plaintext: deleteMessage, - using: dependencies + groupSessionId: fixture.groupId, + plaintext: fixture.deleteMessage, + using: fixture.dependencies ) } - let interactions: [Interaction]? = mockStorage.read { db in try Interaction.fetchAll(db) } + let interactions: [Interaction]? = fixture.mockStorage.read { db in try Interaction.fetchAll(db) } expect(interactions).to(beEmpty()) } // MARK: ---- deletes the group auth data it("deletes the group auth data") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupDelete( db, - groupSessionId: groupId, - plaintext: deleteMessage, - using: dependencies + groupSessionId: fixture.groupId, + plaintext: fixture.deleteMessage, + using: fixture.dependencies ) } - let authData: [Data?]? = mockStorage.read { db in + let authData: [Data?]? = fixture.mockStorage.read { db in try ClosedGroup .select(ClosedGroup.Columns.authData) .asRequest(of: Data?.self) .fetchAll(db) } - let privateKeyData: [Data?]? = mockStorage.read { db in + let privateKeyData: [Data?]? = fixture.mockStorage.read { db in try ClosedGroup .select(ClosedGroup.Columns.groupIdentityPrivateKey) .asRequest(of: Data?.self) @@ -3043,55 +2778,53 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- deletes the group members it("deletes the group members") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupDelete( db, - groupSessionId: groupId, - plaintext: deleteMessage, - using: dependencies + groupSessionId: fixture.groupId, + plaintext: fixture.deleteMessage, + using: fixture.dependencies ) } - let members: [GroupMember]? = mockStorage.read { db in try GroupMember.fetchAll(db) } + let members: [GroupMember]? = fixture.mockStorage.read { db in try GroupMember.fetchAll(db) } expect(members).to(beEmpty()) } // MARK: ---- removes the group libSession state it("removes the group libSession state") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupDelete( db, - groupSessionId: groupId, - plaintext: deleteMessage, - using: dependencies + groupSessionId: fixture.groupId, + plaintext: fixture.deleteMessage, + using: fixture.dependencies ) } - expect(mockLibSessionCache) - .to(call(.exactly(times: 1), matchingParameters: .all) { - $0.removeConfigs(for: groupId) - }) + await fixture.mockLibSessionCache + .verify { $0.removeConfigs(for: fixture.groupId) } + .wasCalled(exactly: 1) } // MARK: ---- removes the cached libSession state dumps it("removes the cached libSession state dumps") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupDelete( db, - groupSessionId: groupId, - plaintext: deleteMessage, - using: dependencies + groupSessionId: fixture.groupId, + plaintext: fixture.deleteMessage, + using: fixture.dependencies ) } - expect(mockLibSessionCache) - .to(call(.exactly(times: 1), matchingParameters: .all) { - $0.removeConfigs(for: groupId) - }) + await fixture.mockLibSessionCache + .verify { $0.removeConfigs(for: fixture.groupId) } + .wasCalled(exactly: 1) - let dumps: [ConfigDump]? = mockStorage.read { db in + let dumps: [ConfigDump]? = fixture.mockStorage.read { db in try ConfigDump - .filter(ConfigDump.Columns.publicKey == groupId.hexString) + .filter(ConfigDump.Columns.publicKey == fixture.groupId.hexString) .fetchAll(db) } expect(dumps).to(beEmpty()) @@ -3099,212 +2832,218 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ------ unsubscribes from push notifications it("unsubscribes from push notifications") { - mockUserDefaults + try await fixture.mockUserDefaults .when { $0.string(forKey: UserDefaults.StringKey.deviceToken.rawValue) } .thenReturn(Data([5, 4, 3, 2, 1]).toHexString()) - mockUserDefaults + try await fixture.mockUserDefaults .when { $0.bool(forKey: UserDefaults.BoolKey.isUsingFullAPNs.rawValue) } .thenReturn(true) - let expectedRequest: Network.PreparedRequest = mockStorage.read { db in - try Network.PushNotification.preparedUnsubscribe( - token: Data([5, 4, 3, 2, 1]), - swarms: [ - ( - groupId, - Authentication.groupMember( - groupSessionId: groupId, - authData: Data([1, 2, 3]) - ) - ) - ], - using: dependencies - ) - }! - - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupDelete( db, - groupSessionId: groupId, - plaintext: deleteMessage, - using: dependencies + groupSessionId: fixture.groupId, + plaintext: fixture.deleteMessage, + using: fixture.dependencies ) } - expect(mockNetwork) - .to(call(.exactly(times: 1), matchingParameters: .all) { network in - network.send( - expectedRequest.body, - to: expectedRequest.destination, - requestTimeout: expectedRequest.requestTimeout, - requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout + await fixture.mockNetwork + .verify { + $0.send( + endpoint: Network.PushNotification.Endpoint.unsubscribe, + destination: .server( + method: .post, + server: Network.PushNotification.server, + queryParameters: [:], + headers: [:], + x25519PublicKey: Network.PushNotification.serverPublicKey + ), + body: try! JSONEncoder(using: fixture.dependencies).encode( + Network.PushNotification.UnsubscribeRequest( + subscriptions: [ + Network.PushNotification.UnsubscribeRequest.Subscription( + serviceInfo: Network.PushNotification.ServiceInfo( + token: Data([5, 4, 3, 2, 1]).toHexString() + ), + authMethod: try! Authentication.with( + swarmPublicKey: fixture.groupId.hexString, + using: fixture.dependencies + ), + timestamp: 1234567890 + ) + ] + ) + ), + category: .standard, + requestTimeout: Network.defaultTimeout, + overallTimeout: nil ) - }) + } + .wasCalled(exactly: Network.PushNotification.maxRetryCount + 1, timeout: .milliseconds(100)) } // MARK: ---- and the group is an invitation context("and the group is an invitation") { beforeEach { - mockStorage.write { db in + fixture.mockStorage.write { db in try ClosedGroup.updateAll(db, ClosedGroup.Columns.invited.set(to: true)) } } // MARK: ------ deletes the thread it("deletes the thread") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupDelete( db, - groupSessionId: groupId, - plaintext: deleteMessage, - using: dependencies + groupSessionId: fixture.groupId, + plaintext: fixture.deleteMessage, + using: fixture.dependencies ) } - let threads: [SessionThread]? = mockStorage.read { db in try SessionThread.fetchAll(db) } + let threads: [SessionThread]? = fixture.mockStorage.read { db in try SessionThread.fetchAll(db) } expect(threads).to(beEmpty()) } // MARK: ------ deletes the group it("deletes the group") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupDelete( db, - groupSessionId: groupId, - plaintext: deleteMessage, - using: dependencies + groupSessionId: fixture.groupId, + plaintext: fixture.deleteMessage, + using: fixture.dependencies ) } - let groups: [ClosedGroup]? = mockStorage.read { db in try ClosedGroup.fetchAll(db) } + let groups: [ClosedGroup]? = fixture.mockStorage.read { db in try ClosedGroup.fetchAll(db) } expect(groups).to(beEmpty()) } // MARK: ---- stops the poller it("stops the poller") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupDelete( db, - groupSessionId: groupId, - plaintext: deleteMessage, - using: dependencies + groupSessionId: fixture.groupId, + plaintext: fixture.deleteMessage, + using: fixture.dependencies ) } - expect(mockGroupPollersCache) - .to(call(.exactly(times: 1), matchingParameters: .all) { - $0.stopAndRemovePoller(for: groupId.hexString) - }) + await fixture.mockGroupPollerManager + .verify { await $0.stopAndRemovePoller(for: fixture.groupId.hexString) } + .wasCalled(exactly: 1) } // MARK: ------ removes the group from the USER_GROUPS config it("removes the group from the USER_GROUPS config") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupDelete( db, - groupSessionId: groupId, - plaintext: deleteMessage, - using: dependencies + groupSessionId: fixture.groupId, + plaintext: fixture.deleteMessage, + using: fixture.dependencies ) } - var cGroupId: [CChar] = groupId.hexString.cString(using: .utf8)! + var cGroupId: [CChar] = fixture.groupId.hexString.cString(using: .utf8)! var userGroup: ugroups_group_info = ugroups_group_info() - expect(user_groups_get_group(userGroupsConfig.conf, &userGroup, &cGroupId)).to(beFalse()) + expect(user_groups_get_group(fixture.userGroupsConfig.conf, &userGroup, &cGroupId)).to(beFalse()) } } // MARK: ---- and the group is not an invitation context("and the group is not an invitation") { beforeEach { - mockStorage.write { db in + fixture.mockStorage.write { db in try ClosedGroup.updateAll(db, ClosedGroup.Columns.invited.set(to: false)) } } // MARK: ------ does not delete the thread it("does not delete the thread") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupDelete( db, - groupSessionId: groupId, - plaintext: deleteMessage, - using: dependencies + groupSessionId: fixture.groupId, + plaintext: fixture.deleteMessage, + using: fixture.dependencies ) } - let threads: [SessionThread]? = mockStorage.read { db in try SessionThread.fetchAll(db) } + let threads: [SessionThread]? = fixture.mockStorage.read { db in try SessionThread.fetchAll(db) } expect(threads).toNot(beEmpty()) } // MARK: ------ does not remove the group from the USER_GROUPS config it("does not remove the group from the USER_GROUPS config") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupDelete( db, - groupSessionId: groupId, - plaintext: deleteMessage, - using: dependencies + groupSessionId: fixture.groupId, + plaintext: fixture.deleteMessage, + using: fixture.dependencies ) } - var cGroupId: [CChar] = groupId.hexString.cString(using: .utf8)! + var cGroupId: [CChar] = fixture.groupId.hexString.cString(using: .utf8)! var userGroup: ugroups_group_info = ugroups_group_info() - expect(user_groups_get_group(userGroupsConfig.conf, &userGroup, &cGroupId)).to(beTrue()) + expect(user_groups_get_group(fixture.userGroupsConfig.conf, &userGroup, &cGroupId)).to(beTrue()) } // MARK: ---- stops the poller and flags the group to not poll it("stops the poller and flags the group to not poll") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupDelete( db, - groupSessionId: groupId, - plaintext: deleteMessage, - using: dependencies + groupSessionId: fixture.groupId, + plaintext: fixture.deleteMessage, + using: fixture.dependencies ) } - let shouldPoll: [Bool]? = mockStorage.read { db in + let shouldPoll: [Bool]? = fixture.mockStorage.read { db in try ClosedGroup .select(ClosedGroup.Columns.shouldPoll) .asRequest(of: Bool.self) .fetchAll(db) } - expect(mockGroupPollersCache) - .to(call(.exactly(times: 1), matchingParameters: .all) { - $0.stopAndRemovePoller(for: groupId.hexString) - }) + await fixture.mockGroupPollerManager + .verify { await $0.stopAndRemovePoller(for: fixture.groupId.hexString) } + .wasCalled(exactly: 1) expect(shouldPoll).to(equal([false])) } // MARK: ------ marks the group in USER_GROUPS as kicked it("marks the group in USER_GROUPS as kicked") { - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handleGroupDelete( db, - groupSessionId: groupId, - plaintext: deleteMessage, - using: dependencies + groupSessionId: fixture.groupId, + plaintext: fixture.deleteMessage, + using: fixture.dependencies ) } - expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.markAsKicked(groupSessionIds: [groupId.hexString]) - }) + await fixture.mockLibSessionCache + .verify { try $0.markAsKicked(groupSessionIds: [fixture.groupId.hexString]) } + .wasCalled(exactly: 1) } } // MARK: ---- throws if the data is invalid it("throws if the data is invalid") { - deleteMessage = Data([1, 2, 3]) + fixture.deleteMessage = Data([1, 2, 3]) - mockStorage.write { db in + fixture.mockStorage.write { db in expect { try MessageReceiver.handleGroupDelete( db, - groupSessionId: groupId, - plaintext: deleteMessage, - using: dependencies + groupSessionId: fixture.groupId, + plaintext: fixture.deleteMessage, + using: fixture.dependencies ) } .to(throwError(MessageReceiverError.invalidMessage)) @@ -3313,18 +3052,18 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- throws if the included member id does not match the current user it("throws if the included member id does not match the current user") { - deleteMessage = try! LibSessionMessage.groupKicked( + fixture.deleteMessage = try! LibSessionMessage.groupKicked( memberId: "051111111111111111111111111111111111111111111111111111111111111111", groupKeysGen: 1 ).1 - mockStorage.write { db in + fixture.mockStorage.write { db in expect { try MessageReceiver.handleGroupDelete( db, - groupSessionId: groupId, - plaintext: deleteMessage, - using: dependencies + groupSessionId: fixture.groupId, + plaintext: fixture.deleteMessage, + using: fixture.dependencies ) } .to(throwError(MessageReceiverError.invalidMessage)) @@ -3333,18 +3072,18 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- throws if the key generation is earlier than the current keys generation it("throws if the key generation is earlier than the current keys generation") { - deleteMessage = try! LibSessionMessage.groupKicked( + fixture.deleteMessage = try! LibSessionMessage.groupKicked( memberId: "05\(TestConstants.publicKey)", groupKeysGen: 0 ).1 - mockStorage.write { db in + fixture.mockStorage.write { db in expect { try MessageReceiver.handleGroupDelete( db, - groupSessionId: groupId, - plaintext: deleteMessage, - using: dependencies + groupSessionId: fixture.groupId, + plaintext: fixture.deleteMessage, + using: fixture.dependencies ) } .to(throwError(MessageReceiverError.invalidMessage)) @@ -3358,29 +3097,29 @@ class MessageReceiverGroupsSpec: QuickSpec { // Only update members if they already exist in the group var cMemberId: [CChar] = "051111111111111111111111111111111111111111111111111111111111111112".cString(using: .utf8)! var groupMember: config_group_member = config_group_member() - _ = groups_members_get_or_construct(groupMembersConf, &groupMember, &cMemberId) + _ = groups_members_get_or_construct(fixture.groupMembersConfig.conf, &groupMember, &cMemberId) groupMember.set(\.name, to: "TestOtherMember") groupMember.invited = 1 - groups_members_set(groupMembersConf, &groupMember) + groups_members_set(fixture.groupMembersConfig.conf, &groupMember) - mockStorage.write { db in + fixture.mockStorage.write { db in try SessionThread.upsert( db, - id: groupId.hexString, + id: fixture.groupId.hexString, variant: .group, values: SessionThread.TargetValues( creationDateTimestamp: .setTo(1234567890), shouldBeVisible: .setTo(true) ), - using: dependencies + using: fixture.dependencies ) try ClosedGroup( - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, name: "TestGroup", formationTimestamp: 1234567890, shouldPoll: true, - groupIdentityPrivateKey: groupSecretKey, + groupIdentityPrivateKey: fixture.groupSecretKey, authData: nil, invited: false ).upsert(db) @@ -3389,9 +3128,9 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- updates a pending member entry to an accepted member it("updates a pending member entry to an accepted member") { - mockStorage.write { db in + fixture.mockStorage.write { db in try GroupMember( - groupId: groupId.hexString, + groupId: fixture.groupId.hexString, profileId: "051111111111111111111111111111111111111111111111111111111111111112", role: .standard, roleStatus: .pending, @@ -3399,20 +3138,20 @@ class MessageReceiverGroupsSpec: QuickSpec { ).upsert(db) } - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handle( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: visibleMessage, + message: fixture.visibleMessage, serverExpirationTimestamp: nil, - associatedWithProto: visibleMessageProto, + associatedWithProto: fixture.visibleMessageProto, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let members: [GroupMember]? = mockStorage.read { db in try GroupMember.fetchAll(db) } + let members: [GroupMember]? = fixture.mockStorage.read { db in try GroupMember.fetchAll(db) } expect(members?.count).to(equal(1)) expect(members?.first?.profileId).to(equal( "051111111111111111111111111111111111111111111111111111111111111112" @@ -3422,7 +3161,7 @@ class MessageReceiverGroupsSpec: QuickSpec { var cMemberId: [CChar] = "051111111111111111111111111111111111111111111111111111111111111112".cString(using: .utf8)! var groupMember: config_group_member = config_group_member() - expect(groups_members_get(groupMembersConf, &groupMember, &cMemberId)).to(beTrue()) + expect(groups_members_get(fixture.groupMembersConfig.conf, &groupMember, &cMemberId)).to(beTrue()) expect(groupMember.invited).to(equal(0)) } @@ -3430,13 +3169,13 @@ class MessageReceiverGroupsSpec: QuickSpec { it("updates a failed member entry to an accepted member") { var cMemberId1: [CChar] = "051111111111111111111111111111111111111111111111111111111111111112".cString(using: .utf8)! var groupMember1: config_group_member = config_group_member() - _ = groups_members_get(groupMembersConf, &groupMember1, &cMemberId1) + _ = groups_members_get(fixture.groupMembersConfig.conf, &groupMember1, &cMemberId1) groupMember1.invited = 2 - groups_members_set(groupMembersConf, &groupMember1) + groups_members_set(fixture.groupMembersConfig.conf, &groupMember1) - mockStorage.write { db in + fixture.mockStorage.write { db in try GroupMember( - groupId: groupId.hexString, + groupId: fixture.groupId.hexString, profileId: "051111111111111111111111111111111111111111111111111111111111111112", role: .standard, roleStatus: .failed, @@ -3444,20 +3183,20 @@ class MessageReceiverGroupsSpec: QuickSpec { ).upsert(db) } - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handle( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: visibleMessage, + message: fixture.visibleMessage, serverExpirationTimestamp: nil, - associatedWithProto: visibleMessageProto, + associatedWithProto: fixture.visibleMessageProto, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } - let members: [GroupMember]? = mockStorage.read { db in try GroupMember.fetchAll(db) } + let members: [GroupMember]? = fixture.mockStorage.read { db in try GroupMember.fetchAll(db) } expect(members?.count).to(equal(1)) expect(members?.first?.profileId).to(equal( "051111111111111111111111111111111111111111111111111111111111111112" @@ -3467,32 +3206,32 @@ class MessageReceiverGroupsSpec: QuickSpec { var cMemberId: [CChar] = "051111111111111111111111111111111111111111111111111111111111111112".cString(using: .utf8)! var groupMember: config_group_member = config_group_member() - expect(groups_members_get(groupMembersConf, &groupMember, &cMemberId)).to(beTrue()) + expect(groups_members_get(fixture.groupMembersConfig.conf, &groupMember, &cMemberId)).to(beTrue()) expect(groupMember.invited).to(equal(0)) } // MARK: ---- updates the entry in libSession directly if there is no database value it("updates the entry in libSession directly if there is no database value") { - mockStorage.write { db in + fixture.mockStorage.write { db in _ = try GroupMember.deleteAll(db) } - mockStorage.write { db in + fixture.mockStorage.write { db in try MessageReceiver.handle( db, - threadId: groupId.hexString, + threadId: fixture.groupId.hexString, threadVariant: .group, - message: visibleMessage, + message: fixture.visibleMessage, serverExpirationTimestamp: nil, - associatedWithProto: visibleMessageProto, + associatedWithProto: fixture.visibleMessageProto, suppressNotifications: false, - using: dependencies + using: fixture.dependencies ) } var cMemberId: [CChar] = "051111111111111111111111111111111111111111111111111111111111111112".cString(using: .utf8)! var groupMember: config_group_member = config_group_member() - expect(groups_members_get(groupMembersConf, &groupMember, &cMemberId)).to(beTrue()) + expect(groups_members_get(fixture.groupMembersConfig.conf, &groupMember, &cMemberId)).to(beTrue()) expect(groupMember.invited).to(equal(0)) } } @@ -3500,6 +3239,473 @@ class MessageReceiverGroupsSpec: QuickSpec { } } +// MARK: - Configuration + +private class MessageReceiverGroupsTestFixture: FixtureBase { + var mockStorage: Storage { + mock(for: .storage) { dependencies in + SynchronousStorage( + customWriter: try! DatabaseQueue(), + using: dependencies + ) + } + } + var mockNetwork: MockNetwork { mock(for: .network) } + var mockJobRunner: MockJobRunner { mock(for: .jobRunner) } + var mockAppContext: MockAppContext { mock(for: .appContext) } + var mockUserDefaults: MockUserDefaults { mock(defaults: .standard) } + var mockCrypto: MockCrypto { mock(for: .crypto) } + var mockKeychain: MockKeychain { mock(for: .keychain) } + var mockFileManager: MockFileManager { mock(for: .fileManager) } + var mockExtensionHelper: MockExtensionHelper { mock(for: .extensionHelper) } + var mockGroupPollerManager: MockGroupPollerManager { mock(for: .groupPollerManager) } + var mockNotificationsManager: MockNotificationsManager { mock(for: .notificationsManager) } + var mockGeneralCache: MockGeneralCache { mock(cache: .general) } + var mockLibSessionCache: MockLibSessionCache { mock(cache: .libSession) } + var mockPoller: MockPoller { mock() } + + let userGroupsConfig: LibSession.Config + let convoInfoVolatileConfig: LibSession.Config + let groupInfoConfig: LibSession.Config + let groupMembersConfig: LibSession.Config + let groupKeysConfig: LibSession.Config + + let groupSeed: Data + let groupKeyPair: KeyPair + let groupId: SessionId + let groupSecretKey: Data + + let inviteMessage: GroupUpdateInviteMessage + let promoteMessage: GroupUpdatePromoteMessage + var infoChangedMessage: GroupUpdateInfoChangeMessage + var memberChangedMessage: GroupUpdateMemberChangeMessage + let memberLeftMessage: GroupUpdateMemberLeftMessage + let memberLeftNotificationMessage: GroupUpdateMemberLeftNotificationMessage + let inviteResponseMessage: GroupUpdateInviteResponseMessage + var deleteMessage: Data + var deleteContentMessage: GroupUpdateDeleteMemberContentMessage + let visibleMessageProto: SNProtoContent + let visibleMessage: VisibleMessage + + override init() { + let constants = Self.setupConstants() + groupSeed = constants.groupSeed + groupKeyPair = constants.groupKeyPair + groupId = constants.groupId + groupSecretKey = constants.groupSecretKey + + let configs = Self.setupConfigs(constants: constants) + userGroupsConfig = configs.userGroupsConfig + convoInfoVolatileConfig = configs.convoInfoVolatileConfig + groupInfoConfig = configs.groupInfoConfig + groupMembersConfig = configs.groupMembersConfig + groupKeysConfig = configs.groupKeysConfig + + let messages = Self.setupMessages(constants: constants) + inviteMessage = messages.inviteMessage + promoteMessage = messages.promoteMessage + infoChangedMessage = messages.infoChangedMessage + memberChangedMessage = messages.memberChangedMessage + memberLeftMessage = messages.memberLeftMessage + memberLeftNotificationMessage = messages.memberLeftNotificationMessage + inviteResponseMessage = messages.inviteResponseMessage + deleteMessage = messages.deleteMessage + deleteContentMessage = messages.deleteContentMessage + visibleMessageProto = messages.visibleMessageProto + visibleMessage = messages.visibleMessage + + super.init() + } + + static func create() async throws -> MessageReceiverGroupsTestFixture { + let fixture: MessageReceiverGroupsTestFixture = MessageReceiverGroupsTestFixture() + try await fixture.applyBaselineStubs() + + return fixture + } + + // MARK: - Setup + + typealias Constants = (groupSeed: Data, groupId: SessionId, groupKeyPair: KeyPair, groupSecretKey: Data) + private static func setupConstants() -> Constants { + let groupSeed: Data = Data(hex: "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210") + let groupId: SessionId = SessionId( + .group, + hex: "03cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece" + ) + let groupKeyPair: KeyPair = try! Crypto(using: .any).tryGenerate(.ed25519KeyPair(seed: Array(groupSeed))) + let groupSecretKey: Data = Data(hex: + "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210" + + "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece" + ) + + return (groupSeed, groupId, groupKeyPair, groupSecretKey) + } + + typealias Configs = ( + userGroupsConfig: LibSession.Config, + convoInfoVolatileConfig: LibSession.Config, + groupInfoConfig: LibSession.Config, + groupMembersConfig: LibSession.Config, + groupKeysConfig: LibSession.Config + ) + private static func setupConfigs(constants: Constants) -> Configs { + var secretKey: [UInt8] = Array(Data(hex: TestConstants.edSecretKey)) + var groupEdPK: [UInt8] = constants.groupKeyPair.publicKey + var groupEdSK: [UInt8] = constants.groupKeyPair.secretKey + + let userGroupsConfig: LibSession.Config = { + var conf: UnsafeMutablePointer! + _ = user_groups_init(&conf, &secretKey, nil, 0, nil) + + return .userGroups(conf) + }() + let convoInfoVolatileConfig: LibSession.Config = { + var conf: UnsafeMutablePointer! + _ = convo_info_volatile_init(&conf, &secretKey, nil, 0, nil) + + return .convoInfoVolatile(conf) + }() + let groupInfoConf: UnsafeMutablePointer = { + var conf: UnsafeMutablePointer! + _ = groups_info_init(&conf, &groupEdPK, &groupEdSK, nil, 0, nil) + + return conf + }() + let groupMembersConf: UnsafeMutablePointer = { + var conf: UnsafeMutablePointer! + _ = groups_members_init(&conf, &groupEdPK, &groupEdSK, nil, 0, nil) + + return conf + }() + let groupKeysConf: UnsafeMutablePointer = { + var conf: UnsafeMutablePointer! + _ = groups_keys_init(&conf, &secretKey, &groupEdPK, &groupEdSK, groupInfoConf, groupMembersConf, nil, 0, nil) + + return conf + }() + let groupInfoConfig: LibSession.Config = .groupInfo(groupInfoConf) + let groupMembersConfig: LibSession.Config = .groupMembers(groupMembersConf) + let groupKeysConfig: LibSession.Config = .groupKeys( + groupKeysConf, + info: groupInfoConf, + members: groupMembersConf + ) + + return (userGroupsConfig, convoInfoVolatileConfig, groupInfoConfig, groupMembersConfig, groupKeysConfig) + } + + typealias Messages = ( + inviteMessage: GroupUpdateInviteMessage, + promoteMessage: GroupUpdatePromoteMessage, + infoChangedMessage: GroupUpdateInfoChangeMessage, + memberChangedMessage: GroupUpdateMemberChangeMessage, + memberLeftMessage: GroupUpdateMemberLeftMessage, + memberLeftNotificationMessage: GroupUpdateMemberLeftNotificationMessage, + inviteResponseMessage: GroupUpdateInviteResponseMessage, + deleteMessage: Data, + deleteContentMessage: GroupUpdateDeleteMemberContentMessage, + visibleMessageProto: SNProtoContent, + visibleMessage: VisibleMessage + ) + private static func setupMessages(constants: Constants) -> Messages { + let inviteMessage = { + let result: GroupUpdateInviteMessage = GroupUpdateInviteMessage( + inviteeSessionIdHexString: "TestId", + groupSessionId: constants.groupId, + groupName: "TestGroup", + memberAuthData: Data([1, 2, 3]), + profile: nil, + adminSignature: .standard(signature: "TestSignature".bytes) + ) + result.sender = "051111111111111111111111111111111111111111111111111111111111111111" + result.sentTimestampMs = 1234567890000 + + return result + }() + let promoteMessage = { + let result: GroupUpdatePromoteMessage = GroupUpdatePromoteMessage( + groupIdentitySeed: constants.groupSeed, + groupName: "TestGroup", + sentTimestampMs: 1234567890000 + ) + result.sender = "051111111111111111111111111111111111111111111111111111111111111111" + + return result + }() + let infoChangedMessage = { + let result: GroupUpdateInfoChangeMessage = GroupUpdateInfoChangeMessage( + changeType: .name, + updatedName: "TestGroup Rename", + updatedExpiration: nil, + adminSignature: .standard(signature: "TestSignature".bytes) + ) + result.sender = "051111111111111111111111111111111111111111111111111111111111111111" + result.sentTimestampMs = 1234567800000 + + return result + }() + let memberChangedMessage = { + let result: GroupUpdateMemberChangeMessage = GroupUpdateMemberChangeMessage( + changeType: .added, + memberSessionIds: ["051111111111111111111111111111111111111111111111111111111111111112"], + historyShared: false, + adminSignature: .standard(signature: "TestSignature".bytes) + ) + result.sender = "051111111111111111111111111111111111111111111111111111111111111111" + result.sentTimestampMs = 1234567800000 + + return result + }() + let memberLeftMessage = { + let result: GroupUpdateMemberLeftMessage = GroupUpdateMemberLeftMessage() + result.sender = "051111111111111111111111111111111111111111111111111111111111111112" + result.sentTimestampMs = 1234567800000 + + return result + }() + let memberLeftNotificationMessage = { + let result: GroupUpdateMemberLeftNotificationMessage = GroupUpdateMemberLeftNotificationMessage() + result.sender = "051111111111111111111111111111111111111111111111111111111111111112" + result.sentTimestampMs = 1234567800000 + + return result + }() + let inviteResponseMessage = { + let result: GroupUpdateInviteResponseMessage = GroupUpdateInviteResponseMessage( + isApproved: true, + profile: VisibleMessage.VMProfile(displayName: "TestOtherMember"), + sentTimestampMs: 1234567800000 + ) + result.sender = "051111111111111111111111111111111111111111111111111111111111111112" + + return result + }() + let deleteMessage = try! LibSessionMessage.groupKicked( + memberId: "05\(TestConstants.publicKey)", + groupKeysGen: 1 + ).1 + let deleteContentMessage = { + let result: GroupUpdateDeleteMemberContentMessage = GroupUpdateDeleteMemberContentMessage( + memberSessionIds: ["051111111111111111111111111111111111111111111111111111111111111112"], + messageHashes: [], + adminSignature: .standard(signature: "TestSignature".bytes) + ) + result.sender = "051111111111111111111111111111111111111111111111111111111111111112" + result.sentTimestampMs = 1234567800000 + + return result + }() + let visibleMessageProto = { + let proto = SNProtoContent.builder() + proto.setSigTimestamp((1234568890 - (60 * 10)) * 1000) + + let dataMessage = SNProtoDataMessage.builder() + dataMessage.setBody("Test") + proto.setDataMessage(try! dataMessage.build()) + return try! proto.build() + }() + let visibleMessage = { + let result = VisibleMessage( + sender: "051111111111111111111111111111111111111111111111111111111111111112", + sentTimestampMs: ((1234568890 - (60 * 10)) * 1000), + text: "Test" + ) + result.receivedTimestampMs = (1234568890 * 1000) + return result + }() + + return ( + inviteMessage, + promoteMessage, + infoChangedMessage, + memberChangedMessage, + memberLeftMessage, + memberLeftNotificationMessage, + inviteResponseMessage, + deleteMessage, + deleteContentMessage, + visibleMessageProto, + visibleMessage + ) + } + + // MARK: - Default State + + private func applyBaselineStubs() async throws { + try await applyBaselineStorage() + try await applyBaselineNetwork() + try await applyBaselineJobRunner() + try await applyBaselineAppContext() + try await applyBaselineUserDefaults() + try await applyBaselineCrypto() + try await applyBaselineKeychain() + try await applyBaselineFileManager() + try await applyBaselineExtensionHelper() + try await applyBaselineGroupPollerManager() + try await applyBaselineNotificationsManager() + try await applyBaselineGeneralCache() + try await applyBaselineLibSessionCache() + try await applyBaselinePoller() + } + + private func applyBaselineStorage() async throws { + try await mockStorage.perform(migrations: SNMessagingKit.migrations, onProgressUpdate: nil) + try await mockStorage.writeAsync { db in + try Identity(variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey)).insert(db) + try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).insert(db) + try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) + try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) + + try Profile( + id: "05\(TestConstants.publicKey)", + name: "TestCurrentUser" + ).insert(db) + } + } + + private func applyBaselineNetwork() async throws { + try await mockNetwork.defaultInitialSetup(using: dependencies) + await mockNetwork.removeRequestMocks() + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } + .thenReturn(MockNetwork.response(with: FileUploadResponse(id: "1"))) + } + + private func applyBaselineJobRunner() async throws { + try await mockJobRunner + .when { $0.jobInfoFor(jobs: .any, state: .any, variant: .any) } + .thenReturn([:]) + try await mockJobRunner + .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } + .thenReturn(nil) + try await mockJobRunner + .when { $0.upsert(.any, job: .any, canStartJob: .any) } + .thenReturn(nil) + try await mockJobRunner + .when { $0.manuallyTriggerResult(.any, result: .any) } + .thenReturn(()) + } + + private func applyBaselineAppContext() async throws { + try await mockAppContext.when { $0.isMainApp }.thenReturn(false) + } + + private func applyBaselineUserDefaults() async throws { + try await mockUserDefaults.defaultInitialSetup() + try await mockUserDefaults.when { $0.string(forKey: .any) }.thenReturn(nil) + } + + private func applyBaselineCrypto() async throws { + try await mockCrypto + .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } + .thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes)) + try await mockCrypto + .when { $0.generate(.signatureSubaccount(config: .any, verificationBytes: .any, memberAuthData: .any)) } + .thenReturn(Authentication.Signature.subaccount( + subaccount: "TestSubAccount".bytes, + subaccountSig: "TestSubAccountSignature".bytes, + signature: "TestSignature".bytes + )) + try await mockCrypto + .when { $0.verify(.signature(message: .any, publicKey: .any, signature: .any)) } + .thenReturn(true) + try await mockCrypto.when { $0.generate(.ed25519KeyPair(seed: .any)) }.thenReturn(groupKeyPair) + try await mockCrypto + .when { $0.verify(.memberAuthData(groupSessionId: .any, ed25519SecretKey: .any, memberAuthData: .any)) } + .thenReturn(true) + try await mockCrypto + .when { $0.generate(.hash(message: .any, key: .any, length: .any)) } + .thenReturn("TestHash".bytes) + } + + private func applyBaselineKeychain() async throws { + try await mockKeychain + .when { + try $0.migrateLegacyKeyIfNeeded( + legacyKey: .any, + legacyService: .any, + toKey: .pushNotificationEncryptionKey + ) + } + .thenReturn(()) + try await mockKeychain + .when { + try $0.getOrGenerateEncryptionKey( + forKey: .any, + length: .any, + cat: .any, + legacyKey: .any, + legacyService: .any + ) + } + .thenReturn(Data([1, 2, 3])) + try await mockKeychain + .when { try $0.data(forKey: .pushNotificationEncryptionKey) } + .thenReturn(Data((0..? { + switch self { + case .groupKeys(let conf, _, _): return conf + default: return nil + } + } } private extension Result { diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift index ddebdba938..bfa8f03a36 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift @@ -5,6 +5,7 @@ import Combine import GRDB import SessionUtil import SessionUtilitiesKit +import TestUtilities import Quick import Nimble @@ -12,7 +13,7 @@ import Nimble @testable import SessionMessagingKit @testable import SessionNetworkingKit -class MessageSenderGroupsSpec: QuickSpec { +class MessageSenderGroupsSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -27,147 +28,16 @@ class MessageSenderGroupsSpec: QuickSpec { dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) dependencies.forceSynchronous = true } - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + @TestState var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), - migrations: SNMessagingKit.migrations, - using: dependencies, - initialData: { db in - try Identity(variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey)).insert(db) - try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).insert(db) - try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) - try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) - - try Profile( - id: "05\(TestConstants.publicKey)", - name: "TestCurrentUser" - ).insert(db) - } - ) - @TestState(defaults: .standard, in: dependencies) var mockUserDefaults: MockUserDefaults! = MockUserDefaults( - initialSetup: { userDefaults in - userDefaults.when { $0.string(forKey: .any) }.thenReturn(nil) - userDefaults.when { $0.set(.any, forKey: .any) }.thenReturn(()) - } - ) - @TestState(singleton: .jobRunner, in: dependencies) var mockJobRunner: MockJobRunner! = MockJobRunner( - initialSetup: { jobRunner in - jobRunner - .when { $0.jobInfoFor(jobs: .any, state: .any, variant: .any) } - .thenReturn([:]) - jobRunner - .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } - .thenReturn(nil) - jobRunner - .when { $0.upsert(.any, job: .any, canStartJob: .any) } - .thenReturn(nil) - } - ) - @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( - initialSetup: { network in - network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn(Network.BatchResponse.mockConfigSyncResponse) - network - .when { $0.getSwarm(for: .any) } - .thenReturn([ - LibSession.Snode( - ip: "1.1.1.1", - quicPort: 1, - ed25519PubkeyHex: TestConstants.edPublicKey - ), - LibSession.Snode( - ip: "1.1.1.1", - quicPort: 2, - ed25519PubkeyHex: TestConstants.edPublicKey - ), - LibSession.Snode( - ip: "1.1.1.1", - quicPort: 3, - ed25519PubkeyHex: TestConstants.edPublicKey - ) - ]) - } - ) - @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( - initialSetup: { crypto in - crypto - .when { $0.generate(.ed25519KeyPair()) } - .thenReturn( - KeyPair( - publicKey: Data(hex: groupId.hexString).bytes, - secretKey: groupSecretKey.bytes - ) - ) - crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } - .thenReturn( - KeyPair( - publicKey: Data(hex: groupId.hexString).bytes, - secretKey: groupSecretKey.bytes - ) - ) - crypto - .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } - .thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes)) - crypto - .when { $0.generate(.memberAuthData(config: .any, groupSessionId: .any, memberId: .any)) } - .thenReturn(Authentication.Info.groupMember( - groupSessionId: SessionId(.standard, hex: TestConstants.publicKey), - authData: "TestAuthData".data(using: .utf8)! - )) - crypto - .when { $0.generate(.tokenSubaccount(config: .any, groupSessionId: .any, memberId: .any)) } - .thenReturn(Array("TestSubAccountToken".data(using: .utf8)!)) - crypto - .when { try $0.tryGenerate(.randomBytes(.any)) } - .thenReturn(Data((0.. = mockStorage.write { db in + mockStorage.write { db in // Need the auth data to exist in the database to prepare the request _ = try SessionThread.upsert( db, @@ -444,38 +424,10 @@ class MessageSenderGroupsSpec: QuickSpec { invited: nil ).upsert(db) - let preparedRequest: Network.PreparedRequest = try Network.SnodeAPI.preparedSequence( - requests: [ - try Network.SnodeAPI - .preparedSendMessage( - message: SnodeMessage( - recipient: groupId.hexString, - data: Data([1, 2, 3]), - ttl: ConfigDump.Variant.groupInfo.ttl, - timestampMs: 1234567890 - ), - in: ConfigDump.Variant.groupInfo.namespace, - authMethod: try Authentication.with( - db, - swarmPublicKey: groupId.hexString, - using: dependencies - ), - using: dependencies - ) - ], - requireAllBatchResponses: false, - swarmPublicKey: groupId.hexString, - snodeRetrievalRetryCount: 0, - requestAndPathBuildTimeout: Network.defaultTimeout, - using: dependencies - ) - // Remove the debug group so it can be created during the actual test try ClosedGroup.filter(id: groupId.hexString).deleteAll(db) try SessionThread.filter(id: groupId.hexString).deleteAll(db) - - return preparedRequest - }! + } MessageSender .createGroup( @@ -489,22 +441,54 @@ class MessageSenderGroupsSpec: QuickSpec { ) .sinkAndStore(in: &disposables) - expect(mockNetwork) - .to(call(.exactly(times: 1), matchingParameters: .all) { network in - network.send( - expectedRequest.body, - to: expectedRequest.destination, - requestTimeout: expectedRequest.requestTimeout, - requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout + await mockNetwork + .verify { + $0.send( + endpoint: Network.StorageServer.Endpoint.sequence, + destination: .randomSnode(swarmPublicKey: groupId.hexString), + body: try! JSONEncoder(using: dependencies).encode( + Network.BatchRequest( + requestsKey: .requests, + requests: [ + try Network.StorageServer.preparedSendMessage( + request: Network.StorageServer.SendMessageRequest( + recipient: groupId.hexString, + namespace: ConfigDump.Variant.groupInfo.namespace, + data: Data([1, 2, 3]), + ttl: ConfigDump.Variant.groupInfo.ttl, + timestampMs: 1234567890000, + authMethod: Authentication.groupAdmin( + groupSessionId: groupId, + ed25519SecretKey: Array(groupSecretKey) + ) + ), + using: dependencies + ) + ] + ) + ), + category: .standard, + requestTimeout: Network.defaultTimeout, + overallTimeout: Network.defaultTimeout ) - }) + } + .wasCalled(exactly: 1, timeout: .milliseconds(100)) } // MARK: ---- and the group configuration sync fails context("and the group configuration sync fails") { beforeEach { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn(MockNetwork.errorResponse()) } @@ -523,7 +507,7 @@ class MessageSenderGroupsSpec: QuickSpec { .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) - expect(error).to(matchError(TestError.mock)) + await expect(error).toEventually(matchError(TestError.mock)) } // MARK: ------ removes the config state @@ -541,10 +525,9 @@ class MessageSenderGroupsSpec: QuickSpec { .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) - expect(mockLibSessionCache) - .to(call(.exactly(times: 1), matchingParameters: .all) { cache in - cache.removeConfigs(for: groupId) - }) + await mockLibSessionCache + .verify { $0.removeConfigs(for: groupId) } + .wasCalled(exactly: 1, timeout: .milliseconds(100)) } // MARK: ------ removes the data from the database @@ -562,11 +545,11 @@ class MessageSenderGroupsSpec: QuickSpec { .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) - let threads: [SessionThread]? = mockStorage.read { db in try SessionThread.fetchAll(db) } + await expect { mockStorage.read { db in try SessionThread.fetchAll(db) } } + .toEventually(beEmpty()) let groups: [ClosedGroup]? = mockStorage.read { db in try ClosedGroup.fetchAll(db) } let members: [GroupMember]? = mockStorage.read { db in try GroupMember.fetchAll(db) } - expect(threads).to(beEmpty()) expect(groups).to(beEmpty()) expect(members).to(beEmpty()) } @@ -575,7 +558,7 @@ class MessageSenderGroupsSpec: QuickSpec { // MARK: ------ does not upload an image if none is provided it("does not upload an image if none is provided") { // Prevent the ConfigSyncJob network request by making the libSession cache appear empty - mockLibSessionCache.when { $0.isEmpty }.thenReturn(true) + try await mockLibSessionCache.when { $0.isEmpty }.thenReturn(true) MessageSender .createGroup( @@ -590,26 +573,39 @@ class MessageSenderGroupsSpec: QuickSpec { .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) - let expectedRequest: Network.PreparedRequest = try Network - .preparedUpload(data: TestConstants.validImageData, using: dependencies) - - expect(mockNetwork) - .toNot(call { network in - network.send( - expectedRequest.body, - to: expectedRequest.destination, - requestTimeout: expectedRequest.requestTimeout, - requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout + await mockNetwork + .verify { + $0.send( + endpoint: Network.FileServer.Endpoint.file, + destination: .serverUpload( + server: Network.FileServer.fileServer, + x25519PublicKey: Network.FileServer.fileServerPublicKey, + fileName: nil + ), + body: TestConstants.validImageData, + category: .upload, + requestTimeout: Network.fileUploadTimeout, + overallTimeout: nil ) - }) + } + .wasNotCalled() } // MARK: ------ with an image context("with an image") { // MARK: ------ uploads the image it("uploads the image") { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn(MockNetwork.response(with: FileUploadResponse(id: "1"))) MessageSender @@ -625,30 +621,46 @@ class MessageSenderGroupsSpec: QuickSpec { .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) - let expectedRequest: Network.PreparedRequest = try Network + let expectedRequest: Network.PreparedRequest = try Network.FileServer .preparedUpload( data: TestConstants.validImageData, - requestAndPathBuildTimeout: Network.fileUploadTimeout, + overallTimeout: Network.fileUploadTimeout, using: dependencies ) - expect(mockNetwork) - .to(call(.exactly(times: 1), matchingParameters: .all) { network in - network.send( - expectedRequest.body, - to: expectedRequest.destination, - requestTimeout: expectedRequest.requestTimeout, - requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout + await mockNetwork + .verify { + $0.send( + endpoint: Network.FileServer.Endpoint.file, + destination: .serverUpload( + server: Network.FileServer.fileServer, + x25519PublicKey: Network.FileServer.fileServerPublicKey, + fileName: nil + ), + body: TestConstants.validImageData, + category: .upload, + requestTimeout: Network.fileUploadTimeout, + overallTimeout: Network.fileUploadTimeout ) - }) + } + .wasCalled(exactly: 1) } // MARK: ------ saves the image info to the group it("saves the image info to the group") { // Prevent the ConfigSyncJob network request by making the libSession cache appear empty - mockLibSessionCache.when { $0.isEmpty }.thenReturn(true) - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + try await mockLibSessionCache.when { $0.isEmpty }.thenReturn(true) + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn(MockNetwork.response(with: FileUploadResponse(id: "1"))) MessageSender @@ -673,8 +685,17 @@ class MessageSenderGroupsSpec: QuickSpec { // MARK: ------ fails if the image fails to upload it("fails if the image fails to upload") { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn(Fail(error: NetworkError.unknown).eraseToAnyPublisher()) MessageSender @@ -708,9 +729,9 @@ class MessageSenderGroupsSpec: QuickSpec { ) .sinkAndStore(in: &disposables) - expect(mockJobRunner) - .to(call(.exactly(times: 1), matchingParameters: .all) { jobRunner in - jobRunner.add( + await mockJobRunner + .verify { + $0.add( .any, job: Job( variant: .groupInviteMember, @@ -726,22 +747,21 @@ class MessageSenderGroupsSpec: QuickSpec { dependantJob: nil, canStartJob: true ) - }) + } + .wasCalled(exactly: 1) } // MARK: ------ and trying to subscribe for push notifications context("and trying to subscribe for push notifications") { - @TestState var expectedRequest: Network.PreparedRequest! - beforeEach { // Need to set `isUsingFullAPNs` to true to generate the `expectedRequest` - mockUserDefaults + try await mockUserDefaults .when { $0.string(forKey: UserDefaults.StringKey.deviceToken.rawValue) } .thenReturn(Data([5, 4, 3, 2, 1]).toHexString()) - mockUserDefaults + try await mockUserDefaults .when { $0.bool(forKey: UserDefaults.BoolKey.isUsingFullAPNs.rawValue) } .thenReturn(true) - expectedRequest = mockStorage.write { db in + mockStorage.write { db in _ = try SessionThread.upsert( db, id: groupId.hexString, @@ -760,34 +780,19 @@ class MessageSenderGroupsSpec: QuickSpec { groupIdentityPrivateKey: groupSecretKey, invited: nil ).upsert(db) - let result = try Network.PushNotification.preparedSubscribe( - token: Data([5, 4, 3, 2, 1]), - swarms: [ - ( - groupId, - Authentication.groupAdmin( - groupSessionId: groupId, - ed25519SecretKey: Array(groupSecretKey) - ) - ) - ], - using: dependencies - ) // Remove the debug group so it can be created during the actual test try ClosedGroup.filter(id: groupId.hexString).deleteAll(db) try SessionThread.filter(id: groupId.hexString).deleteAll(db) - - return result }! } // MARK: ---- subscribes when they are enabled it("subscribes when they are enabled") { - mockUserDefaults + try await mockUserDefaults .when { $0.string(forKey: UserDefaults.StringKey.deviceToken.rawValue) } .thenReturn(Data([5, 4, 3, 2, 1]).toHexString()) - mockUserDefaults + try await mockUserDefaults .when { $0.bool(forKey: UserDefaults.BoolKey.isUsingFullAPNs.rawValue) } .thenReturn(true) @@ -803,25 +808,58 @@ class MessageSenderGroupsSpec: QuickSpec { ) .sinkAndStore(in: &disposables) - expect(mockNetwork) - .to(call(.exactly(times: 1), matchingParameters: .all) { network in - network.send( - expectedRequest.body, - to: expectedRequest.destination, - requestTimeout: expectedRequest.requestTimeout, - requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout + await mockNetwork + .verify { + $0.send( + endpoint: Network.PushNotification.Endpoint.subscribe, + destination: .server( + method: .post, + server: Network.PushNotification.server, + queryParameters: [:], + headers: [:], + x25519PublicKey: Network.PushNotification.serverPublicKey + ), + body: try! JSONEncoder(using: dependencies).encode( + Network.PushNotification.SubscribeRequest( + subscriptions: [ + Network.PushNotification.SubscribeRequest.Subscription( + namespaces: [ + .groupMessages, + .configGroupKeys, + .configGroupInfo, + .configGroupMembers, + .revokedRetrievableGroupMessages + ], + includeMessageData: true, + serviceInfo: Network.PushNotification.ServiceInfo( + token: Data([5, 4, 3, 2, 1]).toHexString() + ), + notificationsEncryptionKey: Data([1, 2, 3]), + authMethod: try! Authentication.with( + swarmPublicKey: groupId.hexString, + using: dependencies + ), + timestamp: 1234567890 + ) + ] + ) + ), + category: .standard, + requestTimeout: Network.defaultTimeout, + overallTimeout: nil ) - }) + } + .wasCalled(exactly: Network.PushNotification.maxRetryCount + 1, timeout: .milliseconds(100)) } // MARK: ---- does not subscribe if push notifications are disabled it("does not subscribe if push notifications are disabled") { // Prevent the ConfigSyncJob network request by making the libSession cache appear empty - mockLibSessionCache.when { $0.isEmpty }.thenReturn(true) - mockUserDefaults + try await mockLibSessionCache.when { $0.isEmpty }.thenReturn(true) + try await mockUserDefaults .when { $0.string(forKey: UserDefaults.StringKey.deviceToken.rawValue) } .thenReturn(Data([5, 4, 3, 2, 1]).toHexString()) - mockUserDefaults + try await mockUserDefaults .when { $0.bool(forKey: UserDefaults.BoolKey.isUsingFullAPNs.rawValue) } .thenReturn(false) @@ -837,24 +875,28 @@ class MessageSenderGroupsSpec: QuickSpec { ) .sinkAndStore(in: &disposables) - expect(mockNetwork).toNot(call { network in - network.send( - expectedRequest.body, - to: expectedRequest.destination, - requestTimeout: expectedRequest.requestTimeout, - requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout - ) - }) + await mockNetwork + .verify { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } + .wasNotCalled() } // MARK: ---- does not subscribe if there is no push token it("does not subscribe if there is no push token") { // Prevent the ConfigSyncJob network request by making the libSession cache appear empty - mockLibSessionCache.when { $0.isEmpty }.thenReturn(true) - mockUserDefaults + try await mockLibSessionCache.when { $0.isEmpty }.thenReturn(true) + try await mockUserDefaults .when { $0.string(forKey: UserDefaults.StringKey.deviceToken.rawValue) } .thenReturn(nil) - mockUserDefaults + try await mockUserDefaults .when { $0.bool(forKey: UserDefaults.BoolKey.isUsingFullAPNs.rawValue) } .thenReturn(true) @@ -870,14 +912,18 @@ class MessageSenderGroupsSpec: QuickSpec { ) .sinkAndStore(in: &disposables) - expect(mockNetwork).toNot(call { network in - network.send( - expectedRequest.body, - to: expectedRequest.destination, - requestTimeout: expectedRequest.requestTimeout, - requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout - ) - }) + await mockNetwork + .verify { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } + .wasNotCalled() } } } @@ -885,8 +931,17 @@ class MessageSenderGroupsSpec: QuickSpec { // MARK: -- when adding members to a group context("when adding members to a group") { beforeEach { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockAddMemberConfigSyncResponse) // Rekey a couple of times to increase the key generation to 1 @@ -993,8 +1048,17 @@ class MessageSenderGroupsSpec: QuickSpec { // MARK: ---- and granting access to historic messages context("and granting access to historic messages") { beforeEach { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockAddMemberHistoricConfigSyncResponse) } @@ -1031,45 +1095,6 @@ class MessageSenderGroupsSpec: QuickSpec { "LPczVOFKOPs+rrB3aUpMsNUnJHOEhW9g6zi/UPjuCWTnnvpxlMTpHaTFlMTp+NjQ6dKi86jZJ" + "l3oiJEA5h5pBE5oOJHQNvtF8GOcsYwrIFTZKnI7AGkBSu1TxP0xLWwTUzjOGMgmKvlIgkQ6e9" + "r3JBmU=" - let expectedRequest: Network.PreparedRequest = try Network.SnodeAPI.preparedSequence( - requests: [] - .appending(try Network.SnodeAPI.preparedUnrevokeSubaccounts( - subaccountsToUnrevoke: [Array("TestSubAccountToken".data(using: .utf8)!)], - authMethod: Authentication.groupAdmin( - groupSessionId: groupId, - ed25519SecretKey: Array(groupSecretKey) - ), - using: dependencies - )) - .appending(try Network.SnodeAPI.preparedSendMessage( - message: SnodeMessage( - recipient: groupId.hexString, - data: Data(base64Encoded: requestDataString)!, - ttl: ConfigDump.Variant.groupKeys.ttl, - timestampMs: UInt64(1234567890000) - ), - in: .configGroupKeys, - authMethod: Authentication.groupAdmin( - groupSessionId: groupId, - ed25519SecretKey: Array(groupSecretKey) - ), - using: dependencies - )) - .appending(try Network.SnodeAPI.preparedDeleteMessages( - serverHashes: ["testHash"], - requireSuccessfulDeletion: false, - authMethod: Authentication.groupAdmin( - groupSessionId: groupId, - ed25519SecretKey: Array(groupSecretKey) - ), - using: dependencies - )), - requireAllBatchResponses: true, - swarmPublicKey: groupId.hexString, - snodeRetrievalRetryCount: 0, // This job has it's own retry mechanism - requestAndPathBuildTimeout: Network.defaultTimeout, - using: dependencies - ) MessageSender.addGroupMembers( groupSessionId: groupId.hexString, @@ -1090,15 +1115,55 @@ class MessageSenderGroupsSpec: QuickSpec { _ = groups_keys_load_message(groupKeysConf, &fakeHash3, pushResult, pushResultLen, 1234567890, groupInfoConf, groupMembersConf) } - expect(mockNetwork) - .to(call(.exactly(times: 1), matchingParameters: .all) { network in - network.send( - expectedRequest.body, - to: expectedRequest.destination, - requestTimeout: expectedRequest.requestTimeout, - requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout + await mockNetwork + .verify { + $0.send( + endpoint: Network.StorageServer.Endpoint.sequence, + destination: .randomSnode(swarmPublicKey: groupId.hexString), + body: try! JSONEncoder(using: dependencies).encode( + Network.BatchRequest( + requestsKey: .requests, + requests: [ + try Network.StorageServer.preparedUnrevokeSubaccounts( + subaccountsToUnrevoke: [Array("TestSubAccountToken".data(using: .utf8)!)], + authMethod: Authentication.groupAdmin( + groupSessionId: groupId, + ed25519SecretKey: Array(groupSecretKey) + ), + using: dependencies + ), + try Network.StorageServer.preparedSendMessage( + request: Network.StorageServer.SendMessageRequest( + recipient: groupId.hexString, + namespace: .configGroupKeys, + data: Data(base64Encoded: requestDataString)!, + ttl: ConfigDump.Variant.groupKeys.ttl, + timestampMs: UInt64(1234567890000), + authMethod: Authentication.groupAdmin( + groupSessionId: groupId, + ed25519SecretKey: Array(groupSecretKey) + ) + ), + using: dependencies + ), + try Network.StorageServer.preparedDeleteMessages( + serverHashes: ["testHash"], + requireSuccessfulDeletion: false, + authMethod: Authentication.groupAdmin( + groupSessionId: groupId, + ed25519SecretKey: Array(groupSecretKey) + ), + using: dependencies + ) + ] + ) + ), + category: .standard, + requestTimeout: Network.defaultTimeout, + overallTimeout: Network.defaultTimeout ) - }) + } + .wasCalled(exactly: 1) } // MARK: ---- schedules member invite jobs @@ -1112,9 +1177,9 @@ class MessageSenderGroupsSpec: QuickSpec { using: dependencies ).sinkUntilComplete() - expect(mockJobRunner) - .to(call(.exactly(times: 1), matchingParameters: .all) { jobRunner in - jobRunner.add( + await mockJobRunner + .verify { + $0.add( .any, job: Job( variant: .groupInviteMember, @@ -1130,7 +1195,8 @@ class MessageSenderGroupsSpec: QuickSpec { dependantJob: nil, canStartJob: true ) - }) + } + .wasCalled(exactly: 1) } // MARK: ---- adds a member change control message @@ -1169,9 +1235,9 @@ class MessageSenderGroupsSpec: QuickSpec { using: dependencies ).sinkUntilComplete() - expect(mockJobRunner) - .to(call(.exactly(times: 1), matchingParameters: .all) { jobRunner in - jobRunner.add( + await mockJobRunner + .verify { + $0.add( .any, job: Job( variant: .messageSend, @@ -1198,15 +1264,25 @@ class MessageSenderGroupsSpec: QuickSpec { dependantJob: nil, canStartJob: false ) - }) + } + .wasCalled(exactly: 1) } } // MARK: ---- and not granting access to historic messages context("and not granting access to historic messages") { beforeEach { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockAddMemberConfigSyncResponse) } @@ -1246,34 +1322,6 @@ class MessageSenderGroupsSpec: QuickSpec { // MARK: ---- includes the unrevoke subaccounts as part of the config sync sequence it("includes the unrevoke subaccounts as part of the config sync sequence") { - let expectedRequest: Network.PreparedRequest = try Network.SnodeAPI.preparedSequence( - requests: [] - .appending(try Network.SnodeAPI - .preparedUnrevokeSubaccounts( - subaccountsToUnrevoke: [Array("TestSubAccountToken".data(using: .utf8)!)], - authMethod: Authentication.groupAdmin( - groupSessionId: groupId, - ed25519SecretKey: Array(groupSecretKey) - ), - using: dependencies - ) - ) - .appending(try Network.SnodeAPI.preparedDeleteMessages( - serverHashes: ["testHash"], - requireSuccessfulDeletion: false, - authMethod: Authentication.groupAdmin( - groupSessionId: groupId, - ed25519SecretKey: Array(groupSecretKey) - ), - using: dependencies - )), - requireAllBatchResponses: true, - swarmPublicKey: groupId.hexString, - snodeRetrievalRetryCount: 0, // This job has it's own retry mechanism - requestAndPathBuildTimeout: Network.defaultTimeout, - using: dependencies - ) - MessageSender.addGroupMembers( groupSessionId: groupId.hexString, members: [ @@ -1283,15 +1331,43 @@ class MessageSenderGroupsSpec: QuickSpec { using: dependencies ).sinkUntilComplete() - expect(mockNetwork) - .to(call(.exactly(times: 1), matchingParameters: .all) { network in - network.send( - expectedRequest.body, - to: expectedRequest.destination, - requestTimeout: expectedRequest.requestTimeout, - requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout + await mockNetwork + .verify { + $0.send( + endpoint: Network.StorageServer.Endpoint.sequence, + destination: .randomSnode(swarmPublicKey: groupId.hexString), + body: try! JSONEncoder(using: dependencies).encode( + Network.BatchRequest( + requestsKey: .requests, + requests: [ + try Network.StorageServer.preparedUnrevokeSubaccounts( + subaccountsToUnrevoke: [ + Array("TestSubAccountToken".data(using: .utf8)!) + ], + authMethod: Authentication.groupAdmin( + groupSessionId: groupId, + ed25519SecretKey: Array(groupSecretKey) + ), + using: dependencies + ), + try Network.StorageServer.preparedDeleteMessages( + serverHashes: ["testHash"], + requireSuccessfulDeletion: false, + authMethod: Authentication.groupAdmin( + groupSessionId: groupId, + ed25519SecretKey: Array(groupSecretKey) + ), + using: dependencies + ) + ] + ) + ), + category: .standard, + requestTimeout: Network.defaultTimeout, + overallTimeout: Network.defaultTimeout ) - }) + } + .wasCalled(exactly: 1) } // MARK: ---- schedules member invite jobs @@ -1305,9 +1381,9 @@ class MessageSenderGroupsSpec: QuickSpec { using: dependencies ).sinkUntilComplete() - expect(mockJobRunner) - .to(call(.exactly(times: 1), matchingParameters: .all) { jobRunner in - jobRunner.add( + await mockJobRunner + .verify { + $0.add( .any, job: Job( variant: .groupInviteMember, @@ -1323,7 +1399,8 @@ class MessageSenderGroupsSpec: QuickSpec { dependantJob: nil, canStartJob: true ) - }) + } + .wasCalled(exactly: 1) } // MARK: ---- adds a member change control message @@ -1362,9 +1439,9 @@ class MessageSenderGroupsSpec: QuickSpec { using: dependencies ).sinkUntilComplete() - expect(mockJobRunner) - .to(call(.exactly(times: 1), matchingParameters: .all) { jobRunner in - jobRunner.add( + await mockJobRunner + .verify { + $0.add( .any, job: Job( variant: .messageSend, @@ -1391,7 +1468,8 @@ class MessageSenderGroupsSpec: QuickSpec { dependantJob: nil, canStartJob: false ) - }) + } + .wasCalled(exactly: 1) } // MARK: ---- sorts the members in the control message deterministically @@ -1407,9 +1485,9 @@ class MessageSenderGroupsSpec: QuickSpec { using: dependencies ).sinkUntilComplete() - expect(mockJobRunner) - .to(call(.exactly(times: 1), matchingParameters: .all) { jobRunner in - jobRunner.add( + await mockJobRunner + .verify { + $0.add( .any, job: Job( variant: .messageSend, @@ -1438,7 +1516,8 @@ class MessageSenderGroupsSpec: QuickSpec { dependantJob: nil, canStartJob: false ) - }) + } + .wasCalled(exactly: 1) } } } @@ -1447,8 +1526,14 @@ class MessageSenderGroupsSpec: QuickSpec { // MARK: - Mock Types -extension SendMessagesResponse: Mocked { - static var mock: SendMessagesResponse = SendMessagesResponse( +extension Network.StorageServer.SendMessagesResponse: @retroactive Mocked { + public static var any: Network.StorageServer.SendMessagesResponse = Network.StorageServer.SendMessagesResponse( + hash: .any, + swarm: .any, + hardFork: .any, + timeOffset: .any + ) + public static var mock: Network.StorageServer.SendMessagesResponse = Network.StorageServer.SendMessagesResponse( hash: "hash", swarm: [:], hardFork: [1, 2], @@ -1456,16 +1541,26 @@ extension SendMessagesResponse: Mocked { ) } -extension UnrevokeSubaccountResponse: Mocked { - static var mock: UnrevokeSubaccountResponse = UnrevokeSubaccountResponse( +extension Network.StorageServer.UnrevokeSubaccountResponse: @retroactive Mocked { + public static var any: Network.StorageServer.UnrevokeSubaccountResponse = Network.StorageServer.UnrevokeSubaccountResponse( + swarm: .any, + hardFork: .any, + timeOffset: .any + ) + public static var mock: Network.StorageServer.UnrevokeSubaccountResponse = Network.StorageServer.UnrevokeSubaccountResponse( swarm: [:], hardFork: [], timeOffset: 0 ) } -extension DeleteMessagesResponse: Mocked { - static var mock: DeleteMessagesResponse = DeleteMessagesResponse( +extension Network.StorageServer.DeleteMessagesResponse: @retroactive Mocked { + public static var any: Network.StorageServer.DeleteMessagesResponse = Network.StorageServer.DeleteMessagesResponse( + swarm: .any, + hardFork: .any, + timeOffset: .any + ) + public static var mock: Network.StorageServer.DeleteMessagesResponse = Network.StorageServer.DeleteMessagesResponse( swarm: [:], hardFork: [], timeOffset: 0 @@ -1475,29 +1570,31 @@ extension DeleteMessagesResponse: Mocked { // MARK: - Mock Batch Responses extension Network.BatchResponse { + typealias API = Network.StorageServer + // MARK: - Valid Responses fileprivate static let mockConfigSyncResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( with: [ - (Network.SnodeAPI.Endpoint.sendMessage, SendMessagesResponse.mockBatchSubResponse()), - (Network.SnodeAPI.Endpoint.sendMessage, SendMessagesResponse.mockBatchSubResponse()), - (Network.SnodeAPI.Endpoint.sendMessage, SendMessagesResponse.mockBatchSubResponse()), - (Network.SnodeAPI.Endpoint.deleteMessages, DeleteMessagesResponse.mockBatchSubResponse()) + (API.Endpoint.sendMessage, API.SendMessagesResponse.mockBatchSubResponse()), + (API.Endpoint.sendMessage, API.SendMessagesResponse.mockBatchSubResponse()), + (API.Endpoint.sendMessage, API.SendMessagesResponse.mockBatchSubResponse()), + (API.Endpoint.deleteMessages, API.DeleteMessagesResponse.mockBatchSubResponse()) ] ) fileprivate static let mockAddMemberConfigSyncResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( with: [ - (Network.SnodeAPI.Endpoint.unrevokeSubaccount, UnrevokeSubaccountResponse.mockBatchSubResponse()), - (Network.SnodeAPI.Endpoint.deleteMessages, DeleteMessagesResponse.mockBatchSubResponse()) + (API.Endpoint.unrevokeSubaccount, API.UnrevokeSubaccountResponse.mockBatchSubResponse()), + (API.Endpoint.deleteMessages, API.DeleteMessagesResponse.mockBatchSubResponse()) ] ) fileprivate static let mockAddMemberHistoricConfigSyncResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( with: [ - (Network.SnodeAPI.Endpoint.unrevokeSubaccount, UnrevokeSubaccountResponse.mockBatchSubResponse()), - (Network.SnodeAPI.Endpoint.sendMessage, SendMessagesResponse.mockBatchSubResponse()), - (Network.SnodeAPI.Endpoint.deleteMessages, DeleteMessagesResponse.mockBatchSubResponse()) + (API.Endpoint.unrevokeSubaccount, API.UnrevokeSubaccountResponse.mockBatchSubResponse()), + (API.Endpoint.sendMessage, API.SendMessagesResponse.mockBatchSubResponse()), + (API.Endpoint.deleteMessages, API.DeleteMessagesResponse.mockBatchSubResponse()) ] ) } diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderSpec.swift index ee73e9fbc0..b2b6ce554d 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageSenderSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderSpec.swift @@ -10,47 +10,45 @@ import Nimble @testable import SessionMessagingKit -class MessageSenderSpec: QuickSpec { +class MessageSenderSpec: AsyncSpec { override class func spec() { // MARK: Configuration @TestState var dependencies: TestDependencies! = TestDependencies() - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + @TestState var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), - migrations: SNMessagingKit.migrations, - using: dependencies, - initialData: { db in + using: dependencies + ) + @TestState var mockCrypto: MockCrypto! = .create(using: dependencies) + @TestState var mockGeneralCache: MockGeneralCache! = .create(using: dependencies) + + beforeEach { + try await mockGeneralCache.defaultInitialSetup() + dependencies.set(cache: .general, to: mockGeneralCache) + + try await mockStorage.perform(migrations: SNMessagingKit.migrations) + try await mockStorage.writeAsync { db in try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) } - ) - @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( - initialSetup: { crypto in - crypto - .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } - .thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes)) - crypto - .when { $0.generate(.randomBytes(24)) } - .thenReturn(Array(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!)) - crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } - .thenReturn( - KeyPair( - publicKey: Array(Data(hex: TestConstants.edPublicKey)), - secretKey: Array(Data(hex: TestConstants.edSecretKey)) - ) + dependencies.set(singleton: .storage, to: mockStorage) + + try await mockCrypto + .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } + .thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes)) + try await mockCrypto + .when { $0.generate(.randomBytes(24)) } + .thenReturn(Array(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!)) + try await mockCrypto + .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .thenReturn( + KeyPair( + publicKey: Array(Data(hex: TestConstants.edPublicKey)), + secretKey: Array(Data(hex: TestConstants.edSecretKey)) ) - } - ) - @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( - initialSetup: { cache in - cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) - cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) - cache - .when { $0.ed25519Seed } - .thenReturn(Array(Array(Data(hex: TestConstants.edSecretKey)).prefix(upTo: 32))) - } - ) + ) + dependencies.set(singleton: .crypto, to: mockCrypto) + } // MARK: - a MessageSender describe("a MessageSender") { @@ -59,12 +57,12 @@ class MessageSenderSpec: QuickSpec { @TestState var preparedRequest: Network.PreparedRequest? beforeEach { - mockCrypto + try await mockCrypto .when { $0.generate(.ciphertextWithSessionProtocol(plaintext: .any, destination: .any)) } .thenReturn(Data([1, 2, 3])) - mockCrypto + try await mockCrypto .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } .thenReturn(Authentication.Signature.standard(signature: [])) } diff --git a/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift b/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift index 5d7db29b23..470e60532b 100644 --- a/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift @@ -3,13 +3,14 @@ import Foundation import SessionUIKit import SessionUtilitiesKit +import TestUtilities import Quick import Nimble @testable import SessionMessagingKit -class NotificationsManagerSpec: QuickSpec { +class NotificationsManagerSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -18,26 +19,9 @@ class NotificationsManagerSpec: QuickSpec { $0.dateNow = Date(timeIntervalSince1970: 1234567890) } ) - @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( - initialSetup: { - $0.defaultInitialSetup() - $0.when { - $0.conversationLastRead( - threadId: .any, - threadVariant: .any, - openGroupUrlInfo: .any - ) - }.thenReturn(1234567800) - } - ) - @TestState(singleton: .extensionHelper, in: dependencies) var mockExtensionHelper: MockExtensionHelper! = MockExtensionHelper( - initialSetup: { helper in - helper.when { $0.hasDedupeRecordSinceLastCleared(threadId: .any) }.thenReturn(false) - } - ) - @TestState(singleton: .notificationsManager, in: dependencies) var mockNotificationsManager: MockNotificationsManager! = MockNotificationsManager( - initialSetup: { $0.defaultInitialSetup() } - ) + @TestState var mockLibSessionCache: MockLibSessionCache! = .create(using: dependencies) + @TestState var mockExtensionHelper: MockExtensionHelper! = .create(using: dependencies) + @TestState var mockNotificationsManager: MockNotificationsManager! = .create(using: dependencies) @TestState var message: Message! = VisibleMessage( sender: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", sentTimestampMs: 1234567892, @@ -51,6 +35,24 @@ class NotificationsManagerSpec: QuickSpec { mutedUntil: nil ) + beforeEach { + try await mockLibSessionCache.defaultInitialSetup() + try await mockLibSessionCache.when { + $0.conversationLastRead( + threadId: .any, + threadVariant: .any, + openGroupUrlInfo: .any + ) + }.thenReturn(1234567800) + dependencies.set(cache: .libSession, to: mockLibSessionCache) + + try await mockNotificationsManager.defaultInitialSetup() + dependencies.set(singleton: .notificationsManager, to: mockNotificationsManager) + + try await mockExtensionHelper.when { $0.hasDedupeRecordSinceLastCleared(threadId: .any) }.thenReturn(false) + dependencies.set(singleton: .extensionHelper, to: mockExtensionHelper) + } + // MARK: - a NotificationsManager - Ensure Should Show describe("a NotificationsManager when ensuring we should show notifications") { // MARK: -- throws if the message has no sender @@ -596,11 +598,11 @@ class NotificationsManagerSpec: QuickSpec { // MARK: -- throws if the sender is blocked it("throws if the sender is blocked") { + try await mockLibSessionCache + .when { $0.isContactBlocked(contactId: .any) } + .thenReturn(true) + expect { - mockLibSessionCache - .when { $0.isContactBlocked(contactId: .any) } - .thenReturn(true) - try mockNotificationsManager.ensureWeShouldShowNotification( message: message, threadId: threadId, @@ -618,17 +620,17 @@ class NotificationsManagerSpec: QuickSpec { // MARK: -- throws if the message was already read it("throws if the message was already read") { + try await mockLibSessionCache + .when { + $0.conversationLastRead( + threadId: .any, + threadVariant: .any, + openGroupUrlInfo: .any + ) + } + .thenReturn(1234567899) + expect { - mockLibSessionCache - .when { - $0.conversationLastRead( - threadId: .any, - threadVariant: .any, - openGroupUrlInfo: .any - ) - } - .thenReturn(1234567899) - try mockNotificationsManager.ensureWeShouldShowNotification( message: message, threadId: threadId, @@ -836,7 +838,9 @@ class NotificationsManagerSpec: QuickSpec { // MARK: ---- returns the formatted string containing the truncated id and group name when the displayNameRetriever returns null it("returns the formatted string containing the truncated id and group name when the displayNameRetriever returns null") { - mockLibSessionCache.when { $0.groupName(groupSessionId: .any) }.thenReturn("TestGroup") + try await mockLibSessionCache + .when { $0.groupName(groupSessionId: .any) } + .thenReturn("TestGroup") expect { try mockNotificationsManager.notificationTitle( @@ -1256,9 +1260,11 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { false } ) }.toNot(throwError()) - expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.isMessageRequest(threadId: "05\(TestConstants.publicKey)", threadVariant: .contact) - }) + await mockLibSessionCache + .verify { + $0.isMessageRequest(threadId: "05\(TestConstants.publicKey)", threadVariant: .contact) + } + .wasCalled(exactly: 1) } // MARK: -- retrieves notification settings from the notification maanager @@ -1281,15 +1287,18 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { false } ) }.toNot(throwError()) - expect(mockNotificationsManager).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.settings(threadId: "05\(TestConstants.publicKey)", threadVariant: .contact) - }) + + await mockNotificationsManager + .verify { + $0.settings(threadId: "05\(TestConstants.publicKey)", threadVariant: .contact) + } + .wasCalled(exactly: 1) } // MARK: -- checks whether it should show for messages requests if the message is a message request it("checks whether it should show for messages requests if the message is a message request") { var didCallShouldShowForMessageRequest: Bool = false - mockLibSessionCache + try await mockLibSessionCache .when { $0.isMessageRequest(threadId: .any, threadVariant: .any) } .thenReturn(true) @@ -1337,27 +1346,29 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { false } ) }.toNot(throwError()) - expect(mockNotificationsManager).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.addNotificationRequest( - content: NotificationContent( - threadId: "05\(TestConstants.publicKey)", - threadVariant: .contact, - identifier: "05\(TestConstants.publicKey)-TestId", - category: .incomingMessage, - title: "0588...c65b", - body: "Test", - sound: .note, - applicationState: .background - ), - notificationSettings: Preferences.NotificationSettings( - previewType: .nameAndPreview, - sound: .defaultNotificationSound, - mentionsOnly: false, - mutedUntil: nil - ), - extensionBaseUnreadCount: 1 - ) - }) + await mockNotificationsManager + .verify { + $0.addNotificationRequest( + content: NotificationContent( + threadId: "05\(TestConstants.publicKey)", + threadVariant: .contact, + identifier: "05\(TestConstants.publicKey)-TestId", + category: .incomingMessage, + title: "0588...c65b", + body: "Test", + sound: .note, + applicationState: .background + ), + notificationSettings: Preferences.NotificationSettings( + previewType: .nameAndPreview, + sound: .defaultNotificationSound, + mentionsOnly: false, + mutedUntil: nil + ), + extensionBaseUnreadCount: 1 + ) + } + .wasCalled(exactly: 1) } // MARK: -- uses a random identifier for reaction notifications @@ -1393,29 +1404,31 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { false } ) }.toNot(throwError()) - expect(mockNotificationsManager).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.addNotificationRequest( - content: NotificationContent( - threadId: "05\(TestConstants.publicKey)", - threadVariant: .contact, - identifier: "00000000-0000-0000-0000-000000000001", - category: .incomingMessage, - title: "0588...c65b", - body: "emojiReactsNotification" - .put(key: "emoji", value: "A") - .localized(), - sound: .note, - applicationState: .background - ), - notificationSettings: Preferences.NotificationSettings( - previewType: .nameAndPreview, - sound: .defaultNotificationSound, - mentionsOnly: false, - mutedUntil: nil - ), - extensionBaseUnreadCount: 1 - ) - }) + await mockNotificationsManager + .verify { + $0.addNotificationRequest( + content: NotificationContent( + threadId: "05\(TestConstants.publicKey)", + threadVariant: .contact, + identifier: "00000000-0000-0000-0000-000000000001", + category: .incomingMessage, + title: "0588...c65b", + body: "emojiReactsNotification" + .put(key: "emoji", value: "A") + .localized(), + sound: .note, + applicationState: .background + ), + notificationSettings: Preferences.NotificationSettings( + previewType: .nameAndPreview, + sound: .defaultNotificationSound, + mentionsOnly: false, + mutedUntil: nil + ), + extensionBaseUnreadCount: 1 + ) + } + .wasCalled(exactly: 1) } } } diff --git a/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerManagerSpec.swift b/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerManagerSpec.swift new file mode 100644 index 0000000000..5f3d1f00ca --- /dev/null +++ b/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerManagerSpec.swift @@ -0,0 +1,240 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import Combine +import GRDB +import SessionNetworkingKit +import SessionUtilitiesKit + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class CommunityPollerManagerSpec: AsyncSpec { + override class func spec() { + @TestState var fixture: CommunityPollerManagerTestFixture! + + beforeEach { + fixture = try await CommunityPollerManagerTestFixture.create() + } + + // MARK: - a CommunityPollerManager + describe("a CommunityPollerManager") { + // MARK: -- when starting polling + context("when starting polling") { + // MARK: ---- creates pollers for all of the communities + it("creates pollers for all of the communities") { + try await fixture.setupForActivePolling() + await fixture.manager.startAllPollers() + + await expect { await fixture.manager.serversBeingPolled } + .to(equal(["testserver", "testserver1"])) + } + + // MARK: ---- creates a poll task + it("creates a poll task") { + try await fixture.setupForActivePolling() + await fixture.manager.startAllPollers() + + await expect { await fixture.manager.allPollers.count } .to(equal(2)) + try await require { await fixture.manager.allPollers.count }.to(equal(2)) + await expect { await fixture.manager.allPollers[0].pollTask }.toNot(beNil()) + await expect { await fixture.manager.allPollers[1].pollTask }.toNot(beNil()) + } + + // MARK: ---- does not create additional pollers if it's already polling + it("does not create additional pollers if it's already polling") { + try await fixture.setupForActivePolling() + await fixture.manager + .getOrCreatePoller(for: CommunityPoller.Info(server: "testserver", pollFailureCount: 0)) + .startIfNeeded() + await fixture.manager + .getOrCreatePoller(for: CommunityPoller.Info(server: "testserver1", pollFailureCount: 0)) + .startIfNeeded() + + await fixture.manager.startAllPollers() + + await expect { await fixture.manager.allPollers.count }.to(equal(2)) + } + } + + // MARK: -- when stopping polling + context("when stopping polling") { + // MARK: ---- removes all pollers + it("removes all pollers") { + await fixture.manager.startAllPollers() + await fixture.manager.stopAndRemoveAllPollers() + + await expect { await fixture.manager.allPollers.count }.to(equal(0)) + } + + // MARK: ---- updates the isPolling flag + it("updates the isPolling flag") { + await fixture.manager.startAllPollers() + + let poller1 = await fixture.manager.getOrCreatePoller(for: CommunityPoller.Info(server: "testserver", pollFailureCount: 0)) + let poller2 = await fixture.manager.getOrCreatePoller(for: CommunityPoller.Info(server: "testserver1", pollFailureCount: 0)) + await fixture.manager.stopAndRemoveAllPollers() + + await expect { await poller1.pollTask }.to(beNil()) + await expect { await poller2.pollTask }.to(beNil()) + } + } + } + } +} + +// MARK: - Configuration + +private class CommunityPollerManagerTestFixture: FixtureBase { + var mockStorage: Storage { + mock(for: .storage) { dependencies in + Storage( + customWriter: try! DatabaseQueue(), + using: dependencies + ) + } + } + var mockNetwork: MockNetwork { mock(for: .network) } + var mockAppContext: MockAppContext { mock(for: .appContext) } + var mockUserDefaults: MockUserDefaults { mock(defaults: .standard) } + var mockGeneralCache: MockGeneralCache { mock(cache: .general) } + var mockOpenGroupManager: MockOpenGroupManager { mock(for: .openGroupManager) } + var mockCrypto: MockCrypto { mock(for: .crypto) } + lazy var manager: CommunityPollerManager = CommunityPollerManager(using: dependencies) + + static func create() async throws -> CommunityPollerManagerTestFixture { + let fixture: CommunityPollerManagerTestFixture = CommunityPollerManagerTestFixture() + try await fixture.applyBaselineStubs() + + return fixture + } + + // MARK: - Default State + + private func applyBaselineStubs() async throws { + try await applyBaselineStorage() + try await applyBaselineNetwork() + try await applyBaselineAppContext() + try await applyBaselineUserDefaults() + try await applyBaselineGeneralCache() + try await applyBaselineOpenGroupManager() + try await applyBaselineCrypto() + } + + private func applyBaselineStorage() async throws { + try await mockStorage.perform(migrations: SNMessagingKit.migrations, onProgressUpdate: nil) + try await mockStorage.writeAsync { db in + try Identity(variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey)).insert(db) + try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).insert(db) + try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) + try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) + + try OpenGroup( + server: "testServer", + roomToken: "testRoom", + publicKey: TestConstants.publicKey, + isActive: true, + name: "Test", + roomDescription: nil, + imageId: nil, + userCount: 0, + infoUpdates: 0 + ).insert(db) + try OpenGroup( + server: "testServer1", + roomToken: "testRoom1", + publicKey: TestConstants.publicKey, + isActive: true, + name: "Test1", + roomDescription: nil, + imageId: nil, + userCount: 0, + infoUpdates: 0 + ).insert(db) + try Capability(openGroupServer: "testServer", variant: .sogs, isMissing: false).insert(db) + } + } + + private func applyBaselineNetwork() async throws { + try await mockNetwork.defaultInitialSetup(using: dependencies) + await mockNetwork.removeRequestMocks() + + /// Delay for 10 seconds because we don't want the Poller to get stuck in a recursive loop + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } + .thenReturn( + MockNetwork.response(with: FileUploadResponse(id: "1")) + .delay(for: .seconds(10), scheduler: DispatchQueue.main) + .eraseToAnyPublisher() + ) + } + + private func applyBaselineAppContext() async throws { + try await mockAppContext.when { await $0.isMainAppAndActive }.thenReturn(false) + } + + private func applyBaselineUserDefaults() async throws { + try await mockUserDefaults.defaultInitialSetup() + } + + private func applyBaselineGeneralCache() async throws { + try await mockGeneralCache + .when { $0.sessionId } + .thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) + try await mockGeneralCache + .when { $0.ed25519SecretKey } + .thenReturn(Array(Data(hex: TestConstants.edSecretKey))) + try await mockGeneralCache + .when { $0.ed25519Seed } + .thenReturn(Array(Array(Data(hex: TestConstants.edSecretKey)).prefix(upTo: 32))) + } + + private func applyBaselineOpenGroupManager() async throws { + try await mockOpenGroupManager.defaultInitialSetup() + } + + private func applyBaselineCrypto() async throws { + try await mockCrypto + .when { $0.generate(.hash(message: .any, key: .any, length: .any)) } + .thenReturn([]) + try await mockCrypto + .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } + .thenReturn( + KeyPair( + publicKey: Data(hex: TestConstants.publicKey).bytes, + secretKey: Data(hex: TestConstants.edSecretKey).bytes + ) + ) + try await mockCrypto + .when { $0.generate(.signatureBlind15(message: .any, serverPublicKey: .any, ed25519SecretKey: .any)) } + .thenReturn("TestSogsSignature".bytes) + try await mockCrypto + .when { $0.generate(.randomBytes(16)) } + .thenReturn(Array(Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!)) + try await mockCrypto + .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .thenReturn( + KeyPair( + publicKey: Array(Data(hex: TestConstants.edPublicKey)), + secretKey: Array(Data(hex: TestConstants.edSecretKey)) + ) + ) + } + + // MARK: - Test Specific Configurations + + @MainActor func setupForActivePolling() async throws { + try await mockAppContext.when { $0.isMainAppAndActive }.thenReturn(true) + } +} diff --git a/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift b/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift deleted file mode 100644 index 79f98b853e..0000000000 --- a/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift +++ /dev/null @@ -1,186 +0,0 @@ -// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. - -import UIKit -import Combine -import GRDB -import SessionNetworkingKit -import SessionUtilitiesKit - -import Quick -import Nimble - -@testable import SessionMessagingKit - -class CommunityPollerSpec: AsyncSpec { - override class func spec() { - // MARK: Configuration - - @TestState var dependencies: TestDependencies! = TestDependencies { dependencies in - dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) - dependencies.forceSynchronous = true - } - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( - customWriter: try! DatabaseQueue(), - migrations: SNMessagingKit.migrations, - using: dependencies, - initialData: { db in - try Identity(variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey)).insert(db) - try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).insert(db) - try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) - try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) - - try OpenGroup( - server: "testServer", - roomToken: "testRoom", - publicKey: TestConstants.publicKey, - isActive: true, - name: "Test", - roomDescription: nil, - imageId: nil, - userCount: 0, - infoUpdates: 0 - ).insert(db) - try OpenGroup( - server: "testServer1", - roomToken: "testRoom1", - publicKey: TestConstants.publicKey, - isActive: true, - name: "Test1", - roomDescription: nil, - imageId: nil, - userCount: 0, - infoUpdates: 0 - ).insert(db) - try Capability(openGroupServer: "testServer", variant: .sogs, isMissing: false).insert(db) - } - ) - @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( - initialSetup: { network in - // Delay for 10 seconds because we don't want the Poller to get stuck in a recursive loop - network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn( - MockNetwork.response(with: FileUploadResponse(id: "1")) - .delay(for: .seconds(10), scheduler: DispatchQueue.main) - .eraseToAnyPublisher() - ) - } - ) - @TestState(singleton: .appContext, in: dependencies) var mockAppContext: MockAppContext! = MockAppContext( - initialSetup: { context in - context.when { @MainActor in $0.isMainAppAndActive }.thenReturn(false) - } - ) - @TestState(defaults: .standard, in: dependencies) var mockUserDefaults: MockUserDefaults! = MockUserDefaults() - @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( - initialSetup: { cache in - cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) - cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) - cache - .when { $0.ed25519Seed } - .thenReturn(Array(Array(Data(hex: TestConstants.edSecretKey)).prefix(upTo: 32))) - } - ) - @TestState(cache: .openGroupManager, in: dependencies) var mockOGMCache: MockOGMCache! = MockOGMCache( - initialSetup: { cache in - cache.when { $0.pendingChanges }.thenReturn([]) - cache.when { $0.getLastSuccessfulCommunityPollTimestamp() }.thenReturn(0) - } - ) - @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( - initialSetup: { crypto in - crypto - .when { $0.generate(.hash(message: .any, key: .any, length: .any)) } - .thenReturn([]) - crypto - .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } - .thenReturn( - KeyPair( - publicKey: Data(hex: TestConstants.publicKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ) - ) - crypto - .when { $0.generate(.signatureBlind15(message: .any, serverPublicKey: .any, ed25519SecretKey: .any)) } - .thenReturn("TestSogsSignature".bytes) - crypto - .when { $0.generate(.randomBytes(16)) } - .thenReturn(Array(Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!)) - crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } - .thenReturn( - KeyPair( - publicKey: Array(Data(hex: TestConstants.edPublicKey)), - secretKey: Array(Data(hex: TestConstants.edSecretKey)) - ) - ) - } - ) - @TestState var cache: CommunityPoller.Cache! = CommunityPoller.Cache(using: dependencies) - - // MARK: - a CommunityPollerCache - describe("a CommunityPollerCache") { - // MARK: -- when starting polling - context("when starting polling") { - beforeEach { - mockAppContext.when { @MainActor in $0.isMainAppAndActive }.thenReturn(true) - } - - // MARK: ---- creates pollers for all of the communities - it("creates pollers for all of the communities") { - cache.startAllPollers() - - await expect(cache.serversBeingPolled).toEventually(equal(["testserver", "testserver1"])) - } - - // MARK: ---- updates the isPolling flag - it("updates the isPolling flag") { - cache.startAllPollers() - - await expect(cache.allPollers.count).toEventually(equal(2)) - try require(cache.allPollers.count).to(equal(2)) - expect(cache.allPollers[0].isPolling).to(beTrue()) - expect(cache.allPollers[1].isPolling).to(beTrue()) - } - - // MARK: ---- does not create additional pollers if it's already polling - it("does not create additional pollers if it's already polling") { - cache - .getOrCreatePoller(for: CommunityPoller.Info(server: "testserver", pollFailureCount: 0)) - .startIfNeeded() - cache - .getOrCreatePoller(for: CommunityPoller.Info(server: "testserver1", pollFailureCount: 0)) - .startIfNeeded() - - cache.startAllPollers() - - expect(cache.allPollers.count).to(equal(2)) - } - } - - // MARK: -- when stopping polling - context("when stopping polling") { - beforeEach { - cache.startAllPollers() - } - - // MARK: ---- removes all pollers - it("removes all pollers") { - cache.stopAndRemoveAllPollers() - - expect(cache.allPollers.count).to(equal(0)) - } - - // MARK: ---- updates the isPolling flag - it("updates the isPolling flag") { - let poller1 = cache.getOrCreatePoller(for: CommunityPoller.Info(server: "testserver", pollFailureCount: 0)) - let poller2 = cache.getOrCreatePoller(for: CommunityPoller.Info(server: "testserver1", pollFailureCount: 0)) - cache.stopAndRemoveAllPollers() - - expect(poller1.isPolling).to(beFalse()) - expect(poller2.isPolling).to(beFalse()) - } - } - } - } -} diff --git a/SessionMessagingKitTests/Shared Models/SessionThreadViewModelSpec.swift b/SessionMessagingKitTests/Shared Models/SessionThreadViewModelSpec.swift index 38cc13e864..1704cc41b0 100644 --- a/SessionMessagingKitTests/Shared Models/SessionThreadViewModelSpec.swift +++ b/SessionMessagingKitTests/Shared Models/SessionThreadViewModelSpec.swift @@ -8,15 +8,18 @@ import SessionUtilitiesKit @testable import SessionMessagingKit -class SessionThreadViewModelSpec: QuickSpec { +class SessionThreadViewModelSpec: AsyncSpec { override class func spec() { // MARK: Configuration @TestState var dependencies: TestDependencies! = TestDependencies() - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + @TestState var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), - using: dependencies, - initialData: { db in + using: dependencies + ) + + beforeEach { + try await mockStorage.writeAsync { db in try db.create(table: "testMessage") { t in t.column("body", .text).notNull() } @@ -28,7 +31,8 @@ class SessionThreadViewModelSpec: QuickSpec { t.column("body") } } - ) + dependencies.set(singleton: .storage, to: mockStorage) + } // MARK: - a SessionThreadViewModel describe("a SessionThreadViewModel") { diff --git a/SessionMessagingKitTests/Utilities/ExtensionHelperSpec.swift b/SessionMessagingKitTests/Utilities/ExtensionHelperSpec.swift index 13dd801e31..374399f658 100644 --- a/SessionMessagingKitTests/Utilities/ExtensionHelperSpec.swift +++ b/SessionMessagingKitTests/Utilities/ExtensionHelperSpec.swift @@ -5,6 +5,7 @@ import GRDB import SessionUtil import SessionNetworkingKit import SessionUtilitiesKit +import TestUtilities import Quick import Nimble @@ -21,58 +22,62 @@ class ExtensionHelperSpec: AsyncSpec { dependencies.forceSynchronous = true } ) - @TestState(singleton: .extensionHelper, in: dependencies) var extensionHelper: ExtensionHelper! = ExtensionHelper(using: dependencies) - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + @TestState var extensionHelper: ExtensionHelper! = ExtensionHelper(using: dependencies) + @TestState var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), - migrations: SNMessagingKit.migrations, using: dependencies ) - @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( - initialSetup: { crypto in - crypto.when { $0.generate(.hash(message: .any)) }.thenReturn([1, 2, 3]) - crypto - .when { $0.generate(.ciphertextWithXChaCha20(plaintext: .any, encKey: .any)) } - .thenReturn(Data([4, 5, 6])) - } - ) - @TestState(singleton: .fileManager, in: dependencies) var mockFileManager: MockFileManager! = MockFileManager( - initialSetup: { $0.defaultInitialSetup() } - ) - @TestState(singleton: .keychain, in: dependencies) var mockKeychain: MockKeychain! = MockKeychain( - initialSetup: { keychain in - keychain - .when { - try $0.getOrGenerateEncryptionKey( - forKey: .any, - length: .any, - cat: .any, - legacyKey: .any, - legacyService: .any - ) - } - .thenReturn(Data([1, 2, 3])) - } - ) - @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( - initialSetup: { $0.defaultInitialSetup() } - ) - - @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( - initialSetup: { cache in - cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) - } - ) + @TestState var mockCrypto: MockCrypto! = .create(using: dependencies) + @TestState var mockFileManager: MockFileManager! = .create(using: dependencies) + @TestState var mockKeychain: MockKeychain! = .create(using: dependencies) + @TestState var mockGeneralCache: MockGeneralCache! = .create(using: dependencies) + @TestState var mockLibSessionCache: MockLibSessionCache! = .create(using: dependencies) @TestState var mockLogger: MockLogger! = MockLogger() + beforeEach { + dependencies.set(singleton: .extensionHelper, to: extensionHelper) + + try await mockGeneralCache.defaultInitialSetup() + dependencies.set(cache: .general, to: mockGeneralCache) + + try await mockLibSessionCache.defaultInitialSetup() + dependencies.set(cache: .libSession, to: mockLibSessionCache) + + try await mockFileManager.defaultInitialSetup() + dependencies.set(singleton: .fileManager, to: mockFileManager) + + try await mockStorage.perform(migrations: SNMessagingKit.migrations) + dependencies.set(singleton: .storage, to: mockStorage) + + try await mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn([1, 2, 3]) + try await mockCrypto + .when { $0.generate(.ciphertextWithXChaCha20(plaintext: .any, encKey: .any)) } + .thenReturn(Data([4, 5, 6])) + dependencies.set(singleton: .crypto, to: mockCrypto) + + try await mockKeychain + .when { + try $0.getOrGenerateEncryptionKey( + forKey: .any, + length: .any, + cat: .any, + legacyKey: .any, + legacyService: .any + ) + } + .thenReturn(Data([1, 2, 3])) + dependencies.set(singleton: .keychain, to: mockKeychain) + } + // MARK: - an ExtensionHelper - File Management describe("an ExtensionHelper") { // MARK: -- can delete the entire cache it("can delete the entire cache") { extensionHelper.deleteCache() - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - try? $0.removeItem(atPath: "/test/extensionCache") - }) + await mockFileManager + .verify { try? $0.removeItem(atPath: "/test/extensionCache") } + .wasCalled(exactly: 1) } // MARK: -- when writing an encrypted file @@ -84,9 +89,11 @@ class ExtensionHelperSpec: AsyncSpec { uniqueIdentifier: "uniqueId" ) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - try? $0.ensureDirectoryExists(at: "/test/extensionCache/conversations/010203/dedupe") - }) + await mockFileManager + .verify { + try? $0.ensureDirectoryExists(at: "/test/extensionCache/conversations/010203/dedupe") + } + .wasCalled(exactly: 1) } // MARK: ---- protects the write directory @@ -96,9 +103,11 @@ class ExtensionHelperSpec: AsyncSpec { uniqueIdentifier: "uniqueId" ) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - try? $0.protectFileOrFolder(at: "/test/extensionCache/conversations/010203/dedupe") - }) + await mockFileManager + .verify { + try? $0.protectFileOrFolder(at: "/test/extensionCache/conversations/010203/dedupe") + } + .wasCalled(exactly: 1) } // MARK: ---- generates a temporary file path @@ -108,9 +117,11 @@ class ExtensionHelperSpec: AsyncSpec { uniqueIdentifier: "uniqueId" ) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.temporaryFilePath(fileExtension: nil) - }) + await mockFileManager + .verify { + $0.temporaryFilePath(fileExtension: nil) + } + .wasCalled(exactly: 1) } // MARK: ---- writes the encrypted data to the temporary file path @@ -120,9 +131,11 @@ class ExtensionHelperSpec: AsyncSpec { uniqueIdentifier: "uniqueId" ) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.createFile(atPath: "tmpFile", contents: Data([4, 5, 6])) - }) + await mockFileManager + .verify { + $0.createFile(atPath: "tmpFile", contents: Data([4, 5, 6])) + } + .wasCalled(exactly: 1) } // MARK: ---- replaces the destination path with the temporary file @@ -132,17 +145,19 @@ class ExtensionHelperSpec: AsyncSpec { uniqueIdentifier: "uniqueId" ) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - _ = try $0.replaceItem( - atPath: "/test/extensionCache/conversations/010203/dedupe/010203", - withItemAtPath: "tmpFile" - ) - }) + await mockFileManager + .verify { + _ = try $0.replaceItem( + atPath: "/test/extensionCache/conversations/010203/dedupe/010203", + withItemAtPath: "tmpFile" + ) + } + .wasCalled(exactly: 1) } // MARK: ---- throws when failing to retrieve the encryption key it("throws when failing to retrieve the encryption key") { - mockKeychain + try await mockKeychain .when { try $0.getOrGenerateEncryptionKey( forKey: .any, @@ -164,7 +179,7 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- throws encryption errors it("throws encryption errors") { - mockCrypto + try await mockCrypto .when { try $0.tryGenerate( .ciphertextWithXChaCha20(plaintext: .any, encKey: .any) @@ -182,7 +197,7 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- throws when it fails to write to disk it("throws when it fails to write to disk") { - mockFileManager + try await mockFileManager .when { $0.createFile(atPath: .any, contents: .any) } .thenReturn(false) @@ -196,7 +211,7 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- does not throw when attempting to remove an existing item at the destination fails it("does not throw when attempting to remove an existing item at the destination fails") { - mockFileManager + try await mockFileManager .when { try $0.removeItem(atPath: .any) } .thenThrow(TestError.mock) @@ -210,7 +225,7 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- throws when it fails to move the temp file to the final location it("throws when it fails to move the temp file to the final location") { - mockFileManager + try await mockFileManager .when { _ = try $0.replaceItem(atPath: .any, withItemAtPath: .any) } .thenThrow(TestError.mock) @@ -237,20 +252,24 @@ class ExtensionHelperSpec: AsyncSpec { unreadCount: 1 ) }.toNot(throwError()) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.createFile(atPath: "tmpFile", contents: Data(base64Encoded: "BAUG")) - }) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - _ = try $0.replaceItem( - atPath: "/test/extensionCache/metadata", - withItemAtPath: "tmpFile" - ) - }) + await mockFileManager + .verify { + $0.createFile(atPath: "tmpFile", contents: Data(base64Encoded: "BAUG")) + } + .wasCalled(exactly: 1) + await mockFileManager + .verify { + _ = try $0.replaceItem( + atPath: "/test/extensionCache/metadata", + withItemAtPath: "tmpFile" + ) + } + .wasCalled(exactly: 1) } // MARK: ---- throws when failing to write the file it("throws when failing to write the file") { - mockFileManager + try await mockFileManager .when { _ = try $0.replaceItem(atPath: .any, withItemAtPath: .any) } .thenThrow(TestError.mock) @@ -268,8 +287,8 @@ class ExtensionHelperSpec: AsyncSpec { context("when loading user metadata") { // MARK: ---- loads the data correctly it("loads the data correctly") { - mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3])) - mockCrypto + try await mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3])) + try await mockCrypto .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } .thenReturn( try! JSONEncoder(using: dependencies) @@ -292,7 +311,7 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- returns null if there is no file it("returns null if there is no file") { - mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(nil) + try await mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(nil) let result: ExtensionHelper.UserMetadata? = extensionHelper.loadUserMetadata() @@ -301,8 +320,8 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- returns null if it fails to decrypt the file it("returns null if it fails to decrypt the file") { - mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3])) - mockCrypto + try await mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3])) + try await mockCrypto .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } .thenReturn(nil) @@ -313,8 +332,8 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- returns null if it fails to decode the data it("returns null if it fails to decode the data") { - mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3])) - mockCrypto + try await mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3])) + try await mockCrypto .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } .thenReturn(Data([1, 2, 3])) @@ -331,13 +350,13 @@ class ExtensionHelperSpec: AsyncSpec { context("when checking whether it has a dedupe record since the last clear") { // MARK: ---- returns true when at least one record exists that is newer than the last cleared timestamp it("returns true when at least one record exists that is newer than the last cleared timestamp") { - mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn([1, 2, 3]) - mockFileManager.when { try $0.contentsOfDirectory(atPath: .any) }.thenReturn(["Test1234"]) - mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) - mockFileManager + try await mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn([1, 2, 3]) + try await mockFileManager.when { try $0.contentsOfDirectory(atPath: .any) }.thenReturn(["Test1234"]) + try await mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) + try await mockFileManager .when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/010203/dedupe/010203") } .thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567800)]) - mockFileManager + try await mockFileManager .when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/010203/dedupe/Test1234") } .thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567900)]) @@ -346,18 +365,22 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- returns false when it cannot get the conversation path it("returns false when it cannot get the conversation path") { - mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) + try await mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) expect(extensionHelper.hasDedupeRecordSinceLastCleared(threadId: "threadId")).to(beFalse()) } // MARK: ---- returns false when a record does not exist it("returns false when a record does not exist") { - mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn([1, 2, 3]) - mockFileManager.when { try $0.contentsOfDirectory(atPath: .any) }.thenReturn([]) - mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) - mockFileManager - .when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/010203/dedupe/010203") } + try await mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn([1, 2, 3]) + try await mockFileManager.when { try $0.contentsOfDirectory(atPath: .any) }.thenReturn([]) + try await mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) + try await mockFileManager + .when { + try $0.attributesOfItem( + atPath: "/test/extensionCache/conversations/010203/dedupe/010203" + ) + } .thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567800)]) expect(extensionHelper.hasDedupeRecordSinceLastCleared(threadId: "threadId")).to(beFalse()) @@ -365,13 +388,17 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- returns false when a record exists but is too old it("returns false when a record exists but is too old") { - mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn([1, 2, 3]) - mockFileManager.when { try $0.contentsOfDirectory(atPath: .any) }.thenReturn(["Test1234"]) - mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) - mockFileManager - .when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/010203/dedupe/010203") } + try await mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn([1, 2, 3]) + try await mockFileManager.when { try $0.contentsOfDirectory(atPath: .any) }.thenReturn(["Test1234"]) + try await mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) + try await mockFileManager + .when { + try $0.attributesOfItem( + atPath: "/test/extensionCache/conversations/010203/dedupe/010203" + ) + } .thenReturn([FileAttributeKey.modificationDate: Date(timeIntervalSince1970: 1234567900)]) - mockFileManager + try await mockFileManager .when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/010203/dedupe/Test1234") } .thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567800)]) @@ -380,12 +407,12 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- ignores the lastCleared file when comparing dedupe records it("ignores the lastCleared file when comparing dedupe records") { - mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn([1, 2, 3]) - mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) - mockFileManager + try await mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn([1, 2, 3]) + try await mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: .any) } .thenReturn(["010203"]) - mockFileManager + try await mockFileManager .when { try $0.attributesOfItem(atPath: .any) } .thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567900)]) @@ -394,13 +421,17 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- returns true when at least one record exists and there is no last cleared timestamp it("returns true when at least one record exists and there is no last cleared timestamp") { - mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn([1, 2, 3]) - mockFileManager.when { try $0.contentsOfDirectory(atPath: .any) }.thenReturn(["Test1234"]) - mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) - mockFileManager - .when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/010203/dedupe/010203") } + try await mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn([1, 2, 3]) + try await mockFileManager.when { try $0.contentsOfDirectory(atPath: .any) }.thenReturn(["Test1234"]) + try await mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) + try await mockFileManager + .when { + try $0.attributesOfItem( + atPath: "/test/extensionCache/conversations/010203/dedupe/010203" + ) + } .thenReturn([:]) - mockFileManager + try await mockFileManager .when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/010203/dedupe/Test1234") } .thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567900)]) @@ -412,7 +443,7 @@ class ExtensionHelperSpec: AsyncSpec { context("when checking for dedupe records") { // MARK: ---- returns true when a record exists it("returns true when a record exists") { - mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) + try await mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) expect(extensionHelper.dedupeRecordExists( threadId: "threadId", @@ -422,7 +453,7 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- returns false when a record does not exist it("returns false when a record does not exist") { - mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(false) + try await mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(false) expect(extensionHelper.dedupeRecordExists( threadId: "threadId", @@ -432,7 +463,7 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- returns false when failing to generate a hash it("returns false when failing to generate a hash") { - mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) + try await mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) expect(extensionHelper.dedupeRecordExists( threadId: "threadId", @@ -450,17 +481,19 @@ class ExtensionHelperSpec: AsyncSpec { uniqueIdentifier: "uniqueId" ) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - _ = try $0.replaceItem( - atPath: "/test/extensionCache/conversations/010203/dedupe/010203", - withItemAtPath: "tmpFile" - ) - }) + await mockFileManager + .verify { + _ = try $0.replaceItem( + atPath: "/test/extensionCache/conversations/010203/dedupe/010203", + withItemAtPath: "tmpFile" + ) + } + .wasCalled(exactly: 1) } // MARK: ---- throws when failing to generate a hash it("throws when failing to generate a hash") { - mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) + try await mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) expect { try extensionHelper.createDedupeRecord( @@ -472,7 +505,7 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- throws when failing to write the file it("throws when failing to write the file") { - mockFileManager + try await mockFileManager .when { _ = try $0.replaceItem(atPath: .any, withItemAtPath: .any) } .thenThrow(TestError.mock) @@ -494,9 +527,11 @@ class ExtensionHelperSpec: AsyncSpec { uniqueIdentifier: "uniqueId" ) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - try? $0.removeItem(atPath: "/test/extensionCache/conversations/010203/dedupe/010203") - }) + await mockFileManager + .verify { + try? $0.removeItem(atPath: "/test/extensionCache/conversations/010203/dedupe/010203") + } + .wasCalled(exactly: 1) } // MARK: ---- removes the parent directory if it is empty @@ -506,28 +541,32 @@ class ExtensionHelperSpec: AsyncSpec { uniqueIdentifier: "uniqueId" ) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - try? $0.removeItem(atPath: "/test/extensionCache/conversations/010203/dedupe") - }) + await mockFileManager + .verify { + try? $0.removeItem(atPath: "/test/extensionCache/conversations/010203/dedupe") + } + .wasCalled(exactly: 1) } // MARK: ---- leaves the parent directory if not empty it("leaves the parent directory if not empty") { - mockFileManager.when { $0.isDirectoryEmpty(atPath: .any) }.thenReturn(false) + try await mockFileManager.when { $0.isDirectoryEmpty(atPath: .any) }.thenReturn(false) try? extensionHelper.removeDedupeRecord( threadId: "threadId", uniqueIdentifier: "uniqueId" ) - expect(mockFileManager).toNot(call(.exactly(times: 1), matchingParameters: .all) { - try? $0.removeItem(atPath: "/test/extensionCache/conversations/010203/dedupe") - }) + await mockFileManager + .verify { + try? $0.removeItem(atPath: "/test/extensionCache/conversations/010203/dedupe") + } + .wasNotCalled() } // MARK: ---- does nothing when failing to generate a hash it("does nothing when failing to generate a hash") { - mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) + try await mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) expect { try extensionHelper.removeDedupeRecord( @@ -535,14 +574,16 @@ class ExtensionHelperSpec: AsyncSpec { uniqueIdentifier: "uniqueId" ) }.toNot(throwError(ExtensionHelperError.failedToStoreDedupeRecord)) - expect(mockFileManager).toNot(call(.exactly(times: 1), matchingParameters: .all) { - try? $0.removeItem(atPath: "/test/extensionCache/conversations/010203/dedupe/010203") - }) + await mockFileManager + .verify { + try? $0.removeItem(atPath: "/test/extensionCache/conversations/010203/dedupe/010203") + } + .wasNotCalled() } // MARK: ---- throws when failing to remove the file it("throws when failing to remove the file") { - mockFileManager.when { try $0.removeItem(atPath: .any) }.thenThrow(TestError.mock) + try await mockFileManager.when { try $0.removeItem(atPath: .any) }.thenThrow(TestError.mock) expect { try extensionHelper.removeDedupeRecord( @@ -557,7 +598,7 @@ class ExtensionHelperSpec: AsyncSpec { context("when upserting a last cleared record") { // MARK: ---- creates the file successfully it("creates the file successfully") { - mockCrypto + try await mockCrypto .when { $0.generate(.ciphertextWithXChaCha20(plaintext: .any, encKey: .any)) } .thenReturn(Data()) @@ -565,34 +606,42 @@ class ExtensionHelperSpec: AsyncSpec { try extensionHelper.upsertLastClearedRecord(threadId: "threadId") }.toNot(throwError()) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.createFile(atPath: "tmpFile", contents: Data()) - }) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - _ = try $0.replaceItem( - atPath: "/test/extensionCache/conversations/010203/dedupe/010203", - withItemAtPath: "tmpFile" - ) - }) + await mockFileManager + .verify { $0.createFile(atPath: "tmpFile", contents: Data()) } + .wasCalled(exactly: 1) + await mockFileManager + .verify { + _ = try $0.replaceItem( + atPath: "/test/extensionCache/conversations/010203/dedupe/010203", + withItemAtPath: "tmpFile" + ) + } + .wasCalled(exactly: 1) } // MARK: ---- throws when failing to generate a hash it("throws when failing to generate a hash") { - mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) + try await mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) expect { try extensionHelper.upsertLastClearedRecord(threadId: "threadId") }.to(throwError(ExtensionHelperError.failedToUpdateLastClearedRecord)) - expect(mockFileManager).toNot(call(.exactly(times: 1), matchingParameters: .all) { - try? $0.removeItem(atPath: "/test/extensionCache/conversations/010203/dedupe/010203") - }) - expect(mockFileManager).toNot(call { $0.createFile(atPath: .any, contents: .any) }) - expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) }) + await mockFileManager + .verify { + try? $0.removeItem(atPath: "/test/extensionCache/conversations/010203/dedupe/010203") + } + .wasNotCalled() + await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any) } + .wasNotCalled() + await mockFileManager + .verify { try $0.replaceItem(atPath: .any, withItemAtPath: .any) } + .wasNotCalled() } // MARK: ---- throws when failing to write the file it("throws when failing to write the file") { - mockFileManager + try await mockFileManager .when { _ = try $0.replaceItem(atPath: .any, withItemAtPath: .any) } .thenThrow(TestError.mock) @@ -613,8 +662,8 @@ class ExtensionHelperSpec: AsyncSpec { context("when retrieving the last updated timestamp") { // MARK: ---- returns the timestamp it("returns the timestamp") { - mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) - mockFileManager + try await mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) + try await mockFileManager .when { try $0.attributesOfItem(atPath: .any) } .thenReturn([.modificationDate: Date(timeIntervalSince1970: 1234567890)]) @@ -626,7 +675,7 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- returns zero when it fails to generate a hash it("returns zero when it fails to generate a hash") { - mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) + try await mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) expect(extensionHelper.lastUpdatedTimestamp( for: SessionId(.standard, hex: TestConstants.publicKey), @@ -636,7 +685,7 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- throws when failing to retrieve file metadata it("throws when failing to retrieve file metadata") { - mockFileManager.when { try $0.attributesOfItem(atPath: .any) }.thenReturn(nil) + try await mockFileManager.when { try $0.attributesOfItem(atPath: .any) }.thenReturn(nil) expect(extensionHelper.lastUpdatedTimestamp( for: SessionId(.standard, hex: TestConstants.publicKey), @@ -658,28 +707,34 @@ class ExtensionHelperSpec: AsyncSpec { ), replaceExisting: true ) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.createFile(atPath: "tmpFile", contents: Data(base64Encoded: "BAUG")) - }) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - _ = try $0.replaceItem( - atPath: "/test/extensionCache/conversations/010203/dumps/010203", - withItemAtPath: "tmpFile" - ) - }) + await mockFileManager + .verify { $0.createFile(atPath: "tmpFile", contents: Data(base64Encoded: "BAUG")) } + .wasCalled(exactly: 1) + await mockFileManager + .verify { + _ = try $0.replaceItem( + atPath: "/test/extensionCache/conversations/010203/dumps/010203", + withItemAtPath: "tmpFile" + ) + } + .wasCalled(exactly: 1) } // MARK: ---- does nothing when given a null dump it("does nothing when given a null dump") { extensionHelper.replicate(dump: nil, replaceExisting: true) - expect(mockFileManager).toNot(call { $0.createFile(atPath: .any, contents: .any) }) - expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) }) + await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any) } + .wasNotCalled() + await mockFileManager + .verify { try $0.replaceItem(atPath: .any, withItemAtPath: .any) } + .wasNotCalled() } // MARK: ---- does nothing when failing to generate a hash it("does nothing when failing to generate a hash") { - mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) + try await mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) extensionHelper.replicate( dump: ConfigDump( @@ -691,13 +746,17 @@ class ExtensionHelperSpec: AsyncSpec { replaceExisting: true ) - expect(mockFileManager).toNot(call { $0.createFile(atPath: .any, contents: .any) }) - expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) }) + await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any) } + .wasNotCalled() + await mockFileManager + .verify { try $0.replaceItem(atPath: .any, withItemAtPath: .any) } + .wasNotCalled() } // MARK: ---- does nothing a file already exists and we do not want to replace it it("does nothing a file already exists and we do not want to replace it") { - mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) + try await mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) extensionHelper.replicate( dump: ConfigDump( @@ -709,13 +768,17 @@ class ExtensionHelperSpec: AsyncSpec { replaceExisting: false ) - expect(mockFileManager).toNot(call { $0.createFile(atPath: .any, contents: .any) }) - expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) }) + await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any) } + .wasNotCalled() + await mockFileManager + .verify { try $0.replaceItem(atPath: .any, withItemAtPath: .any) } + .wasNotCalled() } // MARK: ---- logs an error when failing to write the file it("logs an error when failing to write the file") { - mockFileManager + try await mockFileManager .when { _ = try $0.replaceItem(atPath: .any, withItemAtPath: .any) } .thenThrow(TestError.mock) @@ -794,18 +857,18 @@ class ExtensionHelperSpec: AsyncSpec { ] beforeEach { - mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(nil) - mockFileManager + try await mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(nil) + try await mockFileManager .when { try $0.attributesOfItem(atPath: .any) } .thenReturn([.modificationDate: Date(timeIntervalSince1970: 1234567800)]) - mockCrypto + try await mockCrypto .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } .thenReturn(Data([1, 2, 3])) - mockValues.forEach { value in - mockCrypto + for value in mockValues { + try await mockCrypto .when { $0.generate(.hash(message: Array(value.key.data(using: .utf8)!))) } .thenReturn(value.hashValue) - mockCrypto + try await mockCrypto .when { $0.generate(.ciphertextWithXChaCha20(plaintext: value.plaintext, encKey: .any)) } .thenReturn(value.ciphertext) } @@ -829,85 +892,80 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- replicates successfully it("replicates successfully") { - extensionHelper.replicateAllConfigDumpsIfNeeded( + await extensionHelper.replicateAllConfigDumpsIfNeeded( userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"), allDumpSessionIds: [SessionId(.standard, hex: "05\(TestConstants.publicKey)")] ) - - let allCreateFileCalls: [CallDetails]? = await expect(mockFileManager - .allCalls { $0.createFile(atPath: .any, contents: .any) }) - .toEventually(haveCount(5)) - .retrieveValue() - let allMoveFileCalls: [CallDetails]? = await expect(mockFileManager - .allCalls { try $0.replaceItem(atPath: .any, withItemAtPath: .any) }) - .toEventually(haveCount(5)) - .retrieveValue() - - let emptyOptions: String = "Optional(__C.NSFileManagerItemReplacementOptions(rawValue: 0))" - expect((allCreateFileCalls?.map { $0.parameterSummary }).map { Set($0) }) + let createFileCallInfo: RecordedCallInfo? = await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any) } + .wasCalled(exactly: 5) + let moveFileCallInfo: RecordedCallInfo? = await mockFileManager + .verify { try $0.replaceItem(atPath: .any, withItemAtPath: .any) } + .wasCalled(exactly: 5) + + let opt: String = "NSFileManagerItemReplacementOptions(rawValue: 0)" + expect((createFileCallInfo?.matchingCalls.map { $0.parameterSummary }).map { Set($0) }) .to(equal([ - "[tmpFile, Data(base64Encoded: AgME), nil]", - "[tmpFile, Data(base64Encoded: AwQF), nil]", - "[tmpFile, Data(base64Encoded: BAUG), nil]", - "[tmpFile, Data(base64Encoded: BQYH), nil]", - "[tmpFile, Data(base64Encoded: BgcI), nil]" + "[\"tmpFile\", Data(base64Encoded: AgME), nil]", + "[\"tmpFile\", Data(base64Encoded: AwQF), nil]", + "[\"tmpFile\", Data(base64Encoded: BAUG), nil]", + "[\"tmpFile\", Data(base64Encoded: BQYH), nil]", + "[\"tmpFile\", Data(base64Encoded: BgcI), nil]" ])) - expect((allMoveFileCalls?.map { $0.parameterSummary }).map { Set($0) }) + expect((moveFileCallInfo?.matchingCalls.map { $0.parameterSummary }).map { Set($0) }) .to(equal([ - "[/test/extensionCache/conversations/010203/dumps/020304, tmpFile, nil, \(emptyOptions)]", - "[/test/extensionCache/conversations/010203/dumps/030405, tmpFile, nil, \(emptyOptions)]", - "[/test/extensionCache/conversations/010203/dumps/040506, tmpFile, nil, \(emptyOptions)]", - "[/test/extensionCache/conversations/010203/dumps/050607, tmpFile, nil, \(emptyOptions)]", - "[/test/extensionCache/conversations/010203/dumps/060708, tmpFile, nil, \(emptyOptions)]" + "[\"/test/extensionCache/conversations/010203/dumps/020304\", \"tmpFile\", nil, \(opt)]", + "[\"/test/extensionCache/conversations/010203/dumps/030405\", \"tmpFile\", nil, \(opt)]", + "[\"/test/extensionCache/conversations/010203/dumps/040506\", \"tmpFile\", nil, \(opt)]", + "[\"/test/extensionCache/conversations/010203/dumps/050607\", \"tmpFile\", nil, \(opt)]", + "[\"/test/extensionCache/conversations/010203/dumps/060708\", \"tmpFile\", nil, \(opt)]" ])) } // MARK: ---- replicates all user configs if they cannot be found it("replicates all user configs if they cannot be found") { - mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(false) + try await mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(false) - extensionHelper.replicateAllConfigDumpsIfNeeded( + await extensionHelper.replicateAllConfigDumpsIfNeeded( userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"), allDumpSessionIds: [SessionId(.standard, hex: "05\(TestConstants.publicKey)")] ) - let allCreateFileCalls: [CallDetails]? = await expect(mockFileManager - .allCalls { $0.createFile(atPath: .any, contents: .any) }) - .toEventually(haveCount(5)) - .retrieveValue() - let allMoveFileCalls: [CallDetails]? = await expect(mockFileManager - .allCalls { try $0.replaceItem(atPath: .any, withItemAtPath: .any) }) - .toEventually(haveCount(5)) - .retrieveValue() - - let emptyOptions: String = "Optional(__C.NSFileManagerItemReplacementOptions(rawValue: 0))" - expect((allCreateFileCalls?.map { $0.parameterSummary }).map { Set($0) }) + let createFileCallInfo: RecordedCallInfo? = await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any) } + .wasCalled(exactly: 5) + let moveFileCallInfo: RecordedCallInfo? = await mockFileManager + .verify { try $0.replaceItem(atPath: .any, withItemAtPath: .any) } + .wasCalled(exactly: 5) + + let opt: String = "NSFileManagerItemReplacementOptions(rawValue: 0)" + expect((createFileCallInfo?.matchingCalls.map { $0.parameterSummary }).map { Set($0) }) .to(equal([ - "[tmpFile, Data(base64Encoded: AgME), nil]", - "[tmpFile, Data(base64Encoded: AwQF), nil]", - "[tmpFile, Data(base64Encoded: BAUG), nil]", - "[tmpFile, Data(base64Encoded: BQYH), nil]", - "[tmpFile, Data(base64Encoded: BgcI), nil]" + "[\"tmpFile\", Data(base64Encoded: AgME), nil]", + "[\"tmpFile\", Data(base64Encoded: AwQF), nil]", + "[\"tmpFile\", Data(base64Encoded: BAUG), nil]", + "[\"tmpFile\", Data(base64Encoded: BQYH), nil]", + "[\"tmpFile\", Data(base64Encoded: BgcI), nil]" ])) - expect((allMoveFileCalls?.map { $0.parameterSummary }).map { Set($0) }) + expect((moveFileCallInfo?.matchingCalls.map { $0.parameterSummary }).map { Set($0) }) .to(equal([ - "[/test/extensionCache/conversations/010203/dumps/020304, tmpFile, nil, \(emptyOptions)]", - "[/test/extensionCache/conversations/010203/dumps/030405, tmpFile, nil, \(emptyOptions)]", - "[/test/extensionCache/conversations/010203/dumps/040506, tmpFile, nil, \(emptyOptions)]", - "[/test/extensionCache/conversations/010203/dumps/050607, tmpFile, nil, \(emptyOptions)]", - "[/test/extensionCache/conversations/010203/dumps/060708, tmpFile, nil, \(emptyOptions)]" + "[\"/test/extensionCache/conversations/010203/dumps/020304\", \"tmpFile\", nil, \(opt)]", + "[\"/test/extensionCache/conversations/010203/dumps/030405\", \"tmpFile\", nil, \(opt)]", + "[\"/test/extensionCache/conversations/010203/dumps/040506\", \"tmpFile\", nil, \(opt)]", + "[\"/test/extensionCache/conversations/010203/dumps/050607\", \"tmpFile\", nil, \(opt)]", + "[\"/test/extensionCache/conversations/010203/dumps/060708\", \"tmpFile\", nil, \(opt)]" ])) } // MARK: ---- replicates all configs for a group if one cannot be found it("replicates all configs for a group if one cannot be found") { - mockValues.forEach { value in + for value in mockValues { guard let variant: ConfigDump.Variant = value.variant else { return } let isGroupVariant: Bool = ConfigDump.Variant.groupVariants.contains(variant) let convo: String = (isGroupVariant ? "090807" : "010203") let dump: String = value.hashValue.toHexString() - mockFileManager + try await mockFileManager .when { $0.fileExists( atPath: "/test/extensionCache/conversations/\(convo)/dumps/\(dump)" @@ -916,7 +974,7 @@ class ExtensionHelperSpec: AsyncSpec { .thenReturn(!isGroupVariant) } - extensionHelper.replicateAllConfigDumpsIfNeeded( + await extensionHelper.replicateAllConfigDumpsIfNeeded( userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"), allDumpSessionIds: [ SessionId(.standard, hex: "05\(TestConstants.publicKey)"), @@ -924,116 +982,133 @@ class ExtensionHelperSpec: AsyncSpec { ] ) - let allCreateFileCalls: [CallDetails]? = await expect(mockFileManager - .allCalls { $0.createFile(atPath: .any, contents: .any) }) - .toEventually(haveCount(3)) - .retrieveValue() - let allMoveFileCalls: [CallDetails]? = await expect(mockFileManager - .allCalls { try $0.replaceItem(atPath: .any, withItemAtPath: .any) }) - .toEventually(haveCount(3)) - .retrieveValue() - - let emptyOptions: String = "Optional(__C.NSFileManagerItemReplacementOptions(rawValue: 0))" - expect((allCreateFileCalls?.map { $0.parameterSummary }).map { Set($0) }) + let createFileCallInfo: RecordedCallInfo? = await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any) } + .wasCalled(exactly: 3) + let moveFileCallInfo: RecordedCallInfo? = await mockFileManager + .verify { try $0.replaceItem(atPath: .any, withItemAtPath: .any) } + .wasCalled(exactly: 3) + + let opt: String = "Optional(__C.NSFileManagerItemReplacementOptions(rawValue: 0))" + expect((createFileCallInfo?.matchingCalls.map { $0.parameterSummary }).map { Set($0) }) .to(equal([ - "[tmpFile, Data(base64Encoded: BgUE), nil]", - "[tmpFile, Data(base64Encoded: BwYF), nil]", - "[tmpFile, Data(base64Encoded: CAcG), nil]" + "[\"tmpFile\", Data(base64Encoded: BgUE), nil]", + "[\"tmpFile\", Data(base64Encoded: BwYF), nil]", + "[\"tmpFile\", Data(base64Encoded: CAcG), nil]" ])) - expect((allMoveFileCalls?.map { $0.parameterSummary }).map { Set($0) }) + expect((moveFileCallInfo?.matchingCalls.map { $0.parameterSummary }).map { Set($0) }) .to(equal([ - "[/test/extensionCache/conversations/090807/dumps/060504, tmpFile, nil, \(emptyOptions)]", - "[/test/extensionCache/conversations/090807/dumps/070605, tmpFile, nil, \(emptyOptions)]", - "[/test/extensionCache/conversations/090807/dumps/080706, tmpFile, nil, \(emptyOptions)]" + "[\"/test/extensionCache/conversations/090807/dumps/060504\", \"tmpFile\", nil, \(opt)]", + "[\"/test/extensionCache/conversations/090807/dumps/070605\", \"tmpFile\", nil, \(opt)]", + "[\"/test/extensionCache/conversations/090807/dumps/080706\", \"tmpFile\", nil, \(opt)]" ])) } // MARK: ---- does nothing when failing to generate a hash it("does nothing when failing to generate a hash") { - mockValues.forEach { value in - mockCrypto + for value in mockValues { + try await mockCrypto .when { $0.generate(.hash(message: Array(value.key.data(using: .utf8)!))) } .thenReturn(nil) } - extensionHelper.replicateAllConfigDumpsIfNeeded( + await extensionHelper.replicateAllConfigDumpsIfNeeded( userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"), allDumpSessionIds: [SessionId(.standard, hex: "05\(TestConstants.publicKey)")] ) - expect(mockFileManager).toNot(call { $0.createFile(atPath: .any, contents: .any) }) - expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) }) + await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any) } + .wasNotCalled() + await mockFileManager + .verify { try $0.replaceItem(atPath: .any, withItemAtPath: .any) } + .wasNotCalled() } // MARK: ---- does nothing if valid dumps already exist it("does nothing if valid dumps already exist") { - mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3])) - mockCrypto + try await mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) + try await mockCrypto .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } .thenReturn(Data([1, 2, 3])) - extensionHelper.replicateAllConfigDumpsIfNeeded( + await extensionHelper.replicateAllConfigDumpsIfNeeded( userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"), allDumpSessionIds: [SessionId(.standard, hex: "05\(TestConstants.publicKey)")] ) - expect(mockFileManager).toNot(call { $0.createFile(atPath: .any, contents: .any) }) - expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) }) + await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any) } + .wasNotCalled() + await mockFileManager + .verify { try $0.replaceItem(atPath: .any, withItemAtPath: .any) } + .wasNotCalled() } // MARK: ---- does nothing if there are no dumps in the database it("does nothing if there are no dumps in the database") { mockStorage.write { db in try ConfigDump.deleteAll(db) } - extensionHelper.replicateAllConfigDumpsIfNeeded( + await extensionHelper.replicateAllConfigDumpsIfNeeded( userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"), allDumpSessionIds: [] ) - expect(mockFileManager).toNot(call { $0.createFile(atPath: .any, contents: .any) }) - expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) }) + await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any) } + .wasNotCalled() + await mockFileManager + .verify { try $0.replaceItem(atPath: .any, withItemAtPath: .any) } + .wasNotCalled() } // MARK: ---- does nothing if the existing replicated dump was newer than the fetched one it("does nothing if the existing replicated dump was newer than the fetched one") { dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) - mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) - mockFileManager + try await mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) + try await mockFileManager .when { try $0.attributesOfItem(atPath: .any) } .thenReturn([.modificationDate: Date(timeIntervalSince1970: 1234567891)]) - extensionHelper.replicateAllConfigDumpsIfNeeded( + await extensionHelper.replicateAllConfigDumpsIfNeeded( userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"), allDumpSessionIds: [SessionId(.standard, hex: "05\(TestConstants.publicKey)")] ) - expect(mockFileManager).toNot(call { $0.createFile(atPath: .any, contents: .any) }) - expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) }) + await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any) } + .wasNotCalled() + await mockFileManager + .verify { try $0.replaceItem(atPath: .any, withItemAtPath: .any) } + .wasNotCalled() } // MARK: ---- does nothing if it fails to replicate it("does nothing if it fails to replicate") { - mockCrypto - .when { $0.generate(.ciphertextWithXChaCha20(plaintext: Data([2, 3, 4]), encKey: .any)) } - .thenThrow(TestError.mock) - mockCrypto - .when { $0.generate(.ciphertextWithXChaCha20(plaintext: Data([5, 6, 7]), encKey: .any)) } - .thenThrow(TestError.mock) + for value in mockValues { + try await mockCrypto + .when { $0.generate(.ciphertextWithXChaCha20(plaintext: value.plaintext, encKey: .any)) } + .thenThrow(TestError.mock) + } - extensionHelper.replicateAllConfigDumpsIfNeeded( + await extensionHelper.replicateAllConfigDumpsIfNeeded( userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"), allDumpSessionIds: [SessionId(.standard, hex: "05\(TestConstants.publicKey)")] ) - expect(mockFileManager).toNot(call { $0.createFile(atPath: .any, contents: .any) }) - expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) }) + await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any) } + .wasNotCalled() + await mockFileManager + .verify { try $0.replaceItem(atPath: .any, withItemAtPath: .any) } + .wasNotCalled() } } // MARK: -- when refreshing the dump modified date context("when refreshing the dump modified date") { beforeEach { - mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) + try await mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) } // MARK: ---- updates the modified date @@ -1043,43 +1118,49 @@ class ExtensionHelperSpec: AsyncSpec { variant: .userProfile ) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.setAttributes( - [.modificationDate: Date(timeIntervalSince1970: 1234567890)], - ofItemAtPath: "/test/extensionCache/conversations/010203/dumps/010203" - ) - }) + await mockFileManager + .verify { + try $0.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 1234567890)], + ofItemAtPath: "/test/extensionCache/conversations/010203/dumps/010203" + ) + } + .wasCalled(exactly: 1) } // MARK: ---- does nothing when it fails to generate a hash it("does nothing when it fails to generate a hash") { - mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) + try await mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) extensionHelper.refreshDumpModifiedDate( sessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"), variant: .userProfile ) - expect(mockFileManager).toNot(call { try $0.setAttributes(.any, ofItemAtPath: .any) }) + await mockFileManager + .verify { try $0.setAttributes(.any, ofItemAtPath: .any) } + .wasNotCalled() } // MARK: ---- does nothing if the file does not exist it("does nothing if the file does not exist") { - mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(false) + try await mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(false) extensionHelper.refreshDumpModifiedDate( sessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"), variant: .userProfile ) - expect(mockFileManager).toNot(call { try $0.setAttributes(.any, ofItemAtPath: .any) }) + await mockFileManager + .verify { try $0.setAttributes(.any, ofItemAtPath: .any) } + .wasNotCalled() } } // MARK: -- when loading user configs context("when loading user configs") { beforeEach { - mockCrypto + try await mockCrypto .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } .thenReturn(Data([1, 2, 3])) } @@ -1090,8 +1171,8 @@ class ExtensionHelperSpec: AsyncSpec { let configs: [LibSession.Config] = [ .userProfile(ptr), .userGroups(ptr), .contacts(ptr), .convoInfoVolatile(ptr) ] - configs.forEach { config in - mockLibSessionCache + for config in configs { + try await mockLibSessionCache .when { try $0.loadState( for: config.variant, @@ -1109,41 +1190,49 @@ class ExtensionHelperSpec: AsyncSpec { userSessionId: SessionId(.standard, hex: TestConstants.publicKey), userEd25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)) ) - expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.setConfig( - for: .userProfile, - sessionId: SessionId(.standard, hex: TestConstants.publicKey), - to: .userProfile(ptr) - ) - }) - expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.setConfig( - for: .userGroups, - sessionId: SessionId(.standard, hex: TestConstants.publicKey), - to: .userGroups(ptr) - ) - }) - expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.setConfig( - for: .contacts, - sessionId: SessionId(.standard, hex: TestConstants.publicKey), - to: .contacts(ptr) - ) - }) - expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.setConfig( - for: .convoInfoVolatile, - sessionId: SessionId(.standard, hex: TestConstants.publicKey), - to: .convoInfoVolatile(ptr) - ) - }) + await mockLibSessionCache + .verify { + $0.setConfig( + for: .userProfile, + sessionId: SessionId(.standard, hex: TestConstants.publicKey), + to: .userProfile(ptr) + ) + } + .wasCalled(exactly: 1) + await mockLibSessionCache + .verify { + $0.setConfig( + for: .userGroups, + sessionId: SessionId(.standard, hex: TestConstants.publicKey), + to: .userGroups(ptr) + ) + } + .wasCalled(exactly: 1) + await mockLibSessionCache + .verify { + $0.setConfig( + for: .contacts, + sessionId: SessionId(.standard, hex: TestConstants.publicKey), + to: .contacts(ptr) + ) + } + .wasCalled(exactly: 1) + await mockLibSessionCache + .verify { + $0.setConfig( + for: .convoInfoVolatile, + sessionId: SessionId(.standard, hex: TestConstants.publicKey), + to: .convoInfoVolatile(ptr) + ) + } + .wasCalled(exactly: 1) ptr.deallocate() } // MARK: ---- loads the default states when failing to load config data it("loads the default states when failing to load config data") { - mockLibSessionCache + try await mockLibSessionCache .when { try $0.loadState( for: .any, @@ -1160,45 +1249,53 @@ class ExtensionHelperSpec: AsyncSpec { userSessionId: SessionId(.standard, hex: TestConstants.publicKey), userEd25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)) ) - expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.loadDefaultStateFor( - variant: .userProfile, - sessionId: SessionId(.standard, hex: TestConstants.publicKey), - userEd25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)), - groupEd25519SecretKey: nil - ) - }) - expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.loadDefaultStateFor( - variant: .userGroups, - sessionId: SessionId(.standard, hex: TestConstants.publicKey), - userEd25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)), - groupEd25519SecretKey: nil - ) - }) - expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.loadDefaultStateFor( - variant: .contacts, - sessionId: SessionId(.standard, hex: TestConstants.publicKey), - userEd25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)), - groupEd25519SecretKey: nil - ) - }) - expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.loadDefaultStateFor( - variant: .convoInfoVolatile, - sessionId: SessionId(.standard, hex: TestConstants.publicKey), - userEd25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)), - groupEd25519SecretKey: nil - ) - }) + await mockLibSessionCache + .verify { + $0.loadDefaultStateFor( + variant: .userProfile, + sessionId: SessionId(.standard, hex: TestConstants.publicKey), + userEd25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)), + groupEd25519SecretKey: nil + ) + } + .wasCalled(exactly: 1) + await mockLibSessionCache + .verify { + $0.loadDefaultStateFor( + variant: .userGroups, + sessionId: SessionId(.standard, hex: TestConstants.publicKey), + userEd25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)), + groupEd25519SecretKey: nil + ) + } + .wasCalled(exactly: 1) + await mockLibSessionCache + .verify { + $0.loadDefaultStateFor( + variant: .contacts, + sessionId: SessionId(.standard, hex: TestConstants.publicKey), + userEd25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)), + groupEd25519SecretKey: nil + ) + } + .wasCalled(exactly: 1) + await mockLibSessionCache + .verify { + $0.loadDefaultStateFor( + variant: .convoInfoVolatile, + sessionId: SessionId(.standard, hex: TestConstants.publicKey), + userEd25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)), + groupEd25519SecretKey: nil + ) + } + .wasCalled(exactly: 1) } } // MARK: -- when loading group configs context("when loading group configs") { beforeEach { - mockCrypto + try await mockCrypto .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } .thenReturn(Data([1, 2, 3])) } @@ -1210,8 +1307,8 @@ class ExtensionHelperSpec: AsyncSpec { let configs: [LibSession.Config] = [ .groupKeys(keysPtr, info: ptr, members: ptr), .groupMembers(ptr), .groupInfo(ptr) ] - configs.forEach { config in - mockLibSessionCache + for config in configs { + try await mockLibSessionCache .when { try $0.loadState( for: config.variant, @@ -1231,27 +1328,33 @@ class ExtensionHelperSpec: AsyncSpec { userEd25519SecretKey: [1, 2, 3] ) }.toNot(throwError()) - expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.setConfig( - for: .groupKeys, - sessionId: SessionId(.group, hex: TestConstants.publicKey), - to: .groupKeys(keysPtr, info: ptr, members: ptr) - ) - }) - expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.setConfig( - for: .groupMembers, - sessionId: SessionId(.group, hex: TestConstants.publicKey), - to: .groupMembers(ptr) - ) - }) - expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.setConfig( - for: .groupInfo, - sessionId: SessionId(.group, hex: TestConstants.publicKey), - to: .groupInfo(ptr) - ) - }) + await mockLibSessionCache + .verify { + $0.setConfig( + for: .groupKeys, + sessionId: SessionId(.group, hex: TestConstants.publicKey), + to: .groupKeys(keysPtr, info: ptr, members: ptr) + ) + } + .wasCalled(exactly: 1) + await mockLibSessionCache + .verify { + $0.setConfig( + for: .groupMembers, + sessionId: SessionId(.group, hex: TestConstants.publicKey), + to: .groupMembers(ptr) + ) + } + .wasCalled(exactly: 1) + await mockLibSessionCache + .verify { + $0.setConfig( + for: .groupInfo, + sessionId: SessionId(.group, hex: TestConstants.publicKey), + to: .groupInfo(ptr) + ) + } + .wasCalled(exactly: 1) keysPtr.deallocate() ptr.deallocate() @@ -1265,9 +1368,9 @@ class ExtensionHelperSpec: AsyncSpec { .groupKeys(keysPtr, info: ptr, members: ptr), .groupInfo(ptr) ] - mockCrypto.removeMocksFor { $0.generate(.hash(message: .any)) } - configs.forEach { config in - mockLibSessionCache + await mockCrypto.removeMocksFor { $0.generate(.hash(message: .any)) } + for config in configs { + try await mockLibSessionCache .when { try $0.loadState( for: config.variant, @@ -1278,14 +1381,14 @@ class ExtensionHelperSpec: AsyncSpec { ) } .thenReturn(config) - mockCrypto + try await mockCrypto .when { $0.generate(.hash(message: Array("DumpSalt-\(config.variant)".utf8))) } .thenReturn([0, 1, 2]) } - mockCrypto + try await mockCrypto .when { $0.generate(.hash(message: Array("ConvoIdSalt-03\(TestConstants.publicKey)".utf8))) } .thenReturn([4, 5, 6]) - mockCrypto + try await mockCrypto .when { $0.generate(.hash(message: Array("DumpSalt-\(ConfigDump.Variant.groupMembers)".utf8))) } @@ -1311,7 +1414,7 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- does nothing if it cannot get a dump for the config it("does nothing if it cannot get a dump for the config") { - mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(nil) + try await mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(nil) expect { try extensionHelper.loadGroupConfigStateIfNeeded( @@ -1320,9 +1423,9 @@ class ExtensionHelperSpec: AsyncSpec { userEd25519SecretKey: [1, 2, 3] ) }.toNot(throwError()) - expect(mockLibSessionCache).toNot(call { - $0.setConfig(for: .any, sessionId: .any, to: .any) - }) + await mockLibSessionCache + .verify { $0.setConfig(for: .any, sessionId: .any, to: .any) } + .wasNotCalled() } // MARK: ---- does nothing if the provided public key is not for a group @@ -1334,9 +1437,9 @@ class ExtensionHelperSpec: AsyncSpec { userEd25519SecretKey: [1, 2, 3] ) }.toNot(throwError()) - expect(mockLibSessionCache).toNot(call { - $0.setConfig(for: .any, sessionId: .any, to: .any) - }) + await mockLibSessionCache + .verify { $0.setConfig(for: .any, sessionId: .any, to: .any) } + .wasNotCalled() } } } @@ -1352,14 +1455,14 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: -- when replicating notification settings context("when replicating notification settings") { beforeEach { - mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(nil) - mockFileManager + try await mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(nil) + try await mockFileManager .when { try $0.attributesOfItem(atPath: .any) } .thenReturn([.modificationDate: Date(timeIntervalSince1970: 1234567800)]) - mockCrypto + try await mockCrypto .when { $0.generate(.hash(message: .any)) } .thenReturn([0, 1, 2]) - mockCrypto + try await mockCrypto .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } .thenReturn(Data([1, 2, 3])) } @@ -1377,15 +1480,17 @@ class ExtensionHelperSpec: AsyncSpec { ], replaceExisting: true ) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.createFile(atPath: "tmpFile", contents: Data(base64Encoded: "BAUG")) - }) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - _ = try $0.replaceItem( - atPath: "/test/extensionCache/notificationSettings", - withItemAtPath: "tmpFile" - ) - }) + await mockFileManager + .verify { $0.createFile(atPath: "tmpFile", contents: Data(base64Encoded: "BAUG")) } + .wasCalled(exactly: 1) + await mockFileManager + .verify { + _ = try $0.replaceItem( + atPath: "/test/extensionCache/notificationSettings", + withItemAtPath: "tmpFile" + ) + } + .wasCalled(exactly: 1) } // MARK: ---- excludes values with default settings @@ -1415,20 +1520,22 @@ class ExtensionHelperSpec: AsyncSpec { ], replaceExisting: true ) - expect(mockCrypto).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.generate( - .ciphertextWithXChaCha20( - plaintext: try JSONEncoder(using: dependencies) - .encode(expectedResult), - encKey: [1, 2, 3] + await mockCrypto + .verify { + $0.generate( + .ciphertextWithXChaCha20( + plaintext: try JSONEncoder(using: dependencies) + .encode(expectedResult), + encKey: [1, 2, 3] + ) ) - ) - }) + } + .wasCalled(exactly: 1) } // MARK: ---- does nothing if the settings already exist and we do not want to replace existing it("does nothing if the settings already exist and we do not want to replace existing") { - mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) + try await mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) try? extensionHelper.replicate( settings: [ @@ -1442,17 +1549,18 @@ class ExtensionHelperSpec: AsyncSpec { replaceExisting: false ) - expect(mockFileManager).toNot(call { $0.createFile(atPath: .any, contents: .any) }) - expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) }) + await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any) } + .wasNotCalled() + await mockFileManager + .verify { try $0.replaceItem(atPath: .any, withItemAtPath: .any) } + .wasNotCalled() } // MARK: ---- does nothing if it fails to replicate it("does nothing if it fails to replicate") { - mockCrypto - .when { $0.generate(.ciphertextWithXChaCha20(plaintext: Data([2, 3, 4]), encKey: .any)) } - .thenThrow(TestError.mock) - mockCrypto - .when { $0.generate(.ciphertextWithXChaCha20(plaintext: Data([5, 6, 7]), encKey: .any)) } + try await mockCrypto + .when { $0.generate(.ciphertextWithXChaCha20(plaintext: .any, encKey: .any)) } .thenThrow(TestError.mock) try? extensionHelper.replicate( @@ -1467,8 +1575,12 @@ class ExtensionHelperSpec: AsyncSpec { replaceExisting: true ) - expect(mockFileManager).toNot(call { $0.createFile(atPath: .any, contents: .any) }) - expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) }) + await mockFileManager + .verify { $0.createFile(atPath: .any, contents: .any) } + .wasNotCalled() + await mockFileManager + .verify { try $0.replaceItem(atPath: .any, withItemAtPath: .any) } + .wasNotCalled() } } @@ -1476,8 +1588,8 @@ class ExtensionHelperSpec: AsyncSpec { context("when loading notification settings") { // MARK: ---- loads the data correctly it("loads the data correctly") { - mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3])) - mockCrypto + try await mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3])) + try await mockCrypto .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } .thenReturn( try! JSONEncoder(using: dependencies) @@ -1517,7 +1629,7 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- returns null if there is no file it("returns null if there is no file") { - mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(nil) + try await mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(nil) let result: [String: Preferences.NotificationSettings]? = extensionHelper.loadNotificationSettings( previewType: .nameAndPreview, @@ -1529,8 +1641,8 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- returns null if it fails to decrypt the file it("returns null if it fails to decrypt the file") { - mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3])) - mockCrypto + try await mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3])) + try await mockCrypto .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } .thenReturn(nil) @@ -1544,8 +1656,8 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- returns null if it fails to decode the data it("returns null if it fails to decode the data") { - mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3])) - mockCrypto + try await mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3])) + try await mockCrypto .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } .thenReturn(Data([1, 2, 3])) @@ -1569,17 +1681,17 @@ class ExtensionHelperSpec: AsyncSpec { context("when retrieving the unread message count") { // MARK: ---- returns the count it("returns the count") { - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } .thenReturn(["a"]) - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory( atPath: "/test/extensionCache/conversations/a/unread" ) } .thenReturn(["b", "c", "d", "e", "f"]) - mockCrypto + try await mockCrypto .when { $0.generate(.hash(message: Array("a".utf8) + Array("messageRequest".utf8))) } .thenReturn([3, 4, 5]) let validPaths: [String] = [ @@ -1590,13 +1702,13 @@ class ExtensionHelperSpec: AsyncSpec { "/test/extensionCache/conversations/a/unread/e", "/test/extensionCache/conversations/a/unread/f" ] - validPaths.forEach { path in - mockFileManager.when { $0.fileExists(atPath: path) }.thenReturn(true) + for path in validPaths { + try await mockFileManager.when { $0.fileExists(atPath: path) }.thenReturn(true) } - mockFileManager + try await mockFileManager .when { $0.fileExists(atPath: "/test/extensionCache/conversations/a/unread/030405") } .thenReturn(false) - mockFileManager + try await mockFileManager .when { try $0.attributesOfItem(atPath: .any) } .thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567900)]) @@ -1605,17 +1717,17 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- adds the total from multiple conversations it("adds the total from multiple conversations") { - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } .thenReturn(["a", "b"]) - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory( atPath: "/test/extensionCache/conversations/a/unread" ) } .thenReturn(["c", "d", "e"]) - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory( atPath: "/test/extensionCache/conversations/b/unread" @@ -1632,13 +1744,13 @@ class ExtensionHelperSpec: AsyncSpec { "/test/extensionCache/conversations/b/unread/g", "/test/extensionCache/conversations/b/unread/h" ] - validPaths.forEach { path in - mockFileManager.when { $0.fileExists(atPath: path) }.thenReturn(true) + for path in validPaths { + try await mockFileManager.when { $0.fileExists(atPath: path) }.thenReturn(true) } - mockFileManager + try await mockFileManager .when { $0.fileExists(atPath: "/test/extensionCache/conversations/a/unread/030405") } .thenReturn(false) - mockFileManager + try await mockFileManager .when { try $0.attributesOfItem(atPath: .any) } .thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567900)]) @@ -1647,28 +1759,28 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- only counts message requests as a single item even with multiple unread messages it("only counts message requests as a single item even with multiple unread messages") { - mockFileManager.removeMocksFor { try $0.contentsOfDirectory(atPath: .any) } - mockFileManager + await mockFileManager.removeMocksFor { try $0.contentsOfDirectory(atPath: .any) } + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } .thenReturn(["a"]) - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory( atPath: "/test/extensionCache/conversations/a/unread" ) } .thenReturn(["030405", "b", "c", "d"]) - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory( atPath: "/test/extensionCache/conversations/a/dedupe" ) } .thenReturn(["b1", "b1-legacy", "c1", "c1-legacy", "d1", "d1-legacy"]) - mockCrypto + try await mockCrypto .when { $0.generate(.hash(message: Array("a".utf8) + Array("messageRequest".utf8))) } .thenReturn([3, 4, 5]) - mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) + try await mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) let validPaths: [String] = [ "/test/extensionCache/conversations/a/unread/b", "/test/extensionCache/conversations/a/unread/c", @@ -1680,12 +1792,12 @@ class ExtensionHelperSpec: AsyncSpec { "/test/extensionCache/conversations/a/dedupe/d1", "/test/extensionCache/conversations/a/dedupe/d1-legacy" ] - validPaths.forEach { path in - mockFileManager + for path in validPaths { + try await mockFileManager .when { try $0.attributesOfItem(atPath: path) } .thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567900)]) } - mockFileManager + try await mockFileManager .when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/a/unread/030405") } .thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234560000)]) @@ -1694,10 +1806,10 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- ignores hidden files in the conversations directory it("ignores hidden files in the conversations directory") { - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } .thenReturn([".test", "a"]) - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory( atPath: "/test/extensionCache/conversations/a/unread" @@ -1713,13 +1825,13 @@ class ExtensionHelperSpec: AsyncSpec { "/test/extensionCache/conversations/a/unread/f", "/test/extensionCache/conversations/a/unread/g" ] - validPaths.forEach { path in - mockFileManager.when { $0.fileExists(atPath: path) }.thenReturn(true) + for path in validPaths { + try await mockFileManager.when { $0.fileExists(atPath: path) }.thenReturn(true) } - mockFileManager + try await mockFileManager .when { $0.fileExists(atPath: "/test/extensionCache/conversations/a/unread/030405") } .thenReturn(false) - mockFileManager + try await mockFileManager .when { try $0.attributesOfItem(atPath: .any) } .thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567900)]) @@ -1728,10 +1840,10 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- ignores hidden files in the unread directory it("ignores hidden files in the unread directory") { - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } .thenReturn(["a"]) - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory( atPath: "/test/extensionCache/conversations/a/unread" @@ -1747,13 +1859,13 @@ class ExtensionHelperSpec: AsyncSpec { "/test/extensionCache/conversations/a/unread/f", "/test/extensionCache/conversations/a/unread/g" ] - validPaths.forEach { path in - mockFileManager.when { $0.fileExists(atPath: path) }.thenReturn(true) + for path in validPaths { + try await mockFileManager.when { $0.fileExists(atPath: path) }.thenReturn(true) } - mockFileManager + try await mockFileManager .when { $0.fileExists(atPath: "/test/extensionCache/conversations/a/unread/030405") } .thenReturn(false) - mockFileManager + try await mockFileManager .when { try $0.attributesOfItem(atPath: .any) } .thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567890)]) @@ -1762,30 +1874,30 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- ignores conversations without an unread directory it("ignores conversations without an unread directory") { - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } .thenReturn(["a", "b"]) - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory( atPath: "/test/extensionCache/conversations/a/unread" ) } .thenReturn(["c", "d", "e"]) - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory( atPath: "/test/extensionCache/conversations/b/unread" ) } .thenReturn(["f", "g", "h"]) - mockFileManager + try await mockFileManager .when { $0.fileExists(atPath: "/test/extensionCache/conversations/a/unread") } .thenReturn(true) - mockFileManager + try await mockFileManager .when { $0.fileExists(atPath: "/test/extensionCache/conversations/b/unread") } .thenReturn(false) - mockFileManager + try await mockFileManager .when { try $0.attributesOfItem(atPath: .any) } .thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567900)]) @@ -1794,36 +1906,36 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- ignores message request conversations if the user has seen the message requests stub it("ignores message request conversations if the user has seen the message requests stub") { - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } .thenReturn(["a"]) - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory( atPath: "/test/extensionCache/conversations/a/unread" ) } .thenReturn(["b", "c", "d", "e", "f"]) - mockCrypto + try await mockCrypto .when { $0.generate(.hash(message: Array("a".utf8) + Array("messageRequest".utf8))) } .thenReturn([3, 4, 5]) - mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) - mockFileManager + try await mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) + try await mockFileManager .when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/a/unread/b") } .thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567600)]) - mockFileManager + try await mockFileManager .when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/a/unread/c") } .thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567700)]) - mockFileManager + try await mockFileManager .when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/a/unread/d") } .thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567800)]) - mockFileManager + try await mockFileManager .when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/a/unread/e") } .thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567900)]) - mockFileManager + try await mockFileManager .when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/a/unread/f") } .thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234568000)]) - mockFileManager + try await mockFileManager .when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/a/unread/030405") } .thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567900)]) @@ -1832,7 +1944,7 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- returns null if retrieving the conversation hashes throws it("returns null if retrieving the conversation hashes throws") { - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: .any) } .thenThrow(TestError.mock) @@ -1841,12 +1953,12 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- returns null if retrieving the conversation hashes throws it("returns null if retrieving the conversation hashes throws") { - mockFileManager.removeMocksFor { try $0.contentsOfDirectory(atPath: .any) } - mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) - mockFileManager + await mockFileManager.removeMocksFor { try $0.contentsOfDirectory(atPath: .any) } + try await mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } .thenReturn(["a"]) - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory( atPath: "/test/extensionCache/conversations/a/unread" @@ -1864,11 +1976,11 @@ class ExtensionHelperSpec: AsyncSpec { it("saves the message correctly") { expect { try extensionHelper.saveMessage( - SnodeReceivedMessage( + Network.StorageServer.Message( snode: nil, publicKey: "05\(TestConstants.publicKey)", namespace: .default, - rawMessage: GetMessagesResponse.RawMessage( + rawMessage: Network.StorageServer.GetMessagesResponse.RawMessage( base64EncodedDataString: "TestData", expirationMs: nil, hash: "TestHash", @@ -1886,11 +1998,11 @@ class ExtensionHelperSpec: AsyncSpec { it("saves config messages to the correct path") { expect { try extensionHelper.saveMessage( - SnodeReceivedMessage( + Network.StorageServer.Message( snode: nil, publicKey: "05\(TestConstants.publicKey)", namespace: .configUserProfile, - rawMessage: GetMessagesResponse.RawMessage( + rawMessage: Network.StorageServer.GetMessagesResponse.RawMessage( base64EncodedDataString: "TestData", expirationMs: nil, hash: "TestHash", @@ -1902,23 +2014,25 @@ class ExtensionHelperSpec: AsyncSpec { isMessageRequest: false ) }.toNot(throwError()) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - _ = try $0.replaceItem( - atPath: "/test/extensionCache/conversations/010203/config/010203", - withItemAtPath: "tmpFile" - ) - }) + await mockFileManager + .verify { + _ = try $0.replaceItem( + atPath: "/test/extensionCache/conversations/010203/config/010203", + withItemAtPath: "tmpFile" + ) + } + .wasCalled(exactly: 1) } // MARK: ---- saves unread standard messages to the correct path it("saves unread standard messages to the correct path") { expect { try extensionHelper.saveMessage( - SnodeReceivedMessage( + Network.StorageServer.Message( snode: nil, publicKey: "05\(TestConstants.publicKey)", namespace: .default, - rawMessage: GetMessagesResponse.RawMessage( + rawMessage: Network.StorageServer.GetMessagesResponse.RawMessage( base64EncodedDataString: "TestData", expirationMs: nil, hash: "TestHash", @@ -1930,34 +2044,36 @@ class ExtensionHelperSpec: AsyncSpec { isMessageRequest: false ) }.toNot(throwError()) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - _ = try $0.replaceItem( - atPath: "/test/extensionCache/conversations/010203/unread/010203", - withItemAtPath: "tmpFile" - ) - }) + await mockFileManager + .verify { + _ = try $0.replaceItem( + atPath: "/test/extensionCache/conversations/010203/unread/010203", + withItemAtPath: "tmpFile" + ) + } + .wasCalled(exactly: 1) } // MARK: ---- writes the message request stub file for unread message request messages it("writes the message request stub file for unread message request messages") { - mockCrypto.removeMocksFor { $0.generate(.hash(message: .any)) } - mockCrypto + await mockCrypto.removeMocksFor { $0.generate(.hash(message: .any)) } + try await mockCrypto .when { $0.generate(.hash(message: Array("ConvoIdSalt-05\(TestConstants.publicKey)".utf8))) } .thenReturn([1, 2, 3]) - mockCrypto + try await mockCrypto .when { $0.generate(.hash(message: Array("UnreadMessageSalt-TestHash".utf8))) } .thenReturn([2, 3, 4]) - mockCrypto + try await mockCrypto .when { $0.generate(.hash(message: [1, 2, 3] + Array("messageRequest".utf8))) } .thenReturn([3, 4, 5]) expect { try extensionHelper.saveMessage( - SnodeReceivedMessage( + Network.StorageServer.Message( snode: nil, publicKey: "05\(TestConstants.publicKey)", namespace: .default, - rawMessage: GetMessagesResponse.RawMessage( + rawMessage: Network.StorageServer.GetMessagesResponse.RawMessage( base64EncodedDataString: "TestData", expirationMs: nil, hash: "TestHash", @@ -1970,14 +2086,14 @@ class ExtensionHelperSpec: AsyncSpec { ) }.toNot(throwError()) - let emptyOptions: String = "Optional(__C.NSFileManagerItemReplacementOptions(rawValue: 0))" - expect(mockFileManager - .allCalls { try $0.replaceItem(atPath: .any, withItemAtPath: .any) }? - .map { $0.parameterSummary } - ) - .to(equal([ - "[/test/extensionCache/conversations/010203/unread/030405, tmpFile, nil, \(emptyOptions)]", - "[/test/extensionCache/conversations/010203/unread/020304, tmpFile, nil, \(emptyOptions)]" + let opt: String = "NSFileManagerItemReplacementOptions(rawValue: 0)" + let replaceItemCallInfo: RecordedCallInfo? = await mockFileManager + .verify { try $0.replaceItem(atPath: .any, withItemAtPath: .any) } + .wasCalled(exactly: 2) + + expect(replaceItemCallInfo?.matchingCalls.map { $0.parameterSummary }).to(equal([ + "[\"/test/extensionCache/conversations/010203/unread/030405\", \"tmpFile\", nil, \(opt)]", + "[\"/test/extensionCache/conversations/010203/unread/020304\", \"tmpFile\", nil, \(opt)]" ])) } @@ -1985,11 +2101,11 @@ class ExtensionHelperSpec: AsyncSpec { it("saves read standard messages to the correct path") { expect { try extensionHelper.saveMessage( - SnodeReceivedMessage( + Network.StorageServer.Message( snode: nil, publicKey: "05\(TestConstants.publicKey)", namespace: .default, - rawMessage: GetMessagesResponse.RawMessage( + rawMessage: Network.StorageServer.GetMessagesResponse.RawMessage( base64EncodedDataString: "TestData", expirationMs: nil, hash: "TestHash", @@ -2001,25 +2117,27 @@ class ExtensionHelperSpec: AsyncSpec { isMessageRequest: false ) }.toNot(throwError()) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - try $0.replaceItem( - atPath: "/test/extensionCache/conversations/010203/read/010203", - withItemAtPath: "tmpFile" - ) - }) + await mockFileManager + .verify { + try $0.replaceItem( + atPath: "/test/extensionCache/conversations/010203/read/010203", + withItemAtPath: "tmpFile" + ) + } + .wasCalled(exactly: 1) } // MARK: ---- does nothing when failing to generate a hash it("does nothing when failing to generate a hash") { - mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) + try await mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) expect { try extensionHelper.saveMessage( - SnodeReceivedMessage( + Network.StorageServer.Message( snode: nil, publicKey: "05\(TestConstants.publicKey)", namespace: .default, - rawMessage: GetMessagesResponse.RawMessage( + rawMessage: Network.StorageServer.GetMessagesResponse.RawMessage( base64EncodedDataString: "TestData", expirationMs: nil, hash: "TestHash", @@ -2031,22 +2149,24 @@ class ExtensionHelperSpec: AsyncSpec { isMessageRequest: false ) }.toNot(throwError()) - expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) }) + await mockFileManager + .verify { try $0.replaceItem(atPath: .any, withItemAtPath: .any) } + .wasNotCalled() } // MARK: ---- throws when failing to write the file it("throws when failing to write the file") { - mockFileManager + try await mockFileManager .when { _ = try $0.replaceItem(atPath: .any, withItemAtPath: .any) } .thenThrow(TestError.mock) expect { try extensionHelper.saveMessage( - SnodeReceivedMessage( + Network.StorageServer.Message( snode: nil, publicKey: "05\(TestConstants.publicKey)", namespace: .default, - rawMessage: GetMessagesResponse.RawMessage( + rawMessage: Network.StorageServer.GetMessagesResponse.RawMessage( base64EncodedDataString: "TestData", expirationMs: nil, hash: "TestHash", @@ -2065,38 +2185,18 @@ class ExtensionHelperSpec: AsyncSpec { context("when waiting for messages to be loaded") { // MARK: ---- stops waiting once messages are loaded it("stops waiting once messages are loaded") { - Task { - try? await Task.sleep(for: .milliseconds(10)) - try? await extensionHelper.loadMessages() - } await expect { - await extensionHelper.waitUntilMessagesAreLoaded(timeout: .milliseconds(150)) - }.to(beTrue()) + await extensionHelper.waitUntilMessagesAreLoaded(timeout: .seconds(5)) + }.toEventually(beTrue()) } // MARK: ---- times out if it takes longer than the timeout specified it("times out if it takes longer than the timeout specified") { - await expect { - await extensionHelper.waitUntilMessagesAreLoaded(timeout: .milliseconds(50)) - }.to(beFalse()) - } - - // MARK: ---- does not wait if messages have already been loaded - it("does not wait if messages have already been loaded") { - await expect { try await extensionHelper.loadMessages() }.toNot(throwError()) - await expect { - await extensionHelper.waitUntilMessagesAreLoaded(timeout: .milliseconds(100)) - }.to(beTrue()) - } - - // MARK: ---- waits if messages have already been loaded but we indicate we will load them again - it("waits if messages have already been loaded but we indicate we will load them again") { - await expect { try await extensionHelper.loadMessages() }.toNot(throwError()) + dependencies[feature: .forceSlowDatabaseQueries] = true - extensionHelper.willLoadMessages() await expect { - await extensionHelper.waitUntilMessagesAreLoaded(timeout: .milliseconds(50)) - }.to(beFalse()) + await extensionHelper.waitUntilMessagesAreLoaded(timeout: .milliseconds(100)) + }.toEventually(beFalse()) } } @@ -2116,29 +2216,29 @@ class ExtensionHelperSpec: AsyncSpec { ] beforeEach { - mockValues.forEach { key, value in - mockFileManager + for (key, value) in mockValues { + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: key) } .thenReturn([value]) } - mockCrypto + try await mockCrypto .when { $0.generate(.hash( message: Array("ConvoIdSalt-05\(TestConstants.publicKey)".data(using: .utf8)!) )) } .thenReturn([1, 2, 3]) - mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3])) - mockCrypto + try await mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3])) + try await mockCrypto .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } .thenReturn( try! JSONEncoder(using: dependencies) .encode( - SnodeReceivedMessage( + Network.StorageServer.Message( snode: nil, publicKey: "05\(TestConstants.publicKey)", namespace: .default, - rawMessage: GetMessagesResponse.RawMessage( + rawMessage: Network.StorageServer.GetMessagesResponse.RawMessage( base64EncodedDataString: try! MessageWrapper.wrap( type: .sessionMessage, timestampMs: 1234567890, @@ -2156,14 +2256,14 @@ class ExtensionHelperSpec: AsyncSpec { let dataMessage = SNProtoDataMessage.builder() dataMessage.setBody("Test") content.setDataMessage(try! dataMessage.build()) - mockCrypto + try await mockCrypto .when { $0.generate(.plaintextWithSessionProtocol(ciphertext: .any)) } .thenReturn((try! content.build().serializedData(), "05\(TestConstants.publicKey)")) } // MARK: ---- successfully loads messages it("successfully loads messages") { - await expect { try await extensionHelper.loadMessages() }.toNot(throwError()) + await expect { try await extensionHelper.loadMessages() }.toEventuallyNot(throwError()) let interactions: [Interaction]? = mockStorage.read { try Interaction.fetchAll($0) } expect(interactions?.count).to(equal(1)) @@ -2172,7 +2272,7 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- always tries to load messages from the current users conversation it("always tries to load messages from the current users conversation") { - mockCrypto + try await mockCrypto .when { $0.generate(.hash( message: Array("ConvoIdSalt-05\(TestConstants.publicKey)".data(using: .utf8)!) @@ -2180,108 +2280,53 @@ class ExtensionHelperSpec: AsyncSpec { } .thenReturn(Array(Data(hex: "0000550000"))) - await expect { try await extensionHelper.loadMessages() }.toNot(throwError()) - expect(mockFileManager).to(call(matchingParameters: .all) { - try $0.contentsOfDirectory( - atPath: "/test/extensionCache/conversations/0000550000/config" - ) - }) - expect(mockFileManager).to(call(matchingParameters: .all) { - try $0.contentsOfDirectory( - atPath: "/test/extensionCache/conversations/0000550000/read" - ) - }) - expect(mockFileManager).to(call(matchingParameters: .all) { - try $0.contentsOfDirectory( - atPath: "/test/extensionCache/conversations/0000550000/unread" - ) - }) + await expect { try await extensionHelper.loadMessages() }.toEventuallyNot(throwError()) + await mockFileManager + .verify { + try $0.contentsOfDirectory( + atPath: "/test/extensionCache/conversations/0000550000/config" + ) + } + .wasCalled(exactly: 1) + await mockFileManager + .verify { + try $0.contentsOfDirectory( + atPath: "/test/extensionCache/conversations/0000550000/read" + ) + } + .wasCalled(exactly: 1) + await mockFileManager + .verify { + try $0.contentsOfDirectory( + atPath: "/test/extensionCache/conversations/0000550000/unread" + ) + } + .wasCalled(exactly: 1) } // MARK: ---- loads config messages before other messages it("loads config messages before other messages") { - await expect { try await extensionHelper.loadMessages() }.toNot(throwError()) - - let key: FunctionConsumer.Key = FunctionConsumer.Key( - name: "contentsOfDirectory(atPath:)", - generics: [], - paramCount: 1 - ) - expect(mockFileManager.functionConsumer.calls[key]).to(equal([ - CallDetails( - parameterSummary: "[/test/extensionCache/conversations]", - allParameterSummaryCombinations: [ - ParameterCombination(count: 0, summary: "[]"), - ParameterCombination(count: 1, summary: "[/test/extensionCache/conversations]") - ] - ), - CallDetails( - parameterSummary: "[/test/extensionCache/conversations/010203/config]", - allParameterSummaryCombinations: [ - ParameterCombination(count: 0, summary: "[]"), - ParameterCombination( - count: 1, - summary: "[/test/extensionCache/conversations/010203/config]" - ) - ] - ), - CallDetails( - parameterSummary: "[/test/extensionCache/conversations/010203/read]", - allParameterSummaryCombinations: [ - ParameterCombination(count: 0, summary: "[]"), - ParameterCombination( - count: 1, - summary: "[/test/extensionCache/conversations/010203/read]" - ) - ] - ), - CallDetails( - parameterSummary: "[/test/extensionCache/conversations/010203/unread]", - allParameterSummaryCombinations: [ - ParameterCombination(count: 0, summary: "[]"), - ParameterCombination( - count: 1, - summary: "[/test/extensionCache/conversations/010203/unread]" - ) - ] - ), - CallDetails( - parameterSummary: "[/test/extensionCache/conversations/a/config]", - allParameterSummaryCombinations: [ - ParameterCombination(count: 0, summary: "[]"), - ParameterCombination( - count: 1, - summary: "[/test/extensionCache/conversations/a/config]" - ) - ] - ), - CallDetails( - parameterSummary: "[/test/extensionCache/conversations/a/read]", - allParameterSummaryCombinations: [ - ParameterCombination(count: 0, summary: "[]"), - ParameterCombination( - count: 1, - summary: "[/test/extensionCache/conversations/a/read]" - ) - ] - ), - CallDetails( - parameterSummary: "[/test/extensionCache/conversations/a/unread]", - allParameterSummaryCombinations: [ - ParameterCombination(count: 0, summary: "[]"), - ParameterCombination( - count: 1, - summary: "[/test/extensionCache/conversations/a/unread]" - ) - ] - ) + await expect { try await extensionHelper.loadMessages() }.toEventuallyNot(throwError()) + + let callInfo: RecordedCallInfo? = await mockFileManager + .verify { try $0.contentsOfDirectory(atPath: .any) } + .wasCalled(exactly: 7) + expect(callInfo?.matchingCalls.map { $0.parameterSummary }).to(equal([ + "[\"/test/extensionCache/conversations\"]", + "[\"/test/extensionCache/conversations/010203/config\"]", + "[\"/test/extensionCache/conversations/010203/read\"]", + "[\"/test/extensionCache/conversations/010203/unread\"]", + "[\"/test/extensionCache/conversations/a/config\"]", + "[\"/test/extensionCache/conversations/a/read\"]", + "[\"/test/extensionCache/conversations/a/unread\"]" ])) } // MARK: ---- removes messages from disk it("removes messages from disk") { - mockFileManager.when { try $0.contentsOfDirectory(atPath: .any) }.thenReturn([]) - mockCrypto + mockFileManager.handler.clearCalls() + try await mockFileManager.when { try $0.contentsOfDirectory(atPath: .any) }.thenReturn([]) + try await mockCrypto .when { $0.generate(.hash( message: Array("ConvoIdSalt-05\(TestConstants.publicKey)".data(using: .utf8)!) @@ -2289,40 +2334,34 @@ class ExtensionHelperSpec: AsyncSpec { } .thenReturn(Array(Data(hex: "0000550000"))) - await expect { try await extensionHelper.loadMessages() }.toNot(throwError()) - expect(mockFileManager).to(call(matchingParameters: .all) { - try $0.removeItem( - atPath: "/test/extensionCache/conversations/0000550000/config" - ) - }) - expect(mockFileManager).to(call(matchingParameters: .all) { - try $0.removeItem( - atPath: "/test/extensionCache/conversations/0000550000/read" - ) - }) - expect(mockFileManager).to(call(matchingParameters: .all) { - try $0.removeItem( - atPath: "/test/extensionCache/conversations/0000550000/unread" - ) - }) + await expect { try await extensionHelper.loadMessages() }.toEventuallyNot(throwError()) + let callInfo: RecordedCallInfo? = await mockFileManager + .verify { try $0.removeItem(atPath: .any) } + .wasCalled(exactly: 3) + + expect(callInfo?.matchingCalls.map { $0.parameterSummary }).to(equal([ + "[\"/test/extensionCache/conversations/0000550000/config\"]", + "[\"/test/extensionCache/conversations/0000550000/read\"]", + "[\"/test/extensionCache/conversations/0000550000/unread\"]" + ])) } // MARK: ---- logs when finished it("logs when finished") { await mockLogger.clearLogs() // Clear logs first to make it easier to debug - mockValues.forEach { key, value in - mockFileManager + for key in mockValues.keys { + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: key) } .thenReturn([]) } - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } .thenReturn(["a"]) - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations/a/read") } .thenReturn(["c"]) - await expect { try await extensionHelper.loadMessages() }.toNot(throwError()) + await expect { try await extensionHelper.loadMessages() }.toEventuallyNot(throwError()) await expect { await mockLogger.logs }.toEventually(contain( MockLogger.LogOutput( level: .info, @@ -2344,22 +2383,22 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- logs an error when failing to process a config message it("logs an error when failing to process a config message") { await mockLogger.clearLogs() // Clear logs first to make it easier to debug - mockValues.forEach { key, value in - mockFileManager + for key in mockValues.keys { + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: key) } .thenReturn([]) } - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } .thenReturn(["a"]) - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations/a/config") } .thenReturn(["b"]) - mockCrypto + try await mockCrypto .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } .thenReturn(nil) - await expect { try await extensionHelper.loadMessages() }.toNot(throwError()) + await expect { try await extensionHelper.loadMessages() }.toEventuallyNot(throwError()) await expect { await mockLogger.logs }.toEventually(contain( MockLogger.LogOutput( level: .error, @@ -2397,22 +2436,22 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- logs an error when failing to process a standard message it("logs an error when failing to process a standard message") { await mockLogger.clearLogs() // Clear logs first to make it easier to debug - mockValues.forEach { key, value in - mockFileManager + for key in mockValues.keys { + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: key) } .thenReturn([]) } - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } .thenReturn(["a"]) - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations/a/read") } .thenReturn(["c"]) - mockCrypto + try await mockCrypto .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } .thenReturn(nil) - await expect { try await extensionHelper.loadMessages() }.toNot(throwError()) + await expect { try await extensionHelper.loadMessages() }.toEventuallyNot(throwError()) await expect { await mockLogger.logs }.toEventually(contain( MockLogger.LogOutput( level: .error, @@ -2450,19 +2489,19 @@ class ExtensionHelperSpec: AsyncSpec { // MARK: ---- succeeds even if it fails to remove files after processing it("succeeds even if it fails to remove files after processing") { await mockLogger.clearLogs() // Clear logs first to make it easier to debug - mockValues.forEach { key, value in - mockFileManager + for key in mockValues.keys { + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: key) } .thenReturn([]) } - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } .thenReturn(["a"]) - mockFileManager + try await mockFileManager .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations/a/read") } .thenReturn(["c"]) - mockFileManager.when { try $0.removeItem(atPath: .any) }.thenThrow(TestError.mock) - mockCrypto + try await mockFileManager.when { try $0.removeItem(atPath: .any) }.thenThrow(TestError.mock) + try await mockCrypto .when { $0.generate(.hash( message: Array("ConvoIdSalt-05\(TestConstants.publicKey)".data(using: .utf8)!) @@ -2470,7 +2509,7 @@ class ExtensionHelperSpec: AsyncSpec { } .thenReturn(Array(Data(hex: "0000550000"))) - await expect { try await extensionHelper.loadMessages() }.toNot(throwError()) + await expect { try await extensionHelper.loadMessages() }.toEventuallyNot(throwError()) await expect { await mockLogger.logs }.toEventually(contain( MockLogger.LogOutput( level: .info, diff --git a/SessionMessagingKitTests/_TestUtilities/CustomArgSummaryDescribable+SessionMessagingKit.swift b/SessionMessagingKitTests/_TestUtilities/ArgumentDescribing+SMK.swift similarity index 94% rename from SessionMessagingKitTests/_TestUtilities/CustomArgSummaryDescribable+SessionMessagingKit.swift rename to SessionMessagingKitTests/_TestUtilities/ArgumentDescribing+SMK.swift index fa8fe05502..13e8a44e55 100644 --- a/SessionMessagingKitTests/_TestUtilities/CustomArgSummaryDescribable+SessionMessagingKit.swift +++ b/SessionMessagingKitTests/_TestUtilities/ArgumentDescribing+SMK.swift @@ -3,9 +3,10 @@ import Foundation import SessionMessagingKit import SessionUtilitiesKit +import TestUtilities -extension Job: CustomArgSummaryDescribable { - var customArgSummaryDescribable: String? { +extension Job: @retroactive ArgumentDescribing { + public var summary: String? { switch variant { case .attachmentUpload: guard diff --git a/SessionMessagingKitTests/_TestUtilities/CommonSMKMockExtensions.swift b/SessionMessagingKitTests/_TestUtilities/CommonSMKMockExtensions.swift deleted file mode 100644 index edda4ebd98..0000000000 --- a/SessionMessagingKitTests/_TestUtilities/CommonSMKMockExtensions.swift +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtil -import SessionUIKit -import SessionUtilitiesKit - -@testable import SessionMessagingKit - -extension Message.Destination: Mocked { - static var mock: Message.Destination = .contact(publicKey: "") -} - -extension LibSession.Config: Mocked { - static var mock: LibSession.Config = { - var conf = config_object() - return withUnsafeMutablePointer(to: &conf) { .contacts($0) } - }() -} - -extension ConfigDump.Variant: Mocked { - static var mock: ConfigDump.Variant = .userProfile -} - -extension LibSession.CacheBehaviour: Mocked { - static var mock: LibSession.CacheBehaviour = .skipAutomaticConfigSync -} - -extension LibSession.OpenGroupUrlInfo: Mocked { - static var mock: LibSession.OpenGroupUrlInfo = LibSession.OpenGroupUrlInfo( - threadId: .mock, - server: .mock, - roomToken: .mock, - publicKey: .mock - ) -} - -extension ObservableKey: Mocked { - static var mock: ObservableKey = "mockObservableKey" -} - -extension SessionThread: Mocked { - static var mock: SessionThread = SessionThread( - id: .mock, - variant: .contact, - creationDateTimestamp: 0, - shouldBeVisible: false, - isPinned: false, - messageDraft: nil, - notificationSound: nil, - mutedUntilTimestamp: nil, - onlyNotifyForMentions: false, - markedAsUnread: nil, - pinnedPriority: nil - ) -} - -extension SessionThread.Variant: Mocked { - static var mock: SessionThread.Variant = .contact -} - -extension Interaction.Variant: Mocked { - static var mock: Interaction.Variant = .standardIncoming -} - -extension Interaction: Mocked { - static var mock: Interaction = Interaction( - id: 123456, - serverHash: .mock, - messageUuid: nil, - threadId: .mock, - authorId: .mock, - variant: .mock, - body: .mock, - timestampMs: 1234567890, - receivedAtTimestampMs: 1234567890, - wasRead: false, - hasMention: false, - expiresInSeconds: nil, - expiresStartedAtMs: nil, - linkPreviewUrl: nil, - openGroupServerMessageId: nil, - openGroupWhisper: false, - openGroupWhisperMods: false, - openGroupWhisperTo: nil, - state: .sent, - recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - isProMessage: false - ) -} - -extension VisibleMessage: Mocked { - static var mock: VisibleMessage = VisibleMessage(text: "mock") -} - -extension KeychainStorage.DataKey: Mocked { - static var mock: KeychainStorage.DataKey = .dbCipherKeySpec -} - -extension NotificationCategory: Mocked { - static var mock: NotificationCategory = .incomingMessage -} - -extension NotificationContent: Mocked { - static var mock: NotificationContent = NotificationContent( - threadId: .mock, - threadVariant: .mock, - identifier: .mock, - category: .mock, - applicationState: .any - ) -} - -extension Preferences.NotificationSettings: Mocked { - static var mock: Preferences.NotificationSettings = Preferences.NotificationSettings( - previewType: .mock, - sound: .mock, - mentionsOnly: false, - mutedUntil: nil - ) -} - -extension ImageDataManager.DataSource: Mocked { - static var mock: ImageDataManager.DataSource = ImageDataManager.DataSource.data("mock", Data([1, 2, 3])) -} - -enum MockLibSessionConvertible: Int, Codable, LibSessionConvertibleEnum, Mocked { - typealias LibSessionType = Int - - static var mock: MockLibSessionConvertible = .mockValue - - case mockValue = 0 - - public static var defaultLibSessionValue: LibSessionType { 0 } - public var libSessionValue: LibSessionType { 0 } - - public init(_ libSessionValue: LibSessionType) { - self = .mockValue - } -} - -extension Preferences.Sound: Mocked { - static var mock: Preferences.Sound = .defaultNotificationSound -} - -extension Preferences.NotificationPreviewType: Mocked { - static var mock: Preferences.NotificationPreviewType = .defaultPreviewType -} - -extension Theme: Mocked { - static var mock: Theme = .defaultTheme -} - -extension Theme.PrimaryColor: Mocked { - static var mock: Theme.PrimaryColor = .defaultPrimaryColor -} - -extension ConfigDump: Mocked { - static var mock: ConfigDump = ConfigDump( - variant: .invalid, - sessionId: "", - data: Data(), - timestampMs: 1234567890 - ) -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockCommunityPoller.swift b/SessionMessagingKitTests/_TestUtilities/MockCommunityPoller.swift deleted file mode 100644 index f13ea85853..0000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockCommunityPoller.swift +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Combine - -@testable import SessionMessagingKit - -class MockCommunityPoller: Mock, CommunityPollerType { - var isPolling: Bool { mock() } - var receivedPollResponse: AnyPublisher { mock() } - - func startIfNeeded() { mockNoReturn() } - func stop() { mockNoReturn() } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockCommunityPollerCache.swift b/SessionMessagingKitTests/_TestUtilities/MockCommunityPollerCache.swift index 415417abe1..1b8c7398ff 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockCommunityPollerCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockCommunityPollerCache.swift @@ -1,15 +1,40 @@ // Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. import Foundation +import TestUtilities @testable import SessionMessagingKit -class MockCommunityPollerCache: Mock, CommunityPollerCacheType { - var serversBeingPolled: Set { mock() } - var allPollers: [CommunityPollerType] { mock() } +class MockCommunityPollerManager: CommunityPollerManagerType, Mockable { + nonisolated let handler: MockHandler - func startAllPollers() { mockNoReturn() } - @discardableResult func getOrCreatePoller(for info: CommunityPoller.Info) -> CommunityPollerType { mock(args: [info]) } - func stopAndRemovePoller(for server: String) { mockNoReturn(args: [server]) } - func stopAndRemoveAllPollers() { mockNoReturn() } + required init(handler: MockHandler) { + self.handler = handler + + /// Register `any PollerType` with the `MockFallbackRegistry` so we don't need to explicitly mock `getOrCreatePoller` + MockFallbackRegistry.register( + for: (any PollerType).self, + provider: { MockPoller(handler: .invalid()) } + ) + } + + required init(handlerForBuilder: any MockFunctionHandler) { + self.handler = MockHandler(forwardingHandler: handlerForBuilder) + } + + nonisolated var syncState: CommunityPollerManagerSyncState { handler.mock() } + var serversBeingPolled: Set { get async { handler.mock() } } + var allPollers: [any PollerType] { get async { handler.mock() } } + + func startAllPollers() async { handler.mockNoReturn() } + @discardableResult func getOrCreatePoller(for info: CommunityPoller.Info) async -> any PollerType { + handler.mock(args: [info]) + } + func stopAndRemovePoller(for server: String) async { handler.mockNoReturn(args: [server]) } + func stopAndRemoveAllPollers() async { handler.mockNoReturn() } +} + +extension CommunityPoller.Info: @retroactive Mocked { + public static let any: CommunityPoller.Info = CommunityPoller.Info(server: .any, pollFailureCount: .any) + public static let mock: CommunityPoller.Info = CommunityPoller.Info(server: .mock, pollFailureCount: .mock) } diff --git a/SessionMessagingKitTests/_TestUtilities/MockDisplayPictureCache.swift b/SessionMessagingKitTests/_TestUtilities/MockDisplayPictureCache.swift deleted file mode 100644 index 6e51eb570f..0000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockDisplayPictureCache.swift +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Combine -import SessionUtilitiesKit - -@testable import SessionMessagingKit - -class MockDisplayPictureCache: Mock, DisplayPictureCacheType { - var downloadsToSchedule: Set { - get { return mock() } - set { mockNoReturn(args: [newValue]) } - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockExtensionHelper.swift b/SessionMessagingKitTests/_TestUtilities/MockExtensionHelper.swift index 06a4562e50..cdd94a11e2 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockExtensionHelper.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockExtensionHelper.swift @@ -3,12 +3,23 @@ import Foundation import SessionNetworkingKit import SessionUtilitiesKit +import TestUtilities @testable import SessionMessagingKit -class MockExtensionHelper: Mock, ExtensionHelperType { +class MockExtensionHelper: ExtensionHelperType, Mockable { + public var handler: MockHandler + + required init(handler: MockHandler) { + self.handler = handler + } + + required init(handlerForBuilder: any MockFunctionHandler) { + self.handler = MockHandler(forwardingHandler: handlerForBuilder) + } + func deleteCache() { - mockNoReturn() + handler.mockNoReturn() } // MARK: - User Metadata @@ -18,51 +29,51 @@ class MockExtensionHelper: Mock, ExtensionHelperType { ed25519SecretKey: [UInt8], unreadCount: Int? ) throws { - try mockThrowingNoReturn(args: [sessionId, ed25519SecretKey, unreadCount]) + try handler.mockThrowingNoReturn(args: [sessionId, ed25519SecretKey, unreadCount]) } func loadUserMetadata() -> ExtensionHelper.UserMetadata? { - return mock() + return handler.mock() } // MARK: - Deduping func hasDedupeRecordSinceLastCleared(threadId: String) -> Bool { - return mock(args: [threadId]) + return handler.mock(args: [threadId]) } func dedupeRecordExists(threadId: String, uniqueIdentifier: String) -> Bool { - return mock(args: [threadId, uniqueIdentifier]) + return handler.mock(args: [threadId, uniqueIdentifier]) } func createDedupeRecord(threadId: String, uniqueIdentifier: String) throws { - return try mockThrowing(args: [threadId, uniqueIdentifier]) + return try handler.mockThrowing(args: [threadId, uniqueIdentifier]) } func removeDedupeRecord(threadId: String, uniqueIdentifier: String) throws { - return try mockThrowing(args: [threadId, uniqueIdentifier]) + return try handler.mockThrowing(args: [threadId, uniqueIdentifier]) } func upsertLastClearedRecord(threadId: String) throws { - try mockThrowingNoReturn(args: [threadId]) + try handler.mockThrowingNoReturn(args: [threadId]) } // MARK: - Config Dumps func lastUpdatedTimestamp(for sessionId: SessionId, variant: ConfigDump.Variant) -> TimeInterval { - return mock(args: [sessionId, variant]) + return handler.mock(args: [sessionId, variant]) } func replicate(dump: ConfigDump?, replaceExisting: Bool) { - mockNoReturn(args: [dump, replaceExisting]) + handler.mockNoReturn(args: [dump, replaceExisting]) } - func replicateAllConfigDumpsIfNeeded(userSessionId: SessionId, allDumpSessionIds: Set) { - mockNoReturn(args: [userSessionId, allDumpSessionIds]) + func replicateAllConfigDumpsIfNeeded(userSessionId: SessionId, allDumpSessionIds: Set) async { + handler.mockNoReturn(args: [userSessionId, allDumpSessionIds]) } func refreshDumpModifiedDate(sessionId: SessionId, variant: ConfigDump.Variant) { - mockNoReturn(args: [sessionId, variant]) + handler.mockNoReturn(args: [sessionId, variant]) } func loadUserConfigState( @@ -70,7 +81,7 @@ class MockExtensionHelper: Mock, ExtensionHelperType { userSessionId: SessionId, userEd25519SecretKey: [UInt8] ) { - mockNoReturn(args: [cache, userSessionId, userEd25519SecretKey]) + handler.mockNoReturn(args: [cache, userSessionId, userEd25519SecretKey]) } func loadGroupConfigStateIfNeeded( @@ -78,41 +89,41 @@ class MockExtensionHelper: Mock, ExtensionHelperType { swarmPublicKey: String, userEd25519SecretKey: [UInt8] ) throws -> [ConfigDump.Variant: Bool] { - return mock(args: [cache, swarmPublicKey, userEd25519SecretKey]) + return handler.mock(args: [cache, swarmPublicKey, userEd25519SecretKey]) } // MARK: - Notification Settings func replicate(settings: [String: Preferences.NotificationSettings], replaceExisting: Bool) throws { - try mockThrowingNoReturn(args: [settings, replaceExisting]) + try handler.mockThrowingNoReturn(args: [settings, replaceExisting]) } func loadNotificationSettings( previewType: Preferences.NotificationPreviewType, sound: Preferences.Sound ) -> [String: Preferences.NotificationSettings]? { - return mock(args: [previewType, sound]) + return handler.mock(args: [previewType, sound]) } // MARK: - Messages func unreadMessageCount() -> Int? { - return mock() + return handler.mock() } - func saveMessage(_ message: SnodeReceivedMessage?, threadId: String, isUnread: Bool, isMessageRequest: Bool) throws { - try mockThrowingNoReturn(args: [message, threadId, isUnread, isMessageRequest]) + func saveMessage(_ message: Network.StorageServer.Message?, threadId: String, isUnread: Bool, isMessageRequest: Bool) throws { + try handler.mockThrowingNoReturn(args: [message, threadId, isUnread, isMessageRequest]) } func willLoadMessages() { - mockNoReturn() + handler.mockNoReturn() } func loadMessages() async throws { - try mockThrowingNoReturn() + try handler.mockThrowingNoReturn() } @discardableResult func waitUntilMessagesAreLoaded(timeout: DispatchTimeInterval) async -> Bool { - return mock(args: [timeout]) + return handler.mock(args: [timeout]) } } diff --git a/SessionMessagingKitTests/_TestUtilities/MockGroupPollerCache.swift b/SessionMessagingKitTests/_TestUtilities/MockGroupPollerCache.swift index a9238310e5..e2cef74a42 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockGroupPollerCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockGroupPollerCache.swift @@ -1,12 +1,25 @@ // Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. import Foundation +import TestUtilities @testable import SessionMessagingKit -class MockGroupPollerCache: Mock, GroupPollerCacheType { - func startAllPollers() { mockNoReturn() } - @discardableResult func getOrCreatePoller(for swarmPublicKey: String) -> SwarmPollerType { mock(args: [swarmPublicKey]) } - func stopAndRemovePoller(for swarmPublicKey: String) { mockNoReturn(args: [swarmPublicKey]) } - func stopAndRemoveAllPollers() { mockNoReturn() } +final class MockGroupPollerManager: GroupPollerManagerType, Mockable { + let handler: MockHandler + + required init(handler: MockHandler) { + self.handler = handler + } + + required init(handlerForBuilder: any MockFunctionHandler) { + self.handler = MockHandler(forwardingHandler: handlerForBuilder) + } + + func startAllPollers() { handler.mockNoReturn() } + @discardableResult func getOrCreatePoller(for swarmPublicKey: String) -> any PollerType { + return handler.mock(args: [swarmPublicKey]) + } + func stopAndRemovePoller(for swarmPublicKey: String) { handler.mockNoReturn(args: [swarmPublicKey]) } + func stopAndRemoveAllPollers() { handler.mockNoReturn() } } diff --git a/SessionMessagingKitTests/_TestUtilities/MockImageDataManager.swift b/SessionMessagingKitTests/_TestUtilities/MockImageDataManager.swift index 78693da700..836e17cdfd 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockImageDataManager.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockImageDataManager.swift @@ -2,14 +2,25 @@ import Foundation import SessionUIKit +import TestUtilities @testable import SessionMessagingKit -class MockImageDataManager: Mock, ImageDataManagerType { +class MockImageDataManager: ImageDataManagerType, Mockable { + public var handler: MockHandler + + required init(handler: MockHandler) { + self.handler = handler + } + + required init(handlerForBuilder: any MockFunctionHandler) { + self.handler = MockHandler(forwardingHandler: handlerForBuilder) + } + @discardableResult func load( _ source: ImageDataManager.DataSource ) async -> ImageDataManager.ProcessedImageData? { - return mock(args: [source]) + return handler.mock(args: [source]) } @MainActor @@ -17,22 +28,22 @@ class MockImageDataManager: Mock, ImageDataManagerType { _ source: ImageDataManager.DataSource, onComplete: @MainActor @escaping (ImageDataManager.ProcessedImageData?) -> Void ) { - mockNoReturn(args: [source], untrackedArgs: [onComplete]) + handler.mockNoReturn(args: [source]) } func cacheImage(_ image: UIImage, for identifier: String) async { - mockNoReturn(args: [image, identifier]) + handler.mockNoReturn(args: [image, identifier]) } func cachedImage(identifier: String) async -> ImageDataManager.ProcessedImageData? { - return mock(args: [identifier]) + return handler.mock(args: [identifier]) } func removeImage(identifier: String) async { - mockNoReturn(args: [identifier]) + handler.mockNoReturn(args: [identifier]) } func clearCache() async { - mockNoReturn() + handler.mockNoReturn() } } diff --git a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift index d6d04cce23..6ea73efcca 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift @@ -4,19 +4,30 @@ import Foundation import Combine import SessionUIKit import SessionUtilitiesKit -import GRDB +import TestUtilities @testable import SessionMessagingKit -class MockLibSessionCache: Mock, LibSessionCacheType { - var userSessionId: SessionId { mock() } - var isEmpty: Bool { mock() } - var allDumpSessionIds: Set { mock() } +class MockLibSessionCache: LibSessionCacheType, Mockable { + public var handler: MockHandler + + required init(handler: MockHandler) { + self.handler = handler + } + + required init(handlerForBuilder: any MockFunctionHandler) { + self.handler = MockHandler(forwardingHandler: handlerForBuilder) + } + + var dependencies: Dependencies { handler.erasedDependencies as! Dependencies } + var userSessionId: SessionId { handler.mock() } + var isEmpty: Bool { handler.mock() } + var allDumpSessionIds: Set { handler.mock() } // MARK: - State Management - func loadState(_ db: ObservingDatabase, requestId: String?) { - mockNoReturn(args: [requestId], untrackedArgs: [db]) + func loadState(_ db: ObservingDatabase, userEd25519SecretKey: [UInt8]) throws { + try handler.mockThrowingNoReturn(args: [db, userEd25519SecretKey]) } func loadDefaultStateFor( @@ -25,7 +36,7 @@ class MockLibSessionCache: Mock, LibSessionCacheType { userEd25519SecretKey: [UInt8], groupEd25519SecretKey: [UInt8]? ) { - mockNoReturn(args: [variant, sessionId, userEd25519SecretKey, groupEd25519SecretKey]) + handler.mockNoReturn(args: [variant, sessionId, userEd25519SecretKey, groupEd25519SecretKey]) } func loadState( @@ -35,23 +46,23 @@ class MockLibSessionCache: Mock, LibSessionCacheType { groupEd25519SecretKey: [UInt8]?, cachedData: Data? ) throws -> LibSession.Config { - return try mockThrowing(args: [variant, sessionId, userEd25519SecretKey, groupEd25519SecretKey, cachedData]) + return try handler.mockThrowing(args: [variant, sessionId, userEd25519SecretKey, groupEd25519SecretKey, cachedData]) } func hasConfig(for variant: ConfigDump.Variant, sessionId: SessionId) -> Bool { - return mock(args: [variant, sessionId]) + return handler.mock(args: [variant, sessionId]) } func config(for variant: ConfigDump.Variant, sessionId: SessionId) -> LibSession.Config? { - return mock(args: [variant, sessionId]) + return handler.mock(args: [variant, sessionId]) } func setConfig(for variant: ConfigDump.Variant, sessionId: SessionId, to config: LibSession.Config) { - mockNoReturn(args: [variant, sessionId, config]) + handler.mockNoReturn(args: [variant, sessionId, config]) } func removeConfigs(for sessionId: SessionId) { - mockNoReturn(args: [sessionId]) + handler.mockNoReturn(args: [sessionId]) } func createDump( @@ -60,17 +71,17 @@ class MockLibSessionCache: Mock, LibSessionCacheType { sessionId: SessionId, timestampMs: Int64 ) throws -> ConfigDump? { - return try mockThrowing(args: [config, variant, sessionId, timestampMs]) + return try handler.mockThrowing(args: [config, variant, sessionId, timestampMs]) } // MARK: - Pushes func syncAllPendingPushes(_ db: ObservingDatabase) { - mockNoReturn(untrackedArgs: [db]) + handler.mockNoReturn(args: [db]) } func withCustomBehaviour(_ behaviour: LibSession.CacheBehaviour, for sessionId: SessionId, variant: ConfigDump.Variant?, change: @escaping () throws -> ()) throws { - try mockThrowingNoReturn(args: [behaviour, sessionId, variant], untrackedArgs: [change]) + try handler.mockThrowingNoReturn(args: [behaviour, sessionId, variant, change]) } func performAndPushChange( @@ -79,7 +90,7 @@ class MockLibSessionCache: Mock, LibSessionCacheType { sessionId: SessionId, change: @escaping (LibSession.Config?) throws -> () ) throws { - try mockThrowingNoReturn(args: [variant, sessionId], untrackedArgs: [db, change]) + try handler.mockThrowingNoReturn(args: [db, variant, sessionId, change]) } func perform( @@ -87,11 +98,11 @@ class MockLibSessionCache: Mock, LibSessionCacheType { sessionId: SessionId, change: @escaping (LibSession.Config?) throws -> () ) throws -> LibSession.Mutation { - return try mockThrowing(args: [variant, sessionId], untrackedArgs: [change]) + return try handler.mockThrowing(args: [variant, sessionId, change]) } func pendingPushes(swarmPublicKey: String) throws -> LibSession.PendingPushes { - return mock(args: [swarmPublicKey]) + return handler.mock(args: [swarmPublicKey]) } func createDumpMarkingAsPushed( @@ -99,21 +110,21 @@ class MockLibSessionCache: Mock, LibSessionCacheType { sentTimestamp: Int64, swarmPublicKey: String ) throws -> [ConfigDump] { - return try mockThrowing(args: [data, sentTimestamp, swarmPublicKey]) + return try handler.mockThrowing(args: [data, sentTimestamp, swarmPublicKey]) } func addEvent(_ event: ObservedEvent) { - mockNoReturn(args: [event]) + handler.mockNoReturn(args: [event]) } // MARK: - Config Message Handling func configNeedsDump(_ config: LibSession.Config?) -> Bool { - return mock(args: [config]) + return handler.mock(args: [config]) } func activeHashes(for swarmPublicKey: String) -> [String] { - return mock(args: [swarmPublicKey]) + return handler.mock(args: [swarmPublicKey]) } func mergeConfigMessages( @@ -121,24 +132,30 @@ class MockLibSessionCache: Mock, LibSessionCacheType { messages: [ConfigMessageReceiveJob.Details.MessageInfo], afterMerge: (SessionId, ConfigDump.Variant, LibSession.Config?, Int64, [ObservableKey: Any]) throws -> ConfigDump? ) throws -> [LibSession.MergeResult] { - try mockThrowingNoReturn(args: [swarmPublicKey, messages]) - - /// **Note:** Since `afterMerge` is non-escaping (and we don't want to change it to be so for the purposes of mocking - /// in unit test) we just call it directly instead of storing in `untrackedArgs` - let expectation: MockFunction = getExpectation(args: [swarmPublicKey, messages]) - - guard - expectation.closureCallArgs.count == 4, - let sessionId: SessionId = expectation.closureCallArgs[0] as? SessionId, - let variant: ConfigDump.Variant = expectation.closureCallArgs[1] as? ConfigDump.Variant, - let timestamp: Int64 = expectation.closureCallArgs[3] as? Int64, - let oldState: [ObservableKey: Any] = expectation.closureCallArgs[4] as? [ObservableKey: Any] - else { - return try mockThrowing(args: [swarmPublicKey, messages]) - } - - _ = try afterMerge(sessionId, variant, expectation.closureCallArgs[2] as? LibSession.Config, timestamp, oldState) - return try mockThrowing(args: [swarmPublicKey, messages]) + try handler.mockThrowingNoReturn(args: [swarmPublicKey, messages]) + // TODO: Is this needed???? +// /// **Note:** Since `afterMerge` is non-escaping (and we don't want to change it to be so for the purposes of mocking +// /// in unit test) we just call it directly instead of storing in `untrackedArgs` +// let expectation: handler.MockFunction = getExpectation(args: [swarmPublicKey, messages]) +// handler.recordedCallInfo(for: { +// $0.mergeConfigMessages( +// swarmPublicKey: swarmPublicKey, +// messages: messages, +// afterMerge: { _, _, _, _, _ in }) +// }) +// +// guard +// expectation.closureCallArgs.count == 4, +// let sessionId: SessionId = expectation.closureCallArgs[0] as? SessionId, +// let variant: ConfigDump.Variant = expectation.closureCallArgs[1] as? ConfigDump.Variant, +// let timestamp: Int64 = expectation.closureCallArgs[3] as? Int64, +// let oldState: [ObservableKey: Any] = expectation.closureCallArgs[4] as? [ObservableKey: Any] +// else { +// return try handler.mockThrowing(args: [swarmPublicKey, messages]) +// } +// +// _ = try afterMerge(sessionId, variant, expectation.closureCallArgs[2] as? LibSession.Config, timestamp, oldState) + return try handler.mockThrowing(args: [swarmPublicKey, messages]) } func handleConfigMessages( @@ -146,46 +163,46 @@ class MockLibSessionCache: Mock, LibSessionCacheType { swarmPublicKey: String, messages: [ConfigMessageReceiveJob.Details.MessageInfo] ) throws { - try mockThrowingNoReturn(args: [swarmPublicKey, messages], untrackedArgs: [db]) + try handler.mockThrowingNoReturn(args: [db, swarmPublicKey, messages]) } func unsafeDirectMergeConfigMessage( swarmPublicKey: String, messages: [ConfigMessageReceiveJob.Details.MessageInfo] ) throws { - try mockThrowingNoReturn(args: [swarmPublicKey, messages]) + try handler.mockThrowingNoReturn(args: [swarmPublicKey, messages]) } // MARK: - State Access - var displayName: String? { mock() } + var displayName: String? { handler.mock() } func has(_ key: Setting.BoolKey) -> Bool { - return mock(generics: [Bool.self], args: [key]) + return handler.mock(generics: [Bool.self], args: [key]) } func has(_ key: Setting.EnumKey) -> Bool { - return mock(generics: [Setting.EnumKey.self], args: [key]) + return handler.mock(generics: [Setting.EnumKey.self], args: [key]) } func get(_ key: Setting.BoolKey) -> Bool { - return mock(generics: [Bool.self], args: [key]) + return handler.mock(generics: [Bool.self], args: [key]) } func get(_ key: Setting.EnumKey) -> T? where T : LibSessionConvertibleEnum { - return mock(generics: [T.self], args: [key]) + return handler.mock(generics: [T.self], args: [key]) } func set(_ key: Setting.BoolKey, _ value: Bool?) { - mockNoReturn(generics: [Bool.self], args: [key, value]) + handler.mockNoReturn(generics: [Bool.self], args: [key, value]) } func set(_ key: Setting.EnumKey, _ value: T?) where T : LibSessionConvertibleEnum { - mockNoReturn(generics: [T.self], args: [key, value]) + handler.mockNoReturn(generics: [T.self], args: [key, value]) } func updateProfile(displayName: String, displayPictureUrl: String?, displayPictureEncryptionKey: Data?) throws { - try mockThrowingNoReturn(args: [displayName, displayPictureUrl, displayPictureEncryptionKey]) + try handler.mockThrowingNoReturn(args: [displayName, displayPictureUrl, displayPictureEncryptionKey]) } func canPerformChange( @@ -193,7 +210,7 @@ class MockLibSessionCache: Mock, LibSessionCacheType { threadVariant: SessionThread.Variant, changeTimestampMs: Int64 ) -> Bool { - return mock(args: [threadId, threadVariant, changeTimestampMs]) + return handler.mock(args: [threadId, threadVariant, changeTimestampMs]) } func conversationInConfig( @@ -202,7 +219,7 @@ class MockLibSessionCache: Mock, LibSessionCacheType { visibleOnly: Bool, openGroupUrlInfo: LibSession.OpenGroupUrlInfo? ) -> Bool { - return mock(args: [threadId, threadVariant, visibleOnly, openGroupUrlInfo]) + return handler.mock(args: [threadId, threadVariant, visibleOnly, openGroupUrlInfo]) } func conversationDisplayName( @@ -213,7 +230,7 @@ class MockLibSessionCache: Mock, LibSessionCacheType { openGroupName: String?, openGroupUrlInfo: LibSession.OpenGroupUrlInfo? ) -> String { - return mock(args: [threadId, threadVariant, contactProfile, visibleMessage, openGroupName, openGroupUrlInfo]) + return handler.mock(args: [threadId, threadVariant, contactProfile, visibleMessage, openGroupName, openGroupUrlInfo]) } func conversationLastRead( @@ -221,14 +238,14 @@ class MockLibSessionCache: Mock, LibSessionCacheType { threadVariant: SessionThread.Variant, openGroupUrlInfo: LibSession.OpenGroupUrlInfo? ) -> Int64? { - return mock(args: [threadId, threadVariant, openGroupUrlInfo]) + return handler.mock(args: [threadId, threadVariant, openGroupUrlInfo]) } func isMessageRequest( threadId: String, threadVariant: SessionThread.Variant ) -> Bool { - return mock(args: [threadId, threadVariant]) + return handler.mock(args: [threadId, threadVariant]) } func pinnedPriority( @@ -236,7 +253,7 @@ class MockLibSessionCache: Mock, LibSessionCacheType { threadVariant: SessionThread.Variant, openGroupUrlInfo: LibSession.OpenGroupUrlInfo? ) -> Int32 { - return mock(args: [threadId, threadVariant, openGroupUrlInfo]) + return handler.mock(args: [threadId, threadVariant, openGroupUrlInfo]) } func notificationSettings( @@ -244,22 +261,22 @@ class MockLibSessionCache: Mock, LibSessionCacheType { threadVariant: SessionThread.Variant, openGroupUrlInfo: LibSession.OpenGroupUrlInfo? ) -> Preferences.NotificationSettings { - return mock(args: [threadId, threadVariant, openGroupUrlInfo]) + return handler.mock(args: [threadId, threadVariant, openGroupUrlInfo]) } func disappearingMessagesConfig( threadId: String, threadVariant: SessionThread.Variant ) -> DisappearingMessagesConfiguration? { - return mock(args: [threadId, threadVariant]) + return handler.mock(args: [threadId, threadVariant]) } func isContactBlocked(contactId: String) -> Bool { - return mock(args: [contactId]) + return handler.mock(args: [contactId]) } func isContactApproved(contactId: String) -> Bool { - return mock(args: [contactId]) + return handler.mock(args: [contactId]) } func profile( @@ -268,80 +285,87 @@ class MockLibSessionCache: Mock, LibSessionCacheType { threadVariant: SessionThread.Variant?, visibleMessage: VisibleMessage? ) -> Profile? { - return mock(args: [contactId, threadId, threadVariant, visibleMessage]) + return handler.mock(args: [contactId, threadId, threadVariant, visibleMessage]) } func displayPictureUrl(threadId: String, threadVariant: SessionThread.Variant) -> String? { - return mock(args: [threadId, threadVariant]) + return handler.mock(args: [threadId, threadVariant]) } func hasCredentials(groupSessionId: SessionId) -> Bool { - return mock(args: [groupSessionId]) + return handler.mock(args: [groupSessionId]) } func secretKey(groupSessionId: SessionId) -> [UInt8]? { - return mock(args: [groupSessionId]) + return handler.mock(args: [groupSessionId]) } func isAdmin(groupSessionId: SessionId) -> Bool { - return mock(args: [groupSessionId]) + return handler.mock(args: [groupSessionId]) } func loadAdminKey( groupIdentitySeed: Data, groupSessionId: SessionId ) throws { - try mockThrowingNoReturn(args: [groupIdentitySeed, groupSessionId]) + try handler.mockThrowingNoReturn(args: [groupIdentitySeed, groupSessionId]) } func markAsInvited(groupSessionIds: [String]) throws { - try mockThrowingNoReturn(args: [groupSessionIds]) + try handler.mockThrowingNoReturn(args: [groupSessionIds]) } func markAsKicked(groupSessionIds: [String]) throws { - try mockThrowingNoReturn(args: [groupSessionIds]) + try handler.mockThrowingNoReturn(args: [groupSessionIds]) } func wasKickedFromGroup(groupSessionId: SessionId) -> Bool { - return mock(args: [groupSessionId]) + return handler.mock(args: [groupSessionId]) } func groupName(groupSessionId: SessionId) -> String? { - return mock(args: [groupSessionId]) + return handler.mock(args: [groupSessionId]) } func groupIsDestroyed(groupSessionId: SessionId) -> Bool { - return mock(args: [groupSessionId]) + return handler.mock(args: [groupSessionId]) } func groupDeleteBefore(groupSessionId: SessionId) -> TimeInterval? { - return mock(args: [groupSessionId]) + return handler.mock(args: [groupSessionId]) } func groupDeleteAttachmentsBefore(groupSessionId: SessionId) -> TimeInterval? { - return mock(args: [groupSessionId]) + return handler.mock(args: [groupSessionId]) + } + + func authData(groupSessionId: SessionId) -> GroupAuthData { + return handler.mock(args: [groupSessionId]) } } // MARK: - Convenience -extension Mock where T == LibSessionCacheType { - func defaultInitialSetup(configs: [ConfigDump.Variant: LibSession.Config?] = [:]) { +extension MockLibSessionCache { + func defaultInitialSetup(configs: [ConfigDump.Variant: LibSession.Config?] = [:]) async throws { let userSessionId: SessionId = SessionId(.standard, hex: TestConstants.publicKey) - configs.forEach { key, value in + for (key, value) in configs { switch value { case .none: break - case .some(let config): self.when { $0.config(for: key, sessionId: .any) }.thenReturn(config) + case .some(let config): + try await self + .when { $0.config(for: key, sessionId: .any) } + .thenReturn(config) } } - self.when { $0.isEmpty }.thenReturn(false) - self.when { $0.userSessionId }.thenReturn(userSessionId) - self.when { $0.setConfig(for: .any, sessionId: .any, to: .any) }.thenReturn(()) - self.when { $0.removeConfigs(for: .any) }.thenReturn(()) - self.when { $0.hasConfig(for: .any, sessionId: .any) }.thenReturn(true) - self + try await self.when { $0.isEmpty }.thenReturn(false) + try await self.when { $0.userSessionId }.thenReturn(userSessionId) + try await self.when { $0.setConfig(for: .any, sessionId: .any, to: .any) }.thenReturn(()) + try await self.when { $0.removeConfigs(for: .any) }.thenReturn(()) + try await self.when { $0.hasConfig(for: .any, sessionId: .any) }.thenReturn(true) + try await self .when { $0.loadDefaultStateFor( variant: .any, @@ -351,36 +375,36 @@ extension Mock where T == LibSessionCacheType { ) } .thenReturn(()) - self + try await self .when { try $0.pendingPushes(swarmPublicKey: .any) } .thenReturn(LibSession.PendingPushes()) - self.when { $0.configNeedsDump(.any) }.thenReturn(false) - self.when { $0.activeHashes(for: .any) }.thenReturn([]) - self + try await self.when { $0.configNeedsDump(.any) }.thenReturn(false) + try await self.when { $0.activeHashes(for: .any) }.thenReturn([]) + try await self .when { try $0.createDump(config: .any, for: .any, sessionId: .any, timestampMs: .any) } .thenReturn(nil) - self + try await self .when { try $0.withCustomBehaviour(.any, for: .any, variant: .any, change: { }) } - .then { args, untrackedArgs in - let callback: (() throws -> Void)? = (untrackedArgs[test: 0] as? () throws -> Void) + .then { args in + let callback: (() throws -> Void)? = (args[test: 3] as? () throws -> Void) try? callback?() } .thenReturn(()) - self + try await self .when { try $0.performAndPushChange(.any, for: .any, sessionId: .any, change: { _ in }) } - .then { args, untrackedArgs in - let callback: ((LibSession.Config?) throws -> Void)? = (untrackedArgs[test: 1] as? (LibSession.Config?) throws -> Void) + .then { args in + let callback: ((LibSession.Config?) throws -> Void)? = (args[test: 3] as? (LibSession.Config?) throws -> Void) - switch configs[(args[test: 0] as? ConfigDump.Variant ?? .invalid)] { + switch configs[(args[test: 1] as? ConfigDump.Variant ?? .invalid)] { case .none: break case .some(let config): try? callback?(config) } } .thenReturn(()) - self + try await self .when { try $0.perform(for: .any, sessionId: .any, change: { _ in }) } - .then { args, untrackedArgs in - let callback: ((LibSession.Config?) throws -> Void)? = (untrackedArgs[test: 0] as? (LibSession.Config?) throws -> Void) + .then { args in + let callback: ((LibSession.Config?) throws -> Void)? = (args[test: 2] as? (LibSession.Config?) throws -> Void) switch configs[(args[test: 0] as? ConfigDump.Variant ?? .invalid)] { case .none: break @@ -388,7 +412,7 @@ extension Mock where T == LibSessionCacheType { } } .thenReturn(nil) - self + try await self .when { try $0.createDumpMarkingAsPushed( data: .any, @@ -397,7 +421,7 @@ extension Mock where T == LibSessionCacheType { ) } .thenReturn([]) - self + try await self .when { $0.conversationInConfig( threadId: .any, @@ -407,7 +431,7 @@ extension Mock where T == LibSessionCacheType { ) } .thenReturn(true) - self + try await self .when { $0.conversationLastRead( threadId: .any, @@ -416,48 +440,61 @@ extension Mock where T == LibSessionCacheType { ) } .thenReturn(nil) - self + try await self .when { $0.canPerformChange(threadId: .any, threadVariant: .any, changeTimestampMs: .any) } .thenReturn(true) - self + try await self .when { $0.isMessageRequest(threadId: .any, threadVariant: .any) } .thenReturn(false) - self + try await self .when { $0.pinnedPriority(threadId: .any, threadVariant: .any, openGroupUrlInfo: .any) } .thenReturn(LibSession.defaultNewThreadPriority) - self + try await self .when { $0.disappearingMessagesConfig(threadId: .any, threadVariant: .any) } .thenReturn(nil) - self.when { $0.isContactBlocked(contactId: .any) }.thenReturn(false) - self + try await self.when { $0.isContactBlocked(contactId: .any) }.thenReturn(false) + try await self .when { $0.profile(contactId: .any, threadId: .any, threadVariant: .any, visibleMessage: .any) } .thenReturn(Profile(id: "TestProfileId", name: "TestProfileName")) - self.when { $0.hasCredentials(groupSessionId: .any) }.thenReturn(true) - self.when { $0.secretKey(groupSessionId: .any) }.thenReturn(nil) - self.when { $0.isAdmin(groupSessionId: .any) }.thenReturn(true) - self.when { try $0.loadAdminKey(groupIdentitySeed: .any, groupSessionId: .any) }.thenReturn(()) - self.when { try $0.markAsKicked(groupSessionIds: .any) }.thenReturn(()) - self.when { try $0.markAsInvited(groupSessionIds: .any) }.thenReturn(()) - self.when { $0.wasKickedFromGroup(groupSessionId: .any) }.thenReturn(false) - self.when { $0.groupName(groupSessionId: .any) }.thenReturn("TestGroupName") - self.when { $0.groupIsDestroyed(groupSessionId: .any) }.thenReturn(false) - self.when { $0.groupDeleteBefore(groupSessionId: .any) }.thenReturn(nil) - self.when { $0.groupDeleteAttachmentsBefore(groupSessionId: .any) }.thenReturn(nil) - self.when { $0.get(.any) }.thenReturn(false) - self.when { $0.get(.any) }.thenReturn(MockLibSessionConvertible.mock) - self.when { $0.get(.any) }.thenReturn(Preferences.Sound.defaultNotificationSound) - self.when { $0.get(.any) }.thenReturn(Preferences.NotificationPreviewType.defaultPreviewType) - self.when { $0.get(.any) }.thenReturn(Theme.defaultTheme) - self.when { $0.get(.any) }.thenReturn(Theme.PrimaryColor.defaultPrimaryColor) - self.when { $0.set(.any, true) }.thenReturn(()) - self.when { $0.set(.any, false) }.thenReturn(()) - self.when { $0.set(.defaultNotificationSound, Preferences.Sound.mock) }.thenReturn(()) - self.when { $0.set(.preferencesNotificationPreviewType, Preferences.NotificationPreviewType.mock) }.thenReturn(()) - self.when { $0.set(.theme, Theme.mock) }.thenReturn(()) - self.when { $0.set(.themePrimaryColor, Theme.PrimaryColor.mock) }.thenReturn(()) - self.when { $0.addEvent(.any) }.thenReturn(()) - self + try await self.when { $0.hasCredentials(groupSessionId: .any) }.thenReturn(true) + try await self.when { $0.secretKey(groupSessionId: .any) }.thenReturn(nil) + try await self.when { $0.isAdmin(groupSessionId: .any) }.thenReturn(true) + try await self.when { try $0.loadAdminKey(groupIdentitySeed: .any, groupSessionId: .any) }.thenReturn(()) + try await self.when { try $0.markAsKicked(groupSessionIds: .any) }.thenReturn(()) + try await self.when { try $0.markAsInvited(groupSessionIds: .any) }.thenReturn(()) + try await self.when { $0.wasKickedFromGroup(groupSessionId: .any) }.thenReturn(false) + try await self.when { $0.groupName(groupSessionId: .any) }.thenReturn("TestGroupName") + try await self.when { $0.groupIsDestroyed(groupSessionId: .any) }.thenReturn(false) + try await self.when { $0.groupDeleteBefore(groupSessionId: .any) }.thenReturn(nil) + try await self.when { $0.groupDeleteAttachmentsBefore(groupSessionId: .any) }.thenReturn(nil) + try await self.when { $0.get(.any) }.thenReturn(false) + try await self.when { $0.get(.any) }.thenReturn(MockLibSessionConvertible.any) + try await self.when { $0.get(.any) }.thenReturn(Preferences.Sound.any) + try await self.when { $0.get(.any) }.thenReturn(Preferences.NotificationPreviewType.any) + try await self.when { $0.get(.any) }.thenReturn(Theme.any) + try await self.when { $0.get(.any) }.thenReturn(Theme.PrimaryColor.any) + try await self.when { $0.set(.any, true) }.thenReturn(()) + try await self.when { $0.set(.any, false) }.thenReturn(()) + try await self.when { $0.set(.defaultNotificationSound, Preferences.Sound.any) }.thenReturn(()) + try await self + .when { $0.set(.preferencesNotificationPreviewType, Preferences.NotificationPreviewType.any) } + .thenReturn(()) + try await self.when { $0.set(.theme, Theme.any) }.thenReturn(()) + try await self.when { $0.set(.themePrimaryColor, Theme.PrimaryColor.any) }.thenReturn(()) + try await self.when { $0.addEvent(.any) }.thenReturn(()) + try await self .when { $0.displayPictureUrl(threadId: .any, threadVariant: .any) } .thenReturn(nil) + try await self + .when { $0.authData(groupSessionId: .any) } + .thenReturn(GroupAuthData( + groupIdentityPrivateKey: Data([ + 1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8, + 1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8, + 1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8, + 1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8 + ]), + authData: nil + )) } } diff --git a/SessionMessagingKitTests/_TestUtilities/MockNotificationsManager.swift b/SessionMessagingKitTests/_TestUtilities/MockNotificationsManager.swift index d73ce6685d..1bb2c369e5 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockNotificationsManager.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockNotificationsManager.swift @@ -5,28 +5,39 @@ import Combine import GRDB import SessionMessagingKit import SessionUtilitiesKit +import TestUtilities -public class MockNotificationsManager: Mock, NotificationsManagerType { - public required init(using dependencies: Dependencies) { - super.init() - - mockNoReturn(untrackedArgs: [dependencies]) +class MockNotificationsManager: NotificationsManagerType, Mockable { + let handler: MockHandler + var dependencies: Dependencies { handler.erasedDependencies as! Dependencies } + + required init(handler: MockHandler) { + self.handler = handler } - internal required init(functionHandler: MockFunctionHandler? = nil, initialSetup: ((Mock) -> ())? = nil) { - super.init(functionHandler: functionHandler, initialSetup: initialSetup) + required init(handlerForBuilder: any MockFunctionHandler) { + self.handler = MockHandler(forwardingHandler: handlerForBuilder) + } + + public required init(using dependencies: Dependencies) { + handler = MockHandler( + dummyProvider: { _ in MockNotificationsManager(handler: .invalid()) }, + erasedDependenciesKey: nil, + using: dependencies + ) + handler.mockNoReturn() } public func setDelegate(_ delegate: (any UNUserNotificationCenterDelegate)?) { - mockNoReturn(args: [delegate]) + handler.mockNoReturn(args: [delegate]) } public func registerSystemNotificationSettings() -> AnyPublisher { - return mock() + return handler.mock() } public func settings(threadId: String?, threadVariant: SessionThread.Variant) -> Preferences.NotificationSettings { - return mock(args: [threadId, threadVariant]) + return handler.mock(args: [threadId, threadVariant]) } public func updateSettings( @@ -35,18 +46,18 @@ public class MockNotificationsManager: Mock, Notificat mentionsOnly: Bool, mutedUntil: TimeInterval? ) { - return mock(args: [threadId, threadVariant, mentionsOnly, mutedUntil]) + return handler.mock(args: [threadId, threadVariant, mentionsOnly, mutedUntil]) } public func notificationUserInfo( threadId: String, threadVariant: SessionThread.Variant - ) -> [String: Any] { - return mock(args: [threadId, threadVariant]) + ) -> [String: AnyHashable] { + return handler.mock(args: [threadId, threadVariant]) } public func notificationShouldPlaySound(applicationState: UIApplication.State) -> Bool { - return mock(args: [applicationState]) + return handler.mock(args: [applicationState]) } public func notifyForFailedSend( @@ -54,11 +65,11 @@ public class MockNotificationsManager: Mock, Notificat threadVariant: SessionThread.Variant, applicationState: UIApplication.State ) { - mockNoReturn(args: [threadId, threadVariant, applicationState]) + handler.mockNoReturn(args: [threadId, threadVariant, applicationState]) } public func scheduleSessionNetworkPageLocalNotifcation(force: Bool) { - mockNoReturn(args: [force]) + handler.mockNoReturn(args: [force]) } public func addNotificationRequest( @@ -66,29 +77,29 @@ public class MockNotificationsManager: Mock, Notificat notificationSettings: Preferences.NotificationSettings, extensionBaseUnreadCount: Int? ) { - mockNoReturn(args: [content, notificationSettings, extensionBaseUnreadCount]) + handler.mockNoReturn(args: [content, notificationSettings, extensionBaseUnreadCount]) } public func cancelNotifications(identifiers: [String]) { - mockNoReturn(args: [identifiers]) + handler.mockNoReturn(args: [identifiers]) } public func clearAllNotifications() { - mockNoReturn() + handler.mockNoReturn() } } // MARK: - Convenience -extension Mock where T == NotificationsManagerType { - func defaultInitialSetup() { - self +extension MockNotificationsManager { + func defaultInitialSetup() async throws { + try await self .when { $0.notificationUserInfo(threadId: .any, threadVariant: .any) } .thenReturn([:]) - self + try await self .when { $0.notificationShouldPlaySound(applicationState: .any) } .thenReturn(false) - self + try await self .when { $0.addNotificationRequest( content: .any, @@ -97,10 +108,10 @@ extension Mock where T == NotificationsManagerType { ) } .thenReturn(()) - self + try await self .when { $0.cancelNotifications(identifiers: .any) } .thenReturn(()) - self + try await self .when { $0.settings(threadId: .any, threadVariant: .any) } .thenReturn( Preferences.NotificationSettings( @@ -110,7 +121,7 @@ extension Mock where T == NotificationsManagerType { mutedUntil: nil ) ) - self + try await self .when { $0.updateSettings( threadId: .any, diff --git a/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift b/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift deleted file mode 100644 index 191d60c4d0..0000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Combine -import SessionUtilitiesKit - -@testable import SessionMessagingKit - -class MockOGMCache: Mock, OGMCacheType { - var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error> { - mock() - } - - var pendingChanges: [OpenGroupManager.PendingChange] { - get { return mock() } - set { mockNoReturn(args: [newValue]) } - } - - func getLastSuccessfulCommunityPollTimestamp() -> TimeInterval { - return mock() - } - - func setLastSuccessfulCommunityPollTimestamp(_ timestamp: TimeInterval) { - mockNoReturn(args: [timestamp]) - } - - func setDefaultRoomInfo(_ info: [OpenGroupManager.DefaultRoomInfo]) { - mockNoReturn(args: [info]) - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockOpenGroupManager.swift b/SessionMessagingKitTests/_TestUtilities/MockOpenGroupManager.swift new file mode 100644 index 0000000000..18e61ac6a9 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockOpenGroupManager.swift @@ -0,0 +1,165 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import SessionNetworkingKit +import SessionUtilitiesKit +import TestUtilities + +@testable import SessionMessagingKit + +actor MockOpenGroupManager: OpenGroupManagerType, Mockable { + nonisolated let handler: MockHandler + var dependencies: Dependencies { handler.erasedDependencies as! Dependencies } + nonisolated var syncState: OpenGroupManagerSyncState { handler.mock() } + nonisolated var defaultRooms: AsyncStream<[OpenGroupManager.DefaultRoomInfo]> { handler.mock() } + var pendingChanges: [OpenGroupManager.PendingChange] { handler.mock() } + + internal init(handler: MockHandler) { + self.handler = handler + } + + internal init(handlerForBuilder: any MockFunctionHandler) { + self.handler = MockHandler(forwardingHandler: handlerForBuilder) + } + + nonisolated func hasExistingOpenGroup( + _ db: ObservingDatabase, + roomToken: String, + server: String, + publicKey: String + ) -> Bool { + return handler.mock(args: [db, roomToken, server, publicKey]) + } + + nonisolated func add( + _ db: ObservingDatabase, + roomToken: String, + server: String, + publicKey: String, + forceVisible: Bool + ) -> Bool { + return handler.mock(args: [db, roomToken, server, publicKey, forceVisible]) + } + + nonisolated func performInitialRequestsAfterAdd( + queue: DispatchQueue, + successfullyAddedGroup: Bool, + roomToken: String, + server: String, + publicKey: String + ) -> AnyPublisher { + return handler.mock(args: [queue, successfullyAddedGroup, roomToken, server, publicKey]) + } + + nonisolated func delete( + _ db: ObservingDatabase, + openGroupId: String, + skipLibSessionUpdate: Bool + ) throws { + try handler.mockThrowingNoReturn(args: [db, openGroupId, skipLibSessionUpdate]) + } + + func setDefaultRoomInfo(_ info: [OpenGroupManager.DefaultRoomInfo]) async { + handler.mockNoReturn(args: [info]) + } + + func getLastSuccessfulCommunityPollTimestamp() -> TimeInterval { + return handler.mock() + } + + func setLastSuccessfulCommunityPollTimestamp(_ timestamp: TimeInterval) { + handler.mockNoReturn(args: [timestamp]) + } + + nonisolated func handleCapabilities( + _ db: ObservingDatabase, + capabilities: Network.SOGS.CapabilitiesResponse, + on server: String + ) { + handler.mockNoReturn(args: [db, capabilities, server]) + } + + nonisolated func handlePollInfo( + _ db: ObservingDatabase, + pollInfo: Network.SOGS.RoomPollInfo, + publicKey maybePublicKey: String?, + for roomToken: String, + on server: String + ) throws { + try handler.mockThrowingNoReturn(args: [db, pollInfo, maybePublicKey, roomToken, server]) + } + + nonisolated func handleMessages( + _ db: ObservingDatabase, + messages: [Network.SOGS.Message], + for roomToken: String, + on server: String + ) -> [MessageReceiver.InsertedInteractionInfo?] { + return handler.mock(args: [db, messages, roomToken, server]) + } + + nonisolated func handleDirectMessages( + _ db: ObservingDatabase, + messages: [Network.SOGS.DirectMessage], + fromOutbox: Bool, + on server: String + ) -> [MessageReceiver.InsertedInteractionInfo?] { + return handler.mock(args: [db, messages, fromOutbox, server]) + } + + nonisolated func addPendingReaction( + emoji: String, + id: Int64, + in roomToken: String, + on server: String, + type: OpenGroupManager.PendingChange.ReactAction + ) -> OpenGroupManager.PendingChange { + return handler.mock(args: [emoji, id, roomToken, server, type]) + } + + func updatePendingChange(_ pendingChange: OpenGroupManager.PendingChange, seqNo: Int64?) { + return handler.mock(args: [pendingChange, seqNo]) + } + + func removePendingChange(_ pendingChange: OpenGroupManager.PendingChange) { + return handler.mockNoReturn(args: [pendingChange]) + } + + nonisolated func doesOpenGroupSupport( + _ db: ObservingDatabase, + capability: Capability.Variant, + on server: String? + ) -> Bool { + return handler.mock(args: [db, capability, server]) + } + + nonisolated func isUserModeratorOrAdmin( + _ db: ObservingDatabase, + publicKey: String, + for roomToken: String?, + on server: String?, + currentUserSessionIds: Set + ) -> Bool { + return handler.mock(args: [db, publicKey, roomToken, server, currentUserSessionIds]) + } +} + +// MARK: - Convenience + +extension MockOpenGroupManager { + func defaultInitialSetup() async throws { + try await self.when { await $0.pendingChanges }.thenReturn([]) + try await self + .when { $0.syncState } + .thenReturn( + OpenGroupManagerSyncState( + pendingChanges: [], + using: dependencies + ) + ) + try await self.when { await $0.getLastSuccessfulCommunityPollTimestamp() }.thenReturn(0) + try await self.when { await $0.setDefaultRoomInfo(.any) }.thenReturn(()) + try await self.when { $0.handleCapabilities(.any, capabilities: .any, on: .any) }.thenReturn(()) + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockPoller.swift b/SessionMessagingKitTests/_TestUtilities/MockPoller.swift index cfc5968bd2..9b5f537939 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockPoller.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockPoller.swift @@ -4,79 +4,90 @@ import Foundation import Combine import SessionNetworkingKit import SessionUtilitiesKit +import TestUtilities @testable import SessionMessagingKit -extension PollerDestination: Mocked { static var mock: PollerDestination { .swarm(TestConstants.publicKey) } } - -class MockPoller: Mock, PollerType { +actor MockPoller: PollerType, Mockable { typealias PollResponse = Void - var pollerQueue: DispatchQueue { DispatchQueue.main } - var pollerName: String { mock() } - var pollerDestination: PollerDestination { mock() } - var logStartAndStopCalls: Bool { mock() } - var receivedPollResponse: AnyPublisher { mock() } - var isPolling: Bool { - get { mock() } - set { mockNoReturn(args: [newValue]) } - } + nonisolated let handler: MockHandler + + var dependencies: Dependencies { handler.erasedDependencies as! Dependencies } + var dependenciesKey: Dependencies.Key? { handler.erasedDependenciesKey as? Dependencies.Key } + var pollerName: String { handler.mock() } + var destination: PollerDestination { handler.mock() } + var logStartAndStopCalls: Bool { handler.mock() } + nonisolated var receivedPollResponse: AsyncStream { handler.mock() } var pollCount: Int { - get { mock() } - set { mockNoReturn(args: [newValue]) } + get { handler.mock() } + set { handler.mockNoReturn(args: [newValue]) } } var failureCount: Int { - get { mock() } - set { mockNoReturn(args: [newValue]) } + get { handler.mock() } + set { handler.mockNoReturn(args: [newValue]) } } var lastPollStart: TimeInterval { - get { mock() } - set { mockNoReturn(args: [newValue]) } + get { handler.mock() } + set { handler.mockNoReturn(args: [newValue]) } } - var cancellable: AnyCancellable? { - get { mock() } - set { mockNoReturn(args: [newValue]) } + var pollTask: Task? { + get { handler.mock() } + set { handler.mockNoReturn(args: [newValue]) } } - required init( + init( pollerName: String, - pollerQueue: DispatchQueue, - pollerDestination: PollerDestination, - pollerDrainBehaviour: ThreadSafeObject, - namespaces: [Network.SnodeAPI.Namespace], + destination: PollerDestination, + swarmDrainStrategy: SwarmDrainer.Strategy, + namespaces: [Network.StorageServer.Namespace], failureCount: Int, shouldStoreMessages: Bool, logStartAndStopCalls: Bool, customAuthMethod: (any AuthenticationMethod)?, + key: Dependencies.Key?, using dependencies: Dependencies ) { - super.init() - - mockNoReturn( + handler = MockHandler( + dummyProvider: { _ in MockPoller(handler: .invalid()) }, + erasedDependenciesKey: key, + using: dependencies + ) + handler.mockNoReturn( args: [ pollerName, - pollerQueue, - pollerDestination, - pollerDrainBehaviour, + destination, + swarmDrainStrategy, namespaces, failureCount, shouldStoreMessages, logStartAndStopCalls, - customAuthMethod - ], - untrackedArgs: [dependencies] + customAuthMethod, + key + ] ) } - internal required init(functionHandler: MockFunctionHandler? = nil, initialSetup: ((Mock) -> ())? = nil) { - super.init(functionHandler: functionHandler, initialSetup: initialSetup) + internal init(handler: MockHandler) { + self.handler = handler } - func startIfNeeded() { mockNoReturn() } - func stop() { mockNoReturn() } + internal init(handlerForBuilder: any MockFunctionHandler) { + self.handler = MockHandler(forwardingHandler: handlerForBuilder) + } + + func startIfNeeded(forceStartInBackground: Bool) async { handler.mockNoReturn(args: [forceStartInBackground]) } + func stop() { handler.mockNoReturn() } - func pollerDidStart() { mockNoReturn() } - func poll(forceSynchronousProcessing: Bool) -> AnyPublisher { mock(args: [forceSynchronousProcessing]) } - func nextPollDelay() -> AnyPublisher { mock() } - func handlePollError(_ error: Error, _ lastError: Error?) -> PollerErrorResponse { mock(args: [error, lastError]) } + func pollerDidStart() { handler.mockNoReturn() } + func pollerReceivedResponse(_ response: PollResponse) async { handler.mockNoReturn(args: [response]) } + func pollerDidStop() { handler.mockNoReturn() } + func poll(forceSynchronousProcessing: Bool) async throws -> PollResult { + return try handler.mockThrowing(args: [forceSynchronousProcessing]) + } + func pollFromBackground() async throws -> PollResult { + return try handler.mockThrowing() + } + func nextPollDelay() async -> TimeInterval { return handler.mock() } + func handlePollError(_ error: Error) async { handler.mockNoReturn(args: [error]) } } diff --git a/SessionMessagingKitTests/_TestUtilities/MockSwarmPoller.swift b/SessionMessagingKitTests/_TestUtilities/MockSwarmPoller.swift deleted file mode 100644 index 05af305032..0000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockSwarmPoller.swift +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Combine -import SessionNetworkingKit -import SessionUtilitiesKit - -@testable import SessionMessagingKit - -class MockSwarmPoller: Mock, SwarmPollerType & PollerType { - var pollerQueue: DispatchQueue { DispatchQueue.main } - var pollerName: String { mock() } - var pollerDestination: PollerDestination { mock() } - var logStartAndStopCalls: Bool { mock() } - var receivedPollResponse: AnyPublisher { mock() } - var isPolling: Bool { - get { mock() } - set { mockNoReturn(args: [newValue]) } - } - var pollCount: Int { - get { mock() } - set { mockNoReturn(args: [newValue]) } - } - var failureCount: Int { - get { mock() } - set { mockNoReturn(args: [newValue]) } - } - var lastPollStart: TimeInterval { - get { mock() } - set { mockNoReturn(args: [newValue]) } - } - var cancellable: AnyCancellable? { - get { mock() } - set { mockNoReturn(args: [newValue]) } - } - - required init( - pollerName: String, - pollerQueue: DispatchQueue, - pollerDestination: PollerDestination, - pollerDrainBehaviour: ThreadSafeObject, - namespaces: [Network.SnodeAPI.Namespace], - failureCount: Int, - shouldStoreMessages: Bool, - logStartAndStopCalls: Bool, - customAuthMethod: (any AuthenticationMethod)?, - using dependencies: Dependencies - ) { - super.init() - - mockNoReturn( - args: [ - pollerName, - pollerQueue, - pollerDestination, - pollerDrainBehaviour, - namespaces, - failureCount, - shouldStoreMessages, - logStartAndStopCalls, - customAuthMethod - ], - untrackedArgs: [dependencies] - ) - } - - internal required init(functionHandler: MockFunctionHandler? = nil, initialSetup: ((Mock) -> ())? = nil) { - super.init(functionHandler: functionHandler, initialSetup: initialSetup) - } - - func startIfNeeded() { mockNoReturn() } - func stop() { mockNoReturn() } - - func pollerDidStart() { mockNoReturn() } - func poll(forceSynchronousProcessing: Bool) -> AnyPublisher { mock(args: [forceSynchronousProcessing]) } - func nextPollDelay() -> AnyPublisher { mock() } - func handlePollError(_ error: Error, _ lastError: Error?) -> PollerErrorResponse { mock(args: [error, lastError]) } -} diff --git a/SessionMessagingKitTests/_TestUtilities/Mocked+SMK.swift b/SessionMessagingKitTests/_TestUtilities/Mocked+SMK.swift new file mode 100644 index 0000000000..e1bd636d87 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/Mocked+SMK.swift @@ -0,0 +1,260 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUIKit +import SessionUtilitiesKit +import TestUtilities + +@testable import SessionMessagingKit + +extension Message.Destination: @retroactive Mocked { + public static var any: Message.Destination = .contact(publicKey: String.any) + public static var mock: Message.Destination = .contact(publicKey: "") +} + +extension LibSession.Config: @retroactive Mocked { + public static var any: LibSession.Config = { + var conf = config_object() + return withUnsafeMutablePointer(to: &conf) { .local($0) } + }() + public static var mock: LibSession.Config = { + var conf = config_object() + return withUnsafeMutablePointer(to: &conf) { .contacts($0) } + }() +} + +extension ConfigDump.Variant: @retroactive Mocked { + public static var any: ConfigDump.Variant = .invalid + public static var mock: ConfigDump.Variant = .userProfile +} + +extension LibSession.CacheBehaviour: @retroactive Mocked { + public static var any: LibSession.CacheBehaviour = .skipGroupAdminCheck + public static var mock: LibSession.CacheBehaviour = .skipAutomaticConfigSync +} + +extension LibSession.OpenGroupUrlInfo: @retroactive Mocked { + public static var any: LibSession.OpenGroupUrlInfo = LibSession.OpenGroupUrlInfo( + threadId: .any, + server: .any, + roomToken: .any, + publicKey: .any + ) + public static var mock: LibSession.OpenGroupUrlInfo = LibSession.OpenGroupUrlInfo( + threadId: .mock, + server: .mock, + roomToken: .mock, + publicKey: .mock + ) +} + +extension SessionThread: @retroactive Mocked { + public static var any: SessionThread = SessionThread( + id: .any, + variant: .any, + creationDateTimestamp: .any, + shouldBeVisible: .any, + isPinned: .any, + messageDraft: .any, + notificationSound: .any, + mutedUntilTimestamp: .any, + onlyNotifyForMentions: .any, + markedAsUnread: .any, + pinnedPriority: .any + ) + + public static var mock: SessionThread = SessionThread( + id: .mock, + variant: .mock, + creationDateTimestamp: .mock, + shouldBeVisible: .mock, + isPinned: .mock, + messageDraft: .mock, + notificationSound: .mock, + mutedUntilTimestamp: .mock, + onlyNotifyForMentions: .mock, + markedAsUnread: .mock, + pinnedPriority: .mock + ) +} + +extension SessionThread.Variant: @retroactive Mocked { + public static var any: SessionThread.Variant = .legacyGroup + public static var mock: SessionThread.Variant = .contact +} + +extension Interaction.Variant: @retroactive Mocked { + public static var any: Interaction.Variant = ._legacyStandardIncomingDeleted + public static var mock: Interaction.Variant = .standardIncoming +} + +extension Interaction: @retroactive Mocked { + public static var any: Interaction = Interaction( + id: .any, + serverHash: .any, + messageUuid: .any, + threadId: .any, + authorId: .any, + variant: .any, + body: .any, + timestampMs: .any, + receivedAtTimestampMs: .any, + wasRead: .any, + hasMention: .any, + expiresInSeconds: .any, + expiresStartedAtMs: .any, + linkPreviewUrl: .any, + openGroupServerMessageId: .any, + openGroupWhisper: .any, + openGroupWhisperMods: .any, + openGroupWhisperTo: .any, + state: .sent, + recipientReadTimestampMs: .any, + mostRecentFailureText: .any, + isProMessage: .any + ) + public static var mock: Interaction = Interaction( + id: 123456, + serverHash: .mock, + messageUuid: nil, + threadId: .mock, + authorId: .mock, + variant: .mock, + body: .mock, + timestampMs: 1234567890, + receivedAtTimestampMs: 1234567890, + wasRead: false, + hasMention: false, + expiresInSeconds: nil, + expiresStartedAtMs: nil, + linkPreviewUrl: nil, + openGroupServerMessageId: nil, + openGroupWhisper: false, + openGroupWhisperMods: false, + openGroupWhisperTo: nil, + state: .sent, + recipientReadTimestampMs: nil, + mostRecentFailureText: nil, + isProMessage: false + ) +} + +extension VisibleMessage: @retroactive Mocked { + public static var any: VisibleMessage = VisibleMessage(text: .any) + public static var mock: VisibleMessage = VisibleMessage(text: "mock") +} + +extension KeychainStorage.DataKey: @retroactive Mocked { + public static var any: KeychainStorage.DataKey = "__MOCKED_KEYCHAIN_DATE_KEY_VALUE__" + public static var mock: KeychainStorage.DataKey = .dbCipherKeySpec +} + +extension NotificationCategory: @retroactive Mocked { + public static var any: NotificationCategory = .deprecatedIncomingMessage + public static var mock: NotificationCategory = .incomingMessage +} + +extension NotificationContent: @retroactive Mocked { + public static var any: NotificationContent = NotificationContent( + threadId: .any, + threadVariant: .any, + identifier: .any, + category: .any, + applicationState: .any + ) + public static var mock: NotificationContent = NotificationContent( + threadId: .mock, + threadVariant: .mock, + identifier: .mock, + category: .mock, + applicationState: .mock + ) +} + +extension Preferences.NotificationSettings: @retroactive Mocked { + public static var any: Preferences.NotificationSettings = Preferences.NotificationSettings( + previewType: .any, + sound: .any, + mentionsOnly: .any, + mutedUntil: .any + ) + public static var mock: Preferences.NotificationSettings = Preferences.NotificationSettings( + previewType: .mock, + sound: .mock, + mentionsOnly: .mock, + mutedUntil: .mock + ) +} + +extension ImageDataManager.DataSource: @retroactive Mocked { + public static var any: ImageDataManager.DataSource = ImageDataManager.DataSource.data(.any, .any) + public static var mock: ImageDataManager.DataSource = ImageDataManager.DataSource.data(.mock, .mock) +} + +enum MockLibSessionConvertible: Int, Codable, LibSessionConvertibleEnum, Mocked { + typealias LibSessionType = Int + + public static var any: MockLibSessionConvertible = .anyValue + public static var mock: MockLibSessionConvertible = .mockValue + + case anyValue = 12345554321 + case mockValue = 0 + + public static var defaultLibSessionValue: LibSessionType { 0 } + public var libSessionValue: LibSessionType { 0 } + + public init(_ libSessionValue: LibSessionType) { + self = .mockValue + } +} + +extension Preferences.Sound: @retroactive Mocked { + public static var any: Preferences.Sound = .callFailure + public static var mock: Preferences.Sound = .defaultNotificationSound +} + +extension Preferences.NotificationPreviewType: @retroactive Mocked { + public static var any: Preferences.NotificationPreviewType = .nameNoPreview + public static var mock: Preferences.NotificationPreviewType = .defaultPreviewType +} + +extension Theme: @retroactive Mocked { + public static var any: Theme = .classicLight + public static var mock: Theme = .defaultTheme +} + +extension Theme.PrimaryColor: @retroactive Mocked { + public static var any: Theme.PrimaryColor = .yellow + public static var mock: Theme.PrimaryColor = .defaultPrimaryColor +} + +extension ConfigDump: @retroactive Mocked { + public static var any: ConfigDump = ConfigDump( + variant: .invalid, + sessionId: .any, + data: .any, + timestampMs: .any + ) + public static var mock: ConfigDump = ConfigDump( + variant: .invalid, + sessionId: .mock, + data: .mock, + timestampMs: .mock + ) +} + +extension PollerDestination: @retroactive Mocked { + public static var any: PollerDestination { .swarm(.any) } + public static var mock: PollerDestination { .swarm(TestConstants.publicKey) } +} + +extension CommunityPollerManagerSyncState: @retroactive Mocked { + public static var any: CommunityPollerManagerSyncState = CommunityPollerManagerSyncState( + serversBeingPolled: .any + ) + + public static var mock: CommunityPollerManagerSyncState = CommunityPollerManagerSyncState( + serversBeingPolled: .mock + ) +} diff --git a/SessionNetworkingKit/Configuration/Router.swift b/SessionNetworkingKit/Configuration/Router.swift new file mode 100644 index 0000000000..0cf2f6e35f --- /dev/null +++ b/SessionNetworkingKit/Configuration/Router.swift @@ -0,0 +1,43 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import SessionUtilitiesKit + +// MARK: - FeatureStorage + +public extension FeatureStorage { + static let router: FeatureConfig = Dependencies.create( + identifier: "router", + defaultOption: .onionRequests + ) +} + +// MARK: - Router + +public enum Router: Int, Sendable, FeatureOption, CaseIterable { + case onionRequests = 1 + case lokinet = 2 + case direct = 3 + + // MARK: - Feature Option + + public static var defaultOption: Router = .onionRequests + + public var title: String { + switch self { + case .onionRequests: return "Onion Requests" + case .lokinet: return "Lokinet" + case .direct: return "Direct" + } + } + + public var subtitle: String? { + switch self { + case .onionRequests: return "Requests will be encrypted in multiple layers and send via multiple hops in the network before going to their destination." + case .lokinet: return "Request will be sent via Lokinet." + case .direct: return "Requests will be sent directly to their destination." + } + } +} diff --git a/SessionNetworkingKit/Configuration/ServiceNetwork.swift b/SessionNetworkingKit/Configuration/ServiceNetwork.swift new file mode 100644 index 0000000000..389db7b3a9 --- /dev/null +++ b/SessionNetworkingKit/Configuration/ServiceNetwork.swift @@ -0,0 +1,133 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import SessionUtilitiesKit + +// MARK: - FeatureStorage + +public extension FeatureStorage { + static let serviceNetwork: FeatureConfig = Dependencies.create( + identifier: "serviceNetwork", + defaultOption: .mainnet + ) + + static let devnetConfig: FeatureConfig = Dependencies.create( + identifier: "devnetConfig" + ) +} + +// MARK: - ServiceNetwork + +public enum ServiceNetwork: Int, Sendable, FeatureOption, CaseIterable { + case mainnet = 1 + case testnet = 2 + case devnet = 3 + + // MARK: - Feature Option + + public static var defaultOption: ServiceNetwork = .mainnet + + public var title: String { + switch self { + case .mainnet: return "Mainnet" + case .testnet: return "Testnet" + case .devnet: return "Devnet" + } + } + + public var subtitle: String? { + switch self { + case .mainnet: return "This is the production service node network." + case .testnet: return "This is the test service node network, it should be used for testing features which are currently in development and may be unstable." + case .devnet: return "This is a development service node network, it allows you to point your client at a custom service node network for testing." + } + } +} + +public extension ServiceNetwork { + struct DevnetConfiguration: Equatable, Codable, FeatureOption { + public typealias RawValue = String + + private struct Values: Equatable, Codable { + public let pubkey: String + public let ip: String + public let httpPort: UInt16 + public let omqPort: UInt16 + } + + public static let defaultOption: DevnetConfiguration = DevnetConfiguration( + pubkey: "", + ip: "", + httpPort: 0, + omqPort: 0 + ) + + public let title: String = "Devnet Configuration" + public let subtitle: String? = nil + private let values: Values + + public var pubkey: String { values.pubkey } + public var ip: String { values.ip } + public var httpPort: UInt16 { values.httpPort } + public var omqPort: UInt16 { values.omqPort } + public var isValid: Bool { + let pubkeyValid: Bool = ( + Hex.isValid(values.pubkey) && + values.pubkey.count == 64 + ) + let ipValid: Bool = ( + values.ip.split(separator: ".").count == 4 && + values.ip.split(separator: ".").allSatisfy({ part in + UInt8(part, radix: 10) != nil + }) + ) + + /// The `httpPort` and `omqPort` values are valid by default due to type safety + return (pubkeyValid && ipValid) + } + + /// This is needed to conform to `FeatureOption` so it can be saved to `UserDefaults` + public var rawValue: String { + (try? JSONEncoder().encode(values)).map { String(data: $0, encoding: .utf8) } ?? "" + } + + // MARK: - Initialization + + public init(pubkey: String, ip: String, httpPort: UInt16, omqPort: UInt16) { + self.values = Values(pubkey: pubkey, ip: ip, httpPort: httpPort, omqPort: omqPort) + } + + public init?(rawValue: String) { + guard + let data: Data = rawValue.data(using: .utf8), + let decodedValues: Values = try? JSONDecoder().decode(Values.self, from: data) + else { return nil } + + self.values = decodedValues + } + + // MARK: - Functions + + public func with( + pubkey: String? = nil, + ip: String? = nil, + httpPort: UInt16? = nil, + omqPort: UInt16? = nil + ) -> DevnetConfiguration { + return DevnetConfiguration( + pubkey: (pubkey ?? self.values.pubkey), + ip: (ip ?? self.values.ip), + httpPort: (httpPort ?? self.values.httpPort), + omqPort: (omqPort ?? self.values.omqPort) + ) + } + + // MARK: - Equality + + public static func == (lhs: DevnetConfiguration, rhs: DevnetConfiguration) -> Bool { + return (lhs.values == rhs.values) + } + } +} diff --git a/SessionNetworkingKit/Crypto/Crypto+SessionNetworkingKit.swift b/SessionNetworkingKit/Crypto/Crypto+SessionNetworkingKit.swift index dad09fce75..95a6acab63 100644 --- a/SessionNetworkingKit/Crypto/Crypto+SessionNetworkingKit.swift +++ b/SessionNetworkingKit/Crypto/Crypto+SessionNetworkingKit.swift @@ -1,4 +1,4 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. // // stringlint:disable @@ -11,14 +11,14 @@ import SessionUtilitiesKit internal extension Crypto.Generator { static func sessionId( name: String, - response: Network.SnodeAPI.ONSResolveResponse + response: Network.StorageServer.ONSResolveResponse ) -> Crypto.Generator { return Crypto.Generator( id: "sessionId_for_ONS_response", args: [name, response] ) { guard var cName: [CChar] = name.lowercased().cString(using: .utf8) else { - throw SnodeAPIError.onsDecryptionFailed + throw StorageServerError.onsDecryptionFailed } // Name must be in lowercase @@ -37,7 +37,7 @@ internal extension Crypto.Generator { nil, &cSessionId ) - else { throw SnodeAPIError.onsDecryptionFailed } + else { throw StorageServerError.onsDecryptionFailed } case .some(let nonce): var cNonce: [UInt8] = Array(Data(hex: nonce)) @@ -51,11 +51,10 @@ internal extension Crypto.Generator { &cNonce, &cSessionId ) - else { throw SnodeAPIError.onsDecryptionFailed } + else { throw StorageServerError.onsDecryptionFailed } } return String(cString: cSessionId) } } } - diff --git a/SessionNetworkingKit/FileServer/FileServer.swift b/SessionNetworkingKit/FileServer/FileServer.swift index 90fdec9e08..2386236526 100644 --- a/SessionNetworkingKit/FileServer/FileServer.swift +++ b/SessionNetworkingKit/FileServer/FileServer.swift @@ -46,46 +46,5 @@ public extension Network { .map { String($0) } } } - } - - static func preparedUpload( - data: Data, - requestAndPathBuildTimeout: TimeInterval? = nil, - using dependencies: Dependencies - ) throws -> PreparedRequest { - return try PreparedRequest( - request: Request( - endpoint: FileServer.Endpoint.file, - destination: .serverUpload( - server: FileServer.fileServer, - x25519PublicKey: FileServer.fileServerPublicKey, - fileName: nil - ), - body: data - ), - responseType: FileUploadResponse.self, - requestTimeout: Network.fileUploadTimeout, - requestAndPathBuildTimeout: requestAndPathBuildTimeout, - using: dependencies - ) - } - - static func preparedDownload( - url: URL, - using dependencies: Dependencies - ) throws -> PreparedRequest { - return try PreparedRequest( - request: Request( - endpoint: FileServer.Endpoint.directUrl(url), - destination: .serverDownload( - url: url, - x25519PublicKey: FileServer.fileServerPublicKey, - fileName: nil - ) - ), - responseType: Data.self, - requestTimeout: Network.fileUploadTimeout, - using: dependencies - ) - } + } } diff --git a/SessionNetworkingKit/FileServer/FileServerAPI.swift b/SessionNetworkingKit/FileServer/FileServerAPI.swift index 4b3304b3dd..3ba975af27 100644 --- a/SessionNetworkingKit/FileServer/FileServerAPI.swift +++ b/SessionNetworkingKit/FileServer/FileServerAPI.swift @@ -9,7 +9,7 @@ private typealias Endpoint = Network.FileServer.Endpoint public extension Network.FileServer { static func preparedUpload( data: Data, - requestAndPathBuildTimeout: TimeInterval? = nil, + overallTimeout: TimeInterval? = nil, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( @@ -20,11 +20,12 @@ public extension Network.FileServer { x25519PublicKey: FileServer.fileServerPublicKey, fileName: nil ), - body: data + body: data, + category: .upload, + requestTimeout: Network.fileUploadTimeout, + overallTimeout: overallTimeout ), responseType: FileUploadResponse.self, - requestTimeout: Network.fileUploadTimeout, - requestAndPathBuildTimeout: requestAndPathBuildTimeout, using: dependencies ) } @@ -40,10 +41,11 @@ public extension Network.FileServer { url: url, x25519PublicKey: FileServer.fileServerPublicKey, fileName: nil - ) + ), + category: .download, + requestTimeout: Network.fileUploadTimeout ), responseType: Data.self, - requestTimeout: Network.fileUploadTimeout, using: dependencies ) } diff --git a/SessionNetworkingKit/FileServer/FileServerEndpoint.swift b/SessionNetworkingKit/FileServer/FileServerEndpoint.swift index 5f23b85624..f33f068637 100644 --- a/SessionNetworkingKit/FileServer/FileServerEndpoint.swift +++ b/SessionNetworkingKit/FileServer/FileServerEndpoint.swift @@ -1,4 +1,6 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import Foundation diff --git a/SessionNetworkingKit/LibSession/LibSession+Networking.swift b/SessionNetworkingKit/LibSession/LibSession+Networking.swift index 53fabef8b7..024ebaf0cc 100644 --- a/SessionNetworkingKit/LibSession/LibSession+Networking.swift +++ b/SessionNetworkingKit/LibSession/LibSession+Networking.swift @@ -7,374 +7,703 @@ import Combine import SessionUtil import SessionUtilitiesKit -// MARK: - Cache - -public extension Cache { - static let libSessionNetwork: CacheConfig = Dependencies.create( - identifier: "libSessionNetwork", - createInstance: { dependencies in - /// The `libSessionNetwork` cache gets warmed during startup and creates a network instance, populates the snode - /// cache and builds onion requests when created - when running unit tests we don't want to do any of that unless explicitly - /// desired within the test itself so instead we default to a `NoopNetworkCache` when running unit tests - guard !SNUtilitiesKit.isRunningTests else { return LibSession.NoopNetworkCache() } - - return LibSession.NetworkCache(using: dependencies) - }, - mutableInstance: { $0 }, - immutableInstance: { $0 } - ) -} - // MARK: - Log.Category public extension Log.Category { static let network: Log.Category = .create("Network", defaultLevel: .info) } -// MARK: - LibSession.Network +// MARK: - LibSessionNetwork -class LibSessionNetwork: NetworkType { +actor LibSessionNetwork: NetworkType { + fileprivate typealias Response = ( + success: Bool, + timeout: Bool, + statusCode: Int, + headers: [String: String], + data: Data? + ) + + private static var snodeCachePath: String { "\(SessionFileManager.nonInjectedAppSharedDataDirectoryPath)/snodeCache" } + private let dependencies: Dependencies + private let dependenciesPtr: UnsafeMutableRawPointer + private var network: UnsafeMutablePointer? = nil + nonisolated private let internalNetworkStatus: CurrentValueAsyncStream = CurrentValueAsyncStream(.unknown) + private let singlePathMode: Bool + + public private(set) var isSuspended: Bool = false + public var hardfork: Int { + get async { + guard let network = try? await getOrCreateNetwork() else { return 0 } + + return Int(session_network_hardfork(network)) + } + } + public var softfork: Int { + get async { + guard let network = try? await getOrCreateNetwork() else { return 0 } + + return Int(session_network_softfork(network)) + } + } + public var networkTimeOffsetMs: Int64 { + get async { + guard let network = try? await getOrCreateNetwork() else { return 0 } + + return Int64(session_network_time_offset(network)) + } + } + + nonisolated public var networkStatus: AsyncStream { internalNetworkStatus.stream } + + @available(*, deprecated, message: "Should try to refactor the code to use proper async/await") + nonisolated public let syncState: NetworkSyncState + + @available(*, deprecated, message: "We want to shift from Combine to Async/Await when possible") + private let networkInstance: CurrentValueSubject?, Error> = CurrentValueSubject(nil) // MARK: - Initialization - init(using dependencies: Dependencies) { + init(singlePathMode: Bool, using dependencies: Dependencies) { self.dependencies = dependencies + self.dependenciesPtr = Unmanaged.passRetained(dependencies).toOpaque() + self.syncState = NetworkSyncState( + hardfork: dependencies[defaults: .standard, key: .hardfork], + softfork: dependencies[defaults: .standard, key: .hardfork], + using: dependencies + ) + self.singlePathMode = singlePathMode + + /// Create the network object + Task { [self] in + /// If the app has been set to `forceOffline` then we need to explicitly set the network status to disconnected (because + /// it'll never be set otherwise) + if dependencies[feature: .forceOffline] { + await setNetworkStatus(status: .disconnected) + } + + /// Create the `network` instance so it can do any setup required + _ = try? await getOrCreateNetwork() + } + } + + deinit { + // Send completion events to the observables (so they can resubscribe to a future instance) + Task { [status = internalNetworkStatus] in + await status.send(.disconnected) + await status.finishCurrentStreams() + } + + // Finish the `networkInstance` (since it's a `CurrentValueSubject` we want to ensure it doesn't + // hold the `network` instance since we are about to free it below + self.networkInstance.send(nil) + self.networkInstance.send(completion: .finished) + + // Clear the network changed callbacks (just in case, since we are going to free the + // dependenciesPtr) and then free the network object + switch network { + case .none: break + case .some(let network): + session_network_set_status_changed_callback(network, nil, nil) + session_network_set_network_info_changed_callback(network, nil, nil) + session_network_free(network) + } + + // Finally we need to make sure to clean up the unbalanced retain to the dependencies + Unmanaged.fromOpaque(dependenciesPtr).release() } // MARK: - NetworkType - func getSwarm(for swarmPublicKey: String) -> AnyPublisher, Error> { - typealias Output = Result, Error> + func getActivePaths() async throws -> [LibSession.Path] { + let network = try await getOrCreateNetwork() + + var cPathsPtr: UnsafeMutablePointer? + var cPathsLen: Int = 0 + session_network_get_active_paths(network, &cPathsPtr, &cPathsLen) + defer { + if let paths = cPathsPtr { + session_network_paths_free(paths) + } + } - return dependencies - .mutate(cache: .libSessionNetwork) { $0.getOrCreateNetwork() } - .tryMapCallbackContext(type: Output.self) { ctx, network in - let sessionId: SessionId = try SessionId(from: swarmPublicKey) + guard + cPathsLen > 0, + let cPaths: UnsafeMutablePointer = cPathsPtr + else { return [] } + + return (0.. 0, let cNodes: UnsafePointer = cPaths[index].nodes { + nodes = (0.. = cPaths[index].onion_metadata { + category = Network.RequestCategory(onionMeta.get(\.category)) + } + else if let lokinetMeta: UnsafePointer = cPaths[index].lokinet_metadata { + destinationPubkey = lokinetMeta.get(\.destination_pubkey) + destinationAddress = lokinetMeta.get(\.destination_snode_address) + } + + return LibSession.Path( + nodes: nodes, + category: category, + destinationPubkey: destinationPubkey, + destinationSnodeAddress: destinationAddress + ) + } + } + + func getSwarm(for swarmPublicKey: String) async throws -> Set { + typealias Continuation = CheckedContinuation, Error> + + let network = try await getOrCreateNetwork() + let sessionId: SessionId = try SessionId(from: swarmPublicKey) + + guard let cSwarmPublicKey: [CChar] = sessionId.publicKeyString.cString(using: .utf8) else { + throw LibSessionError.invalidCConversion + } + + return try await withCheckedThrowingContinuation { continuation in + let context = LibSessionNetwork.ContinuationBox(continuation).unsafePointer() + + session_network_get_swarm(network, cSwarmPublicKey, { swarmPtr, swarmSize, ctx in + guard let box = LibSessionNetwork.ContinuationBox.from(unsafePointer: ctx) else { + return + } - guard let cSwarmPublicKey: [CChar] = sessionId.publicKeyString.cString(using: .utf8) else { - throw LibSessionError.invalidCConversion + guard + swarmSize > 0, + let cSwarm: UnsafeMutablePointer = swarmPtr + else { return box.continuation.resume(throwing: StorageServerError.unableToRetrieveSwarm) } + + var nodes: Set = [] + (0.. Set { + typealias Continuation = CheckedContinuation, Error> + + let network = try await getOrCreateNetwork() + + let nodes: Set = try await withCheckedThrowingContinuation { continuation in + let context = LibSessionNetwork.ContinuationBox(continuation).unsafePointer() + + session_network_get_random_nodes(network, UInt16(count), { nodesPtr, nodesSize, ctx in + guard let box = LibSessionNetwork.ContinuationBox.from(unsafePointer: ctx) else { + return } - network_get_swarm(network, cSwarmPublicKey, { swarmPtr, swarmSize, ctx in - guard - swarmSize > 0, - let cSwarm: UnsafeMutablePointer = swarmPtr - else { return CallbackWrapper.run(ctx, .failure(SnodeAPIError.unableToRetrieveSwarm)) } - - var nodes: Set = [] - (0...run(ctx, .success(nodes)) - }, ctx); - } - .tryMap { [dependencies = self.dependencies] result in - dependencies - .mutate(cache: .libSessionNetwork) { - $0.setSnodeNumber( - publicKey: swarmPublicKey, - value: (try? result.get())?.count ?? 0 - ) - } - return try result.successOrThrow() - } - .eraseToAnyPublisher() + guard + nodesSize > 0, + let cSwarm: UnsafeMutablePointer = nodesPtr + else { return box.continuation.resume(throwing: StorageServerError.unableToRetrieveSwarm) } + + var nodes: Set = [] + (0..= count else { + throw StorageServerError.unableToRetrieveSwarm + } + + return nodes } - func getRandomNodes(count: Int) -> AnyPublisher, Error> { - typealias Output = Result, Error> - - return dependencies - .mutate(cache: .libSessionNetwork) { $0.getOrCreateNetwork() } - .tryMapCallbackContext(type: Output.self) { ctx, network in - network_get_random_nodes(network, UInt16(count), { nodesPtr, nodesSize, ctx in - guard - nodesSize > 0, - let cSwarm: UnsafeMutablePointer = nodesPtr - else { return CallbackWrapper.run(ctx, .failure(SnodeAPIError.unableToRetrieveSwarm)) } - - var nodes: Set = [] - (0...run(ctx, .success(nodes)) - }, ctx); + nonisolated func send( + endpoint: E, + destination: Network.Destination, + body: Data?, + category: Network.RequestCategory, + requestTimeout: TimeInterval, + overallTimeout: TimeInterval? + ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + typealias FinalRequestInfo = ( + network: UnsafeMutablePointer, + body: Data?, + destination: Network.Destination + ) + typealias Output = (success: Bool, timeout: Bool, statusCode: Int, headers: [String: String], data: Data?) + + guard !syncState.isSuspended else { + Log.warn(.network, "Attempted to access suspended network.") + return Fail(error: NetworkError.suspended) + .eraseToAnyPublisher() + } + + guard !syncState.dependencies[feature: .forceOffline] else { + return Fail(error: NetworkError.serviceUnavailable) + .delay(for: .seconds(1), scheduler: DispatchQueue.global(qos: .userInitiated)) + .eraseToAnyPublisher() + } + + return networkInstance + .compactMap { $0 } + .first() + .tryFlatMap { [dependencies] network -> AnyPublisher in + switch destination { + case .snode, .server, .serverUpload, .serverDownload, .cached: + return Just((network, body, destination)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + + case .randomSnode(let swarmPublicKey): + guard body != nil else { throw NetworkError.invalidPreparedRequest } + + let swarmSessionId: SessionId = try SessionId(from: swarmPublicKey) + + guard let cSwarmPublicKey: [CChar] = swarmSessionId.publicKeyString.cString(using: .utf8) else { + throw LibSessionError.invalidCConversion + } + + return FutureBox> + .create { ctx in + session_network_get_swarm(network, cSwarmPublicKey, { swarmPtr, swarmSize, ctx in + guard + swarmSize > 0, + let cSwarm: UnsafeMutablePointer = swarmPtr + else { + return LibSessionNetwork.FutureBox>.fail( + error: StorageServerError.unableToRetrieveSwarm, + ptr: ctx + ) + } + + var nodes: Set = [] + (0..>.resolve( + result: nodes, + ptr: ctx + ) + }, ctx) + } + .tryMap { [dependencies] nodes in + try dependencies.randomElement(nodes) ?? { + throw StorageServerError.ranOutOfRandomSnodes(nil) + }() + } + .map { node in + ( + network, + body, + Network.Destination.snode(node, swarmPublicKey: swarmPublicKey) + ) + } + .eraseToAnyPublisher() + } } - .tryMap { result in - switch result { - case .failure(let error): throw error - case .success(let nodes): - guard nodes.count >= count else { throw SnodeAPIError.unableToRetrieveSwarm } + .tryMapCallbackContext(type: Output.self) { ctx, finalRequestInfo in + /// If it's a cached request then just return the cached result immediately + if case .cached(let success, let timeout, let statusCode, let headers, let data) = destination { + return CallbackWrapper.run(ctx, (success, timeout, statusCode, headers, data)) + } + + /// Define the callback to avoid dupolication + typealias ResponseCallback = session_network_response_t + let cCallback: ResponseCallback = { success, timeout, statusCode, cHeaders, cHeadersLen, dataPtr, dataLen, ctx in + let headers: [String: String] = LibSessionNetwork.headers(cHeaders, cHeadersLen) + let data: Data? = dataPtr.map { Data(bytes: $0, count: dataLen) } + CallbackWrapper.run(ctx, (success, timeout, Int(statusCode), headers, data)) + } + let request: LibSessionNetwork.Request = LibSessionNetwork.Request( + endpoint: endpoint, + body: finalRequestInfo.body, + category: category, + requestTimeout: requestTimeout, + overallTimeout: overallTimeout + ) + + switch finalRequestInfo.destination { + case .snode(let snode, _): + try LibSessionNetwork.withSnodeRequestParams(request, snode) { paramsPtr in + session_network_send_request(finalRequestInfo.network, paramsPtr, cCallback, ctx) + } - return nodes + case .server(let info), .serverUpload(let info, _), .serverDownload(let info): + let uploadFileName: String? = { + switch destination { + case .serverUpload(_, let fileName): return fileName + default: return nil + } + }() + + try LibSessionNetwork.withServerRequestParams(request, info, uploadFileName) { paramsPtr in + session_network_send_request(finalRequestInfo.network, paramsPtr, cCallback, ctx) + } + + /// Some destinations are for convenience and redirect to "proper" destination types so if one of them gets here + /// then it is invalid + default: throw NetworkError.invalidPreparedRequest } } + .tryMap { [dependencies] success, timeout, statusCode, headers, maybeData -> (any ResponseInfoType, Data) in + let response: Response = (success, timeout, statusCode, headers, maybeData) + try LibSessionNetwork.throwErrorIfNeeded(response, using: dependencies) + + guard let data: Data = maybeData else { throw NetworkError.parsingFailed } + + return (Network.ResponseInfo(code: statusCode, headers: headers), data) + } .eraseToAnyPublisher() } - func send( - _ body: Data?, - to destination: Network.Destination, + func send( + endpoint: E, + destination: Network.Destination, + body: Data?, + category: Network.RequestCategory, requestTimeout: TimeInterval, - requestAndPathBuildTimeout: TimeInterval? - ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + overallTimeout: TimeInterval? + ) async throws -> (info: ResponseInfoType, value: Data?) { switch destination { - case .server, .serverUpload, .serverDownload, .cached: - return sendRequest( - to: destination, + case .snode, .server, .serverUpload, .serverDownload, .cached: + return try await sendRequest( + endpoint: endpoint, + destination: destination, body: body, + category: category, requestTimeout: requestTimeout, - requestAndPathBuildTimeout: requestAndPathBuildTimeout + overallTimeout: overallTimeout ) - - case .snode: - guard body != nil else { return Fail(error: NetworkError.invalidPreparedRequest).eraseToAnyPublisher() } - return sendRequest( - to: destination, + case .randomSnode(let swarmPublicKey): + guard body != nil else { throw NetworkError.invalidPreparedRequest } + + let swarm: Set = try await getSwarm(for: swarmPublicKey) + let swarmDrainer: SwarmDrainer = SwarmDrainer(swarm: swarm, using: dependencies) + let snode: LibSession.Snode = try await swarmDrainer.selectNextNode() + + return try await self.sendRequest( + endpoint: endpoint, + destination: .snode(snode, swarmPublicKey: swarmPublicKey), body: body, + category: category, requestTimeout: requestTimeout, - requestAndPathBuildTimeout: requestAndPathBuildTimeout + overallTimeout: overallTimeout ) + } + } + + func checkClientVersion(ed25519SecretKey: [UInt8]) async throws -> (info: ResponseInfoType, value: Network.FileServer.AppVersionResponse) { + typealias Continuation = CheckedContinuation + + let network = try await getOrCreateNetwork() + var cEd25519SecretKey: [UInt8] = Array(ed25519SecretKey) + + guard ed25519SecretKey.count == 64 else { throw LibSessionError.invalidCConversion } + let paramsPtr: UnsafeMutablePointer = try session_file_server_get_client_version( + CLIENT_PLATFORM_IOS, + &cEd25519SecretKey, + Int64(floor(Network.defaultTimeout * 1000)), + 0 + ) ?? { throw NetworkError.invalidPreparedRequest }() + defer { session_request_params_free(paramsPtr) } + + let result: Response = try await withCheckedThrowingContinuation { continuation in + let box = LibSessionNetwork.ContinuationBox(continuation) + session_network_send_request(network, paramsPtr, box.cCallback, box.unsafePointer()) + } + + try LibSessionNetwork.throwErrorIfNeeded(result, using: dependencies) + let data: Data = try result.data ?? { throw NetworkError.parsingFailed }() + + return ( + Network.ResponseInfo(code: result.statusCode), + try Network.FileServer.AppVersionResponse.decoded(from: data, using: dependencies) + ) + } + + public func resetNetworkStatus() async { + guard !isSuspended, let network = try? await getOrCreateNetwork() else { return } + + let status: NetworkStatus = NetworkStatus(status: session_network_get_status(network)) + + Log.info(.network, "Network status changed to: \(status)") + await internalNetworkStatus.send(status) + } + + public func setNetworkStatus(status: NetworkStatus) async { + guard status == .disconnected || !isSuspended else { + Log.warn(.network, "Attempted to update network status to '\(status)' for suspended network, closing connections again.") + + switch network { + case .none: return + case .some(let network): return session_network_close_connections(network) + } + } + + /// If we have set the `forceOffline` flag then don't allow non-disconnected status updates + guard status == .disconnected || !dependencies[feature: .forceOffline] else { return } + + /// Notify any subscribers + Log.info(.network, "Network status changed to: \(status)") + await internalNetworkStatus.send(status) + } + + public func setNetworkInfo(networkTimeOffsetMs: Int64, hardfork: Int, softfork: Int) async { + var targetHardfork: Int? + var targetSoftfork: Int? + + /// Check if the version info is newer than the current stored values and update them if so + if hardfork > 1 { + let oldHardfork: Int = dependencies[defaults: .standard, key: .hardfork] + let oldSoftfork: Int = dependencies[defaults: .standard, key: .softfork] + + if (hardfork > oldHardfork) { + targetHardfork = hardfork + targetSoftfork = softfork + dependencies[defaults: .standard, key: .hardfork] = hardfork + dependencies[defaults: .standard, key: .softfork] = softfork + } + else if softfork > oldSoftfork { + targetSoftfork = softfork + dependencies[defaults: .standard, key: .softfork] = softfork + } + } + + /// Update the cached synchronous state + syncState.update( + hardfork: targetHardfork, + softfork: targetSoftfork, + networkTimeOffsetMs: networkTimeOffsetMs + ) + } + + public func suspendNetworkAccess() async { + Log.info(.network, "Network access suspended.") + isSuspended = true + syncState.update(isSuspended: true) + await setNetworkStatus(status: .disconnected) + await dependencies.notify(key: .networkLifecycle(.suspended)) + + switch network { + case .none: break + case .some(let network): session_network_suspend(network) + } + } + + public func resumeNetworkAccess(autoReconnect: Bool) async { + isSuspended = false + syncState.update(isSuspended: false) + Log.info(.network, "Network access resumed.") + await dependencies.notify(key: .networkLifecycle(.resumed)) + + switch network { + case .none: break + case .some(let network): session_network_resume(network, autoReconnect) + } + } + + public func finishCurrentObservations() async { + await internalNetworkStatus.finishCurrentStreams() + } + + public func clearCache() async { + switch network { + case .none: break + case .some(let network): session_network_clear_cache(network) + } + } + + // MARK: - Internal Functions + + private func getOrCreateNetwork() async throws -> UnsafeMutablePointer { + guard !isSuspended else { + Log.warn(.network, "Attempted to access suspended network.") + throw NetworkError.suspended + } + + switch (network, dependencies[feature: .forceOffline]) { + case (_, true): + try await Task.sleep(for: .seconds(1)) + throw NetworkError.serviceUnavailable - case .randomSnode(let swarmPublicKey, let retryCount): - guard (try? SessionId(from: swarmPublicKey)) != nil else { - return Fail(error: SessionIdError.invalidSessionId).eraseToAnyPublisher() + case (.some(let existingNetwork), _): return existingNetwork + + case (.none, _): + guard let cCachePath: [CChar] = LibSessionNetwork.snodeCachePath.cString(using: .utf8) else { + Log.error(.network, "Unable to create network object: \(LibSessionError.invalidCConversion)") + throw NetworkError.invalidState } - guard body != nil else { return Fail(error: NetworkError.invalidPreparedRequest).eraseToAnyPublisher() } - return getSwarm(for: swarmPublicKey) - .tryFlatMapWithRandomSnode(retry: retryCount, using: dependencies) { [weak self] snode in - try self.validOrThrow().sendRequest( - to: .snode(snode, swarmPublicKey: swarmPublicKey), - body: body, - requestTimeout: requestTimeout, - requestAndPathBuildTimeout: requestAndPathBuildTimeout - ) - } + var error: [CChar] = [CChar](repeating: 0, count: 256) + var network: UnsafeMutablePointer? + var cDevnetNodes: [network_service_node] = [] + var config: session_network_config = session_network_config_default() + config.cache_refresh_using_legacy_endpoint = true + config.onionreq_single_path_mode = singlePathMode - case .randomSnodeLatestNetworkTimeTarget(let swarmPublicKey, let retryCount, let bodyWithUpdatedTimestampMs): - guard (try? SessionId(from: swarmPublicKey)) != nil else { - return Fail(error: SessionIdError.invalidSessionId).eraseToAnyPublisher() + switch (dependencies[feature: .serviceNetwork], dependencies[feature: .devnetConfig], dependencies[feature: .devnetConfig].isValid) { + case (.mainnet, _, _): config.netid = SESSION_NETWORK_MAINNET + case (.testnet, _, _), (_, _, false): + config.netid = SESSION_NETWORK_TESTNET + config.enforce_subnet_diversity = false /// On testnet we can't do this as nodes share IPs + + case (.devnet, let devnetConfig, true): + config.netid = SESSION_NETWORK_DEVNET + config.enforce_subnet_diversity = false /// Devnet nodes likely share IPs as well + cDevnetNodes = [LibSession.Snode(devnetConfig).cSnode] } - guard body != nil else { return Fail(error: NetworkError.invalidPreparedRequest).eraseToAnyPublisher() } - return getSwarm(for: swarmPublicKey) - .tryFlatMapWithRandomSnode(retry: retryCount, using: dependencies) { [weak self, dependencies] snode in - try Network.SnodeAPI - .preparedGetNetworkTime(from: snode, using: dependencies) - .send(using: dependencies) - .tryFlatMap { _, timestampMs in - guard - let updatedEncodable: Encodable = bodyWithUpdatedTimestampMs(timestampMs, dependencies), - let updatedBody: Data = try? JSONEncoder(using: dependencies).encode(updatedEncodable) - else { throw NetworkError.invalidPreparedRequest } - - return try self.validOrThrow().sendRequest( - to: .snode(snode, swarmPublicKey: swarmPublicKey), - body: updatedBody, - requestTimeout: requestTimeout, - requestAndPathBuildTimeout: requestAndPathBuildTimeout - ) - .map { info, response -> (ResponseInfoType, Data?) in - ( - Network.SnodeAPI.LatestTimestampResponseInfo( - code: info.code, - headers: info.headers, - timestampMs: timestampMs - ), - response - ) - } + switch dependencies[feature: .router] { + case .onionRequests: config.router = SESSION_NETWORK_ROUTER_ONION_REQUESTS + case .lokinet: config.router = SESSION_NETWORK_ROUTER_LOKINET + case .direct: config.router = SESSION_NETWORK_ROUTER_DIRECT + } + + /// If it's not the main app then we want to run in "Single Path Mode" (no use creating extra paths in the extensions) + if !dependencies[singleton: .appContext].isMainApp { + config.onionreq_single_path_mode = true + } + + try cCachePath.withUnsafeBufferPointer { cachePtr in + try cDevnetNodes.withUnsafeBufferPointer { devnetNodesPtr in + config.cache_dir = cachePtr.baseAddress + + /// Only set the devnet pointers if we are in devnet mode + if config.netid == SESSION_NETWORK_DEVNET { + config.devnet_seed_nodes = devnetNodesPtr.baseAddress + config.devnet_seed_nodes_size = devnetNodesPtr.count + } + + guard session_network_init(&network, &config, &error) else { + let errorString: String = String(cString: error) + +#if targetEnvironment(simulator) + if errorString == "Address already in use" { + Log.critical(.network, "Failed to create network object, if you are using Lokinet then it's possible another simulator instance is running and using the same port. Please close any other simulator instances and try again.") } +#endif + + Log.error(.network, "Unable to create network object: \(errorString)") + throw NetworkError.invalidState + } } - } - } - - func checkClientVersion(ed25519SecretKey: [UInt8]) -> AnyPublisher<(ResponseInfoType, Network.FileServer.AppVersionResponse), Error> { - typealias Output = (success: Bool, timeout: Bool, statusCode: Int, headers: [String: String], data: Data?) - - return dependencies - .mutate(cache: .libSessionNetwork) { $0.getOrCreateNetwork() } - .tryMapCallbackContext(type: Output.self) { ctx, network in - guard ed25519SecretKey.count == 64 else { throw LibSessionError.invalidCConversion } + } - var cEd25519SecretKey: [UInt8] = Array(ed25519SecretKey) + /// Store the newly created network + self.network = network + self.networkInstance.send(network) - network_get_client_version( - network, - CLIENT_PLATFORM_IOS, - &cEd25519SecretKey, - Int64(floor(Network.defaultTimeout * 1000)), - 0, - { success, timeout, statusCode, cHeaders, cHeaderVals, headerLen, dataPtr, dataLen, ctx in - let headers: [String: String] = CallbackWrapper - .headers(cHeaders, cHeaderVals, headerLen) - let data: Data? = dataPtr.map { Data(bytes: $0, count: dataLen) } - CallbackWrapper.run(ctx, (success, timeout, Int(statusCode), headers, data)) - }, - ctx - ) - } - .tryMap { [dependencies] success, timeout, statusCode, headers, maybeData -> (any ResponseInfoType, Network.FileServer.AppVersionResponse) in - try LibSessionNetwork.throwErrorIfNeeded(success, timeout, statusCode, headers, maybeData, using: dependencies) + session_network_set_status_changed_callback(network, { cStatus, ctx in + guard let ctx: UnsafeMutableRawPointer = ctx else { return } + + let status: NetworkStatus = NetworkStatus(status: cStatus) + let dependencies: Dependencies = Unmanaged.fromOpaque(ctx).takeUnretainedValue() + + // Kick off a task so we don't hold up the libSession thread that triggered the update + Task { [network = dependencies[singleton: .network]] in + await network.setNetworkStatus(status: status) + } + }, dependenciesPtr) - guard let data: Data = maybeData else { throw NetworkError.parsingFailed } + session_network_set_network_info_changed_callback(network, { timeOffsetMs, hardfork, softfork, ctx in + guard let ctx: UnsafeMutableRawPointer = ctx else { return } + + let dependencies: Dependencies = Unmanaged.fromOpaque(ctx).takeUnretainedValue() + + // Kick off a task so we don't hold up the libSession thread that triggered the update + Task { [network = dependencies[singleton: .network]] in + await network.setNetworkInfo( + networkTimeOffsetMs: Int64(timeOffsetMs), + hardfork: Int(hardfork), + softfork: Int(softfork) + ) + } + }, dependenciesPtr) - return ( - Network.ResponseInfo(code: statusCode), - try Network.FileServer.AppVersionResponse.decoded(from: data, using: dependencies) - ) - } - .eraseToAnyPublisher() + return try network ?? { throw NetworkError.invalidState }() + } } - // MARK: - Internal Functions - private func sendRequest( - to destination: Network.Destination, + endpoint: (any EndpointType), + destination: Network.Destination, body: T?, + category: Network.RequestCategory, requestTimeout: TimeInterval, - requestAndPathBuildTimeout: TimeInterval? - ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { - typealias Output = (success: Bool, timeout: Bool, statusCode: Int, headers: [String: String], data: Data?) + overallTimeout: TimeInterval? + ) async throws -> (info: ResponseInfoType, value: Data?) { + typealias Continuation = CheckedContinuation - return dependencies - .mutate(cache: .libSessionNetwork) { $0.getOrCreateNetwork() } - .tryMapCallbackContext(type: Output.self) { ctx, network in - // Prepare the parameters - let cPayloadBytes: [UInt8] - - switch body { - case .none: cPayloadBytes = [] - case let data as Data: cPayloadBytes = Array(data) - case let bytes as [UInt8]: cPayloadBytes = bytes - default: - guard let encodedBody: Data = try? JSONEncoder().encode(body) else { - throw SnodeAPIError.invalidPayload + let network = try await getOrCreateNetwork() + let result: Response = try await withCheckedThrowingContinuation { continuation in + let box = LibSessionNetwork.ContinuationBox(continuation) + + /// If it's a cached request then just return the cached result immediately + if case .cached(let success, let timeout, let statusCode, let headers, let data) = destination { + return box.continuation.resume(returning: (success, timeout, Int(statusCode), headers, data)) + } + + /// Define the callback to avoid dupolication + let context = box.unsafePointer() + let request: Request = Request( + endpoint: endpoint, + body: body, + category: category, + requestTimeout: requestTimeout, + overallTimeout: overallTimeout + ) + + do { + switch destination { + case .snode(let snode, _): + try LibSessionNetwork.withSnodeRequestParams(request, snode) { paramsPtr in + session_network_send_request(network, paramsPtr, box.cCallback, context) } - cPayloadBytes = Array(encodedBody) - } - - // Trigger the request - switch destination { - // These types should be processed and converted to a 'snode' destination before - // they get here - case .randomSnode, .randomSnodeLatestNetworkTimeTarget: - throw NetworkError.invalidPreparedRequest - - case .snode(let snode, let swarmPublicKey): - let cSwarmPublicKey: [CChar]? = try swarmPublicKey.map { - _ = try SessionId(from: $0) - - // Quick way to drop '05' prefix if present - return $0.suffix(64).cString(using: .utf8) - }.flatMap { $0 } + case .server(let info), .serverUpload(let info, _), .serverDownload(let info): + let uploadFileName: String? = { + switch destination { + case .serverUpload(_, let fileName): return fileName + default: return nil + } + }() - network_send_onion_request_to_snode_destination( - network, - snode.cSnode, - cPayloadBytes, - cPayloadBytes.count, - cSwarmPublicKey, - Int64(floor(requestTimeout * 1000)), - Int64(floor((requestAndPathBuildTimeout ?? 0) * 1000)), - { success, timeout, statusCode, cHeaders, cHeaderVals, headerLen, dataPtr, dataLen, ctx in - let headers: [String: String] = CallbackWrapper - .headers(cHeaders, cHeaderVals, headerLen) - let data: Data? = dataPtr.map { Data(bytes: $0, count: dataLen) } - CallbackWrapper.run(ctx, (success, timeout, Int(statusCode), headers, data)) - }, - ctx - ) - - case .server: - try destination.withUnsafePointer { cServerDestination in - network_send_onion_request_to_server_destination( - network, - cServerDestination, - cPayloadBytes, - cPayloadBytes.count, - Int64(floor(requestTimeout * 1000)), - Int64(floor((requestAndPathBuildTimeout ?? 0) * 1000)), - { success, timeout, statusCode, cHeaders, cHeaderVals, headerLen, dataPtr, dataLen, ctx in - let headers: [String: String] = CallbackWrapper - .headers(cHeaders, cHeaderVals, headerLen) - let data: Data? = dataPtr.map { Data(bytes: $0, count: dataLen) } - CallbackWrapper.run(ctx, (success, timeout, Int(statusCode), headers, data)) - }, - ctx - ) + try LibSessionNetwork.withServerRequestParams(request, info, uploadFileName) { paramsPtr in + session_network_send_request(network, paramsPtr, box.cCallback, context) } - - case .serverUpload(_, let fileName): - guard !cPayloadBytes.isEmpty else { throw NetworkError.invalidPreparedRequest } - try destination.withUnsafePointer { cServerDestination in - network_upload_to_server( - network, - cServerDestination, - cPayloadBytes, - cPayloadBytes.count, - fileName?.cString(using: .utf8), - Int64(floor(requestTimeout * 1000)), - Int64(floor((requestAndPathBuildTimeout ?? 0) * 1000)), - { success, timeout, statusCode, cHeaders, cHeaderVals, headerLen, dataPtr, dataLen, ctx in - let headers: [String: String] = CallbackWrapper - .headers(cHeaders, cHeaderVals, headerLen) - let data: Data? = dataPtr.map { Data(bytes: $0, count: dataLen) } - CallbackWrapper.run(ctx, (success, timeout, Int(statusCode), headers, data)) - }, - ctx - ) - } - - case .serverDownload: - try destination.withUnsafePointer { cServerDestination in - network_download_from_server( - network, - cServerDestination, - Int64(floor(requestTimeout * 1000)), - Int64(floor((requestAndPathBuildTimeout ?? 0) * 1000)), - { success, timeout, statusCode, cHeaders, cHeaderVals, headerLen, dataPtr, dataLen, ctx in - let headers: [String: String] = CallbackWrapper - .headers(cHeaders, cHeaderVals, headerLen) - let data: Data? = dataPtr.map { Data(bytes: $0, count: dataLen) } - CallbackWrapper.run(ctx, (success, timeout, Int(statusCode), headers, data)) - }, - ctx - ) - } - - case .cached(let success, let timeout, let statusCode, let headers, let data): - CallbackWrapper.run(ctx, (success, timeout, statusCode, headers, data)) + /// Some destinations are for convenience and redirect to "proper" destination types so if one of them gets here + /// then it is invalid + default: throw NetworkError.invalidPreparedRequest } } - .tryMap { [dependencies] success, timeout, statusCode, headers, data -> (any ResponseInfoType, Data?) in - try LibSessionNetwork.throwErrorIfNeeded(success, timeout, statusCode, headers, data, using: dependencies) - return (Network.ResponseInfo(code: statusCode, headers: headers), data) - } - .eraseToAnyPublisher() + catch { box.continuation.resume(throwing: error) } + } + + try LibSessionNetwork.throwErrorIfNeeded(result, using: dependencies) + return (Network.ResponseInfo(code: result.statusCode, headers: result.headers), result.data) } - private static func throwErrorIfNeeded( - _ success: Bool, - _ timeout: Bool, - _ statusCode: Int, - _ headers: [String: String], - _ data: Data?, - using dependencies: Dependencies - ) throws { - guard !success || statusCode < 200 || statusCode > 299 else { return } - guard !timeout else { - switch data.map({ String(data: $0, encoding: .ascii) }) { - case .none: throw NetworkError.timeout(error: "\(NetworkError.unknown)", rawData: data) - case .some(let responseString): throw NetworkError.timeout(error: responseString, rawData: data) + private static func throwErrorIfNeeded(_ response: Response, using dependencies: Dependencies) throws { + guard !response.success || response.statusCode < 200 || response.statusCode > 299 else { return } + guard !response.timeout else { + switch response.data.map({ String(data: $0, encoding: .ascii) }) { + case .none: throw NetworkError.timeout(error: "\(NetworkError.unknown)", rawData: response.data) + case .some(let responseString): + throw NetworkError.timeout(error: responseString, rawData: response.data) } } /// Handle status codes with specific meanings - switch (statusCode, data.map { String(data: $0, encoding: .ascii) }) { - case (400, .none): throw NetworkError.badRequest(error: "\(NetworkError.unknown)", rawData: data) - case (400, .some(let responseString)): throw NetworkError.badRequest(error: responseString, rawData: data) + switch (response.statusCode, response.data.map { String(data: $0, encoding: .ascii) }) { + case (400, .none): + throw NetworkError.badRequest(error: "\(NetworkError.unknown)", rawData: response.data) + + case (400, .some(let responseString)): + throw NetworkError.badRequest(error: responseString, rawData: response.data) case (401, _): Log.warn(.network, "Unauthorised (Failed to verify the signature).") @@ -386,10 +715,10 @@ class LibSessionNetwork: NetworkType { /// A snode will return a `406` but onion requests v4 seems to return `425` so handle both case (406, _), (425, _): Log.warn(.network, "The user's clock is out of sync with the service node network.") - throw SnodeAPIError.clockOutOfSync + throw StorageServerError.clockOutOfSync - case (421, _): throw SnodeAPIError.unassociatedPubkey - case (429, _): throw SnodeAPIError.rateLimited + case (421, _): throw StorageServerError.unassociatedPubkey + case (429, _): throw StorageServerError.rateLimited case (500, _): throw NetworkError.internalServerError case (503, _): throw NetworkError.serviceUnavailable case (502, .none): throw NetworkError.badGateway @@ -398,19 +727,12 @@ class LibSessionNetwork: NetworkType { throw NetworkError.badGateway } - let nodeHex: String = String(responseString.suffix(64)) - - for path in dependencies[cache: .libSessionNetwork].currentPaths { - if let index: Int = path.firstIndex(where: { $0.ed25519PubkeyHex == nodeHex }) { - throw SnodeAPIError.nodeNotFound(index, nodeHex) - } - } - - throw SnodeAPIError.nodeNotFound(nil, nodeHex) + throw StorageServerError.nodeNotFound(String(responseString.suffix(64))) case (504, _): throw NetworkError.gatewayTimeout case (_, .none): throw NetworkError.unknown - case (_, .some(let responseString)): throw NetworkError.requestFailed(error: responseString, rawData: data) + case (_, .some(let responseString)): + throw NetworkError.requestFailed(error: responseString, rawData: response.data) } } } @@ -447,6 +769,76 @@ private extension LibSessionNetwork { } } +private extension LibSessionNetwork { + class ContinuationBox { + let continuation: T + + init(_ continuation: T) { + self.continuation = continuation + } + + // MARK: - Functions + + public func unsafePointer() -> UnsafeMutableRawPointer { Unmanaged.passRetained(self).toOpaque() } + public static func from(unsafePointer: UnsafeMutableRawPointer?) -> ContinuationBox? { + guard let ptr: UnsafeMutableRawPointer = unsafePointer else { return nil } + + return Unmanaged>.fromOpaque(ptr).takeRetainedValue() + } + } + + class FutureBox { + var promise: ((Result) -> Void)? + + static func create(_ closure: @escaping (UnsafeMutableRawPointer) -> Void) -> AnyPublisher { + let box: FutureBox = FutureBox() + + return Future { [box] promise in + box.promise = promise + + let ptr = Unmanaged.passRetained(box).toOpaque() + closure(ptr) + }.eraseToAnyPublisher() + } + + init() {} + + // MARK: - Functions + + public static func resolve(result: T, ptr: UnsafeMutableRawPointer?) { + guard let ptr: UnsafeMutableRawPointer = ptr else { return } + + Unmanaged> + .fromOpaque(ptr) + .takeRetainedValue() + .promise?(.success(result)) + } + + public static func fail(error: Error, ptr: UnsafeMutableRawPointer?) { + guard let ptr: UnsafeMutableRawPointer = ptr else { return } + + Unmanaged> + .fromOpaque(ptr) + .takeRetainedValue() + .promise?(.failure(error)) + } + } +} + +extension LibSessionNetwork.ContinuationBox where T == CheckedContinuation { + var cCallback: session_network_response_t { + return { success, timeout, statusCode, cHeaders, cHeadersLen, dataPtr, dataLen, ctx in + guard let box = LibSessionNetwork.ContinuationBox.from(unsafePointer: ctx) else { + return + } + + let headers: [String: String] = LibSessionNetwork.headers(cHeaders, cHeadersLen) + let data: Data? = dataPtr.map { Data(bytes: $0, count: dataLen) } + box.continuation.resume(returning: (success, timeout, Int(statusCode), headers, data)) + } + } +} + // MARK: - Publisher Convenience fileprivate extension Publisher { @@ -475,17 +867,6 @@ fileprivate extension Publisher { } } -// MARK: - Optional Convenience - -private extension Optional where Wrapped == LibSessionNetwork { - func validOrThrow() throws -> Wrapped { - switch self { - case .none: throw NetworkError.invalidState - case .some(let value): return value - } - } -} - // MARK: - NetworkStatus Convenience private extension NetworkStatus { @@ -501,47 +882,91 @@ private extension NetworkStatus { // MARK: - Snode +extension LibSession { + public struct Path { + public let nodes: [LibSession.Snode] + public let category: Network.RequestCategory? + public let destinationPubkey: String? + public let destinationSnodeAddress: String? + } +} + extension LibSession { public struct Snode: Codable, Hashable, CustomStringConvertible { - public let ip: String - public let quicPort: UInt16 public let ed25519PubkeyHex: String + public let ip: String + public let httpsPort: UInt16 + public let omqPort: UInt16 + public let version: String + public let swarmId: UInt64 - public var address: String { "\(ip):\(quicPort)" } - public var description: String { address } + public var httpsAddress: String { "\(ip):\(httpsPort)" } + public var omqAddress: String { "\(ip):\(omqPort)" } + public var description: String { omqAddress } public var cSnode: network_service_node { var result: network_service_node = network_service_node() - result.ipString = ip - result.set(\.quic_port, to: quicPort) result.set(\.ed25519_pubkey_hex, to: ed25519PubkeyHex) + result.ipString = ip + result.set(\.https_port, to: httpsPort) + result.set(\.omq_port, to: omqPort) + result.versionString = version + result.set(\.swarm_id, to: swarmId) return result } init(_ cSnode: network_service_node) { - ip = cSnode.ipString - quicPort = cSnode.get(\.quic_port) ed25519PubkeyHex = cSnode.get(\.ed25519_pubkey_hex) + ip = cSnode.ipString + httpsPort = cSnode.get(\.https_port) + omqPort = cSnode.get(\.omq_port) + version = cSnode.versionString + swarmId = cSnode.get(\.swarm_id) } - internal init(ip: String, quicPort: UInt16, ed25519PubkeyHex: String) { - self.ip = ip - self.quicPort = quicPort + internal init(_ config: ServiceNetwork.DevnetConfiguration) { + self.ed25519PubkeyHex = config.pubkey + self.ip = config.ip + self.httpsPort = config.httpPort + self.omqPort = config.omqPort + self.version = "" + self.swarmId = 0 + } + + internal init( + ed25519PubkeyHex: String, + ip: String, + httpsPort: UInt16, + quicPort: UInt16, + version: String, + swarmId: UInt64 + ) { self.ed25519PubkeyHex = ed25519PubkeyHex + self.ip = ip + self.httpsPort = httpsPort + self.omqPort = quicPort + self.version = version + self.swarmId = swarmId } public func hash(into hasher: inout Hasher) { - ip.hash(into: &hasher) - quicPort.hash(into: &hasher) ed25519PubkeyHex.hash(into: &hasher) + ip.hash(into: &hasher) + httpsPort.hash(into: &hasher) + omqPort.hash(into: &hasher) + version.hash(into: &hasher) + swarmId.hash(into: &hasher) } public static func == (lhs: Snode, rhs: Snode) -> Bool { return ( + lhs.ed25519PubkeyHex == rhs.ed25519PubkeyHex && lhs.ip == rhs.ip && - lhs.quicPort == rhs.quicPort && - lhs.ed25519PubkeyHex == rhs.ed25519PubkeyHex + lhs.httpsPort == rhs.httpsPort && + lhs.omqPort == rhs.omqPort && + lhs.version == rhs.version && + lhs.swarmId == rhs.swarmId ) } } @@ -562,389 +987,262 @@ extension network_service_node: @retroactive CAccessible, @retroactive CMutable self.ip = (ipParts[0], ipParts[1], ipParts[2], ipParts[3]) } } + + var versionString: String { + get { "\(version.0).\(version.1).\(version.2)" } + set { + let versionParts: [UInt16] = newValue + .components(separatedBy: ".") + .compactMap { UInt16($0) } + + guard versionParts.count == 3 else { return } + + self.version = (versionParts[0], versionParts[1], versionParts[2]) + } + } } // MARK: - Convenience -private extension Network.Destination { - func withUnsafePointer(_ body: (network_server_destination) throws -> Result) throws -> Result { - let method: HTTPMethod - let url: URL - let headers: [HTTPHeader: String]? - let x25519PublicKey: String - - switch self { - case .snode, .randomSnode, .randomSnodeLatestNetworkTimeTarget, .cached: throw NetworkError.invalidPreparedRequest - case .server(let info), .serverUpload(let info, _), .serverDownload(let info): - method = info.method - url = try info.url - headers = info.headers - x25519PublicKey = String(info.x25519PublicKey - .suffix(64)) // Quick way to drop '05' prefix if present - } - - guard let host: String = url.host else { throw NetworkError.invalidURL } - guard x25519PublicKey.count == 64 || x25519PublicKey.count == 66 else { - throw LibSessionError.invalidCConversion - } - - let targetScheme: String = (url.scheme ?? "https") - let endpoint: String = url.path - .appending(url.query.map { value in "?\(value)" } ?? "") - let port: UInt16 = UInt16(url.port ?? (targetScheme == "https" ? 443 : 80)) - let headerKeys: [String] = (headers?.map { $0.key } ?? []) - let headerValues: [String] = (headers?.map { $0.value } ?? []) - let headersSize = headerKeys.count +private extension LibSessionNetwork { + struct Request { + let endpoint: (any EndpointType) + let body: T? + let category: Network.RequestCategory + let requestTimeout: TimeInterval + let overallTimeout: TimeInterval? + } + + static func withSnodeRequestParams( + _ request: Request, + _ node: LibSession.Snode, + _ callback: (UnsafePointer) -> Result + ) throws -> Result { + var cSnode = node.cSnode - // Use scoped closure to avoid manual memory management (crazy nesting but it ends up safer) - return try method.rawValue.withCString { cMethodPtr in - try targetScheme.withCString { cTargetSchemePtr in - try host.withCString { cHostPtr in - try endpoint.withCString { cEndpointPtr in - try x25519PublicKey.withCString { cX25519PubkeyPtr in - try headerKeys.withUnsafeCStrArray { headerKeysPtr in - try headerValues.withUnsafeCStrArray { headerValuesPtr in - let cServerDest = network_server_destination( - method: cMethodPtr, - protocol: cTargetSchemePtr, - host: cHostPtr, - endpoint: cEndpointPtr, - port: port, - x25519_pubkey: cX25519PubkeyPtr, - headers: headerKeysPtr.baseAddress, - header_values: headerValuesPtr.baseAddress, - headers_size: headersSize - ) - - return try body(cServerDest) - } - } + return try withBodyPointer(request.body) { cBodyPtr, bodySize in + withUnsafePointer(to: &cSnode) { cSnodePtr in + request.endpoint.path.withCString { cEndpoint in + let params: session_request_params = session_request_params( + snode_dest: cSnodePtr, + server_dest: nil, + remote_addr_dest: nil, + endpoint: cEndpoint, + body: cBodyPtr, + body_size: bodySize, + category: request.category.libSessionValue, + request_timeout_ms: UInt64(Int64(floor(request.requestTimeout * 1000))), + overall_timeout_ms: UInt64(floor((request.overallTimeout ?? 0) * 1000)), + upload_file_name: nil, + request_id: nil + ) + + return withUnsafePointer(to: params) { paramsPtr in + callback(paramsPtr) + } + } + } + } + } + + static func withServerRequestParams( + _ request: Request, + _ info: Network.Destination.ServerInfo, + _ uploadFileName: String?, + _ callback: (UnsafePointer) -> Result + ) throws -> Result { + return try withBodyPointer(request.body) { cBodyPtr, bodySize in + let pathWithParams: String = Network.Destination.generatePathWithParams( + endpoint: request.endpoint, + queryParameters: info.queryParameters + ) + + return try pathWithParams.withCString { cEndpoint in + try withFileNamePtr(uploadFileName) { cUploadFileNamePtr in + try info.withServerInfoPointer { cServerDestinationPtr in + let params: session_request_params = session_request_params( + snode_dest: nil, + server_dest: cServerDestinationPtr, + remote_addr_dest: nil, + endpoint: cEndpoint, + body: cBodyPtr, + body_size: bodySize, + category: request.category.libSessionValue, + request_timeout_ms: UInt64(floor(request.requestTimeout * 1000)), + overall_timeout_ms: UInt64(floor((request.overallTimeout ?? 0) * 1000)), + upload_file_name: cUploadFileNamePtr, + request_id: nil + ) + + return withUnsafePointer(to: params) { paramsPtr in + callback(paramsPtr) } } } } } } -} - -private extension LibSessionNetwork.CallbackWrapper { - static func headers( - _ cHeaders: UnsafePointer?>?, - _ cHeaderVals: UnsafePointer?>?, - _ count: Int - ) -> [String: String] { - let headers: [String] = ([String](cStringArray: cHeaders, count: count) ?? []) - let headerVals: [String] = ([String](cStringArray: cHeaderVals, count: count) ?? []) - - return zip(headers, headerVals) - .reduce(into: [:]) { result, next in result[next.0] = next.1 } + + private static func withFileNamePtr( + _ uploadFilename: String?, + _ closure: (UnsafePointer?) throws -> Result + ) throws -> Result { + switch uploadFilename { + case .none: return try closure(nil) + case .some(let filename): return try filename.withCString { try closure($0) } + } } -} - -// MARK: - LibSession.NetworkCache -public extension LibSession { - class NetworkCache: NetworkCacheType { - private static var snodeCachePath: String { "\(SessionFileManager.nonInjectedAppSharedDataDirectoryPath)/snodeCache" } + private static func withBodyPointer( + _ body: T?, + _ closure: (UnsafePointer?, Int) throws -> Result + ) throws -> Result { + let maybeBodyData: Data? - private let dependencies: Dependencies - private let dependenciesPtr: UnsafeMutableRawPointer - private var network: UnsafeMutablePointer? = nil - private let _paths: CurrentValueSubject<[[Snode]], Never> = CurrentValueSubject([]) - private let _networkStatus: CurrentValueSubject = CurrentValueSubject(.unknown) - private let _snodeNumber: CurrentValueSubject<[String: Int], Never> = .init([:]) - - public var isSuspended: Bool = false - public var networkStatus: AnyPublisher { _networkStatus.eraseToAnyPublisher() } - - public var paths: AnyPublisher<[[Snode]], Never> { _paths.eraseToAnyPublisher() } - public var hasPaths: Bool { !_paths.value.isEmpty } - public var currentPaths: [[Snode]] { _paths.value } - public var pathsDescription: String { _paths.value.prettifiedDescription } - public var snodeNumber: [String: Int] { _snodeNumber.value } - - // MARK: - Initialization - - public init(using dependencies: Dependencies) { - self.dependencies = dependencies - self.dependenciesPtr = Unmanaged.passRetained(dependencies).toOpaque() - - // Create the network object - getOrCreateNetwork().sinkUntilComplete() - - // If the app has been set to 'forceOffline' then we need to explicitly set the network status - // to disconnected (because it'll never be set otherwise) - if dependencies[feature: .forceOffline] { - DispatchQueue.global(qos: .default).async { [dependencies] in - dependencies.mutate(cache: .libSessionNetwork) { $0.setNetworkStatus(status: .disconnected) } + switch body { + case .none: maybeBodyData = nil + case let data as Data: maybeBodyData = data + case let bytes as [UInt8]: maybeBodyData = Data(bytes) + default: + guard let encodedBody: Data = try? JSONEncoder().encode(body) else { + throw StorageServerError.invalidPayload } - } + + maybeBodyData = encodedBody } - deinit { - // Send completion events to the observables (so they can resubscribe to a future instance) - _paths.send(completion: .finished) - _networkStatus.send(completion: .finished) - _snodeNumber.send(completion: .finished) - - // Clear the network changed callbacks (just in case, since we are going to free the - // dependenciesPtr) and then free the network object - switch network { - case .none: break - case .some(let network): - network_set_status_changed_callback(network, nil, nil) - network_set_paths_changed_callback(network, nil, nil) - network_free(network) - } - - // Finally we need to make sure to clean up the unbalanced retain to the dependencies - Unmanaged.fromOpaque(dependenciesPtr).release() + guard let bodyData: Data = maybeBodyData, !bodyData.isEmpty else { + return try closure(nil, 0) } - // MARK: - Functions - - public func suspendNetworkAccess() { - isSuspended = true - Log.info(.network, "Network access suspended.") - - switch network { - case .none: break - case .some(let network): network_suspend(network) - } - - dependencies.notifyAsync(key: .networkLifecycle(.suspended)) + return try bodyData.withUnsafeBytes { (rawPtr: UnsafeRawBufferPointer) in + let ptr: UnsafePointer? = rawPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) + return try closure(ptr, bodyData.count) } + } +} + +private extension Network.Destination.ServerInfo { + func withServerInfoPointer(_ body: (UnsafePointer) -> Result) throws -> Result { + let x25519PublicKey: String = String(x25519PublicKey.suffix(64)) // Quick way to drop '05' prefix if present - public func resumeNetworkAccess() { - isSuspended = false - - switch network { - case .none: break - case .some(let network): network_resume(network) - } - - Log.info(.network, "Network access resumed.") - dependencies.notifyAsync(key: .networkLifecycle(.resumed)) + guard let host: String = self.host else { throw NetworkError.invalidURL } + guard x25519PublicKey.count == 64 || x25519PublicKey.count == 66 else { + throw LibSessionError.invalidCConversion } - public func getOrCreateNetwork() -> AnyPublisher?, Error> { - guard !isSuspended else { - Log.warn(.network, "Attempted to access suspended network.") - return Fail(error: NetworkError.suspended).eraseToAnyPublisher() - } - - switch (network, dependencies[feature: .forceOffline]) { - case (_, true): - return Fail(error: NetworkError.serviceUnavailable) - .delay(for: .seconds(1), scheduler: DispatchQueue.global(qos: .userInitiated)) - .eraseToAnyPublisher() - - case (.some(let existingNetwork), _): - return Just(existingNetwork) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - - case (.none, _): - let useTestnet: Bool = (dependencies[feature: .serviceNetwork] == .testnet) - let isMainApp: Bool = dependencies[singleton: .appContext].isMainApp - var error: [CChar] = [CChar](repeating: 0, count: 256) - var network: UnsafeMutablePointer? - - guard let cCachePath: [CChar] = NetworkCache.snodeCachePath.cString(using: .utf8) else { - Log.error(.network, "Unable to create network object: \(LibSessionError.invalidCConversion)") - return Fail(error: NetworkError.invalidState).eraseToAnyPublisher() - } - - guard network_init(&network, cCachePath, useTestnet, !isMainApp, true, &error) else { - Log.error(.network, "Unable to create network object: \(String(cString: error))") - return Fail(error: NetworkError.invalidState).eraseToAnyPublisher() - } - - // Store the newly created network - self.network = network - - /// Register the callbacks in the next run loop (this needs to happen in a subsequent run loop because it mutates the - /// `libSessionNetwork` cache and this function gets called during init so could end up with weird order-of-execution issues) - /// - /// **Note:** We do it this way because `DispatchQueue.async` can be optimised out if the code is already running in a - /// queue with the same `qos`, this approach ensures the code will run in a subsequent run loop regardless - let concurrentQueue = DispatchQueue(label: "Network.callback.registration", attributes: .concurrent) - concurrentQueue.async(flags: .barrier) { [weak self] in - guard - let network: UnsafeMutablePointer = self?.network, - let dependenciesPtr: UnsafeMutableRawPointer = self?.dependenciesPtr - else { return } - - // Register for network status changes - network_set_status_changed_callback(network, { cStatus, ctx in - guard let ctx: UnsafeMutableRawPointer = ctx else { return } - - let status: NetworkStatus = NetworkStatus(status: cStatus) - let dependencies: Dependencies = Unmanaged.fromOpaque(ctx).takeUnretainedValue() - - // Dispatch async so we don't hold up the libSession thread that triggered the update - // or have a reentrancy issue with the mutable cache - DispatchQueue.global(qos: .default).async { - dependencies.mutate(cache: .libSessionNetwork) { $0.setNetworkStatus(status: status) } - } - }, dependenciesPtr) - - // Register for path changes - network_set_paths_changed_callback(network, { pathsPtr, pathsLen, ctx in - guard let ctx: UnsafeMutableRawPointer = ctx else { return } - - var paths: [[Snode]] = [] - - if let cPathsPtr: UnsafeMutablePointer = pathsPtr { - var cPaths: [onion_request_path] = [] - - (0...fromOpaque(ctx).takeUnretainedValue() + let targetScheme: String = (self.scheme ?? "https") + let port: UInt16 = UInt16(self.port ?? (targetScheme == "https" ? 443 : 80)) + let headersArray: [String] = headers.flatMap { [$0.key, $0.value] } + + // Use scoped closure to avoid manual memory management (crazy nesting but it ends up safer) + return try method.rawValue.withCString { cMethodPtr in + try targetScheme.withCString { cTargetSchemePtr in + try host.withCString { cHostPtr in + try x25519PublicKey.withCString { cX25519PubkeyPtr in + try headersArray.withUnsafeCStrArray { headersArrayPtr in + let cServerDest = network_server_destination( + method: cMethodPtr, + protocol: cTargetSchemePtr, + host: cHostPtr, + port: port, + x25519_pubkey_hex: cX25519PubkeyPtr, + headers_kv_pairs: headersArrayPtr.baseAddress, + headers_kv_pairs_len: headersArray.count + ) - DispatchQueue.global(qos: .default).async { - dependencies.mutate(cache: .libSessionNetwork) { $0.setPaths(paths: paths) } + return withUnsafePointer(to: cServerDest) { ptr in + body(ptr) } - }, dependenciesPtr) + } } - - return Just(network) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + } } } + } +} + +private extension LibSessionNetwork { + static func headers(_ cHeaders: UnsafePointer?>?, _ count: Int) -> [String: String] { + let headersArray: [String] = ([String](cStringArray: cHeaders, count: count) ?? []) - public func setNetworkStatus(status: NetworkStatus) { - guard status == .disconnected || !isSuspended else { - Log.warn(.network, "Attempted to update network status to '\(status)' for suspended network, closing connections again.") - - switch network { - case .none: return - case .some(let network): return network_close_connections(network) + return stride(from: 0, to: headersArray.count, by: 2) + .reduce(into: [:]) { result, index in + if (index + index) < headersArray.count { + result[headersArray[index]] = headersArray[index + 1] } } - - // Notify any subscribers - Log.info(.network, "Network status changed to: \(status)") - _networkStatus.send(status) - } + } +} + +public extension LibSession { + actor NoopNetwork: NetworkType { + public let isSuspended: Bool = false + public let hardfork: Int = 0 + public let softfork: Int = 0 + public let networkTimeOffsetMs: Int64 = 0 - public func setPaths(paths: [[Snode]]) { - // Notify any subscribers - _paths.send(paths) - } + nonisolated public let networkStatus: AsyncStream = .makeStream().stream + nonisolated public let syncState: NetworkSyncState - public func setSnodeNumber(publicKey: String, value: Int) { - var snodeNumber = _snodeNumber.value - snodeNumber[publicKey] = value - _snodeNumber.send(snodeNumber) + public init(using dependencies: Dependencies) { + syncState = NetworkSyncState( + hardfork: 0, + softfork: 0, + using: dependencies + ) } - public func clearCallbacks() { - switch network { - case .none: break - case .some(let network): - network_set_status_changed_callback(network, nil, nil) - network_set_paths_changed_callback(network, nil, nil) - } - } + public func getActivePaths() async throws -> [LibSession.Path] { return [] } + public func getSwarm(for swarmPublicKey: String) async throws -> Set { return [] } + public func getRandomNodes(count: Int) async throws -> Set { return [] } - public func clearSnodeCache() { - switch network { - case .none: break - case .some(let network): network_clear_cache(network) - } + nonisolated public func send( + endpoint: E, + destination: Network.Destination, + body: Data?, + category: Network.RequestCategory, + requestTimeout: TimeInterval, + overallTimeout: TimeInterval? + ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + return Fail(error: NetworkError.invalidState).eraseToAnyPublisher() } - public func snodeCacheSize() -> Int { - switch network { - case .none: return 0 - case .some(let network): return network_get_snode_cache_size(network) - } + public func send( + endpoint: E, + destination: Network.Destination, + body: Data?, + category: Network.RequestCategory, + requestTimeout: TimeInterval, + overallTimeout: TimeInterval? + ) async throws -> (info: ResponseInfoType, value: Data?) { + return (Network.ResponseInfo(code: -1), nil) } - } - - // MARK: - NetworkCacheType - - /// This is a read-only version of the Cache designed to avoid unintentionally mutating the instance in a non-thread-safe way - protocol NetworkImmutableCacheType: ImmutableCacheType { - var isSuspended: Bool { get } - var networkStatus: AnyPublisher { get } - var paths: AnyPublisher<[[Snode]], Never> { get } - var hasPaths: Bool { get } - var currentPaths: [[Snode]] { get } - var pathsDescription: String { get } - var snodeNumber: [String: Int] { get } - } - - protocol NetworkCacheType: NetworkImmutableCacheType, MutableCacheType { - var isSuspended: Bool { get } - var networkStatus: AnyPublisher { get } - - var paths: AnyPublisher<[[Snode]], Never> { get } - var hasPaths: Bool { get } - var currentPaths: [[Snode]] { get } - var pathsDescription: String { get } - var snodeNumber: [String: Int] { get } - - func suspendNetworkAccess() - func resumeNetworkAccess() - func getOrCreateNetwork() -> AnyPublisher?, Error> - func setNetworkStatus(status: NetworkStatus) - func setPaths(paths: [[Snode]]) - func setSnodeNumber(publicKey: String, value: Int) - func clearCallbacks() - func clearSnodeCache() - func snodeCacheSize() -> Int - } - - class NoopNetworkCache: NetworkCacheType, NoopDependency { - public var isSuspended: Bool { return false } - public var networkStatus: AnyPublisher { - Just(NetworkStatus.unknown).eraseToAnyPublisher() - } - - public var paths: AnyPublisher<[[Snode]], Never> { Just([]).eraseToAnyPublisher() } - public var hasPaths: Bool { return false } - public var currentPaths: [[LibSession.Snode]] { [] } - public var pathsDescription: String { "" } - public var snodeNumber: [String: Int] { [:] } - - public func suspendNetworkAccess() {} - public func resumeNetworkAccess() {} - public func getOrCreateNetwork() -> AnyPublisher?, Error> { - return Fail(error: NetworkError.invalidState) - .eraseToAnyPublisher() + public func checkClientVersion(ed25519SecretKey: [UInt8]) async throws -> (info: ResponseInfoType, value: Network.FileServer.AppVersionResponse) { + return ( + Network.ResponseInfo(code: -1), + Network.FileServer.AppVersionResponse( + version: "", + updated: nil, + name: nil, + notes: nil, + assets: nil, + prerelease: nil + ) + ) } - public func setNetworkStatus(status: NetworkStatus) {} - public func setPaths(paths: [[LibSession.Snode]]) {} - public func setSnodeNumber(publicKey: String, value: Int) {} - public func clearCallbacks() {} - public func clearSnodeCache() {} - public func snodeCacheSize() -> Int { 0 } + public func resetNetworkStatus() async {} + public func setNetworkStatus(status: NetworkStatus) async {} + public func setNetworkInfo(networkTimeOffsetMs: Int64, hardfork: Int, softfork: Int) async {} + public func suspendNetworkAccess() async {} + public func resumeNetworkAccess(autoReconnect: Bool) async {} + public func finishCurrentObservations() async {} + public func clearCache() async {} } } + +extension session_network_config: @retroactive CAccessible, @retroactive CMutable {} +extension session_onion_path_metadata: @retroactive CAccessible {} +extension session_lokinet_tunnel_metadata: @retroactive CAccessible {} diff --git a/SessionNetworkingKit/PushNotification/Models/NotificationMetadata.swift b/SessionNetworkingKit/PushNotification/Models/NotificationMetadata.swift index 817e19185d..b0b50ecb81 100644 --- a/SessionNetworkingKit/PushNotification/Models/NotificationMetadata.swift +++ b/SessionNetworkingKit/PushNotification/Models/NotificationMetadata.swift @@ -21,7 +21,7 @@ public extension Network.PushNotification { public let hash: String /// The swarm namespace in which this message arrived. - public let namespace: Network.SnodeAPI.Namespace + public let namespace: Network.StorageServer.Namespace /// The swarm timestamp when the message was created (unix epoch milliseconds) public let createdTimestampMs: Int64 @@ -49,9 +49,9 @@ extension Network.PushNotification.NotificationMetadata { /// There was a bug at one point where the metadata would include a `null` value for the namespace because we were storing /// messages in a namespace that the storage server didn't have an explicit `namespace_id` for, as a result we need to assume /// that the `namespace` value may not be present in the payload - let namespace: Network.SnodeAPI.Namespace = try container + let namespace: Network.StorageServer.Namespace = try container .decodeIfPresent(Int.self, forKey: .namespace) - .map { Network.SnodeAPI.Namespace(rawValue: $0) } + .map { Network.StorageServer.Namespace(rawValue: $0) } .defaulting(to: .unknown) self = Network.PushNotification.NotificationMetadata( diff --git a/SessionNetworkingKit/PushNotification/Models/SubscribeRequest.swift b/SessionNetworkingKit/PushNotification/Models/SubscribeRequest.swift index 400c8884f8..8654cedeb9 100644 --- a/SessionNetworkingKit/PushNotification/Models/SubscribeRequest.swift +++ b/SessionNetworkingKit/PushNotification/Models/SubscribeRequest.swift @@ -17,7 +17,7 @@ public extension Network.PushNotification { } /// List of integer namespace (-32768 through 32767). These must be sorted in ascending order. - private let namespaces: [Network.SnodeAPI.Namespace] + private let namespaces: [Network.StorageServer.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. @@ -67,7 +67,7 @@ public extension Network.PushNotification { // MARK: - Initialization init( - namespaces: [Network.SnodeAPI.Namespace], + namespaces: [Network.StorageServer.Namespace], includeMessageData: Bool, serviceInfo: ServiceInfo, notificationsEncryptionKey: Data, diff --git a/SessionNetworkingKit/PushNotification/Models/UnsubscribeRequest.swift b/SessionNetworkingKit/PushNotification/Models/UnsubscribeRequest.swift index 0c98e9d3fe..52222d51e1 100644 --- a/SessionNetworkingKit/PushNotification/Models/UnsubscribeRequest.swift +++ b/SessionNetworkingKit/PushNotification/Models/UnsubscribeRequest.swift @@ -53,10 +53,10 @@ extension Network.PushNotification { try container.encode(serviceInfo, forKey: .serviceInfo) - // Use the correct APNS service based on the serviceNetwork (default to mainnet) - switch encoder.dependencies?[feature: .serviceNetwork] { - case .testnet: try container.encode(Service.sandbox, forKey: .service) - case .mainnet, .none: try container.encode(Service.apns, forKey: .service) + // Use the desired APNS service (default to apns) + switch encoder.dependencies?[feature: .pushNotificationService] { + case .sandbox: try container.encode(Service.sandbox, forKey: .service) + case .apns, .none: try container.encode(Service.apns, forKey: .service) } try super.encode(to: encoder) diff --git a/SessionNetworkingKit/PushNotification/PushNotificationAPI.swift b/SessionNetworkingKit/PushNotification/PushNotificationAPI.swift index d852beeaed..aedf6c24db 100644 --- a/SessionNetworkingKit/PushNotification/PushNotificationAPI.swift +++ b/SessionNetworkingKit/PushNotification/PushNotificationAPI.swift @@ -8,21 +8,15 @@ import UserNotifications import SessionUtilitiesKit public extension Network.PushNotification { - static func preparedSubscribe( + static func subscribe( token: Data, - swarms: [(sessionId: SessionId, authMethod: AuthenticationMethod)], + swarmAuthentication: [AuthenticationMethod], using dependencies: Dependencies - ) throws -> Network.PreparedRequest { + ) async throws -> SubscribeResponse { guard dependencies[defaults: .standard, key: .isUsingFullAPNs] else { throw NetworkError.invalidPreparedRequest } - guard !swarms.isEmpty else { - return try Network.PreparedRequest.cached( - SubscribeResponse(subResponses: []), - endpoint: Endpoint.subscribe, - using: dependencies - ) - } + guard !swarmAuthentication.isEmpty else { return SubscribeResponse(subResponses: []) } guard let notificationsEncryptionKey: Data = try? dependencies[singleton: .keychain].getOrGenerateEncryptionKey( forKey: .pushNotificationEncryptionKey, @@ -35,114 +29,110 @@ public extension Network.PushNotification { throw KeychainStorageError.keySpecInvalid } - return try Network.PreparedRequest( - request: Request( - method: .post, - endpoint: Endpoint.subscribe, - body: SubscribeRequest( - subscriptions: swarms.map { sessionId, authMethod -> SubscribeRequest.Subscription in - SubscribeRequest.Subscription( - namespaces: { - switch sessionId.prefix { - case .group: return [ - .groupMessages, - .configGroupKeys, - .configGroupInfo, - .configGroupMembers, - .revokedRetrievableGroupMessages - ] - default: return [ - .default, - .configUserProfile, - .configContacts, - .configConvoInfoVolatile, - .configUserGroups - ] - } - }(), - /// 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: ServiceInfo( - token: token.toHexString() - ), - notificationsEncryptionKey: notificationsEncryptionKey, - authMethod: authMethod, - timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) // Seconds - ) - } - ) - ), - responseType: SubscribeResponse.self, - retryCount: Network.PushNotification.maxRetryCount, - using: dependencies - ) - .handleEvents( - receiveOutput: { _, response in - zip(response.subResponses, swarms).forEach { subResponse, swarm in - guard subResponse.success != true else { return } - - Log.error(.pushNotificationAPI, "Couldn't subscribe for push notifications for: \(swarm.sessionId) due to error (\(subResponse.error ?? -1)): \(subResponse.message ?? "nil").") - } - }, - receiveCompletion: { result in - switch result { - case .finished: break - case .failure(let error): Log.error(.pushNotificationAPI, "Couldn't subscribe for push notifications due to error: \(error).") - } + do { + let request: Network.PreparedRequest = try Network.PreparedRequest( + request: Request( + method: .post, + endpoint: Endpoint.subscribe, + body: SubscribeRequest( + subscriptions: swarmAuthentication.map { authMethod -> SubscribeRequest.Subscription in + SubscribeRequest.Subscription( + namespaces: { + switch try? SessionId.Prefix(from: try? authMethod.swarmPublicKey) { + case .group: return [ + .groupMessages, + .configGroupKeys, + .configGroupInfo, + .configGroupMembers, + .revokedRetrievableGroupMessages + ] + default: return [ + .default, + .configUserProfile, + .configContacts, + .configConvoInfoVolatile, + .configUserGroups + ] + } + }(), + /// 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: ServiceInfo( + token: token.toHexString() + ), + notificationsEncryptionKey: notificationsEncryptionKey, + authMethod: authMethod, + timestamp: (dependencies.networkOffsetTimestampMs() / 1000) // Seconds + ) + } + ), + retryCount: Network.PushNotification.maxRetryCount + ), + responseType: SubscribeResponse.self, + using: dependencies + ) + let response: SubscribeResponse = try await request.send(using: dependencies) + + zip(response.subResponses, swarmAuthentication).forEach { subResponse, authMethod in + guard subResponse.success != true else { return } + + let swarmPublicKey: String = ((try? authMethod.swarmPublicKey) ?? "INVALID") + Log.error(.pushNotificationAPI, "Couldn't subscribe for push notifications for: \(swarmPublicKey) due to error (\(subResponse.error ?? -1)): \(subResponse.message ?? "nil").") } - ) + + return response + } + catch { + Log.error(.pushNotificationAPI, "Couldn't subscribe for push notifications due to error: \(error).") + throw error + } } - static func preparedUnsubscribe( + static func unsubscribe( token: Data, - swarms: [(sessionId: SessionId, authMethod: AuthenticationMethod)], + swarmAuthentication: [AuthenticationMethod], using dependencies: Dependencies - ) throws -> Network.PreparedRequest { - guard !swarms.isEmpty else { - return try Network.PreparedRequest.cached( - UnsubscribeResponse(subResponses: []), - endpoint: Endpoint.subscribe, + ) async throws -> UnsubscribeResponse { + guard !swarmAuthentication.isEmpty else { return UnsubscribeResponse(subResponses: []) } + + do { + let request: Network.PreparedRequest = try Network.PreparedRequest( + request: Request( + method: .post, + endpoint: Endpoint.unsubscribe, + body: UnsubscribeRequest( + subscriptions: swarmAuthentication.map { authMethod -> UnsubscribeRequest.Subscription in + UnsubscribeRequest.Subscription( + serviceInfo: ServiceInfo( + token: token.toHexString() + ), + authMethod: authMethod, + timestamp: (dependencies.networkOffsetTimestampMs() / 1000) // Seconds + ) + } + ), + retryCount: Network.PushNotification.maxRetryCount + ), + responseType: UnsubscribeResponse.self, using: dependencies ) - } - - return try Network.PreparedRequest( - request: Request( - method: .post, - endpoint: Endpoint.unsubscribe, - body: UnsubscribeRequest( - subscriptions: swarms.map { sessionId, authMethod -> UnsubscribeRequest.Subscription in - UnsubscribeRequest.Subscription( - serviceInfo: ServiceInfo( - token: token.toHexString() - ), - authMethod: authMethod, - timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) // Seconds - ) - } - ) - ), - responseType: UnsubscribeResponse.self, - retryCount: Network.PushNotification.maxRetryCount, - using: dependencies - ) - .handleEvents( - receiveOutput: { _, response in - zip(response.subResponses, swarms).forEach { subResponse, swarm in - guard subResponse.success != true else { return } - - Log.error(.pushNotificationAPI, "Couldn't unsubscribe for push notifications for: \(swarm.sessionId) due to error (\(subResponse.error ?? -1)): \(subResponse.message ?? "nil").") - } - }, - receiveCompletion: { result in - switch result { - case .finished: break - case .failure(let error): Log.error(.pushNotificationAPI, "Couldn't unsubscribe for push notifications due to error: \(error).") - } + let response: UnsubscribeResponse = try await request.send(using: dependencies) + + zip(response.subResponses, swarmAuthentication).forEach { subResponse, authMethod in + guard subResponse.success != true else { return } + + let swarmPublicKey: String = ((try? authMethod.swarmPublicKey) ?? "INVALID") + Log.error(.pushNotificationAPI, "Couldn't unsubscribe for push notifications for: \(swarmPublicKey) due to error (\(subResponse.error ?? -1)): \(subResponse.message ?? "nil").") } - ) + + return response + } + catch { + Log.error(.pushNotificationAPI, "Couldn't unsubscribe for push notifications due to error: \(error).") + throw error + } } // MARK: - Notification Handling diff --git a/SessionNetworkingKit/PushNotification/Types/Request+PushNotificationAPI.swift b/SessionNetworkingKit/PushNotification/Types/Request+PushNotificationAPI.swift index 480c90bf65..737c3522b5 100644 --- a/SessionNetworkingKit/PushNotification/Types/Request+PushNotificationAPI.swift +++ b/SessionNetworkingKit/PushNotification/Types/Request+PushNotificationAPI.swift @@ -9,18 +9,20 @@ public extension Request where Endpoint == Network.PushNotification.Endpoint { endpoint: Endpoint, queryParameters: [HTTPQueryParam: String] = [:], headers: [HTTPHeader: String] = [:], - body: T? = nil - ) throws { - self = try Request( + body: T? = nil, + retryCount: Int = 0 + ) { + self = Request( endpoint: endpoint, - destination: try .server( + destination: .server( method: method, server: Network.PushNotification.server, queryParameters: queryParameters, headers: headers, x25519PublicKey: Network.PushNotification.serverPublicKey ), - body: body + body: body, + retryCount: retryCount ) } } diff --git a/SessionNetworkingKit/SOGS/SOGS.swift b/SessionNetworkingKit/SOGS/SOGS.swift index 0c5e5ce75e..22beb84cac 100644 --- a/SessionNetworkingKit/SOGS/SOGS.swift +++ b/SessionNetworkingKit/SOGS/SOGS.swift @@ -9,7 +9,5 @@ public extension Network { public static let defaultServerPublicKey = "a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238" public static let validTimestampVarianceThreshold: TimeInterval = (6 * 60 * 60) internal static let maxInactivityPeriodForPolling: TimeInterval = (14 * 24 * 60 * 60) - - public static let workQueue = DispatchQueue(label: "SOGS.workQueue", qos: .userInitiated) // It's important that this is a serial queue } } diff --git a/SessionNetworkingKit/SOGS/SOGSAPI.swift b/SessionNetworkingKit/SOGS/SOGSAPI.swift index 981058ae32..17022cd867 100644 --- a/SessionNetworkingKit/SOGS/SOGSAPI.swift +++ b/SessionNetworkingKit/SOGS/SOGSAPI.swift @@ -167,7 +167,7 @@ public extension Network.SOGS { authMethod: authMethod ), responseType: Network.BatchResponseMap.self, - additionalSignatureData: AdditionalSigningData(authMethod), + additionalSignatureData: (skipAuthentication ? nil : AdditionalSigningData(authMethod)), using: dependencies ) @@ -197,7 +197,7 @@ public extension Network.SOGS { authMethod: authMethod ), responseType: CapabilitiesResponse.self, - additionalSignatureData: AdditionalSigningData(authMethod), + additionalSignatureData: (skipAuthentication ? nil : AdditionalSigningData(authMethod)), using: dependencies ) @@ -223,7 +223,7 @@ public extension Network.SOGS { authMethod: authMethod ), responseType: [Room].self, - additionalSignatureData: AdditionalSigningData(authMethod), + additionalSignatureData: (skipAuthentication ? nil : AdditionalSigningData(authMethod)), using: dependencies ) @@ -822,11 +822,12 @@ public extension Network.SOGS { x25519PublicKey: publicKey, fileName: fileName ), - body: data + body: data, + category: .upload, + requestTimeout: Network.fileUploadTimeout ), responseType: FileUploadResponse.self, additionalSignatureData: AdditionalSigningData(authMethod), - requestTimeout: Network.fileUploadTimeout, using: dependencies ) .signed(with: Network.SOGS.signRequest, using: dependencies) @@ -863,11 +864,12 @@ public extension Network.SOGS { let preparedRequest = try Network.PreparedRequest( request: Request( endpoint: .roomFileIndividual(roomToken, fileId), - authMethod: authMethod + category: .download, + authMethod: authMethod, + requestTimeout: Network.fileDownloadTimeout ), responseType: Data.self, - additionalSignatureData: AdditionalSigningData(authMethod), - requestTimeout: Network.fileDownloadTimeout, + additionalSignatureData: (skipAuthentication ? nil : AdditionalSigningData(authMethod)), using: dependencies ) @@ -921,7 +923,7 @@ public extension Network.SOGS { /// Remove all message requests from inbox, this methrod will return the number of messages deleted static func preparedClearInbox( requestTimeout: TimeInterval = Network.defaultTimeout, - requestAndPathBuildTimeout: TimeInterval? = nil, + overallTimeout: TimeInterval? = nil, authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { @@ -929,12 +931,12 @@ public extension Network.SOGS { request: Request( method: .delete, endpoint: .inbox, - authMethod: authMethod + authMethod: authMethod, + requestTimeout: requestTimeout, + overallTimeout: overallTimeout ), responseType: DeleteInboxResponse.self, additionalSignatureData: AdditionalSigningData(authMethod), - requestTimeout: requestTimeout, - requestAndPathBuildTimeout: requestAndPathBuildTimeout, using: dependencies ) .signed(with: Network.SOGS.signRequest, using: dependencies) @@ -1226,14 +1228,12 @@ public extension Network.SOGS { // MARK: - Authentication fileprivate static func signatureHeaders( - url: URL, method: HTTPMethod, + pathAndParamsString: String, body: Data?, authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> [HTTPHeader: String] { - let path: String = url.path - .appending(url.query.map { value in "?\(value)" }) let method: String = method.rawValue let timestamp: Int = Int(floor(dependencies.dateNow.timeIntervalSince1970)) @@ -1263,7 +1263,7 @@ public extension Network.SOGS { .appending(contentsOf: nonce) .appending(contentsOf: timestampBytes) .appending(contentsOf: method.bytes) - .appending(contentsOf: path.bytes) + .appending(contentsOf: pathAndParamsString.bytes) .appending(contentsOf: bodyHash ?? []) /// Sign the above message @@ -1369,12 +1369,62 @@ public extension Network.SOGS { preparedRequest: Network.PreparedRequest, using dependencies: Dependencies ) throws -> Network.Destination { + /// Handle the cached and invalid cases first (no need to sign them) + switch preparedRequest.destination { + case .cached: return preparedRequest.destination + case .snode, .randomSnode: throw NetworkError.unauthorised + default: break + } + guard let signingData: AdditionalSigningData = preparedRequest.additionalSignatureData as? AdditionalSigningData else { throw SOGSError.signingFailed } - return try preparedRequest.destination - .signed(data: signingData, body: preparedRequest.body, using: dependencies) + let signatureHeaders: [HTTPHeader: String] = try Network.SOGS.signatureHeaders( + method: preparedRequest.method, + pathAndParamsString: preparedRequest.path, + body: preparedRequest.body, + authMethod: signingData.authMethod, + using: dependencies + ) + + switch preparedRequest.destination { + case .server(let info): + return .server( + info: Network.Destination.ServerInfo( + method: info.method, + server: info.server, + queryParameters: info.queryParameters, + headers: info.headers.updated(with: signatureHeaders), + x25519PublicKey: info.x25519PublicKey + ) + ) + + case .serverUpload(let info, let fileName): + return .serverUpload( + info: Network.Destination.ServerInfo( + method: info.method, + server: info.server, + queryParameters: info.queryParameters, + headers: info.headers.updated(with: signatureHeaders), + x25519PublicKey: info.x25519PublicKey + ), + fileName: fileName + ) + + case .serverDownload(let info): + return .serverDownload( + info: Network.Destination.ServerInfo( + method: info.method, + server: info.server, + queryParameters: info.queryParameters, + headers: info.headers.updated(with: signatureHeaders), + x25519PublicKey: info.x25519PublicKey + ) + ) + + case .snode, .randomSnode, .cached: throw SOGSError.signingFailed + } } } @@ -1387,30 +1437,3 @@ private extension Network.SOGS { } } } - -private extension Network.Destination { - func signed(data: Network.SOGS.AdditionalSigningData, body: Data?, using dependencies: Dependencies) throws -> Network.Destination { - switch self { - case .snode, .randomSnode, .randomSnodeLatestNetworkTimeTarget: throw NetworkError.unauthorised - case .cached: return self - case .server(let info): return .server(info: try info.signed(data, body, using: dependencies)) - case .serverUpload(let info, let fileName): - return .serverUpload(info: try info.signed(data, body, using: dependencies), fileName: fileName) - - case .serverDownload(let info): - return .serverDownload(info: try info.signed(data, body, using: dependencies)) - } - } -} - -private extension Network.Destination.ServerInfo { - func signed(_ data: Network.SOGS.AdditionalSigningData, _ body: Data?, using dependencies: Dependencies) throws -> Network.Destination.ServerInfo { - return updated(with: try Network.SOGS.signatureHeaders( - url: url, - method: method, - body: body, - authMethod: data.authMethod, - using: dependencies - )) - } -} diff --git a/SessionNetworkingKit/SOGS/Types/Request+SOGS.swift b/SessionNetworkingKit/SOGS/Types/Request+SOGS.swift index 5373174584..42afdbfaa7 100644 --- a/SessionNetworkingKit/SOGS/Types/Request+SOGS.swift +++ b/SessionNetworkingKit/SOGS/Types/Request+SOGS.swift @@ -10,22 +10,28 @@ public extension Request where Endpoint == Network.SOGS.Endpoint { queryParameters: [HTTPQueryParam: String] = [:], headers: [HTTPHeader: String] = [:], body: T? = nil, - authMethod: AuthenticationMethod + category: Network.RequestCategory = .standard, + authMethod: AuthenticationMethod, + requestTimeout: TimeInterval = Network.defaultTimeout, + overallTimeout: TimeInterval? = nil ) throws { guard case .community(let server, let publicKey, _, _, _) = authMethod.info else { throw CryptoError.signatureGenerationFailed } - self = try Request( + self = Request( endpoint: endpoint, - destination: try .server( + destination: .server( method: method, server: server, queryParameters: queryParameters, headers: headers, x25519PublicKey: publicKey ), - body: body + body: body, + category: category, + requestTimeout: requestTimeout, + overallTimeout: overallTimeout ) } } diff --git a/SessionNetworkingKit/SessionNetwork/SessionNetwork.swift b/SessionNetworkingKit/SessionNetwork/SessionNetwork.swift index c4c0e933c7..016f8880a2 100644 --- a/SessionNetworkingKit/SessionNetwork/SessionNetwork.swift +++ b/SessionNetworkingKit/SessionNetwork/SessionNetwork.swift @@ -6,12 +6,6 @@ import Foundation public extension Network { enum SessionNetwork { - public static let workQueue: DispatchQueue = DispatchQueue( - label: "SessionNetworkAPI.workQueue", - qos: .userInitiated - ) - public static let client: HTTPClient = HTTPClient() - static let networkAPIServer = "http://networkv1.getsession.org" static let networkAPIServerPublicKey = "cbf461a4431dc9174dceef4421680d743a2a0e1a3131fc794240bcb0bc3dd449" } diff --git a/SessionNetworkingKit/SessionNetwork/SessionNetworkAPI.swift b/SessionNetworkingKit/SessionNetwork/SessionNetworkAPI.swift index 1d11aa7888..8b7203261b 100644 --- a/SessionNetworkingKit/SessionNetwork/SessionNetworkAPI.swift +++ b/SessionNetworkingKit/SessionNetwork/SessionNetworkAPI.swift @@ -24,10 +24,10 @@ public extension Network.SessionNetwork { server: Network.SessionNetwork.networkAPIServer, queryParameters: [:], x25519PublicKey: Network.SessionNetwork.networkAPIServerPublicKey - ) + ), + overallTimeout: Network.defaultTimeout ), responseType: Info.self, - requestAndPathBuildTimeout: Network.defaultTimeout, using: dependencies ) .signed(with: Network.SessionNetwork.signRequest, using: dependencies) @@ -101,18 +101,24 @@ public extension Network.SessionNetwork { using dependencies: Dependencies ) throws -> Network.Destination { guard - let url: URL = preparedRequest.destination.url, + let url: URL = try? preparedRequest.generateUrl(), case let .server(info) = preparedRequest.destination else { throw NetworkError.invalidPreparedRequest } return .server( - info: info.updated( - with: try signatureHeaders( - url: url, - method: preparedRequest.method, - body: preparedRequest.body, - using: dependencies - ) + info: Network.Destination.ServerInfo( + method: info.method, + server: info.server, + queryParameters: info.queryParameters, + headers: info.headers.updated( + with: try signatureHeaders( + url: url, + method: preparedRequest.method, + body: preparedRequest.body, + using: dependencies + ) + ), + x25519PublicKey: info.x25519PublicKey ) ) } diff --git a/SessionNetworkingKit/SessionNetwork/SessionNetworkEndpoint.swift b/SessionNetworkingKit/SessionNetwork/SessionNetworkEndpoint.swift index a96d9fd2d4..bccba92fa4 100644 --- a/SessionNetworkingKit/SessionNetwork/SessionNetworkEndpoint.swift +++ b/SessionNetworkingKit/SessionNetwork/SessionNetworkEndpoint.swift @@ -1,4 +1,6 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import Foundation @@ -8,7 +10,7 @@ public extension Network.SessionNetwork { case price case token - public static var name: String { "NetworkAPI.Endpoint" } + public static var name: String { "SessionNetwork.Endpoint" } public var path: String { switch self { diff --git a/SessionNetworkingKit/SessionNetwork/Types/HTTPClient.swift b/SessionNetworkingKit/SessionNetwork/Types/HTTPClient.swift index eee5080e16..ea8db40f7a 100644 --- a/SessionNetworkingKit/SessionNetwork/Types/HTTPClient.swift +++ b/SessionNetworkingKit/SessionNetwork/Types/HTTPClient.swift @@ -7,6 +7,13 @@ import Combine import GRDB import SessionUtilitiesKit +public extension Singleton { + static let sessionNetworkApiClient: SingletonConfig = Dependencies.create( + identifier: "sessionNetworkApiClient", + createInstance: { dependencies, _ in Network.SessionNetwork.HTTPClient(using: dependencies) } + ) +} + // MARK: - Log.Category public extension Log.Category { @@ -15,42 +22,44 @@ public extension Log.Category { public extension Network.SessionNetwork { final class HTTPClient { - private var cancellable: AnyCancellable? - private var dependencies: Dependencies? + private var getInfoTask: Task? + private var dependencies: Dependencies - public func initialize(using dependencies: Dependencies) { + public init(using dependencies: Dependencies) { self.dependencies = dependencies - cancellable = getInfo(using: dependencies) - .subscribe(on: Network.SessionNetwork.workQueue, using: dependencies) - .receive(on: Network.SessionNetwork.workQueue) - .sink(receiveCompletion: { _ in }, receiveValue: { _ in }) } - public func getInfo(using dependencies: Dependencies) -> AnyPublisher { - cancellable?.cancel() + public func fetchInfoInBackground() { + getInfoTask = Task { + _ = try? await getInfo() + } + } + + public func getInfo() async throws -> Bool { + getInfoTask?.cancel() - let staleTimestampMs: Int64 = dependencies[singleton: .storage].read { db in db[.staleTimestampMs] }.defaulting(to: 0) - guard staleTimestampMs < dependencies[cache: .snodeAPI].currentOffsetTimestampMs() else { - return Just(()) - .delay(for: .milliseconds(500), scheduler: Network.SessionNetwork.workQueue) - .setFailureType(to: Error.self) - .flatMapStorageWritePublisher(using: dependencies) { [dependencies] db, info -> Bool in - db[.lastUpdatedTimestampMs] = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - return true - } - .eraseToAnyPublisher() + let staleTimestampMs: Int64 = (try? await dependencies[singleton: .storage] + .readAsync { db in db[.staleTimestampMs] }) + .defaulting(to: 0) + let currentTimestampMs: Int64 = await dependencies.networkOffsetTimestampMs() + + guard staleTimestampMs < currentTimestampMs else { + try? await Task.sleep(for: .milliseconds(500)) + try await dependencies[singleton: .storage].writeAsync { db in + db[.lastUpdatedTimestampMs] = currentTimestampMs + } + + return true } - return Result { - try Network.SessionNetwork + do { + let info: Network.SessionNetwork.Info = try await Network.SessionNetwork .prepareInfo(using: dependencies) - } - .publisher - .flatMap { [dependencies] in $0.send(using: dependencies) } - .map { _, info in info } - .flatMapStorageWritePublisher(using: dependencies) { [dependencies] db, info -> Bool in + .send(using: dependencies) + + try await dependencies[singleton: .storage].writeAsync { [dependencies] db in // Token info - db[.lastUpdatedTimestampMs] = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + db[.lastUpdatedTimestampMs] = dependencies.networkOffsetTimestampMs() db[.tokenUsd] = info.price?.tokenUsd db[.marketCapUsd] = info.price?.marketCapUsd if let priceTimestamp = info.price?.priceTimestamp { @@ -70,21 +79,19 @@ public extension Network.SessionNetwork { db[.networkSize] = info.network?.networkSize db[.networkStakedTokens] = info.network?.networkStakedTokens db[.networkStakedUSD] = info.network?.networkStakedUSD - - return true } - .catch { error -> AnyPublisher in - Log.error(.sessionNetwork, "Failed to fetch token info due to error: \(error).") - return self.cleanUpSessionNetworkPageData(using: dependencies) - .map { _ in false } - .eraseToAnyPublisher() - - } - .eraseToAnyPublisher() + + return true + } + catch { + Log.error(.sessionNetwork, "Failed to fetch token info due to error: \(error).") + try? await cleanUpSessionNetworkPageData() + return false + } } - private func cleanUpSessionNetworkPageData(using dependencies: Dependencies) -> AnyPublisher { - dependencies[singleton: .storage].writePublisher { db in + private func cleanUpSessionNetworkPageData() async throws { + try await dependencies[singleton: .storage].writeAsync { db in // Token info db[.lastUpdatedTimestampMs] = nil db[.tokenUsd] = nil diff --git a/SessionNetworkingKit/SessionNetworkAPI/HTTPHeader+SessionNetwork.swift b/SessionNetworkingKit/SessionNetworkAPI/HTTPHeader+SessionNetwork.swift new file mode 100644 index 0000000000..464a3b1b9e --- /dev/null +++ b/SessionNetworkingKit/SessionNetworkAPI/HTTPHeader+SessionNetwork.swift @@ -0,0 +1,9 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +public extension HTTPHeader { + static let tokenServerPubKey: HTTPHeader = "X-FS-Pubkey" + static let tokenServerTimestamp: HTTPHeader = "X-FS-Timestamp" + static let tokenServerSignature: HTTPHeader = "X-FS-Signature" +} diff --git a/SessionNetworkingKit/SessionNetworkAPI/SessionNetworkAPI+Database.swift b/SessionNetworkingKit/SessionNetworkAPI/SessionNetworkAPI+Database.swift new file mode 100644 index 0000000000..9316b1a440 --- /dev/null +++ b/SessionNetworkingKit/SessionNetworkAPI/SessionNetworkAPI+Database.swift @@ -0,0 +1,32 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import AudioToolbox +import GRDB +import DifferenceKit +import SessionUtilitiesKit + +public extension KeyValueStore.StringKey { + static let contractAddress: KeyValueStore.StringKey = "contractAddress" +} + +public extension KeyValueStore.DoubleKey { + static let tokenUsd: KeyValueStore.DoubleKey = "tokenUsd" + static let marketCapUsd: KeyValueStore.DoubleKey = "marketCapUsd" + static let stakingRequirement: KeyValueStore.DoubleKey = "stakingRequirement" + static let stakingRewardPool: KeyValueStore.DoubleKey = "stakingRewardPool" + static let networkStakedTokens: KeyValueStore.DoubleKey = "networkStakedTokens" + static let networkStakedUSD: KeyValueStore.DoubleKey = "networkStakedUSD" +} + +public extension KeyValueStore.IntKey { + static let networkSize: KeyValueStore.IntKey = "networkSize" +} + +public extension KeyValueStore.Int64Key { + static let lastUpdatedTimestampMs: KeyValueStore.Int64Key = "lastUpdatedTimestampMs" + static let staleTimestampMs: KeyValueStore.Int64Key = "staleTimestampMs" + static let priceTimestampMs: KeyValueStore.Int64Key = "priceTimestampMs" +} diff --git a/SessionNetworkingKit/SessionNetworkAPI/SessionNetworkAPI+Models.swift b/SessionNetworkingKit/SessionNetworkAPI/SessionNetworkAPI+Models.swift new file mode 100644 index 0000000000..cd1cfec84c --- /dev/null +++ b/SessionNetworkingKit/SessionNetworkAPI/SessionNetworkAPI+Models.swift @@ -0,0 +1,75 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import Combine +import GRDB +import SessionUtilitiesKit + +extension SessionNetworkAPI { + + // MARK: - Price + + public struct Price: Codable, Equatable { + enum CodingKeys: String, CodingKey { + case tokenUsd = "usd" + case marketCapUsd = "usd_market_cap" + case priceTimestamp = "t_price" + case staleTimestamp = "t_stale" + } + + public let tokenUsd: Double? // Current token price (USD) + public let marketCapUsd: Double? // Current market cap value in (USD) + public let priceTimestamp: Int64? // The timestamp the price data is accurate at. (seconds) + public let staleTimestamp: Int64? // Stale timestamp for the price data. (seconds) + } + + // MARK: - Token + + public struct Token: Codable, Equatable { + enum CodingKeys: String, CodingKey { + case stakingRequirement = "staking_requirement" + case stakingRewardPool = "staking_reward_pool" + case contractAddress = "contract_address" + } + + public let stakingRequirement: Double? // The number of tokens required to stake a node. This is the effective "token amount" per node (SESH) + public let stakingRewardPool: Double? // The number of tokens in the staking reward pool (SESH) + public let contractAddress: String? // Token contract address (42 char Hexadecimal - Including 0x prefix) + } + + + // MARK: - Network Info + + public struct NetworkInfo: Codable, Equatable { + enum CodingKeys: String, CodingKey { + case networkSize = "network_size" // The number of nodes in the Session Network (integer) + case networkStakedTokens = "network_staked_tokens" // + case networkStakedUSD = "network_staked_usd" // + } + + public let networkSize: Int? + public let networkStakedTokens: Double? + public let networkStakedUSD: Double? + } + + // MARK: - Info + + public struct Info: Codable, Equatable { + enum CodingKeys: String, CodingKey { + case timestamp = "t" + case statusCode = "status_code" + case price + case token + case network + } + + public let timestamp: Int64? // Request timestamp. (seconds) + public let statusCode: Int? // Status code of the request. + public let price: Price? + public let token: Token? + public let network: NetworkInfo? + } +} + diff --git a/SessionNetworkingKit/SessionNetworkAPI/SessionNetworkAPI+Network.swift b/SessionNetworkingKit/SessionNetworkAPI/SessionNetworkAPI+Network.swift new file mode 100644 index 0000000000..c10b02362f --- /dev/null +++ b/SessionNetworkingKit/SessionNetworkAPI/SessionNetworkAPI+Network.swift @@ -0,0 +1,114 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import Combine +import GRDB +import SessionUtilitiesKit + +// MARK: - Singleton + +public extension Singleton { + static let sessionNetworkApiClient: SingletonConfig = Dependencies.create( + identifier: "sessionNetworkApiClient", + createInstance: { dependencies, _ in SessionNetworkAPI.HTTPClient(using: dependencies) } + ) +} + +// MARK: - Log.Category + +public extension Log.Category { + static let sessionNetwork: Log.Category = .create("SessionNetwork", defaultLevel: .info) +} + +// MARK: - SessionNetworkAPI.HTTPClient + +extension SessionNetworkAPI { + public actor HTTPClient { + private var getInfoTask: Task? + private var dependencies: Dependencies + + public init(using dependencies: Dependencies) { + self.dependencies = dependencies + } + + public func fetchInfoInBackground() { + getInfoTask = Task { + _ = try? await getInfo() + } + } + + public func getInfo() async throws -> Bool { + getInfoTask?.cancel() + + let staleTimestampMs: Int64 = (try? await dependencies[singleton: .storage] + .readAsync { db in db[.staleTimestampMs] }) + .defaulting(to: 0) + + guard staleTimestampMs < dependencies[cache: .snodeAPI].currentOffsetTimestampMs() else { + try? await Task.sleep(for: .milliseconds(500)) + try await dependencies[singleton: .storage].writeAsync { [dependencies] db in + db[.lastUpdatedTimestampMs] = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + } + + return true + } + + do { + let info: SessionNetworkAPI.Info = try await SessionNetworkAPI + .prepareInfo(using: dependencies) + .send(using: dependencies) + + try await dependencies[singleton: .storage].writeAsync { [dependencies] db in + // Token info + db[.lastUpdatedTimestampMs] = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + db[.tokenUsd] = info.price?.tokenUsd + db[.marketCapUsd] = info.price?.marketCapUsd + if let priceTimestamp = info.price?.priceTimestamp { + db[.priceTimestampMs] = priceTimestamp * 1000 + } else { + db[.priceTimestampMs] = nil + } + if let staleTimestamp = info.price?.staleTimestamp { + db[.staleTimestampMs] = staleTimestamp * 1000 + } else { + db[.staleTimestampMs] = nil + } + db[.stakingRequirement] = info.token?.stakingRequirement + db[.stakingRewardPool] = info.token?.stakingRewardPool + db[.contractAddress] = info.token?.contractAddress + // Network info + db[.networkSize] = info.network?.networkSize + db[.networkStakedTokens] = info.network?.networkStakedTokens + db[.networkStakedUSD] = info.network?.networkStakedUSD + } + + return true + } + catch { + Log.error(.sessionNetwork, "Failed to fetch token info due to error: \(error).") + try? await cleanUpSessionNetworkPageData() + return false + } + } + + private func cleanUpSessionNetworkPageData() async throws { + try await dependencies[singleton: .storage].writeAsync { db in + // Token info + db[.lastUpdatedTimestampMs] = nil + db[.tokenUsd] = nil + db[.marketCapUsd] = nil + db[.priceTimestampMs] = nil + db[.staleTimestampMs] = nil + db[.stakingRequirement] = nil + db[.stakingRewardPool] = nil + db[.contractAddress] = nil + // Network info + db[.networkSize] = nil + db[.networkStakedTokens] = nil + db[.networkStakedUSD] = nil + } + } + } +} diff --git a/SessionNetworkingKit/SessionNetworkAPI/SessionNetworkAPI.swift b/SessionNetworkingKit/SessionNetworkAPI/SessionNetworkAPI.swift new file mode 100644 index 0000000000..6d319a39b1 --- /dev/null +++ b/SessionNetworkingKit/SessionNetworkAPI/SessionNetworkAPI.swift @@ -0,0 +1,125 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import Combine +import SessionUtilitiesKit + +public enum SessionNetworkAPI { + // MARK: - Info + + /// General token info. This endpoint combines the `/price` and `/token` endpoint information. + /// + /// `GET/info` + public static func prepareInfo( + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + return try Network.PreparedRequest( + request: Request( + endpoint: Network.NetworkAPI.Endpoint.info, + destination: .server( + method: .get, + server: Network.NetworkAPI.networkAPIServer, + queryParameters: [:], + x25519PublicKey: Network.NetworkAPI.networkAPIServerPublicKey + ), + overallTimeout: Network.defaultTimeout + ), + responseType: Info.self, + using: dependencies + ) + .signed(with: SessionNetworkAPI.signRequest, using: dependencies) + } + + // MARK: - Authentication + + fileprivate static func signatureHeaders( + url: URL, + method: HTTPMethod, + body: Data?, + using dependencies: Dependencies + ) throws -> [HTTPHeader: String] { + let timestamp: UInt64 = UInt64(floor(dependencies.dateNow.timeIntervalSince1970)) + let path: String = url.path + .appending(url.query.map { value in "?\(value)" }) + + let signResult: (publicKey: String, signature: [UInt8]) = try sign( + timestamp: timestamp, + method: method.rawValue, + path: path, + body: body, + using: dependencies + ) + + return [ + HTTPHeader.tokenServerPubKey: signResult.publicKey, + HTTPHeader.tokenServerTimestamp: "\(timestamp)", + HTTPHeader.tokenServerSignature: signResult.signature.toBase64() + ] + } + + private static func sign( + timestamp: UInt64, + method: String, + path: String, + body: Data?, + using dependencies: Dependencies + ) throws -> (publicKey: String, signature: [UInt8]) { + let bodyString: String? = { + guard let bodyData: Data = body else { return nil } + return String(data: bodyData, encoding: .utf8) + }() + + guard + !dependencies[cache: .general].ed25519SecretKey.isEmpty, + let blinded07KeyPair: KeyPair = dependencies[singleton: .crypto].generate( + .versionBlinded07KeyPair( + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey + ) + ), + let signatureResult: [UInt8] = dependencies[singleton: .crypto].generate( + .signatureVersionBlind07( + timestamp: timestamp, + method: method, + path: path, + body: bodyString, + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey + ) + ) + else { throw CryptoError.signatureGenerationFailed } + + return ( + publicKey: SessionId(.versionBlinded07, publicKey: blinded07KeyPair.publicKey).hexString, + signature: signatureResult + ) + } + + private static func signRequest( + preparedRequest: Network.PreparedRequest, + using dependencies: Dependencies + ) throws -> Network.Destination { + guard + let url: URL = try? preparedRequest.generateUrl(), + case let .server(info) = preparedRequest.destination + else { throw NetworkError.invalidPreparedRequest } + + return .server( + info: Network.Destination.ServerInfo( + method: info.method, + server: info.server, + queryParameters: info.queryParameters, + headers: info.headers.updated( + with: try signatureHeaders( + url: url, + method: preparedRequest.method, + body: preparedRequest.body, + using: dependencies + ) + ), + x25519PublicKey: info.x25519PublicKey + ) + ) + } +} + diff --git a/SessionNetworkingKit/StorageServer/Database/SnodeReceivedMessageInfo.swift b/SessionNetworkingKit/StorageServer/Database/SnodeReceivedMessageInfo.swift index eb8857f776..6ae34e6f14 100644 --- a/SessionNetworkingKit/StorageServer/Database/SnodeReceivedMessageInfo.swift +++ b/SessionNetworkingKit/StorageServer/Database/SnodeReceivedMessageInfo.swift @@ -55,12 +55,12 @@ public extension SnodeReceivedMessageInfo { init( snode: LibSession.Snode, swarmPublicKey: String, - namespace: Network.SnodeAPI.Namespace, + namespace: Network.StorageServer.Namespace, hash: String, expirationDateMs: Int64? ) { self.swarmPublicKey = swarmPublicKey - self.snodeAddress = snode.address + self.snodeAddress = snode.omqAddress self.namespace = namespace.rawValue self.hash = hash self.expirationDateMs = (expirationDateMs ?? 0) @@ -75,17 +75,17 @@ public extension SnodeReceivedMessageInfo { static func fetchLastNotExpired( _ db: ObservingDatabase, for snode: LibSession.Snode, - namespace: Network.SnodeAPI.Namespace, + namespace: Network.StorageServer.Namespace, swarmPublicKey: String, using dependencies: Dependencies ) throws -> SnodeReceivedMessageInfo? { - let currentOffsetTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let currentOffsetTimestampMs: Int64 = dependencies.networkOffsetTimestampMs() return try SnodeReceivedMessageInfo .filter(SnodeReceivedMessageInfo.Columns.wasDeletedOrInvalid == false) .filter( SnodeReceivedMessageInfo.Columns.swarmPublicKey == swarmPublicKey && - SnodeReceivedMessageInfo.Columns.snodeAddress == snode.address && + SnodeReceivedMessageInfo.Columns.snodeAddress == snode.omqAddress && SnodeReceivedMessageInfo.Columns.namespace == namespace.rawValue ) .filter(SnodeReceivedMessageInfo.Columns.expirationDateMs > currentOffsetTimestampMs) diff --git a/SessionNetworkingKit/StorageServer/Models/BaseAuthenticatedRequestBody.swift b/SessionNetworkingKit/StorageServer/Models/BaseAuthenticatedRequestBody.swift new file mode 100644 index 0000000000..eea66cde7f --- /dev/null +++ b/SessionNetworkingKit/StorageServer/Models/BaseAuthenticatedRequestBody.swift @@ -0,0 +1,70 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +public extension Network.StorageServer { + class BaseAuthenticatedRequestBody: Encodable { + private enum CodingKeys: String, CodingKey { + case pubkey + case subaccount + case timestampMs = "timestamp" + case ed25519PublicKey = "pubkey_ed25519" + case signatureBase64 = "signature" + case subaccountSignatureBase64 = "subaccount_sig" + } + + internal let timestampMs: UInt64? + internal let authMethod: AuthenticationMethod + + var verificationBytes: [UInt8] { preconditionFailure("abstract class - override in subclass") } + + // MARK: - Initialization + + public init( + timestampMs: UInt64?, + authMethod: AuthenticationMethod + ) { + self.timestampMs = timestampMs + self.authMethod = authMethod + } + + // MARK: - Codable + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(timestampMs, forKey: .timestampMs) + + // Generate the signature for the request for encoding + let signature: Authentication.Signature = try authMethod.generateSignature( + with: verificationBytes, + using: try encoder.dependencies ?? { throw DependenciesError.missingDependencies }() + ) + + switch authMethod.info { + case .standard(let sessionId, let ed25519PublicKey): + try container.encode(sessionId.hexString, forKey: .pubkey) + try container.encode(ed25519PublicKey.toHexString(), forKey: .ed25519PublicKey) + + case .groupAdmin(let sessionId, _): + try container.encode(sessionId.hexString, forKey: .pubkey) + + case .groupMember(let sessionId, _): + try container.encode(sessionId.hexString, forKey: .pubkey) + + case .community: throw CryptoError.signatureGenerationFailed + } + + switch signature { + case .standard(let signature): + try container.encode(signature.toBase64(), forKey: .signatureBase64) + + case .subaccount(let subaccount, let subaccountSig, let signature): + try container.encode(subaccount.toHexString(), forKey: .subaccount) + try container.encode(signature.toBase64(), forKey: .signatureBase64) + try container.encode(subaccountSig.toBase64(), forKey: .subaccountSignatureBase64) + } + } + } +} diff --git a/SessionNetworkingKit/StorageServer/Models/BaseRecursiveResponse.swift b/SessionNetworkingKit/StorageServer/Models/BaseRecursiveResponse.swift new file mode 100644 index 0000000000..e792964ac9 --- /dev/null +++ b/SessionNetworkingKit/StorageServer/Models/BaseRecursiveResponse.swift @@ -0,0 +1,40 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension Network.StorageServer { + public class BaseRecursiveResponse: BaseResponse { + private enum CodingKeys: String, CodingKey { + case swarm + } + + internal let swarm: [String: T] + + // MARK: - Initialization + + internal init( + swarm: [String: T], + hardFork: [Int], + timeOffset: Int64 + ) { + self.swarm = swarm + + super.init(hardForkVersion: hardFork, timeOffset: timeOffset) + } + + required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + swarm = try container.decode([String: T].self, forKey: .swarm) + + try super.init(from: decoder) + } + + public override func encode(to encoder: any Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + try container.encode(swarm, forKey: .swarm) + + try super.encode(to: encoder) + } + } +} diff --git a/SessionNetworkingKit/StorageServer/Models/BaseResponse.swift b/SessionNetworkingKit/StorageServer/Models/BaseResponse.swift new file mode 100644 index 0000000000..0146c7785c --- /dev/null +++ b/SessionNetworkingKit/StorageServer/Models/BaseResponse.swift @@ -0,0 +1,22 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension Network.StorageServer { + public class BaseResponse: Codable { + private enum CodingKeys: String, CodingKey { + case hardForkVersion = "hf" + case timeOffset = "t" + } + + internal let hardForkVersion: [Int] + internal let timeOffset: Int64 + + // MARK: - Initialization + + internal init(hardForkVersion: [Int], timeOffset: Int64) { + self.hardForkVersion = hardForkVersion + self.timeOffset = timeOffset + } + } +} diff --git a/SessionNetworkingKit/StorageServer/Models/BaseSwarmItem.swift b/SessionNetworkingKit/StorageServer/Models/BaseSwarmItem.swift new file mode 100644 index 0000000000..1523c5fcbb --- /dev/null +++ b/SessionNetworkingKit/StorageServer/Models/BaseSwarmItem.swift @@ -0,0 +1,54 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension Network.StorageServer { + public class BaseSwarmItem: Codable { + private enum CodingKeys: String, CodingKey { + case signatureBase64 = "signature" + + case failed + case timeout + case code + case reason + case badPeerResponse = "bad_peer_response" + case queryFailure = "query_failure" + } + + /// Should be present as long as the request didn't fail + public let signatureBase64: String? + + /// `true` if the request failed, possibly accompanied by one of the following: `timeout`, `code`, + /// `reason`, `badPeerResponse`, `queryFailure` + public let failed: Bool + + /// `true` if the inter-swarm request timed out + public let timeout: Bool? + + /// `X` if the inter-swarm request returned error code `X` + public let code: Int? + + /// a reason string, e.g. propagating a thrown exception messages + public let reason: String? + + /// `true` if the peer returned an unparseable response + public let badPeerResponse: Bool? + + /// `true` if the database failed to perform the query + public let queryFailure: Bool? + + // MARK: - Initialization + + public required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + signatureBase64 = try? container.decode(String.self, forKey: .signatureBase64) + failed = ((try? container.decode(Bool.self, forKey: .failed)) ?? false) + timeout = try? container.decode(Bool.self, forKey: .timeout) + code = try? container.decode(Int.self, forKey: .code) + reason = try? container.decode(String.self, forKey: .reason) + badPeerResponse = try? container.decode(Bool.self, forKey: .badPeerResponse) + queryFailure = try? container.decode(Bool.self, forKey: .queryFailure) + } + } +} diff --git a/SessionNetworkingKit/StorageServer/Models/DeleteAllBeforeRequest.swift b/SessionNetworkingKit/StorageServer/Models/DeleteAllBeforeRequest.swift deleted file mode 100644 index bd720328f5..0000000000 --- a/SessionNetworkingKit/StorageServer/Models/DeleteAllBeforeRequest.swift +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -// -// stringlint:disable - -import Foundation -import SessionUtilitiesKit - -extension Network.SnodeAPI { - final class DeleteAllBeforeRequest: SnodeAuthenticatedRequestBody, UpdatableTimestamp { - enum CodingKeys: String, CodingKey { - case beforeMs = "before" - case namespace - } - - let beforeMs: UInt64 - let namespace: Network.SnodeAPI.Namespace? - - override var verificationBytes: [UInt8] { - /// Ed25519 signature of `("delete_before" || namespace || before)`, signed by - /// `pubkey`. Must be base64 encoded (json) or bytes (OMQ). `namespace` is the stringified - /// version of the given non-default namespace parameter (i.e. "-42" or "all"), or the empty - /// string for the default namespace (whether explicitly given or not). - Network.SnodeAPI.Endpoint.deleteAllBefore.path.bytes - .appending( - contentsOf: (namespace == nil ? - "all" : - namespace?.verificationString - )?.bytes - ) - .appending(contentsOf: "\(beforeMs)".data(using: .ascii)?.bytes) - } - - // MARK: - Init - - public init( - beforeMs: UInt64, - namespace: Network.SnodeAPI.Namespace?, - authMethod: AuthenticationMethod, - timestampMs: UInt64 - ) { - self.beforeMs = beforeMs - self.namespace = namespace - - super.init( - authMethod: authMethod, - timestampMs: timestampMs - ) - } - - // MARK: - Coding - - override public func encode(to encoder: Encoder) throws { - var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(beforeMs, forKey: .beforeMs) - - // If no namespace is specified it defaults to the default namespace only (namespace - // 0), so instead in this case we want to explicitly delete from `all` namespaces - switch namespace { - case .some(let namespace): try container.encode(namespace, forKey: .namespace) - case .none: try container.encode("all", forKey: .namespace) - } - - try super.encode(to: encoder) - } - - // MARK: - UpdatableTimestamp - - public func with(timestampMs: UInt64) -> DeleteAllBeforeRequest { - return DeleteAllBeforeRequest( - beforeMs: self.beforeMs, - namespace: self.namespace, - authMethod: self.authMethod, - timestampMs: timestampMs - ) - } - } -} diff --git a/SessionNetworkingKit/StorageServer/Models/DeleteAllBeforeResponse.swift b/SessionNetworkingKit/StorageServer/Models/DeleteAllBeforeResponse.swift deleted file mode 100644 index e55052a26d..0000000000 --- a/SessionNetworkingKit/StorageServer/Models/DeleteAllBeforeResponse.swift +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtilitiesKit - -public class DeleteAllBeforeResponse: SnodeRecursiveResponse {} - -// MARK: - ValidatableResponse - -extension DeleteAllBeforeResponse: ValidatableResponse { - typealias ValidationData = UInt64 - typealias ValidationResponse = Bool - - /// Just one response in the swarm must be valid - internal static var requiredSuccessfulResponses: Int { 1 } - - internal func validResultMap( - swarmPublicKey: String, - validationData: UInt64, - using dependencies: Dependencies - ) throws -> [String: Bool] { - let validationMap: [String: Bool] = swarm.reduce(into: [:]) { result, next in - guard - !next.value.failed, - let signatureBase64: String = next.value.signatureBase64, - let encodedSignature: Data = Data(base64Encoded: signatureBase64) - else { - result[next.key] = false - - if let reason: String = next.value.reason, let statusCode: Int = next.value.code { - Log.warn(.validator(self), "Couldn't delete data from: \(next.key) due to error: \(reason) (\(statusCode)).") - } - else { - Log.warn(.validator(self), "Couldn't delete data from: \(next.key).") - } - return - } - - /// Signature of `( PUBKEY_HEX || BEFORE || DELETEDHASH[0] || ... || DELETEDHASH[N] )` - /// signed by the node's ed25519 pubkey. When doing a multi-namespace delete the `DELETEDHASH` - /// values are totally ordered (i.e. among all the hashes deleted regardless of namespace) - let verificationBytes: [UInt8] = swarmPublicKey.bytes - .appending(contentsOf: "\(validationData)".data(using: .ascii)?.bytes) - .appending(contentsOf: next.value.deleted.joined().bytes) - - result[next.key] = dependencies[singleton: .crypto].verify( - .signature( - message: verificationBytes, - publicKey: Data(hex: next.key).bytes, - signature: encodedSignature.bytes - ) - ) - } - - return try Self.validated(map: validationMap) - } -} diff --git a/SessionNetworkingKit/StorageServer/Models/DeleteAllMessagesRequest.swift b/SessionNetworkingKit/StorageServer/Models/DeleteAllMessagesRequest.swift index 3dc0d5bdbe..723a549727 100644 --- a/SessionNetworkingKit/StorageServer/Models/DeleteAllMessagesRequest.swift +++ b/SessionNetworkingKit/StorageServer/Models/DeleteAllMessagesRequest.swift @@ -3,8 +3,8 @@ import Foundation import SessionUtilitiesKit -extension Network.SnodeAPI { - final class DeleteAllMessagesRequest: SnodeAuthenticatedRequestBody, UpdatableTimestamp { +extension Network.StorageServer { + final class DeleteAllMessagesRequest: BaseAuthenticatedRequestBody { enum CodingKeys: String, CodingKey { case namespace } @@ -14,7 +14,7 @@ extension Network.SnodeAPI { /// /// **Note:** If omitted when sending the request, messages are deleted from the default namespace /// only (namespace 0) - let namespace: Network.SnodeAPI.Namespace + let namespace: Namespace override var verificationBytes: [UInt8] { /// Ed25519 signature of `( "delete_all" || namespace || timestamp )`, where @@ -22,7 +22,7 @@ extension Network.SnodeAPI { /// not), and otherwise the stringified version of the namespace parameter (i.e. "99" or "-42" or "all"). /// The signature must be signed by the ed25519 pubkey in `pubkey` (omitting the leading prefix). /// Must be base64 encoded for json requests; binary for OMQ requests. - Network.SnodeAPI.Endpoint.deleteAll.path.bytes + Endpoint.deleteAll.path.bytes .appending(contentsOf: namespace.verificationString.bytes) .appending(contentsOf: timestampMs.map { "\($0)" }?.data(using: .ascii)?.bytes) } @@ -30,15 +30,15 @@ extension Network.SnodeAPI { // MARK: - Init public init( - namespace: Network.SnodeAPI.Namespace, - authMethod: AuthenticationMethod, - timestampMs: UInt64 + namespace: Namespace, + timestampMs: UInt64, + authMethod: AuthenticationMethod ) { self.namespace = namespace super.init( - authMethod: authMethod, - timestampMs: timestampMs + timestampMs: timestampMs, + authMethod: authMethod ) } @@ -55,15 +55,5 @@ extension Network.SnodeAPI { try super.encode(to: encoder) } - - // MARK: - UpdatableTimestamp - - public func with(timestampMs: UInt64) -> DeleteAllMessagesRequest { - return DeleteAllMessagesRequest( - namespace: self.namespace, - authMethod: self.authMethod, - timestampMs: timestampMs - ) - } } } diff --git a/SessionNetworkingKit/StorageServer/Models/DeleteAllMessagesResponse.swift b/SessionNetworkingKit/StorageServer/Models/DeleteAllMessagesResponse.swift index 966102b203..e3f7ee0175 100644 --- a/SessionNetworkingKit/StorageServer/Models/DeleteAllMessagesResponse.swift +++ b/SessionNetworkingKit/StorageServer/Models/DeleteAllMessagesResponse.swift @@ -3,12 +3,14 @@ import Foundation import SessionUtilitiesKit -public class DeleteAllMessagesResponse: SnodeRecursiveResponse {} +extension Network.StorageServer { + public class DeleteAllMessagesResponse: BaseRecursiveResponse {} +} // MARK: - SwarmItem -public extension DeleteAllMessagesResponse { - class SwarmItem: SnodeSwarmItem { +public extension Network.StorageServer.DeleteAllMessagesResponse { + class SwarmItem: Network.StorageServer.BaseSwarmItem { private enum CodingKeys: String, CodingKey { case deleted } @@ -49,7 +51,7 @@ public extension DeleteAllMessagesResponse { // MARK: - ValidatableResponse -extension DeleteAllMessagesResponse: ValidatableResponse { +extension Network.StorageServer.DeleteAllMessagesResponse: ValidatableResponse { typealias ValidationData = UInt64 typealias ValidationResponse = Bool diff --git a/SessionNetworkingKit/StorageServer/Models/DeleteMessagesRequest.swift b/SessionNetworkingKit/StorageServer/Models/DeleteMessagesRequest.swift index c1736499ac..fc4d2b3f89 100644 --- a/SessionNetworkingKit/StorageServer/Models/DeleteMessagesRequest.swift +++ b/SessionNetworkingKit/StorageServer/Models/DeleteMessagesRequest.swift @@ -3,8 +3,8 @@ import Foundation import SessionUtilitiesKit -extension Network.SnodeAPI { - class DeleteMessagesRequest: SnodeAuthenticatedRequestBody { +extension Network.StorageServer { + class DeleteMessagesRequest: BaseAuthenticatedRequestBody { enum CodingKeys: String, CodingKey { case messageHashes = "messages" case requireSuccessfulDeletion = "required" @@ -17,7 +17,7 @@ extension Network.SnodeAPI { /// Ed25519 signature of `("delete" || messages...)`; this signs the value constructed /// by concatenating "delete" and all `messages` values, using `pubkey` to sign. Must be base64 /// encoded for json requests; binary for OMQ requests. - Network.SnodeAPI.Endpoint.deleteMessages.path.bytes + Endpoint.deleteMessages.path.bytes .appending(contentsOf: messageHashes.joined().bytes) } @@ -31,7 +31,10 @@ extension Network.SnodeAPI { self.messageHashes = messageHashes self.requireSuccessfulDeletion = requireSuccessfulDeletion - super.init(authMethod: authMethod) + super.init( + timestampMs: nil, + authMethod: authMethod + ) } // MARK: - Coding diff --git a/SessionNetworkingKit/StorageServer/Models/DeleteMessagesResponse.swift b/SessionNetworkingKit/StorageServer/Models/DeleteMessagesResponse.swift index f3574eaee0..97e8f1e801 100644 --- a/SessionNetworkingKit/StorageServer/Models/DeleteMessagesResponse.swift +++ b/SessionNetworkingKit/StorageServer/Models/DeleteMessagesResponse.swift @@ -3,12 +3,14 @@ import Foundation import SessionUtilitiesKit -public final class DeleteMessagesResponse: SnodeRecursiveResponse {} +extension Network.StorageServer { + public final class DeleteMessagesResponse: BaseRecursiveResponse {} +} // MARK: - SwarmItem -public extension DeleteMessagesResponse { - class SwarmItem: SnodeSwarmItem { +public extension Network.StorageServer.DeleteMessagesResponse { + class SwarmItem: Network.StorageServer.BaseSwarmItem { private enum CodingKeys: String, CodingKey { case deleted } @@ -36,7 +38,7 @@ public extension DeleteMessagesResponse { // MARK: - ValidatableResponse -extension DeleteMessagesResponse: ValidatableResponse { +extension Network.StorageServer.DeleteMessagesResponse: ValidatableResponse { typealias ValidationData = [String] typealias ValidationResponse = Bool diff --git a/SessionNetworkingKit/StorageServer/Models/GetExpiriesRequest.swift b/SessionNetworkingKit/StorageServer/Models/GetExpiriesRequest.swift index d1f85ebf2b..f8d483fc17 100644 --- a/SessionNetworkingKit/StorageServer/Models/GetExpiriesRequest.swift +++ b/SessionNetworkingKit/StorageServer/Models/GetExpiriesRequest.swift @@ -3,8 +3,8 @@ import Foundation import SessionUtilitiesKit -extension Network.SnodeAPI { - class GetExpiriesRequest: SnodeAuthenticatedRequestBody { +extension Network.StorageServer { + class GetExpiriesRequest: BaseAuthenticatedRequestBody { enum CodingKeys: String, CodingKey { case messageHashes = "messages" } @@ -16,7 +16,7 @@ extension Network.SnodeAPI { override var verificationBytes: [UInt8] { /// Ed25519 signature of `("get_expiries" || timestamp || messages[0] || ... || messages[N])` /// where `timestamp` is expressed as a string (base10). The signature must be base64 encoded (json) or bytes (bt). - Network.SnodeAPI.Endpoint.getExpiries.path.bytes + Endpoint.getExpiries.path.bytes .appending(contentsOf: timestampMs.map { "\($0)" }?.data(using: .ascii)?.bytes) .appending(contentsOf: messageHashes.joined().bytes) } @@ -25,14 +25,14 @@ extension Network.SnodeAPI { public init( messageHashes: [String], - authMethod: AuthenticationMethod, - timestampMs: UInt64 + timestampMs: UInt64, + authMethod: AuthenticationMethod ) { self.messageHashes = messageHashes super.init( - authMethod: authMethod, - timestampMs: timestampMs + timestampMs: timestampMs, + authMethod: authMethod ) } diff --git a/SessionNetworkingKit/StorageServer/Models/GetExpiriesResponse.swift b/SessionNetworkingKit/StorageServer/Models/GetExpiriesResponse.swift index 1c48e0f36e..243ba01371 100644 --- a/SessionNetworkingKit/StorageServer/Models/GetExpiriesResponse.swift +++ b/SessionNetworkingKit/StorageServer/Models/GetExpiriesResponse.swift @@ -3,18 +3,20 @@ import Foundation import SessionUtilitiesKit -public class GetExpiriesResponse: Codable { - private enum CodingKeys: String, CodingKey { - case expiries - } - - public let expiries: [String: UInt64] - - // MARK: - Initialization - - required public init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) +extension Network.StorageServer { + public class GetExpiriesResponse: Codable { + private enum CodingKeys: String, CodingKey { + case expiries + } + + public let expiries: [String: UInt64] + + // MARK: - Initialization - expiries = ((try? container.decode([String: UInt64].self, forKey: .expiries)) ?? [:]) + required public init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + expiries = ((try? container.decode([String: UInt64].self, forKey: .expiries)) ?? [:]) + } } } diff --git a/SessionNetworkingKit/StorageServer/Models/GetMessagesRequest.swift b/SessionNetworkingKit/StorageServer/Models/GetMessagesRequest.swift index c0c3f0ef7b..0bbf179c68 100644 --- a/SessionNetworkingKit/StorageServer/Models/GetMessagesRequest.swift +++ b/SessionNetworkingKit/StorageServer/Models/GetMessagesRequest.swift @@ -3,8 +3,8 @@ import Foundation import SessionUtilitiesKit -extension Network.SnodeAPI { - class GetMessagesRequest: SnodeAuthenticatedRequestBody { +extension Network.StorageServer { + class GetMessagesRequest: BaseAuthenticatedRequestBody { enum CodingKeys: String, CodingKey { case lastHash = "last_hash" case namespace @@ -13,7 +13,7 @@ extension Network.SnodeAPI { } let lastHash: String - let namespace: Network.SnodeAPI.Namespace? + let namespace: Namespace? let maxCount: Int64? let maxSize: Int64? @@ -22,7 +22,7 @@ extension Network.SnodeAPI { /// namespace), or `("retrieve" || timestamp)` when fetching from the default namespace. Both /// namespace and timestamp are the base10 expressions of the relevant values. Must be base64 /// encoded for json requests; binary for OMQ requests. - Network.SnodeAPI.Endpoint.getMessages.path.bytes + Endpoint.getMessages.path.bytes .appending(contentsOf: namespace?.verificationString.bytes) .appending(contentsOf: timestampMs.map { "\($0)" }?.data(using: .ascii)?.bytes) } @@ -31,11 +31,11 @@ extension Network.SnodeAPI { public init( lastHash: String, - namespace: Network.SnodeAPI.Namespace?, - authMethod: AuthenticationMethod, - timestampMs: UInt64, + namespace: Namespace?, maxCount: Int64? = nil, - maxSize: Int64? = nil + maxSize: Int64? = nil, + timestampMs: UInt64, + authMethod: AuthenticationMethod ) { self.lastHash = lastHash self.namespace = namespace @@ -43,8 +43,8 @@ extension Network.SnodeAPI { self.maxSize = maxSize super.init( - authMethod: authMethod, - timestampMs: timestampMs + timestampMs: timestampMs, + authMethod: authMethod ) } diff --git a/SessionNetworkingKit/StorageServer/Models/GetMessagesResponse.swift b/SessionNetworkingKit/StorageServer/Models/GetMessagesResponse.swift index 9c15c1e070..50711c4d90 100644 --- a/SessionNetworkingKit/StorageServer/Models/GetMessagesResponse.swift +++ b/SessionNetworkingKit/StorageServer/Models/GetMessagesResponse.swift @@ -2,72 +2,74 @@ import Foundation -public class GetMessagesResponse: SnodeResponse { - private enum CodingKeys: String, CodingKey { - case messages - case more - } - - public class RawMessage: Codable { +extension Network.StorageServer { + public class GetMessagesResponse: BaseResponse { private enum CodingKeys: String, CodingKey { - case base64EncodedDataString = "data" - case expirationMs = "expiration" - case hash - case timestampMs = "timestamp" + case messages + case more } - public let base64EncodedDataString: String - public let expirationMs: Int64? - public let hash: String - public let timestampMs: Int64 - - public init( - base64EncodedDataString: String, - expirationMs: Int64?, - hash: String, - timestampMs: Int64 - ) { - self.base64EncodedDataString = base64EncodedDataString - self.expirationMs = expirationMs - self.hash = hash - self.timestampMs = timestampMs + public class RawMessage: Codable { + private enum CodingKeys: String, CodingKey { + case base64EncodedDataString = "data" + case expirationMs = "expiration" + case hash + case timestampMs = "timestamp" + } + + public let base64EncodedDataString: String + public let expirationMs: Int64? + public let hash: String + public let timestampMs: Int64 + + public init( + base64EncodedDataString: String, + expirationMs: Int64?, + hash: String, + timestampMs: Int64 + ) { + self.base64EncodedDataString = base64EncodedDataString + self.expirationMs = expirationMs + self.hash = hash + self.timestampMs = timestampMs + } } - } - - public let messages: [RawMessage] - public let more: Bool - - // MARK: - Initialization - - internal init( - messages: [RawMessage], - more: Bool, - hardForkVersion: [Int], - timeOffset: Int64 - ) { - self.messages = messages - self.more = more - super.init( - hardForkVersion: hardForkVersion, - timeOffset: timeOffset - ) - } - - required init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + public let messages: [RawMessage] + public let more: Bool - messages = try container.decode([RawMessage].self, forKey: .messages) - more = try container.decode(Bool.self, forKey: .more) + // MARK: - Initialization - try super.init(from: decoder) - } - - public override func encode(to encoder: any Encoder) throws { - var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - try container.encode(messages, forKey: .messages) - try container.encode(more, forKey: .more) + internal init( + messages: [RawMessage], + more: Bool, + hardForkVersion: [Int], + timeOffset: Int64 + ) { + self.messages = messages + self.more = more + + super.init( + hardForkVersion: hardForkVersion, + timeOffset: timeOffset + ) + } - try super.encode(to: encoder) + required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + messages = try container.decode([RawMessage].self, forKey: .messages) + more = try container.decode(Bool.self, forKey: .more) + + try super.init(from: decoder) + } + + public override func encode(to encoder: any Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + try container.encode(messages, forKey: .messages) + try container.encode(more, forKey: .more) + + try super.encode(to: encoder) + } } } diff --git a/SessionNetworkingKit/StorageServer/Models/GetNetworkTimestampResponse.swift b/SessionNetworkingKit/StorageServer/Models/GetNetworkTimestampResponse.swift index d29488ffc2..e31498970e 100644 --- a/SessionNetworkingKit/StorageServer/Models/GetNetworkTimestampResponse.swift +++ b/SessionNetworkingKit/StorageServer/Models/GetNetworkTimestampResponse.swift @@ -2,8 +2,8 @@ import Foundation -public extension Network.SnodeAPI { - struct GetNetworkTimestampResponse: Decodable { +public extension Network.StorageServer { + class GetNetworkTimestampResponse: BaseResponse { enum CodingKeys: String, CodingKey { case timestamp case version @@ -11,5 +11,39 @@ public extension Network.SnodeAPI { let timestamp: UInt64 let version: [UInt64] + + // MARK: - Initialization + + internal init( + timestamp: UInt64, + version: [UInt64], + hardForkVersion: [Int], + timeOffset: Int64 + ) { + self.timestamp = timestamp + self.version = version + + super.init( + hardForkVersion: hardForkVersion, + timeOffset: timeOffset + ) + } + + required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + timestamp = try container.decode(UInt64.self, forKey: .timestamp) + version = try container.decode([UInt64].self, forKey: .version) + + try super.init(from: decoder) + } + + public override func encode(to encoder: any Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + try container.encode(timestamp, forKey: .timestamp) + try container.encode(version, forKey: .version) + + try super.encode(to: encoder) + } } } diff --git a/SessionNetworkingKit/StorageServer/Models/LegacyGetMessagesRequest.swift b/SessionNetworkingKit/StorageServer/Models/LegacyGetMessagesRequest.swift deleted file mode 100644 index ab008a94bb..0000000000 --- a/SessionNetworkingKit/StorageServer/Models/LegacyGetMessagesRequest.swift +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -extension Network.SnodeAPI { - /// This is the legacy unauthenticated message retrieval request - struct LegacyGetMessagesRequest: Encodable { - enum CodingKeys: String, CodingKey { - case pubkey - case lastHash = "last_hash" - case namespace - case maxCount = "max_count" - case maxSize = "max_size" - } - - let pubkey: String - let lastHash: String - let namespace: Network.SnodeAPI.Namespace? - let maxCount: Int64? - let maxSize: Int64? - - // MARK: - Coding - - public func encode(to encoder: Encoder) throws { - var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(pubkey, forKey: .pubkey) - try container.encode(lastHash, forKey: .lastHash) - try container.encodeIfPresent(namespace, forKey: .namespace) - try container.encodeIfPresent(maxCount, forKey: .maxCount) - try container.encodeIfPresent(maxSize, forKey: .maxSize) - } - } -} diff --git a/SessionNetworkingKit/StorageServer/Models/LegacySendMessageRequest.swift b/SessionNetworkingKit/StorageServer/Models/LegacySendMessageRequest.swift deleted file mode 100644 index a9c1000119..0000000000 --- a/SessionNetworkingKit/StorageServer/Models/LegacySendMessageRequest.swift +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -extension Network.SnodeAPI { - /// This is the legacy unauthenticated message store request - struct LegacySendMessagesRequest: Encodable { - enum CodingKeys: String, CodingKey { - case namespace - } - - let message: SnodeMessage - let namespace: Network.SnodeAPI.Namespace - - // MARK: - Coding - - public func encode(to encoder: Encoder) throws { - var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - - try message.encode(to: encoder) - try container.encode(namespace, forKey: .namespace) - } - } -} diff --git a/SessionNetworkingKit/StorageServer/Models/ONSResolveRequest.swift b/SessionNetworkingKit/StorageServer/Models/ONSResolveRequest.swift index 2e0534cf18..75d1d4ad4f 100644 --- a/SessionNetworkingKit/StorageServer/Models/ONSResolveRequest.swift +++ b/SessionNetworkingKit/StorageServer/Models/ONSResolveRequest.swift @@ -2,7 +2,7 @@ import Foundation -extension Network.SnodeAPI { +extension Network.StorageServer { struct ONSResolveRequest: Encodable { enum CodingKeys: String, CodingKey { case type diff --git a/SessionNetworkingKit/StorageServer/Models/ONSResolveResponse.swift b/SessionNetworkingKit/StorageServer/Models/ONSResolveResponse.swift index 527efed87a..d294441816 100644 --- a/SessionNetworkingKit/StorageServer/Models/ONSResolveResponse.swift +++ b/SessionNetworkingKit/StorageServer/Models/ONSResolveResponse.swift @@ -3,8 +3,8 @@ import Foundation import SessionUtilitiesKit -public extension Network.SnodeAPI { - class ONSResolveResponse: SnodeResponse { +public extension Network.StorageServer { + class ONSResolveResponse: BaseResponse { internal struct Result: Codable { enum CodingKeys: String, CodingKey { case nonce diff --git a/SessionNetworkingKit/StorageServer/Models/OxenDaemonRPCRequest.swift b/SessionNetworkingKit/StorageServer/Models/OxenDaemonRPCRequest.swift index 93ff3933a2..bfeb1c77ce 100644 --- a/SessionNetworkingKit/StorageServer/Models/OxenDaemonRPCRequest.swift +++ b/SessionNetworkingKit/StorageServer/Models/OxenDaemonRPCRequest.swift @@ -2,7 +2,7 @@ import Foundation -extension Network.SnodeAPI { +extension Network.StorageServer { struct OxenDaemonRPCRequest: Encodable { private enum CodingKeys: String, CodingKey { case endpoint @@ -13,7 +13,7 @@ extension Network.SnodeAPI { private let body: T public init( - endpoint: Network.SnodeAPI.Endpoint, + endpoint: Endpoint, body: T ) { self.endpoint = endpoint.path diff --git a/SessionNetworkingKit/StorageServer/Models/RevokeSubaccountRequest.swift b/SessionNetworkingKit/StorageServer/Models/RevokeSubaccountRequest.swift index 7495a14258..04445ac557 100644 --- a/SessionNetworkingKit/StorageServer/Models/RevokeSubaccountRequest.swift +++ b/SessionNetworkingKit/StorageServer/Models/RevokeSubaccountRequest.swift @@ -3,8 +3,8 @@ import Foundation import SessionUtilitiesKit -extension Network.SnodeAPI { - class RevokeSubaccountRequest: SnodeAuthenticatedRequestBody { +extension Network.StorageServer { + class RevokeSubaccountRequest: BaseAuthenticatedRequestBody { enum CodingKeys: String, CodingKey { case subaccountsToRevoke = "revoke" } @@ -14,7 +14,7 @@ extension Network.SnodeAPI { override var verificationBytes: [UInt8] { /// Ed25519 signature of `("revoke_subaccount" || timestamp || SUBACCOUNT_TAG_BYTES...)`; this /// signs the subaccount token, using `pubkey` to sign. Must be base64 encoded for json requests; binary for OMQ requests. - Network.SnodeAPI.Endpoint.revokeSubaccount.path.bytes + Endpoint.revokeSubaccount.path.bytes .appending(contentsOf: timestampMs.map { "\($0)" }?.data(using: .ascii)?.bytes) .appending(contentsOf: Array(subaccountsToRevoke.joined())) } @@ -23,14 +23,14 @@ extension Network.SnodeAPI { public init( subaccountsToRevoke: [[UInt8]], - authMethod: AuthenticationMethod, - timestampMs: UInt64 + timestampMs: UInt64, + authMethod: AuthenticationMethod ) { self.subaccountsToRevoke = subaccountsToRevoke super.init( - authMethod: authMethod, - timestampMs: timestampMs + timestampMs: timestampMs, + authMethod: authMethod ) } diff --git a/SessionNetworkingKit/StorageServer/Models/RevokeSubaccountResponse.swift b/SessionNetworkingKit/StorageServer/Models/RevokeSubaccountResponse.swift index 0b6b44f1f6..5ea06c2873 100644 --- a/SessionNetworkingKit/StorageServer/Models/RevokeSubaccountResponse.swift +++ b/SessionNetworkingKit/StorageServer/Models/RevokeSubaccountResponse.swift @@ -3,11 +3,13 @@ import Foundation import SessionUtilitiesKit -public class RevokeSubaccountResponse: SnodeRecursiveResponse {} +extension Network.StorageServer { + public class RevokeSubaccountResponse: BaseRecursiveResponse {} +} // MARK: - ValidatableResponse -extension RevokeSubaccountResponse: ValidatableResponse { +extension Network.StorageServer.RevokeSubaccountResponse: ValidatableResponse { typealias ValidationData = (subaccountsToRevoke: [[UInt8]], timestampMs: UInt64) typealias ValidationResponse = Bool @@ -49,7 +51,7 @@ extension RevokeSubaccountResponse: ValidatableResponse { ) // If the update signature is invalid then we want to fail here - guard isValid else { throw SnodeAPIError.signatureVerificationFailed } + guard isValid else { throw StorageServerError.signatureVerificationFailed } result[next.key] = isValid } diff --git a/SessionNetworkingKit/StorageServer/Models/SendMessageRequest.swift b/SessionNetworkingKit/StorageServer/Models/SendMessageRequest.swift index 69063606ac..b340b7ffc9 100644 --- a/SessionNetworkingKit/StorageServer/Models/SendMessageRequest.swift +++ b/SessionNetworkingKit/StorageServer/Models/SendMessageRequest.swift @@ -3,14 +3,27 @@ import Foundation import SessionUtilitiesKit -extension Network.SnodeAPI { - class SendMessageRequest: SnodeAuthenticatedRequestBody { - enum CodingKeys: String, CodingKey { +public extension Network.StorageServer { + class SendMessageRequest: BaseAuthenticatedRequestBody { + private enum CodingKeys: String, CodingKey { + case recipient = "pubkey" case namespace + case data + case ttl + case timestampMs = "timestamp" } - let message: SnodeMessage - let namespace: Network.SnodeAPI.Namespace + /// The hex encoded public key of the recipient. + public let recipient: String + + /// The namespace the message shoudl be stored in. + public let namespace: Namespace + + /// The content of the message. + public let data: String + + /// The time to live for the message in milliseconds. + public let ttl: UInt64 override var verificationBytes: [UInt8] { /// Ed25519 signature of `("store" || namespace || timestamp)`, where namespace and @@ -18,7 +31,7 @@ extension Network.SnodeAPI { /// base64 encoded for json requests; binary for OMQ requests. For non-05 type pubkeys (i.e. non /// session ids) the signature will be verified using `pubkey`. For 05 pubkeys, see the following /// option. - Network.SnodeAPI.Endpoint.sendMessage.path.bytes + Endpoint.sendMessage.path.bytes .appending(contentsOf: namespace.verificationString.bytes) .appending(contentsOf: timestampMs.map { "\($0)" }?.data(using: .ascii)?.bytes) } @@ -26,17 +39,21 @@ extension Network.SnodeAPI { // MARK: - Init public init( - message: SnodeMessage, - namespace: Network.SnodeAPI.Namespace, - authMethod: AuthenticationMethod, - timestampMs: UInt64 + recipient: String, + namespace: Namespace, + data: Data, + ttl: UInt64, + timestampMs: UInt64, + authMethod: AuthenticationMethod ) { - self.message = message + self.recipient = recipient self.namespace = namespace + self.data = data.base64EncodedString() + self.ttl = ttl super.init( - authMethod: authMethod, - timestampMs: timestampMs + timestampMs: timestampMs, + authMethod: authMethod ) } @@ -45,13 +62,10 @@ extension Network.SnodeAPI { override public func encode(to encoder: Encoder) throws { var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - /// **Note:** We **MUST** do the `message.encode` before we call `super.encode` because otherwise - /// it will override the `timestampMs` value with the value in the message which is incorrect - we actually want the - /// `timestampMs` value at the time the request was made so that older messages stuck in the job queue don't - /// end up failing due to being outside the approved timestamp window (clients use the timestamp within the message - /// data rather than this one anyway) - try message.encode(to: encoder) + try container.encode(recipient, forKey: .recipient) try container.encode(namespace, forKey: .namespace) + try container.encode(data, forKey: .data) + try container.encode(ttl, forKey: .ttl) try super.encode(to: encoder) } diff --git a/SessionNetworkingKit/StorageServer/Models/SendMessageResponse.swift b/SessionNetworkingKit/StorageServer/Models/SendMessageResponse.swift index 8bea2dfa5b..90d0808980 100644 --- a/SessionNetworkingKit/StorageServer/Models/SendMessageResponse.swift +++ b/SessionNetworkingKit/StorageServer/Models/SendMessageResponse.swift @@ -3,51 +3,53 @@ import Foundation import SessionUtilitiesKit -public final class SendMessagesResponse: SnodeRecursiveResponse { - private enum CodingKeys: String, CodingKey { - case hash - case swarm - } - - public let hash: String - - // MARK: - Initialization - - internal init( - hash: String, - swarm: [String: SwarmItem], - hardFork: [Int], - timeOffset: Int64 - ) { - self.hash = hash +extension Network.StorageServer { + public final class SendMessagesResponse: BaseRecursiveResponse { + private enum CodingKeys: String, CodingKey { + case hash + case swarm + } - super.init( - swarm: swarm, - hardFork: hardFork, - timeOffset: timeOffset - ) - } - - required init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + public let hash: String - hash = try container.decode(String.self, forKey: .hash) + // MARK: - Initialization - try super.init(from: decoder) - } - - public override func encode(to encoder: any Encoder) throws { - var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - try container.encode(hash, forKey: .hash) + internal init( + hash: String, + swarm: [String: SwarmItem], + hardFork: [Int], + timeOffset: Int64 + ) { + self.hash = hash + + super.init( + swarm: swarm, + hardFork: hardFork, + timeOffset: timeOffset + ) + } - try super.encode(to: encoder) + required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + hash = try container.decode(String.self, forKey: .hash) + + try super.init(from: decoder) + } + + public override func encode(to encoder: any Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + try container.encode(hash, forKey: .hash) + + try super.encode(to: encoder) + } } } // MARK: - SwarmItem -public extension SendMessagesResponse { - class SwarmItem: SnodeSwarmItem { +public extension Network.StorageServer.SendMessagesResponse { + class SwarmItem: Network.StorageServer.BaseSwarmItem { private enum CodingKeys: String, CodingKey { case hash case already @@ -83,7 +85,7 @@ public extension SendMessagesResponse { // MARK: - ValidatableResponse -extension SendMessagesResponse: ValidatableResponse { +extension Network.StorageServer.SendMessagesResponse: ValidatableResponse { typealias ValidationData = Void typealias ValidationResponse = Bool diff --git a/SessionNetworkingKit/StorageServer/Models/SnodeAuthenticatedRequestBody.swift b/SessionNetworkingKit/StorageServer/Models/SnodeAuthenticatedRequestBody.swift deleted file mode 100644 index 307e612059..0000000000 --- a/SessionNetworkingKit/StorageServer/Models/SnodeAuthenticatedRequestBody.swift +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtilitiesKit - -class SnodeAuthenticatedRequestBody: Encodable { - private enum CodingKeys: String, CodingKey { - case pubkey - case subaccount - case timestampMs = "timestamp" - case ed25519PublicKey = "pubkey_ed25519" - case signatureBase64 = "signature" - case subaccountSignatureBase64 = "subaccount_sig" - } - - internal let authMethod: AuthenticationMethod - internal let timestampMs: UInt64? - - var verificationBytes: [UInt8] { preconditionFailure("abstract class - override in subclass") } - - // MARK: - Initialization - - public init( - authMethod: AuthenticationMethod, - timestampMs: UInt64? = nil - ) { - self.authMethod = authMethod - self.timestampMs = timestampMs - } - - // MARK: - Codable - - public func encode(to encoder: Encoder) throws { - var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - - // Generate the signature for the request for encoding - let signature: Authentication.Signature = try authMethod.generateSignature( - with: verificationBytes, - using: try encoder.dependencies ?? { throw DependenciesError.missingDependencies }() - ) - try container.encodeIfPresent(timestampMs, forKey: .timestampMs) - - switch authMethod.info { - case .standard(let sessionId, let ed25519PublicKey): - try container.encode(sessionId.hexString, forKey: .pubkey) - try container.encode(ed25519PublicKey.toHexString(), forKey: .ed25519PublicKey) - - case .groupAdmin(let sessionId, _): - try container.encode(sessionId.hexString, forKey: .pubkey) - - case .groupMember(let sessionId, _): - try container.encode(sessionId.hexString, forKey: .pubkey) - - case .community: throw CryptoError.signatureGenerationFailed - } - - switch signature { - case .standard(let signature): - try container.encode(signature.toBase64(), forKey: .signatureBase64) - - case .subaccount(let subaccount, let subaccountSig, let signature): - try container.encode(subaccount.toHexString(), forKey: .subaccount) - try container.encode(signature.toBase64(), forKey: .signatureBase64) - try container.encode(subaccountSig.toBase64(), forKey: .subaccountSignatureBase64) - } - } -} diff --git a/SessionNetworkingKit/StorageServer/Models/SnodeBatchRequest.swift b/SessionNetworkingKit/StorageServer/Models/SnodeBatchRequest.swift deleted file mode 100644 index 083dec184b..0000000000 --- a/SessionNetworkingKit/StorageServer/Models/SnodeBatchRequest.swift +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtilitiesKit - -extension Network.SnodeAPI { - struct BatchRequest: Encodable { - let requests: [Child] - - init(requests: [Info]) { - self.requests = requests.map { $0.child } - } - - // MARK: - BatchRequest.Info - - struct Info { - public let responseType: Decodable.Type - fileprivate let child: Child - - public init(request: SnodeRequest, responseType: R.Type) { - self.child = Child(request: request) - self.responseType = Network.BatchSubResponse.self - } - - public init(request: SnodeRequest) { - self.init( - request: request, - responseType: NoResponse.self - ) - } - } - - // MARK: - BatchRequest.Child - - struct Child: Encodable { - enum CodingKeys: String, CodingKey { - case method - case params - } - - let endpoint: Network.SnodeAPI.Endpoint - - /// The `jsonBodyEncoder` is used to avoid having to make `BatchSubRequest` a generic type (haven't found - /// a good way to keep `BatchSubRequest` encodable using protocols unfortunately so need this work around) - private let jsonBodyEncoder: ((inout KeyedEncodingContainer, CodingKeys) throws -> ())? - - init(request: SnodeRequest) { - self.endpoint = request.endpoint - - self.jsonBodyEncoder = { [body = request.body] container, key in - try container.encode(body, forKey: key) - } - } - - public func encode(to encoder: Encoder) throws { - var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(endpoint.path, forKey: .method) - try jsonBodyEncoder?(&container, .params) - } - } - } -} diff --git a/SessionNetworkingKit/StorageServer/Models/SnodeRecursiveResponse.swift b/SessionNetworkingKit/StorageServer/Models/SnodeRecursiveResponse.swift deleted file mode 100644 index 91e2d2616a..0000000000 --- a/SessionNetworkingKit/StorageServer/Models/SnodeRecursiveResponse.swift +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -public class SnodeRecursiveResponse: SnodeResponse { - private enum CodingKeys: String, CodingKey { - case swarm - } - - internal let swarm: [String: T] - - // MARK: - Initialization - - internal init( - swarm: [String: T], - hardFork: [Int], - timeOffset: Int64 - ) { - self.swarm = swarm - - super.init(hardForkVersion: hardFork, timeOffset: timeOffset) - } - - required init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - - swarm = try container.decode([String: T].self, forKey: .swarm) - - try super.init(from: decoder) - } - - public override func encode(to encoder: any Encoder) throws { - var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - try container.encode(swarm, forKey: .swarm) - - try super.encode(to: encoder) - } -} diff --git a/SessionNetworkingKit/StorageServer/Models/SnodeRequest.swift b/SessionNetworkingKit/StorageServer/Models/SnodeRequest.swift deleted file mode 100644 index c88d159c8f..0000000000 --- a/SessionNetworkingKit/StorageServer/Models/SnodeRequest.swift +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtilitiesKit - -public struct SnodeRequest: Encodable { - private enum CodingKeys: String, CodingKey { - case method - case body = "params" - } - - internal let endpoint: Network.SnodeAPI.Endpoint - internal let body: T - - // MARK: - Initialization - - public init( - endpoint: Network.SnodeAPI.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.path, forKey: .method) - try container.encode(body, forKey: .body) - } -} - -// MARK: - BatchRequestChildRetrievable - -extension SnodeRequest: BatchRequestChildRetrievable where T: BatchRequestChildRetrievable { - public var requests: [Network.BatchRequest.Child] { body.requests } -} - -// MARK: - UpdatableTimestamp - -extension SnodeRequest: UpdatableTimestamp where T: UpdatableTimestamp { - public func with(timestampMs: UInt64) -> SnodeRequest { - return SnodeRequest( - endpoint: self.endpoint, - body: self.body.with(timestampMs: timestampMs) - ) - } -} diff --git a/SessionNetworkingKit/StorageServer/Models/SnodeResponse.swift b/SessionNetworkingKit/StorageServer/Models/SnodeResponse.swift deleted file mode 100644 index 5f783fd192..0000000000 --- a/SessionNetworkingKit/StorageServer/Models/SnodeResponse.swift +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -public class SnodeResponse: Codable { - private enum CodingKeys: String, CodingKey { - case hardForkVersion = "hf" - case timeOffset = "t" - } - - internal let hardForkVersion: [Int] - internal let timeOffset: Int64 - - // MARK: - Initialization - - internal init(hardForkVersion: [Int], timeOffset: Int64) { - self.hardForkVersion = hardForkVersion - self.timeOffset = timeOffset - } -} diff --git a/SessionNetworkingKit/StorageServer/Models/SnodeSwarmItem.swift b/SessionNetworkingKit/StorageServer/Models/SnodeSwarmItem.swift deleted file mode 100644 index 72fc5b0039..0000000000 --- a/SessionNetworkingKit/StorageServer/Models/SnodeSwarmItem.swift +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -public class SnodeSwarmItem: Codable { - private enum CodingKeys: String, CodingKey { - case signatureBase64 = "signature" - - case failed - case timeout - case code - case reason - case badPeerResponse = "bad_peer_response" - case queryFailure = "query_failure" - } - - /// Should be present as long as the request didn't fail - public let signatureBase64: String? - - /// `true` if the request failed, possibly accompanied by one of the following: `timeout`, `code`, - /// `reason`, `badPeerResponse`, `queryFailure` - public let failed: Bool - - /// `true` if the inter-swarm request timed out - public let timeout: Bool? - - /// `X` if the inter-swarm request returned error code `X` - public let code: Int? - - /// a reason string, e.g. propagating a thrown exception messages - public let reason: String? - - /// `true` if the peer returned an unparseable response - public let badPeerResponse: Bool? - - /// `true` if the database failed to perform the query - public let queryFailure: Bool? - - // MARK: - Initialization - - public required init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - - signatureBase64 = try? container.decode(String.self, forKey: .signatureBase64) - failed = ((try? container.decode(Bool.self, forKey: .failed)) ?? false) - timeout = try? container.decode(Bool.self, forKey: .timeout) - code = try? container.decode(Int.self, forKey: .code) - reason = try? container.decode(String.self, forKey: .reason) - badPeerResponse = try? container.decode(Bool.self, forKey: .badPeerResponse) - queryFailure = try? container.decode(Bool.self, forKey: .queryFailure) - } -} diff --git a/SessionNetworkingKit/StorageServer/Models/UnrevokeSubaccountRequest.swift b/SessionNetworkingKit/StorageServer/Models/UnrevokeSubaccountRequest.swift index 3f8cb6f30d..cc4f4c806d 100644 --- a/SessionNetworkingKit/StorageServer/Models/UnrevokeSubaccountRequest.swift +++ b/SessionNetworkingKit/StorageServer/Models/UnrevokeSubaccountRequest.swift @@ -3,8 +3,8 @@ import Foundation import SessionUtilitiesKit -extension Network.SnodeAPI { - class UnrevokeSubaccountRequest: SnodeAuthenticatedRequestBody { +extension Network.StorageServer { + class UnrevokeSubaccountRequest: BaseAuthenticatedRequestBody { enum CodingKeys: String, CodingKey { case subaccountsToUnrevoke = "unrevoke" } @@ -14,7 +14,7 @@ extension Network.SnodeAPI { override var verificationBytes: [UInt8] { /// Ed25519 signature of `("unrevoke_subaccount" || timestamp || subaccount)`; this signs /// the subaccount token, using `pubkey` to sign. Must be base64 encoded for json requests; binary for OMQ requests. - Network.SnodeAPI.Endpoint.unrevokeSubaccount.path.bytes + Endpoint.unrevokeSubaccount.path.bytes .appending(contentsOf: timestampMs.map { "\($0)" }?.data(using: .ascii)?.bytes) .appending(contentsOf: Array(subaccountsToUnrevoke.joined())) } @@ -23,14 +23,14 @@ extension Network.SnodeAPI { public init( subaccountsToUnrevoke: [[UInt8]], - authMethod: AuthenticationMethod, - timestampMs: UInt64 + timestampMs: UInt64, + authMethod: AuthenticationMethod ) { self.subaccountsToUnrevoke = subaccountsToUnrevoke super.init( - authMethod: authMethod, - timestampMs: timestampMs + timestampMs: timestampMs, + authMethod: authMethod ) } diff --git a/SessionNetworkingKit/StorageServer/Models/UnrevokeSubaccountResponse.swift b/SessionNetworkingKit/StorageServer/Models/UnrevokeSubaccountResponse.swift index 3b3f9827ea..e9a519984b 100644 --- a/SessionNetworkingKit/StorageServer/Models/UnrevokeSubaccountResponse.swift +++ b/SessionNetworkingKit/StorageServer/Models/UnrevokeSubaccountResponse.swift @@ -3,11 +3,13 @@ import Foundation import SessionUtilitiesKit -public final class UnrevokeSubaccountResponse: SnodeRecursiveResponse {} +extension Network.StorageServer { + public final class UnrevokeSubaccountResponse: BaseRecursiveResponse {} +} // MARK: - ValidatableResponse -extension UnrevokeSubaccountResponse: ValidatableResponse { +extension Network.StorageServer.UnrevokeSubaccountResponse: ValidatableResponse { typealias ValidationData = (subaccountsToUnrevoke: [[UInt8]], timestampMs: UInt64) typealias ValidationResponse = Bool @@ -49,7 +51,7 @@ extension UnrevokeSubaccountResponse: ValidatableResponse { ) // If the update signature is invalid then we want to fail here - guard isValid else { throw SnodeAPIError.signatureVerificationFailed } + guard isValid else { throw StorageServerError.signatureVerificationFailed } result[next.key] = isValid } diff --git a/SessionNetworkingKit/StorageServer/Models/UpdateExpiryAllRequest.swift b/SessionNetworkingKit/StorageServer/Models/UpdateExpiryAllRequest.swift index dbc4fbf3ff..db297d6232 100644 --- a/SessionNetworkingKit/StorageServer/Models/UpdateExpiryAllRequest.swift +++ b/SessionNetworkingKit/StorageServer/Models/UpdateExpiryAllRequest.swift @@ -5,8 +5,8 @@ import Foundation import SessionUtilitiesKit -extension Network.SnodeAPI { - class UpdateExpiryAllRequest: SnodeAuthenticatedRequestBody { +extension Network.StorageServer { + class UpdateExpiryAllRequest: BaseAuthenticatedRequestBody { enum CodingKeys: String, CodingKey { case expiryMs = "expiry" case namespace @@ -19,14 +19,14 @@ extension Network.SnodeAPI { /// /// **Note:** If omitted when sending the request, message expiries are updated from the default namespace /// only (namespace 0) - let namespace: Network.SnodeAPI.Namespace? + let namespace: Namespace? override var verificationBytes: [UInt8] { /// Ed25519 signature of `("expire_all" || namespace || expiry)`, signed by `pubkey`. Must be /// base64 encoded (json) or bytes (OMQ). namespace should be the stringified namespace for /// non-default namespace expiries (i.e. "42", "-99", "all"), or an empty string for the default /// namespace (whether or not explicitly provided). - Network.SnodeAPI.Endpoint.expireAll.path.bytes + Endpoint.expireAll.path.bytes .appending( contentsOf: (namespace == nil ? "all" : @@ -40,13 +40,16 @@ extension Network.SnodeAPI { public init( expiryMs: UInt64, - namespace: Network.SnodeAPI.Namespace?, + namespace: Namespace?, authMethod: AuthenticationMethod ) { self.expiryMs = expiryMs self.namespace = namespace - super.init(authMethod: authMethod) + super.init( + timestampMs: nil, + authMethod: authMethod + ) } // MARK: - Coding diff --git a/SessionNetworkingKit/StorageServer/Models/UpdateExpiryAllResponse.swift b/SessionNetworkingKit/StorageServer/Models/UpdateExpiryAllResponse.swift index e2655910ce..2ce9abd900 100644 --- a/SessionNetworkingKit/StorageServer/Models/UpdateExpiryAllResponse.swift +++ b/SessionNetworkingKit/StorageServer/Models/UpdateExpiryAllResponse.swift @@ -3,12 +3,14 @@ import Foundation import SessionUtilitiesKit -public class UpdateExpiryAllResponse: SnodeRecursiveResponse {} +extension Network.StorageServer { + public class UpdateExpiryAllResponse: BaseRecursiveResponse {} +} // MARK: - SwarmItem -public extension UpdateExpiryAllResponse { - class SwarmItem: SnodeSwarmItem { +public extension Network.StorageServer.UpdateExpiryAllResponse { + class SwarmItem: Network.StorageServer.BaseSwarmItem { private enum CodingKeys: String, CodingKey { case updated } @@ -49,7 +51,7 @@ public extension UpdateExpiryAllResponse { // MARK: - ValidatableResponse -extension UpdateExpiryAllResponse: ValidatableResponse { +extension Network.StorageServer.UpdateExpiryAllResponse: ValidatableResponse { typealias ValidationData = UInt64 typealias ValidationResponse = [String] @@ -94,7 +96,7 @@ extension UpdateExpiryAllResponse: ValidatableResponse { ) // If the update signature is invalid then we want to fail here - guard isValid else { throw SnodeAPIError.signatureVerificationFailed } + guard isValid else { throw StorageServerError.signatureVerificationFailed } result[next.key] = next.value.updated } diff --git a/SessionNetworkingKit/StorageServer/Models/UpdateExpiryRequest.swift b/SessionNetworkingKit/StorageServer/Models/UpdateExpiryRequest.swift index 6e237557d0..a60019030c 100644 --- a/SessionNetworkingKit/StorageServer/Models/UpdateExpiryRequest.swift +++ b/SessionNetworkingKit/StorageServer/Models/UpdateExpiryRequest.swift @@ -5,8 +5,8 @@ import Foundation import SessionUtilitiesKit -extension Network.SnodeAPI { - class UpdateExpiryRequest: SnodeAuthenticatedRequestBody { +extension Network.StorageServer { + class UpdateExpiryRequest: BaseAuthenticatedRequestBody { enum CodingKeys: String, CodingKey { case messageHashes = "messages" case expiryMs = "expiry" @@ -39,7 +39,7 @@ extension Network.SnodeAPI { /// ` || messages[N])` where `expiry` is the expiry timestamp expressed as a string. /// `ShortenOrExtend` is string signature must be base64 "shorten" if the shorten option is given (and true), /// "extend" if `extend` is true, and empty otherwise. The signature must be base64 encoded (json) or bytes (bt). - Network.SnodeAPI.Endpoint.expire.path.bytes + Endpoint.expire.path.bytes .appending(contentsOf: (shorten == true ? "shorten".bytes : [])) .appending(contentsOf: (extend == true ? "extend".bytes : [])) .appending(contentsOf: "\(expiryMs)".data(using: .ascii)?.bytes) @@ -60,7 +60,10 @@ extension Network.SnodeAPI { self.shorten = shorten self.extend = extend - super.init(authMethod: authMethod) + super.init( + timestampMs: nil, + authMethod: authMethod + ) } // MARK: - Coding diff --git a/SessionNetworkingKit/StorageServer/Models/UpdateExpiryResponse.swift b/SessionNetworkingKit/StorageServer/Models/UpdateExpiryResponse.swift index c1cafb8c71..a0faba6cbd 100644 --- a/SessionNetworkingKit/StorageServer/Models/UpdateExpiryResponse.swift +++ b/SessionNetworkingKit/StorageServer/Models/UpdateExpiryResponse.swift @@ -3,18 +3,20 @@ import Foundation import SessionUtilitiesKit -public class UpdateExpiryResponse: SnodeRecursiveResponse {} - -public struct UpdateExpiryResponseResult { - public let changed: [String: UInt64] - public let unchanged: [String: UInt64] - public let didError: Bool +extension Network.StorageServer { + public class UpdateExpiryResponse: BaseRecursiveResponse {} + + public struct UpdateExpiryResponseResult { + public let changed: [String: UInt64] + public let unchanged: [String: UInt64] + public let didError: Bool + } } // MARK: - SwarmItem -public extension UpdateExpiryResponse { - class SwarmItem: SnodeSwarmItem { +public extension Network.StorageServer.UpdateExpiryResponse { + class SwarmItem: Network.StorageServer.BaseSwarmItem { private enum CodingKeys: String, CodingKey { case updated case unchanged @@ -50,9 +52,9 @@ public extension UpdateExpiryResponse { // MARK: - ValidatableResponse -extension UpdateExpiryResponse: ValidatableResponse { +extension Network.StorageServer.UpdateExpiryResponse: ValidatableResponse { typealias ValidationData = [String] - typealias ValidationResponse = UpdateExpiryResponseResult + typealias ValidationResponse = Network.StorageServer.UpdateExpiryResponseResult /// All responses in the swarm must be valid internal static var requiredSuccessfulResponses: Int { -1 } @@ -61,15 +63,15 @@ extension UpdateExpiryResponse: ValidatableResponse { swarmPublicKey: String, validationData: [String], using dependencies: Dependencies - ) throws -> [String: UpdateExpiryResponseResult] { - let validationMap: [String: UpdateExpiryResponseResult] = try swarm.reduce(into: [:]) { result, next in + ) throws -> [String: Network.StorageServer.UpdateExpiryResponseResult] { + let validationMap: [String: Network.StorageServer.UpdateExpiryResponseResult] = try swarm.reduce(into: [:]) { result, next in guard !next.value.failed, let appliedExpiry: UInt64 = next.value.expiry, let signatureBase64: String = next.value.signatureBase64, let encodedSignature: Data = Data(base64Encoded: signatureBase64) else { - result[next.key] = UpdateExpiryResponseResult(changed: [:], unchanged: [:], didError: true) + result[next.key] = Network.StorageServer.UpdateExpiryResponseResult(changed: [:], unchanged: [:], didError: true) if let reason: String = next.value.reason, let statusCode: Int = next.value.code { Log.warn(.validator(self), "Couldn't update expiry from: \(next.key) due to error: \(reason) (\(statusCode)).") @@ -108,9 +110,9 @@ extension UpdateExpiryResponse: ValidatableResponse { ) // If the update signature is invalid then we want to fail here - guard isValid else { throw SnodeAPIError.signatureVerificationFailed } + guard isValid else { throw StorageServerError.signatureVerificationFailed } - result[next.key] = UpdateExpiryResponseResult( + result[next.key] = Network.StorageServer.UpdateExpiryResponseResult( changed: next.value.updated.reduce(into: [:]) { prev, next in prev[next] = appliedExpiry }, unchanged: next.value.unchanged, didError: false diff --git a/SessionNetworkingKit/StorageServer/SnodeAPI.swift b/SessionNetworkingKit/StorageServer/SnodeAPI.swift deleted file mode 100644 index 68e7357e4a..0000000000 --- a/SessionNetworkingKit/StorageServer/SnodeAPI.swift +++ /dev/null @@ -1,859 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. -// -// stringlint:disable - -import Foundation -import Combine -import GRDB -import Punycode -import SessionUtilitiesKit - -public extension Network { - enum SnodeAPI { - // MARK: - Settings - - public static let maxRetryCount: Int = 8 - - // MARK: - Batching & Polling - - public typealias PollResponse = [SnodeAPI.Namespace: (info: ResponseInfoType, data: PreparedGetMessagesResponse?)] - - public static func preparedPoll( - _ db: ObservingDatabase, - namespaces: [SnodeAPI.Namespace], - refreshingConfigHashes: [String] = [], - from snode: LibSession.Snode, - authMethod: AuthenticationMethod, - using dependencies: Dependencies - ) throws -> Network.PreparedRequest { - // Determine the maxSize each namespace in the request should take up - var requests: [any ErasedPreparedRequest] = [] - let namespaceMaxSizeMap: [SnodeAPI.Namespace: Int64] = SnodeAPI.Namespace.maxSizeMap(for: namespaces) - let fallbackSize: Int64 = (namespaceMaxSizeMap.values.min() ?? 1) - - // If we have any config hashes to refresh TTLs then add those requests first - if !refreshingConfigHashes.isEmpty { - let updatedExpiryMS: Int64 = ( - dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + - (30 * 24 * 60 * 60 * 1000) // 30 days - ) - requests.append( - try SnodeAPI.preparedUpdateExpiry( - serverHashes: refreshingConfigHashes, - updatedExpiryMs: updatedExpiryMS, - extendOnly: true, - ignoreValidationFailure: true, - explicitTargetNode: snode, - authMethod: authMethod, - using: dependencies - ) - ) - } - - // Add the various 'getMessages' requests - requests.append( - contentsOf: try namespaces.map { namespace -> any ErasedPreparedRequest in - try SnodeAPI.preparedGetMessages( - db, - namespace: namespace, - snode: snode, - maxSize: namespaceMaxSizeMap[namespace] - .defaulting(to: fallbackSize), - authMethod: authMethod, - using: dependencies - ) - } - ) - - return try preparedBatch( - requests: requests, - requireAllBatchResponses: true, - snode: snode, - swarmPublicKey: try authMethod.swarmPublicKey, - using: dependencies - ) - .map { (_: ResponseInfoType, batchResponse: Network.BatchResponse) -> [SnodeAPI.Namespace: (info: ResponseInfoType, data: PreparedGetMessagesResponse?)] in - let messageResponses: [Network.BatchSubResponse] = batchResponse - .compactMap { $0 as? Network.BatchSubResponse } - - return zip(namespaces, messageResponses) - .reduce(into: [:]) { result, next in - guard let messageResponse: PreparedGetMessagesResponse = next.1.body else { return } - - result[next.0] = (next.1, messageResponse) - } - } - } - - public static func preparedBatch( - requests: [any ErasedPreparedRequest], - requireAllBatchResponses: Bool, - snode: LibSession.Snode? = nil, - swarmPublicKey: String, - requestTimeout: TimeInterval = Network.defaultTimeout, - requestAndPathBuildTimeout: TimeInterval? = nil, - using dependencies: Dependencies - ) throws -> Network.PreparedRequest { - return try SnodeAPI - .prepareRequest( - request: { - switch snode { - case .none: - return try Request( - endpoint: .batch, - swarmPublicKey: swarmPublicKey, - body: Network.BatchRequest(requestsKey: .requests, requests: requests) - ) - - case .some(let snode): - return try Request( - endpoint: .batch, - snode: snode, - swarmPublicKey: swarmPublicKey, - body: Network.BatchRequest(requestsKey: .requests, requests: requests) - ) - } - }(), - responseType: Network.BatchResponse.self, - requireAllBatchResponses: requireAllBatchResponses, - requestTimeout: requestTimeout, - requestAndPathBuildTimeout: requestAndPathBuildTimeout, - using: dependencies - ) - } - - public static func preparedSequence( - requests: [any ErasedPreparedRequest], - requireAllBatchResponses: Bool, - swarmPublicKey: String, - snodeRetrievalRetryCount: Int, - requestTimeout: TimeInterval = Network.defaultTimeout, - requestAndPathBuildTimeout: TimeInterval? = nil, - using dependencies: Dependencies - ) throws -> Network.PreparedRequest { - return try SnodeAPI - .prepareRequest( - request: Request( - endpoint: .sequence, - swarmPublicKey: swarmPublicKey, - body: Network.BatchRequest(requestsKey: .requests, requests: requests), - snodeRetrievalRetryCount: snodeRetrievalRetryCount - ), - responseType: Network.BatchResponse.self, - requireAllBatchResponses: requireAllBatchResponses, - requestTimeout: requestTimeout, - requestAndPathBuildTimeout: requestAndPathBuildTimeout, - using: dependencies - ) - } - - // MARK: - Retrieve - - public typealias PreparedGetMessagesResponse = (messages: [SnodeReceivedMessage], lastHash: String?) - - public static func preparedGetMessages( - _ db: ObservingDatabase, - namespace: SnodeAPI.Namespace, - snode: LibSession.Snode, - maxSize: Int64? = nil, - authMethod: AuthenticationMethod, - using dependencies: Dependencies - ) throws -> Network.PreparedRequest { - let maybeLastHash: String? = try SnodeReceivedMessageInfo - .fetchLastNotExpired( - db, - for: snode, - namespace: namespace, - swarmPublicKey: try authMethod.swarmPublicKey, - using: dependencies - )? - .hash - let preparedRequest: Network.PreparedRequest = try { - // Check if this namespace requires authentication - guard namespace.requiresReadAuthentication else { - return try SnodeAPI.prepareRequest( - request: Request( - endpoint: .getMessages, - swarmPublicKey: try authMethod.swarmPublicKey, - body: LegacyGetMessagesRequest( - pubkey: try authMethod.swarmPublicKey, - lastHash: (maybeLastHash ?? ""), - namespace: namespace, - maxCount: nil, - maxSize: maxSize - ) - ), - responseType: GetMessagesResponse.self, - using: dependencies - ) - } - - return try SnodeAPI.prepareRequest( - request: Request( - endpoint: .getMessages, - swarmPublicKey: try authMethod.swarmPublicKey, - body: GetMessagesRequest( - lastHash: (maybeLastHash ?? ""), - namespace: namespace, - authMethod: authMethod, - timestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs(), - maxSize: maxSize - ) - ), - responseType: GetMessagesResponse.self, - using: dependencies - ) - }() - - return preparedRequest - .tryMap { _, response -> (messages: [SnodeReceivedMessage], lastHash: String?) in - return ( - try response.messages.compactMap { rawMessage -> SnodeReceivedMessage? in - SnodeReceivedMessage( - snode: snode, - publicKey: try authMethod.swarmPublicKey, - namespace: namespace, - rawMessage: rawMessage - ) - }, - maybeLastHash - ) - } - } - - public static func getSessionID( - for onsName: String, - using dependencies: Dependencies - ) -> AnyPublisher { - let validationCount = 3 - - // The name must be lowercased - let onsName = onsName.lowercased().idnaEncoded ?? onsName.lowercased() - - // Hash the ONS name using BLAKE2b - guard - let nameHash = dependencies[singleton: .crypto].generate( - .hash(message: Array(onsName.utf8)) - ) - else { - return Fail(error: SnodeAPIError.onsHashingFailed) - .eraseToAnyPublisher() - } - - // Ask 3 different snodes for the Session ID associated with the given name hash - let base64EncodedNameHash = nameHash.toBase64() - - return dependencies[singleton: .network] - .getRandomNodes(count: validationCount) - .tryFlatMap { nodes in - Publishers.MergeMany( - try nodes.map { snode in - try SnodeAPI - .prepareRequest( - request: Request( - endpoint: .oxenDaemonRPCCall, - snode: snode, - body: OxenDaemonRPCRequest( - endpoint: .daemonOnsResolve, - body: ONSResolveRequest( - type: 0, // type 0 means Session - base64EncodedNameHash: base64EncodedNameHash - ) - ) - ), - responseType: ONSResolveResponse.self, - using: dependencies - ) - .tryMap { _, response -> String in - try dependencies[singleton: .crypto].tryGenerate( - .sessionId(name: onsName, response: response) - ) - } - .send(using: dependencies) - .map { _, sessionId in sessionId } - .eraseToAnyPublisher() - } - ) - } - .collect() - .tryMap { results -> String in - guard results.count == validationCount, Set(results).count == 1 else { - throw SnodeAPIError.onsValidationFailed - } - - return results[0] - } - .eraseToAnyPublisher() - } - - public static func preparedGetExpiries( - of serverHashes: [String], - authMethod: AuthenticationMethod, - using dependencies: Dependencies - ) throws -> Network.PreparedRequest { - return try SnodeAPI - .prepareRequest( - request: Request( - endpoint: .getExpiries, - swarmPublicKey: try authMethod.swarmPublicKey, - body: GetExpiriesRequest( - messageHashes: serverHashes, - authMethod: authMethod, - timestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - ) - ), - responseType: GetExpiriesResponse.self, - using: dependencies - ) - } - - // MARK: - Store - - public static func preparedSendMessage( - message: SnodeMessage, - in namespace: Namespace, - authMethod: AuthenticationMethod, - using dependencies: Dependencies - ) throws -> Network.PreparedRequest { - let request: Network.PreparedRequest = try { - // Check if this namespace requires authentication - guard namespace.requiresWriteAuthentication else { - return try SnodeAPI.prepareRequest( - request: Request( - endpoint: .sendMessage, - swarmPublicKey: try authMethod.swarmPublicKey, - body: LegacySendMessagesRequest( - message: message, - namespace: namespace - ), - snodeRetrievalRetryCount: 0 // The SendMessageJob already has a retry mechanism - ), - responseType: SendMessagesResponse.self, - requestAndPathBuildTimeout: Network.defaultTimeout, - using: dependencies - ) - } - - return try SnodeAPI.prepareRequest( - request: Request( - endpoint: .sendMessage, - swarmPublicKey: try authMethod.swarmPublicKey, - body: SendMessageRequest( - message: message, - namespace: namespace, - authMethod: authMethod, - timestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - ), - snodeRetrievalRetryCount: 0 // The SendMessageJob already has a retry mechanism - ), - responseType: SendMessagesResponse.self, - requestAndPathBuildTimeout: Network.defaultTimeout, - using: dependencies - ) - }() - - return request - .tryMap { _, response -> SendMessagesResponse in - try response.validateResultMap( - swarmPublicKey: try authMethod.swarmPublicKey, - using: dependencies - ) - - return response - } - } - - // MARK: - Edit - - public static func preparedUpdateExpiry( - serverHashes: [String], - updatedExpiryMs: Int64, - shortenOnly: Bool? = nil, - extendOnly: Bool? = nil, - ignoreValidationFailure: Bool = false, - explicitTargetNode: LibSession.Snode? = nil, - authMethod: AuthenticationMethod, - using dependencies: Dependencies - ) throws -> Network.PreparedRequest<[String: UpdateExpiryResponseResult]> { - // ShortenOnly and extendOnly cannot be true at the same time - guard shortenOnly == nil || extendOnly == nil else { throw NetworkError.invalidPreparedRequest } - - return try SnodeAPI - .prepareRequest( - request: Request( - endpoint: .expire, - swarmPublicKey: try authMethod.swarmPublicKey, - body: UpdateExpiryRequest( - messageHashes: serverHashes, - expiryMs: UInt64(updatedExpiryMs), - shorten: shortenOnly, - extend: extendOnly, - authMethod: authMethod - ) - ), - responseType: UpdateExpiryResponse.self, - using: dependencies - ) - .tryMap { _, response -> [String: UpdateExpiryResponseResult] in - do { - return try response.validResultMap( - swarmPublicKey: try authMethod.swarmPublicKey, - validationData: serverHashes, - using: dependencies - ) - } - catch { - guard ignoreValidationFailure else { throw error } - - return [:] - } - } - .handleEvents( - receiveOutput: { _, result in - /// Since we have updated the TTL we need to make sure we also update the local - /// `SnodeReceivedMessageInfo.expirationDateMs` values so they match the updated swarm, if - /// we had a specific `snode` we we're sending the request to then we should use those values, otherwise - /// we can just grab the first value from the response and use that - let maybeTargetResult: UpdateExpiryResponseResult? = { - guard let snode: LibSession.Snode = explicitTargetNode else { - return result.first?.value - } - - return result[snode.ed25519PubkeyHex] - }() - guard - let targetResult: UpdateExpiryResponseResult = maybeTargetResult, - let groupedExpiryResult: [UInt64: [String]] = targetResult.changed - .updated(with: targetResult.unchanged) - .groupedByValue() - .nullIfEmpty - else { return } - - dependencies[singleton: .storage].writeAsync { db in - try groupedExpiryResult.forEach { updatedExpiry, hashes in - try SnodeReceivedMessageInfo - .filter(hashes.contains(SnodeReceivedMessageInfo.Columns.hash)) - .updateAll( - db, - SnodeReceivedMessageInfo.Columns.expirationDateMs - .set(to: updatedExpiry) - ) - } - } - } - ) - } - - public static func preparedRevokeSubaccounts( - subaccountsToRevoke: [[UInt8]], - authMethod: AuthenticationMethod, - using dependencies: Dependencies - ) throws -> Network.PreparedRequest { - let timestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - - return try SnodeAPI - .prepareRequest( - request: Request( - endpoint: .revokeSubaccount, - swarmPublicKey: try authMethod.swarmPublicKey, - body: RevokeSubaccountRequest( - subaccountsToRevoke: subaccountsToRevoke, - authMethod: authMethod, - timestampMs: timestampMs - ) - ), - responseType: RevokeSubaccountResponse.self, - using: dependencies - ) - .tryMap { _, response -> Void in - try response.validateResultMap( - swarmPublicKey: try authMethod.swarmPublicKey, - validationData: (subaccountsToRevoke, timestampMs), - using: dependencies - ) - - return () - } - } - - public static func preparedUnrevokeSubaccounts( - subaccountsToUnrevoke: [[UInt8]], - authMethod: AuthenticationMethod, - using dependencies: Dependencies - ) throws -> Network.PreparedRequest { - let timestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - - return try SnodeAPI - .prepareRequest( - request: Request( - endpoint: .unrevokeSubaccount, - swarmPublicKey: try authMethod.swarmPublicKey, - body: UnrevokeSubaccountRequest( - subaccountsToUnrevoke: subaccountsToUnrevoke, - authMethod: authMethod, - timestampMs: timestampMs - ) - ), - responseType: UnrevokeSubaccountResponse.self, - using: dependencies - ) - .tryMap { _, response -> Void in - try response.validateResultMap( - swarmPublicKey: try authMethod.swarmPublicKey, - validationData: (subaccountsToUnrevoke, timestampMs), - using: dependencies - ) - - return () - } - } - - // MARK: - Delete - - public static func preparedDeleteMessages( - serverHashes: [String], - requireSuccessfulDeletion: Bool, - authMethod: AuthenticationMethod, - using dependencies: Dependencies - ) throws -> Network.PreparedRequest<[String: Bool]> { - return try SnodeAPI - .prepareRequest( - request: Request( - endpoint: .deleteMessages, - swarmPublicKey: try authMethod.swarmPublicKey, - body: DeleteMessagesRequest( - messageHashes: serverHashes, - requireSuccessfulDeletion: requireSuccessfulDeletion, - authMethod: authMethod - ) - ), - responseType: DeleteMessagesResponse.self, - using: dependencies - ) - .tryMap { _, response -> [String: Bool] in - let validResultMap: [String: Bool] = try response.validResultMap( - swarmPublicKey: try authMethod.swarmPublicKey, - validationData: serverHashes, - using: dependencies - ) - - // If `validResultMap` didn't throw then at least one service node - // deleted successfully so we should mark the hash as invalid so we - // don't try to fetch updates using that hash going forward (if we - // do we would end up re-fetching all old messages) - dependencies[singleton: .storage].writeAsync { db in - try? SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash( - db, - potentiallyInvalidHashes: serverHashes - ) - } - - return validResultMap - } - } - - - /// Clears all the user's data from their swarm. Returns a dictionary of snode public key to deletion confirmation. - public static func preparedDeleteAllMessages( - namespace: SnodeAPI.Namespace, - requestTimeout: TimeInterval = Network.defaultTimeout, - requestAndPathBuildTimeout: TimeInterval? = nil, - authMethod: AuthenticationMethod, - using dependencies: Dependencies - ) throws -> Network.PreparedRequest<[String: Bool]> { - return try SnodeAPI - .prepareRequest( - request: Request( - endpoint: .deleteAll, - swarmPublicKey: try authMethod.swarmPublicKey, - requiresLatestNetworkTime: true, - body: DeleteAllMessagesRequest( - namespace: namespace, - authMethod: authMethod, - timestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - ), - snodeRetrievalRetryCount: 0 - ), - responseType: DeleteAllMessagesResponse.self, - requestTimeout: requestTimeout, - requestAndPathBuildTimeout: requestAndPathBuildTimeout, - using: dependencies - ) - .tryMap { info, response -> [String: Bool] in - guard let targetInfo: LatestTimestampResponseInfo = info as? LatestTimestampResponseInfo else { - throw NetworkError.invalidResponse - } - - return try response.validResultMap( - swarmPublicKey: try authMethod.swarmPublicKey, - validationData: targetInfo.timestampMs, - using: dependencies - ) - } - } - - /// Clears all the user's data from their swarm. Returns a dictionary of snode public key to deletion confirmation. - public static func preparedDeleteAllMessages( - beforeMs: UInt64, - namespace: SnodeAPI.Namespace, - authMethod: AuthenticationMethod, - using dependencies: Dependencies - ) throws -> Network.PreparedRequest<[String: Bool]> { - return try SnodeAPI - .prepareRequest( - request: Request( - endpoint: .deleteAllBefore, - swarmPublicKey: try authMethod.swarmPublicKey, - requiresLatestNetworkTime: true, - body: DeleteAllBeforeRequest( - beforeMs: beforeMs, - namespace: namespace, - authMethod: authMethod, - timestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - ) - ), - responseType: DeleteAllMessagesResponse.self, - retryCount: maxRetryCount, - using: dependencies - ) - .tryMap { _, response -> [String: Bool] in - try response.validResultMap( - swarmPublicKey: try authMethod.swarmPublicKey, - validationData: beforeMs, - using: dependencies - ) - } - } - - // MARK: - Internal API - - public static func preparedGetNetworkTime( - from snode: LibSession.Snode, - using dependencies: Dependencies - ) throws -> Network.PreparedRequest { - return try SnodeAPI - .prepareRequest( - request: Request, Endpoint>( - endpoint: .getInfo, - snode: snode, - body: [:] - ), - responseType: GetNetworkTimestampResponse.self, - using: dependencies - ) - .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))) - dependencies.mutate(cache: .snodeAPI) { $0.setClockOffsetMs(offset) } - - return response.timestamp - } - } - - // MARK: - Convenience - - private static func prepareRequest( - request: Request, - responseType: R.Type, - requireAllBatchResponses: Bool = true, - retryCount: Int = 0, - requestTimeout: TimeInterval = Network.defaultTimeout, - requestAndPathBuildTimeout: TimeInterval? = nil, - using dependencies: Dependencies - ) throws -> Network.PreparedRequest { - return try Network.PreparedRequest( - request: request, - responseType: responseType, - requireAllBatchResponses: requireAllBatchResponses, - retryCount: retryCount, - requestTimeout: requestTimeout, - requestAndPathBuildTimeout: requestAndPathBuildTimeout, - using: dependencies - ) - .handleEvents( - receiveOutput: { _, response in - switch response { - case let snodeResponse as SnodeResponse: - // Update the network offset based on the response so subsequent requests have - // the correct network offset time - let offset = (Int64(snodeResponse.timeOffset) - Int64(floor(dependencies.dateNow.timeIntervalSince1970 * 1000))) - dependencies.mutate(cache: .snodeAPI) { - $0.setClockOffsetMs(offset) - - // Extract and store hard fork information if returned - guard snodeResponse.hardForkVersion.count > 1 else { return } - - if snodeResponse.hardForkVersion[1] > $0.softfork { - $0.softfork = snodeResponse.hardForkVersion[1] - dependencies[defaults: .standard, key: .softfork] = $0.softfork - } - - if snodeResponse.hardForkVersion[0] > $0.hardfork { - $0.hardfork = snodeResponse.hardForkVersion[0] - dependencies[defaults: .standard, key: .hardfork] = $0.hardfork - $0.softfork = snodeResponse.hardForkVersion[1] - dependencies[defaults: .standard, key: .softfork] = $0.softfork - } - } - - default: break - } - } - ) - } - } -} - -// MARK: - Publisher Convenience - -public extension Publisher where Output == Set { - func tryMapWithRandomSnode( - using dependencies: Dependencies, - _ transform: @escaping (LibSession.Snode) throws -> T - ) -> AnyPublisher { - return self - .tryMap { swarm -> T in - var remainingSnodes: Set = swarm - let snode: LibSession.Snode = try dependencies.popRandomElement(&remainingSnodes) ?? { - throw SnodeAPIError.insufficientSnodes - }() - - return try transform(snode) - } - .eraseToAnyPublisher() - } - - func tryFlatMapWithRandomSnode( - maxPublishers: Subscribers.Demand = .unlimited, - retry retries: Int = 0, - drainBehaviour: ThreadSafeObject = .alwaysRandom, - using dependencies: Dependencies, - _ transform: @escaping (LibSession.Snode) throws -> P - ) -> AnyPublisher where T == P.Output, P: Publisher, P.Failure == Error { - return self - .mapError { $0 } - .flatMap(maxPublishers: maxPublishers) { swarm -> AnyPublisher in - // If we don't want to reuse a specific snode multiple times then just grab a - // random one from the swarm every time - var remainingSnodes: Set = drainBehaviour.performUpdateAndMap { behaviour in - switch behaviour { - case .alwaysRandom: return (behaviour, swarm) - case .limitedReuse(_, let targetSnode, _, let usedSnodes, let swarmHash): - // If we've used all of the snodes or the swarm has changed then reset the used list - guard swarmHash == swarm.hashValue && (targetSnode != nil || usedSnodes != swarm) else { - return (behaviour.reset(), swarm) - } - - return (behaviour, swarm.subtracting(usedSnodes)) - } - } - var lastError: Error? - - return Just(()) - .setFailureType(to: Error.self) - .tryFlatMap(maxPublishers: maxPublishers) { _ -> AnyPublisher in - let snode: LibSession.Snode = try drainBehaviour.performUpdateAndMap { behaviour in - switch behaviour { - case .limitedReuse(_, .some(let targetSnode), _, _, _): - return (behaviour.use(snode: targetSnode, from: swarm), targetSnode) - default: break - } - - // Select the next snode - let result: LibSession.Snode = try dependencies.popRandomElement(&remainingSnodes) ?? { - throw SnodeAPIError.ranOutOfRandomSnodes(lastError) - }() - - return (behaviour.use(snode: result, from: swarm), result) - } - - return try transform(snode) - .eraseToAnyPublisher() - } - .mapError { error in - // Prevent nesting the 'ranOutOfRandomSnodes' errors - switch error { - case SnodeAPIError.ranOutOfRandomSnodes: break - default: lastError = error - } - - return error - } - .retry(retries) - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } -} - -// MARK: - SnodeAPI Cache - -public extension Network.SnodeAPI { - class Cache: SnodeAPICacheType { - private let dependencies: Dependencies - public var hardfork: Int - public var softfork: Int - public var clockOffsetMs: Int64 = 0 - - init(using dependencies: Dependencies) { - self.dependencies = dependencies - self.hardfork = dependencies[defaults: .standard, key: .hardfork] - self.softfork = dependencies[defaults: .standard, key: .softfork] - } - - public func currentOffsetTimestampMs() -> T { - let timestampNowMs: Int64 = (Int64(floor(dependencies.dateNow.timeIntervalSince1970 * 1000)) + clockOffsetMs) - - guard let convertedTimestampNowMs: T = T(exactly: timestampNowMs) else { - Log.critical("[SnodeAPI.Cache] Failed to convert the timestamp to the desired type: \(type(of: T.self)).") - return 0 - } - - return convertedTimestampNowMs - } - - public func setClockOffsetMs(_ clockOffsetMs: Int64) { - self.clockOffsetMs = clockOffsetMs - } - } -} - -public extension Cache { - static let snodeAPI: CacheConfig = Dependencies.create( - identifier: "snodeAPI", - createInstance: { dependencies in Network.SnodeAPI.Cache(using: dependencies) }, - mutableInstance: { $0 }, - immutableInstance: { $0 } - ) -} - -// MARK: - SnodeAPICacheType - -/// This is a read-only version of the Cache designed to avoid unintentionally mutating the instance in a non-thread-safe way -public protocol SnodeAPIImmutableCacheType: ImmutableCacheType { - /// The last seen storage server hard fork version. - var hardfork: Int { get } - - /// The last seen storage server soft fork version. - var softfork: Int { get } - - /// The offset between the user's clock and the Service Node's clock. Used in cases where the - /// user's clock is incorrect. - var clockOffsetMs: Int64 { get } - - /// Tthe current user clock timestamp in milliseconds offset by the difference between the user's clock and the clock of the most - /// recent Service Node's that was communicated with. - func currentOffsetTimestampMs() -> T -} - -public protocol SnodeAPICacheType: SnodeAPIImmutableCacheType, MutableCacheType { - /// The last seen storage server hard fork version. - var hardfork: Int { get set } - - /// The last seen storage server soft fork version. - var softfork: Int { get set } - - /// A function to update the offset between the user's clock and the Service Node's clock. - func setClockOffsetMs(_ clockOffsetMs: Int64) -} diff --git a/SessionNetworkingKit/StorageServer/StorageServer.swift b/SessionNetworkingKit/StorageServer/StorageServer.swift new file mode 100644 index 0000000000..8252b0ddb2 --- /dev/null +++ b/SessionNetworkingKit/StorageServer/StorageServer.swift @@ -0,0 +1,12 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import SessionUtilitiesKit + +public extension Network { + enum StorageServer { + public static let maxRetryCount: Int = 8 + } +} diff --git a/SessionNetworkingKit/StorageServer/StorageServerAPI.swift b/SessionNetworkingKit/StorageServer/StorageServerAPI.swift new file mode 100644 index 0000000000..f0a5625102 --- /dev/null +++ b/SessionNetworkingKit/StorageServer/StorageServerAPI.swift @@ -0,0 +1,585 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import Combine +import GRDB +import Punycode +import SessionUtilitiesKit + +private typealias StorageServer = Network.StorageServer +private typealias Endpoint = Network.StorageServer.Endpoint + +public extension Network.StorageServer { + // MARK: - Batching & Polling + + typealias PollResponse = [Namespace: (info: ResponseInfoType, data: PreparedGetMessagesResponse?)] + + static func poll( + namespaces: [Namespace], + lastHashes: [Namespace: String], + refreshingConfigHashes: [String] = [], + from snode: LibSession.Snode, + authMethod: AuthenticationMethod, + using dependencies: Dependencies + ) async throws -> PollResponse { + /// Determine the maxSize each namespace in the request should take up + var requests: [any ErasedPreparedRequest] = [] + let namespaceMaxSizeMap: [Namespace: Int64] = Namespace.maxSizeMap(for: namespaces) + let fallbackSize: Int64 = (namespaceMaxSizeMap.values.min() ?? 1) + + /// If we have any config hashes to refresh TTLs then add those requests first + if !refreshingConfigHashes.isEmpty { + let updatedExpiryMs: Int64 = await ( + dependencies.networkOffsetTimestampMs() + + (30 * 24 * 60 * 60 * 1000) // 30 days + ) + requests.append( + try StorageServer.prepareUpdateExpiryRequest( + serverHashes: refreshingConfigHashes, + updatedExpiryMs: updatedExpiryMs, + extendOnly: true, + authMethod: authMethod, + using: dependencies + ) + ) + } + + /// Add the various `getMessages` requests + requests.append( + contentsOf: try namespaces.map { namespace -> any ErasedPreparedRequest in + try StorageServer.preparedGetMessages( + namespace: namespace, + snode: snode, + lastHash: lastHashes[namespace], + maxSize: namespaceMaxSizeMap[namespace] + .defaulting(to: fallbackSize), + authMethod: authMethod, + using: dependencies + ) + } + ) + + /// Send the request + let request: Network.PreparedRequest = try StorageServer.preparedBatch( + requests: requests, + requireAllBatchResponses: true, + snode: snode, + swarmPublicKey: try authMethod.swarmPublicKey, + using: dependencies + ) + let batchResponse: Network.BatchResponse = try await request.send(using: dependencies) + + /// Process the `updateExpiry` response first + let maybeUpdateExpiryResponse: UpdateExpiryResponse? = batchResponse + .compactMap { $0 as? Network.BatchSubResponse } + .filter { !$0.failedToParseBody } + .compactMap { $0.body } + .first + + if let response: UpdateExpiryResponse = maybeUpdateExpiryResponse { + try await StorageServer.processUpdateExpiryResponse( + response: response, + serverHashes: refreshingConfigHashes, + ignoreValidationFailure: true, + explicitTargetNode: snode, + authMethod: authMethod, + using: dependencies + ) + } + + /// Then extract and return the message responses + let messageResponses: [Network.BatchSubResponse] = batchResponse + .compactMap { $0 as? Network.BatchSubResponse } + + return zip(namespaces, messageResponses).reduce(into: [:]) { result, next in + guard let messageResponse: PreparedGetMessagesResponse = next.1.body else { return } + + result[next.0] = (next.1, messageResponse) + } + } + + static func preparedBatch( + requests: [any ErasedPreparedRequest], + requireAllBatchResponses: Bool, + snode: LibSession.Snode? = nil, + swarmPublicKey: String, + requestTimeout: TimeInterval = Network.defaultTimeout, + overallTimeout: TimeInterval? = nil, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + return try Network.PreparedRequest( + request: { + switch snode { + case .none: + return Request( + endpoint: .batch, + swarmPublicKey: swarmPublicKey, + body: Network.BatchRequest(requestsKey: .requests, requests: requests), + requestTimeout: requestTimeout, + overallTimeout: overallTimeout + ) + + case .some(let snode): + return Request( + endpoint: .batch, + snode: snode, + swarmPublicKey: swarmPublicKey, + body: Network.BatchRequest(requestsKey: .requests, requests: requests), + requestTimeout: requestTimeout, + overallTimeout: overallTimeout + ) + } + }(), + responseType: Network.BatchResponse.self, + requireAllBatchResponses: requireAllBatchResponses, + using: dependencies + ) + } + + static func preparedSequence( + requests: [any ErasedPreparedRequest], + requireAllBatchResponses: Bool, + swarmPublicKey: String, + requestTimeout: TimeInterval = Network.defaultTimeout, + overallTimeout: TimeInterval? = nil, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + return try Network.PreparedRequest( + request: Request( + endpoint: .sequence, + swarmPublicKey: swarmPublicKey, + body: Network.BatchRequest(requestsKey: .requests, requests: requests), + requestTimeout: requestTimeout, + overallTimeout: overallTimeout + ), + responseType: Network.BatchResponse.self, + requireAllBatchResponses: requireAllBatchResponses, + using: dependencies + ) + } + + // MARK: - Retrieve + + typealias PreparedGetMessagesResponse = (messages: [Message], lastHash: String?) + + static func preparedGetMessages( + namespace: Namespace, + snode: LibSession.Snode, + lastHash: String? = nil, + maxSize: Int64? = nil, + authMethod: AuthenticationMethod, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + let preparedRequest: Network.PreparedRequest = try Network.PreparedRequest( + request: Request( + endpoint: .getMessages, + swarmPublicKey: try authMethod.swarmPublicKey, + body: GetMessagesRequest( + lastHash: (lastHash ?? ""), + namespace: namespace, + maxSize: maxSize, + timestampMs: dependencies.networkOffsetTimestampMs(), + authMethod: authMethod + ) + ), + responseType: GetMessagesResponse.self, + using: dependencies + ) + + return preparedRequest + .tryMap { _, response -> (messages: [Message], lastHash: String?) in + return ( + try response.messages.compactMap { rawMessage -> Message? in + Message( + snode: snode, + publicKey: try authMethod.swarmPublicKey, + namespace: namespace, + rawMessage: rawMessage + ) + }, + lastHash + ) + } + } + + static func getSessionID( + for onsName: String, + using dependencies: Dependencies + ) async throws -> String { + let validationCount = 3 + + // The name must be lowercased + let onsName = onsName.lowercased().idnaEncoded ?? onsName.lowercased() + + // Hash the ONS name using BLAKE2b + guard + let nameHash = dependencies[singleton: .crypto].generate( + .hash(message: Array(onsName.utf8)) + ) + else { throw StorageServerError.onsHashingFailed } + + // Ask 3 different snodes for the Session ID associated with the given name hash + let base64EncodedNameHash = nameHash.toBase64() + let nodes: Set = try await dependencies[singleton: .network] + .getRandomNodes(count: validationCount) + let results: [String] = try await withThrowingTaskGroup { [dependencies] group in + for node in nodes { + group.addTask { [dependencies] in + let request: Network.PreparedRequest = try Network.PreparedRequest( + request: Request( + endpoint: .oxenDaemonRPCCall, + snode: node, + body: OxenDaemonRPCRequest( + endpoint: .daemonOnsResolve, + body: ONSResolveRequest( + type: 0, // type 0 means Session + base64EncodedNameHash: base64EncodedNameHash + ) + ) + ), + responseType: ONSResolveResponse.self, + using: dependencies + ) + let response: ONSResolveResponse = try await request.send(using: dependencies) + + return try dependencies[singleton: .crypto].tryGenerate( + .sessionId(name: onsName, response: response) + ) + } + } + + return try await group.reduce(into: []) { result, next in result.append(next) } + } + + guard results.count == validationCount, Set(results).count == 1 else { + throw StorageServerError.onsValidationFailed + } + + return results[0] + } + + static func preparedGetExpiries( + of serverHashes: [String], + authMethod: AuthenticationMethod, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + return try Network.PreparedRequest( + request: Request( + endpoint: .getExpiries, + swarmPublicKey: try authMethod.swarmPublicKey, + body: GetExpiriesRequest( + messageHashes: serverHashes, + timestampMs: dependencies.networkOffsetTimestampMs(), + authMethod: authMethod + ) + ), + responseType: GetExpiriesResponse.self, + using: dependencies + ) + } + + // MARK: - Store + + static func preparedSendMessage( + request: SendMessageRequest, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + let preparedRequest: Network.PreparedRequest = try Network.PreparedRequest( + request: Request( + endpoint: .sendMessage, + swarmPublicKey: try request.authMethod.swarmPublicKey, + body: request, + overallTimeout: Network.defaultTimeout + ), + responseType: SendMessagesResponse.self, + using: dependencies + ) + + return preparedRequest.tryMap { _, response -> SendMessagesResponse in + try response.validateResultMap( + swarmPublicKey: try request.authMethod.swarmPublicKey, + using: dependencies + ) + + return response + } + } + + // MARK: - Edit + + private static func prepareUpdateExpiryRequest( + serverHashes: [String], + updatedExpiryMs: Int64, + shortenOnly: Bool? = nil, + extendOnly: Bool? = nil, + authMethod: AuthenticationMethod, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + // ShortenOnly and extendOnly cannot be true at the same time + guard shortenOnly == nil || extendOnly == nil else { throw NetworkError.invalidPreparedRequest } + + return try Network.PreparedRequest( + request: Request( + endpoint: .expire, + swarmPublicKey: try authMethod.swarmPublicKey, + body: UpdateExpiryRequest( + messageHashes: serverHashes, + expiryMs: UInt64(updatedExpiryMs), + shorten: shortenOnly, + extend: extendOnly, + authMethod: authMethod + ) + ), + responseType: UpdateExpiryResponse.self, + using: dependencies + ) + } + + @discardableResult private static func processUpdateExpiryResponse( + response: UpdateExpiryResponse, + serverHashes: [String], + ignoreValidationFailure: Bool = false, + explicitTargetNode: LibSession.Snode? = nil, + authMethod: AuthenticationMethod, + using dependencies: Dependencies + ) async throws -> [String: UpdateExpiryResponseResult] { + let result: [String: UpdateExpiryResponseResult] = try { + do { + return try response.validResultMap( + swarmPublicKey: try authMethod.swarmPublicKey, + validationData: serverHashes, + using: dependencies + ) + } + catch { + guard ignoreValidationFailure else { throw error } + + return [:] + } + }() + + /// Since we have updated the TTL we need to make sure we also update the local + /// `SnodeReceivedMessageInfo.expirationDateMs` values so they match the updated swarm, if + /// we had a specific `snode` we we're sending the request to then we should use those values, otherwise + /// we can just grab the first value from the response and use that + let maybeTargetResult: UpdateExpiryResponseResult? = { + guard let snode: LibSession.Snode = explicitTargetNode else { + return result.first?.value + } + + return result[snode.ed25519PubkeyHex] + }() + guard + let targetResult: UpdateExpiryResponseResult = maybeTargetResult, + let groupedExpiryResult: [UInt64: [String]] = targetResult.changed + .updated(with: targetResult.unchanged) + .groupedByValue() + .nullIfEmpty + else { return result } + + try? await dependencies[singleton: .storage].writeAsync { db in + try groupedExpiryResult.forEach { updatedExpiry, hashes in + try SnodeReceivedMessageInfo + .filter(hashes.contains(SnodeReceivedMessageInfo.Columns.hash)) + .updateAll( + db, + SnodeReceivedMessageInfo.Columns.expirationDateMs + .set(to: updatedExpiry) + ) + } + } + + return result + } + + static func updateExpiry( + serverHashes: [String], + updatedExpiryMs: Int64, + shortenOnly: Bool? = nil, + extendOnly: Bool? = nil, + ignoreValidationFailure: Bool = false, + explicitTargetNode: LibSession.Snode? = nil, + authMethod: AuthenticationMethod, + using dependencies: Dependencies + ) async throws -> [String: UpdateExpiryResponseResult] { + let request: Network.PreparedRequest = try prepareUpdateExpiryRequest( + serverHashes: serverHashes, + updatedExpiryMs: updatedExpiryMs, + authMethod: authMethod, + using: dependencies + ) + let response: UpdateExpiryResponse = try await request.send(using: dependencies) + + return try await processUpdateExpiryResponse( + response: response, + serverHashes: serverHashes, + authMethod: authMethod, + using: dependencies + ) + } + + static func preparedRevokeSubaccounts( + subaccountsToRevoke: [[UInt8]], + authMethod: AuthenticationMethod, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + let timestampMs: UInt64 = dependencies.networkOffsetTimestampMs() + let preparedRequest: Network.PreparedRequest = try Network.PreparedRequest( + request: Request( + endpoint: .revokeSubaccount, + swarmPublicKey: try authMethod.swarmPublicKey, + body: RevokeSubaccountRequest( + subaccountsToRevoke: subaccountsToRevoke, + timestampMs: timestampMs, + authMethod: authMethod + ) + ), + responseType: RevokeSubaccountResponse.self, + using: dependencies + ) + + return preparedRequest.tryMap { _, response -> Void in + try response.validateResultMap( + swarmPublicKey: try authMethod.swarmPublicKey, + validationData: (subaccountsToRevoke, timestampMs), + using: dependencies + ) + + return () + } + } + + static func preparedUnrevokeSubaccounts( + subaccountsToUnrevoke: [[UInt8]], + authMethod: AuthenticationMethod, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + let timestampMs: UInt64 = dependencies.networkOffsetTimestampMs() + let preparedRequest: Network.PreparedRequest = try Network.PreparedRequest( + request: Request( + endpoint: .unrevokeSubaccount, + swarmPublicKey: try authMethod.swarmPublicKey, + body: UnrevokeSubaccountRequest( + subaccountsToUnrevoke: subaccountsToUnrevoke, + timestampMs: timestampMs, + authMethod: authMethod + ) + ), + responseType: UnrevokeSubaccountResponse.self, + using: dependencies + ) + + return preparedRequest.tryMap { _, response -> Void in + try response.validateResultMap( + swarmPublicKey: try authMethod.swarmPublicKey, + validationData: (subaccountsToUnrevoke, timestampMs), + using: dependencies + ) + + return () + } + } + + // MARK: - Delete + + static func preparedDeleteMessages( + serverHashes: [String], + requireSuccessfulDeletion: Bool, + authMethod: AuthenticationMethod, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest<[String: Bool]> { + let preparedRequest: Network.PreparedRequest = try Network.PreparedRequest( + request: Request( + endpoint: .deleteMessages, + swarmPublicKey: try authMethod.swarmPublicKey, + body: DeleteMessagesRequest( + messageHashes: serverHashes, + requireSuccessfulDeletion: requireSuccessfulDeletion, + authMethod: authMethod + ) + ), + responseType: DeleteMessagesResponse.self, + using: dependencies + ) + + return preparedRequest.tryMap { _, response -> [String: Bool] in + let validResultMap: [String: Bool] = try response.validResultMap( + swarmPublicKey: try authMethod.swarmPublicKey, + validationData: serverHashes, + using: dependencies + ) + + // If `validResultMap` didn't throw then at least one service node + // deleted successfully so we should mark the hash as invalid so we + // don't try to fetch updates using that hash going forward (if we + // do we would end up re-fetching all old messages) + dependencies[singleton: .storage].writeAsync { db in + try? SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash( + db, + potentiallyInvalidHashes: serverHashes + ) + } + + return validResultMap + } + } + + /// Clears all the user's data from their swarm. Returns a dictionary of snode public key to deletion confirmation. + static func preparedDeleteAllMessages( + namespace: Namespace, + snode: LibSession.Snode, + requestTimeout: TimeInterval = Network.defaultTimeout, + overallTimeout: TimeInterval? = nil, + authMethod: AuthenticationMethod, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest<[String: Bool]> { + let timestampMs: UInt64 = dependencies.networkOffsetTimestampMs() + let preparedRequest: Network.PreparedRequest = try Network.PreparedRequest( + request: Request( + endpoint: .deleteAll, + snode: snode, + swarmPublicKey: try authMethod.swarmPublicKey, + body: DeleteAllMessagesRequest( + namespace: namespace, + timestampMs: dependencies.networkOffsetTimestampMs(), + authMethod: authMethod + ), + requestTimeout: requestTimeout, + overallTimeout: overallTimeout + ), + responseType: DeleteAllMessagesResponse.self, + using: dependencies + ) + + return preparedRequest.tryMap { info, response -> [String: Bool] in + return try response.validResultMap( + swarmPublicKey: try authMethod.swarmPublicKey, + validationData: timestampMs, + using: dependencies + ) + } + } + + // MARK: - Internal API + + @discardableResult static func getNetworkTime( + from snode: LibSession.Snode, + using dependencies: Dependencies + ) async throws -> GetNetworkTimestampResponse { + let request: Network.PreparedRequest = try Network.PreparedRequest( + request: Request<[String: String], Endpoint>( + endpoint: .getInfo, + snode: snode, + body: [:] + ), + responseType: GetNetworkTimestampResponse.self, + using: dependencies + ) + + /// **Note:** We have a hook setup in `LibSession+Networking` which gets called during every request that contains + /// the timestamp and fork version info that will cache the values for use, so no need to manually cache the values here + return try await request.send(using: dependencies) + } +} diff --git a/SessionNetworkingKit/StorageServer/SnodeAPIEndpoint.swift b/SessionNetworkingKit/StorageServer/StorageServerEndpoint.swift similarity index 98% rename from SessionNetworkingKit/StorageServer/SnodeAPIEndpoint.swift rename to SessionNetworkingKit/StorageServer/StorageServerEndpoint.swift index 6bd1f1b1a9..7707eb28a1 100644 --- a/SessionNetworkingKit/StorageServer/SnodeAPIEndpoint.swift +++ b/SessionNetworkingKit/StorageServer/StorageServerEndpoint.swift @@ -4,7 +4,7 @@ import Foundation -public extension Network.SnodeAPI { +public extension Network.StorageServer { enum Endpoint: EndpointType { case sendMessage case getMessages diff --git a/SessionNetworkingKit/StorageServer/SnodeAPIError.swift b/SessionNetworkingKit/StorageServer/StorageServerError.swift similarity index 52% rename from SessionNetworkingKit/StorageServer/SnodeAPIError.swift rename to SessionNetworkingKit/StorageServer/StorageServerError.swift index c09b5ed963..69951e888b 100644 --- a/SessionNetworkingKit/StorageServer/SnodeAPIError.swift +++ b/SessionNetworkingKit/StorageServer/StorageServerError.swift @@ -4,7 +4,7 @@ import Foundation -public enum SnodeAPIError: Error, CustomStringConvertible { +public enum StorageServerError: Error, CustomStringConvertible { case clockOutOfSync case snodePoolUpdatingFailed case inconsistentSnodePools @@ -32,51 +32,47 @@ public enum SnodeAPIError: Error, CustomStringConvertible { // Quic case invalidPayload case missingSecretKey - case nodeNotFound(Int?, String) + case nodeNotFound(String) case unassociatedPubkey case unableToRetrieveSwarm public var description: String { switch self { - case .clockOutOfSync: return "Your clock is out of sync with the Service Node network. Please check that your device's clock is set to automatic time (SnodeAPIError.clockOutOfSync)." - case .snodePoolUpdatingFailed: return "Failed to update the Service Node pool (SnodeAPIError.snodePoolUpdatingFailed)." - case .inconsistentSnodePools: return "Received inconsistent Service Node pool information from the Service Node network (SnodeAPIError.inconsistentSnodePools)." - case .noKeyPair: return "Missing user key pair (SnodeAPIError.noKeyPair)." - case .signingFailed: return "Couldn't sign message (SnodeAPIError.signingFailed)." - case .signatureVerificationFailed: return "Failed to verify the signature (SnodeAPIError.signatureVerificationFailed)." - case .invalidIP: return "Invalid IP (SnodeAPIError.invalidIP)." - case .responseFailedValidation: return "Response failed validation (SnodeAPIError.responseFailedValidation)." - case .unauthorised: return "Unauthorized (SnodeAPIError.unauthorised)." - case .rateLimited: return "Rate limited (SnodeAPIError.rateLimited)." - case .missingSnodeVersion: return "Missing Service Node version (SnodeAPIError.missingSnodeVersion)." - case .unsupportedSnodeVersion(let version): return "Unsupported Service Node version: \(version) (SnodeAPIError.unsupportedSnodeVersion)." + case .clockOutOfSync: return "Your clock is out of sync with the Service Node network. Please check that your device's clock is set to automatic time." + case .snodePoolUpdatingFailed: return "Failed to update the Service Node pool." + case .inconsistentSnodePools: return "Received inconsistent Service Node pool information from the Service Node network." + case .noKeyPair: return "Missing user key pair." + case .signingFailed: return "Couldn't sign message." + case .signatureVerificationFailed: return "Failed to verify the signature." + case .invalidIP: return "Invalid IP." + case .responseFailedValidation: return "Response failed validation." + case .unauthorised: return "Storage Server Unauthorized." + case .rateLimited: return "Storage Server Rate limited." + case .missingSnodeVersion: return "Missing Service Node version." + case .unsupportedSnodeVersion(let version): return "Unsupported Service Node version: \(version)." // Onion Request Errors - case .emptySnodePool: return "Service Node pool is empty (SnodeAPIError.emptySnodePool)." - case .insufficientSnodes: return "Couldn't find enough Service Nodes to build a path (SnodeAPIError.insufficientSnodes)." + case .emptySnodePool: return "Service Node pool is empty." + case .insufficientSnodes: return "Couldn't find enough Service Nodes to build a path." case .ranOutOfRandomSnodes(let maybeError): switch maybeError { - case .none: return "Ran out of random snodes (SnodeAPIError.ranOutOfRandomSnodes(nil))." - case .some(let error): return "Ran out of random snodes (SnodeAPIError.ranOutOfRandomSnodes(\(error))." + case .none: return "Ran out of random snodes." + case .some(let error): return "Ran out of random snodes with error: \(error)." } // ONS - case .onsDecryptionFailed: return "Couldn't decrypt ONS name (SnodeAPIError.onsDecryptionFailed)." - case .onsHashingFailed: return "Couldn't compute ONS name hash (SnodeAPIError.onsHashingFailed)." - case .onsValidationFailed: return "ONS name validation failed (SnodeAPIError.onsValidationFailed)." - case .onsNotFound: return "ONS name not found (SnodeAPIError.onsNotFound)" + case .onsDecryptionFailed: return "Couldn't decrypt ONS name." + case .onsHashingFailed: return "Couldn't compute ONS name hash." + case .onsValidationFailed: return "ONS name validation failed." + case .onsNotFound: return "ONS name not found" // Quic - case .invalidPayload: return "Invalid payload (SnodeAPIError.invalidPayload)." - case .missingSecretKey: return "Missing secret key (SnodeAPIError.missingSecretKey)." - case .nodeNotFound(let nodeIndex, _): - switch nodeIndex { - case .some(let index): return "Error in Onion request path, with hop \(index) (SnodeAPIError.nodeNotFound)." - case .none: return "Error in Onion request path (SnodeAPIError.nodeNotFound)." - } + case .invalidPayload: return "Invalid payload." + case .missingSecretKey: return "Missing secret key." + case .nodeNotFound(let nodeHex): return "Error in Onion request path, with node \(nodeHex)." - case .unassociatedPubkey: return "The service node is no longer associated with the public key (SnodeAPIError.unassociatedPubkey)." - case .unableToRetrieveSwarm: return "Unable to retrieve the swarm for the given public key (SnodeAPIError.unableToRetrieveSwarm)." + case .unassociatedPubkey: return "The service node is no longer associated with the public key." + case .unableToRetrieveSwarm: return "Unable to retrieve the swarm for the given public key." } } } diff --git a/SessionNetworkingKit/StorageServer/SnodeAPINamespace.swift b/SessionNetworkingKit/StorageServer/StorageServerNamespace.swift similarity index 94% rename from SessionNetworkingKit/StorageServer/SnodeAPINamespace.swift rename to SessionNetworkingKit/StorageServer/StorageServerNamespace.swift index ea3399d543..f8ab469363 100644 --- a/SessionNetworkingKit/StorageServer/SnodeAPINamespace.swift +++ b/SessionNetworkingKit/StorageServer/StorageServerNamespace.swift @@ -6,7 +6,7 @@ import Foundation import SessionUtil import SessionUtilitiesKit -public extension Network.SnodeAPI { +public extension Network.StorageServer { enum Namespace: Int, Codable, Hashable, CustomStringConvertible { /// Messages sent to one-to-one conversations are stored in this namespace case `default` = 0 @@ -52,22 +52,6 @@ public extension Network.SnodeAPI { // MARK: - Variables - var requiresReadAuthentication: Bool { - switch self { - // Legacy closed groups don't support authenticated retrieval - case .legacyClosedGroup: return false - default: return true - } - } - - var requiresWriteAuthentication: Bool { - switch self { - // Legacy closed groups don't support authenticated storage - case .legacyClosedGroup: return false - default: return true - } - } - /// This flag indicates whether we should provide a `lastHash` when retrieving messages from the specified /// namespace, when `true` we will only receive messages added since the provided `lastHash`, otherwise /// we will retrieve **all** messages from the namespace diff --git a/SessionNetworkingKit/StorageServer/Types/Request+SnodeAPI.swift b/SessionNetworkingKit/StorageServer/Types/Request+SnodeAPI.swift deleted file mode 100644 index 4f2c50e84f..0000000000 --- a/SessionNetworkingKit/StorageServer/Types/Request+SnodeAPI.swift +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. -// -// stringlint:disable - -import Foundation -import SessionUtilitiesKit - -public extension Request where Endpoint == Network.SnodeAPI.Endpoint { - init( - endpoint: Endpoint, - snode: LibSession.Snode, - swarmPublicKey: String? = nil, - body: B - ) throws where T == SnodeRequest { - self = try Request( - endpoint: endpoint, - destination: .snode( - snode, - swarmPublicKey: swarmPublicKey - ), - body: SnodeRequest( - endpoint: endpoint, - body: body - ) - ) - } - - init( - endpoint: Endpoint, - swarmPublicKey: String, - body: B, - snodeRetrievalRetryCount: Int = Network.SnodeAPI.maxRetryCount - ) throws where T == SnodeRequest { - self = try Request( - endpoint: endpoint, - destination: .randomSnode( - swarmPublicKey: swarmPublicKey, - snodeRetrievalRetryCount: snodeRetrievalRetryCount - ), - body: SnodeRequest( - endpoint: endpoint, - body: body - ) - ) - } - - init( - endpoint: Endpoint, - swarmPublicKey: String, - requiresLatestNetworkTime: Bool, - body: B, - snodeRetrievalRetryCount: Int = Network.SnodeAPI.maxRetryCount - ) throws where T == SnodeRequest, B: Encodable & UpdatableTimestamp { - self = try Request( - endpoint: endpoint, - destination: .randomSnodeLatestNetworkTimeTarget( - swarmPublicKey: swarmPublicKey, - snodeRetrievalRetryCount: snodeRetrievalRetryCount, - bodyWithUpdatedTimestampMs: { timestampMs, dependencies in - SnodeRequest( - endpoint: endpoint, - body: body.with(timestampMs: timestampMs) - ) - } - ), - body: SnodeRequest( - endpoint: endpoint, - body: body - ) - ) - } -} diff --git a/SessionNetworkingKit/StorageServer/Types/Request+StorageServer.swift b/SessionNetworkingKit/StorageServer/Types/Request+StorageServer.swift new file mode 100644 index 0000000000..c787ad0c7e --- /dev/null +++ b/SessionNetworkingKit/StorageServer/Types/Request+StorageServer.swift @@ -0,0 +1,48 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import SessionUtilitiesKit + +public extension Request where Endpoint == Network.StorageServer.Endpoint { + init( + endpoint: Endpoint, + snode: LibSession.Snode, + swarmPublicKey: String? = nil, + body: T, + requestTimeout: TimeInterval = Network.defaultTimeout, + overallTimeout: TimeInterval? = nil, + retryCount: Int = 0 + ) { + self = Request( + endpoint: endpoint, + destination: .snode( + snode, + swarmPublicKey: swarmPublicKey + ), + body: body, + requestTimeout: requestTimeout, + overallTimeout: overallTimeout, + retryCount: retryCount + ) + } + + init( + endpoint: Endpoint, + swarmPublicKey: String, + body: T, + requestTimeout: TimeInterval = Network.defaultTimeout, + overallTimeout: TimeInterval? = nil, + retryCount: Int = 0 + ) { + self = Request( + endpoint: endpoint, + destination: .randomSnode(swarmPublicKey: swarmPublicKey), + body: body, + requestTimeout: requestTimeout, + overallTimeout: overallTimeout, + retryCount: retryCount + ) + } +} diff --git a/SessionNetworkingKit/StorageServer/Types/ResponseInfo+SnodeAPI.swift b/SessionNetworkingKit/StorageServer/Types/ResponseInfo+SnodeAPI.swift deleted file mode 100644 index 1c821429aa..0000000000 --- a/SessionNetworkingKit/StorageServer/Types/ResponseInfo+SnodeAPI.swift +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtilitiesKit - -public extension Network.SnodeAPI { - struct LatestTimestampResponseInfo: ResponseInfoType { - public let code: Int - public let headers: [String: String] - public let timestampMs: UInt64 - - public init(code: Int, headers: [String: String], timestampMs: UInt64) { - self.code = code - self.headers = headers - self.timestampMs = timestampMs - } - } -} diff --git a/SessionNetworkingKit/StorageServer/Types/SnodeMessage.swift b/SessionNetworkingKit/StorageServer/Types/SnodeMessage.swift deleted file mode 100644 index a9fbcdd1e3..0000000000 --- a/SessionNetworkingKit/StorageServer/Types/SnodeMessage.swift +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtilitiesKit - -public final class SnodeMessage: Codable { - private enum CodingKeys: String, CodingKey { - case recipient = "pubkey" - case data - case ttl - case timestampMs = "timestamp" - } - - /// The hex encoded public key of the recipient. - public let recipient: String - - /// The content of the message. - public let data: String - - /// The time to live for the message in milliseconds. - public let ttl: UInt64 - - /// When the proof of work was calculated. - /// - /// - Note: Expressed as milliseconds since 00:00:00 UTC on 1 January 1970. - public let timestampMs: UInt64 - - // MARK: - Initialization - - public init(recipient: String, data: Data, ttl: UInt64, timestampMs: UInt64) { - self.recipient = recipient - self.data = data.base64EncodedString() - self.ttl = ttl - self.timestampMs = timestampMs - } -} - -// MARK: - Codable - -extension SnodeMessage { - public convenience init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - - self.init( - recipient: try container.decode(String.self, forKey: .recipient), - data: try Data(base64Encoded: try container.decode(String.self, forKey: .data)) ?? { - throw NetworkError.parsingFailed - }(), - ttl: try container.decode(UInt64.self, forKey: .ttl), - timestampMs: try container.decode(UInt64.self, forKey: .timestampMs) - ) - } - - public func encode(to encoder: Encoder) throws { - var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(recipient, forKey: .recipient) - try container.encode(data, forKey: .data) - try container.encode(ttl, forKey: .ttl) - try container.encode(timestampMs, forKey: .timestampMs) - } -} diff --git a/SessionNetworkingKit/StorageServer/Types/SnodeReceivedMessage.swift b/SessionNetworkingKit/StorageServer/Types/SnodeReceivedMessage.swift deleted file mode 100644 index ad2384fbbb..0000000000 --- a/SessionNetworkingKit/StorageServer/Types/SnodeReceivedMessage.swift +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -// -// stringlint:disable - -import Foundation -import SessionUtilitiesKit - -public struct SnodeReceivedMessage: Codable, CustomDebugStringConvertible { - /// Service nodes cache messages for 14 days so default the expiration for message hashes to '15' days - /// so we don't end up indefinitely storing records which will never be used - public static let defaultExpirationMs: Int64 = ((15 * 24 * 60 * 60) * 1000) - - /// The storage server allows the timestamp within requests to be off by `60s` before erroring - public static let serverClockToleranceMs: Int64 = ((1 * 60) * 1000) - - public let snode: LibSession.Snode? - public let swarmPublicKey: String - public let namespace: Network.SnodeAPI.Namespace - public let hash: String - public let timestampMs: Int64 - public let expirationTimestampMs: Int64 - public let data: Data - - public var info: SnodeReceivedMessageInfo? { - snode.map { snode in - SnodeReceivedMessageInfo( - snode: snode, - swarmPublicKey: swarmPublicKey, - namespace: namespace, - hash: hash, - expirationDateMs: expirationTimestampMs - ) - } - } - - public init?( - snode: LibSession.Snode?, - publicKey: String, - namespace: Network.SnodeAPI.Namespace, - rawMessage: GetMessagesResponse.RawMessage - ) { - guard let data: Data = Data(base64Encoded: rawMessage.base64EncodedDataString) else { - Log.error(.network, "Failed to decode data for message: \(rawMessage).") - return nil - } - - self.snode = snode - self.swarmPublicKey = publicKey - self.namespace = namespace - self.hash = rawMessage.hash - self.timestampMs = rawMessage.timestampMs - self.expirationTimestampMs = (rawMessage.expirationMs ?? SnodeReceivedMessage.defaultExpirationMs) - self.data = data - } - - public var debugDescription: String { - """ - SnodeReceivedMessage( - swarmPublicKey: \(swarmPublicKey), - namespace: \(namespace), - hash: \(hash), - expirationTimestampMs: \(expirationTimestampMs), - timestampMs: \(timestampMs), - data: \(data.base64EncodedString()) - ) - """ - } -} diff --git a/SessionNetworkingKit/StorageServer/Types/StorageServerMessage.swift b/SessionNetworkingKit/StorageServer/Types/StorageServerMessage.swift new file mode 100644 index 0000000000..0f43d96f92 --- /dev/null +++ b/SessionNetworkingKit/StorageServer/Types/StorageServerMessage.swift @@ -0,0 +1,70 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import SessionUtilitiesKit + +public extension Network.StorageServer { + struct Message: Codable, CustomDebugStringConvertible { + /// Service nodes cache messages for 14 days so default the expiration for message hashes to '15' days + /// so we don't end up indefinitely storing records which will never be used + public static let defaultExpirationMs: Int64 = ((15 * 24 * 60 * 60) * 1000) + + /// The storage server allows the timestamp within requests to be off by `60s` before erroring + public static let serverClockToleranceMs: Int64 = ((1 * 60) * 1000) + + public let snode: LibSession.Snode? + public let swarmPublicKey: String + public let namespace: Namespace + public let hash: String + public let timestampMs: Int64 + public let expirationTimestampMs: Int64 + public let data: Data + + public var info: SnodeReceivedMessageInfo? { + snode.map { snode in + SnodeReceivedMessageInfo( + snode: snode, + swarmPublicKey: swarmPublicKey, + namespace: namespace, + hash: hash, + expirationDateMs: expirationTimestampMs + ) + } + } + + public init?( + snode: LibSession.Snode?, + publicKey: String, + namespace: Namespace, + rawMessage: GetMessagesResponse.RawMessage + ) { + guard let data: Data = Data(base64Encoded: rawMessage.base64EncodedDataString) else { + Log.error(.network, "Failed to decode data for message: \(rawMessage).") + return nil + } + + self.snode = snode + self.swarmPublicKey = publicKey + self.namespace = namespace + self.hash = rawMessage.hash + self.timestampMs = rawMessage.timestampMs + self.expirationTimestampMs = (rawMessage.expirationMs ?? Network.StorageServer.Message.defaultExpirationMs) + self.data = data + } + + public var debugDescription: String { + """ + Message( + swarmPublicKey: \(swarmPublicKey), + namespace: \(namespace), + hash: \(hash), + expirationTimestampMs: \(expirationTimestampMs), + timestampMs: \(timestampMs), + data: \(data.base64EncodedString()) + ) + """ + } + } +} diff --git a/SessionNetworkingKit/Types/Destination.swift b/SessionNetworkingKit/Types/Destination.swift index 9e48a355e4..b6b03b379e 100644 --- a/SessionNetworkingKit/Types/Destination.swift +++ b/SessionNetworkingKit/Types/Destination.swift @@ -8,32 +8,19 @@ import SessionUtilitiesKit public extension Network { enum Destination: Equatable { public struct ServerInfo: Equatable { - private static let invalidServer: String = "INVALID_SERVER" - private static let invalidUrl: URL = URL(fileURLWithPath: "INVALID_URL") - - private let server: String - private let queryParameters: [HTTPQueryParam: String] - private let _url: URL - private let _pathAndParamsString: String - public let method: HTTPMethod + public let server: String + public let queryParameters: [HTTPQueryParam: String] public let headers: [HTTPHeader: String] public let x25519PublicKey: String - public var url: URL { - get throws { - guard _url != ServerInfo.invalidUrl else { throw NetworkError.invalidURL } - - return _url - } - } - public var pathAndParamsString: String { - get throws { - guard _url != ServerInfo.invalidUrl else { throw NetworkError.invalidPreparedRequest } - - return _pathAndParamsString - } - } + // Use iOS URL processing to extract the values from `server` + + public var host: String? { URL(string: server)?.host } + public var scheme: String? { URL(string: server)?.scheme } + public var port: Int? { URL(string: server)?.port } + + // MARK: - Initialization public init( method: HTTPMethod, @@ -42,9 +29,6 @@ public extension Network { headers: [HTTPHeader: String], x25519PublicKey: String ) { - self._url = ServerInfo.invalidUrl - self._pathAndParamsString = "" - self.method = method self.server = server self.queryParameters = queryParameters @@ -56,62 +40,27 @@ public extension Network { method: HTTPMethod, url: URL, server: String?, - pathAndParamsString: String?, queryParameters: [HTTPQueryParam: String] = [:], headers: [HTTPHeader: String], x25519PublicKey: String - ) { - self._url = url - self._pathAndParamsString = (pathAndParamsString ?? url.path) - + ) throws { self.method = method - self.server = { + self.server = try { if let explicitServer: String = server { return explicitServer } if let urlHost: String = url.host { return "\(url.scheme.map { "\($0)://" } ?? "")\(urlHost)" } - return ServerInfo.invalidServer + throw NetworkError.invalidURL }() self.queryParameters = queryParameters self.headers = headers self.x25519PublicKey = x25519PublicKey } - - fileprivate func updated(for endpoint: E) throws -> ServerInfo { - let pathAndParamsString: String = generatePathsAndParams(endpoint: endpoint, queryParameters: queryParameters) - - return ServerInfo( - method: method, - url: try (URL(string: "\(server)\(pathAndParamsString)") ?? { throw NetworkError.invalidURL }()), - server: server, - pathAndParamsString: pathAndParamsString, - queryParameters: queryParameters, - headers: headers, - x25519PublicKey: x25519PublicKey - ) - } - - public func updated(with headers: [HTTPHeader: String]) -> ServerInfo { - return ServerInfo( - method: method, - url: _url, - server: server, - pathAndParamsString: _pathAndParamsString, - queryParameters: queryParameters, - headers: self.headers.updated(with: headers), - x25519PublicKey: x25519PublicKey - ) - } } case snode(LibSession.Snode, swarmPublicKey: String?) - case randomSnode(swarmPublicKey: String, snodeRetrievalRetryCount: Int) - case randomSnodeLatestNetworkTimeTarget( - swarmPublicKey: String, - snodeRetrievalRetryCount: Int, - bodyWithUpdatedTimestampMs: ((UInt64, Dependencies) -> Encodable?) - ) + case randomSnode(swarmPublicKey: String) case server(info: ServerInfo) case serverUpload(info: ServerInfo, fileName: String?) case serverDownload(info: ServerInfo) @@ -126,11 +75,10 @@ public extension Network { } } - public var url: URL? { + public var server: String? { switch self { - case .server(let info), .serverUpload(let info, _), .serverDownload(let info): return try? info.url - case .snode, .randomSnode, .randomSnodeLatestNetworkTimeTarget: return nil - case .cached: return nil + case .server(let info), .serverUpload(let info, _), .serverDownload(let info): return info.server + default: return nil } } @@ -139,16 +87,17 @@ public extension Network { case .server(let info), .serverUpload(let info, _), .serverDownload(let info): return info.headers - case .snode, .randomSnode, .randomSnodeLatestNetworkTimeTarget: return [:] + case .snode, .randomSnode: return [:] case .cached(_, _, _, let headers, _): return headers } } - public var urlPathAndParamsString: String { + public var queryParameters: [HTTPQueryParam: String] { switch self { case .server(let info), .serverUpload(let info, _), .serverDownload(let info): - return ((try? info.pathAndParamsString) ?? "") - default: return "" + return info.queryParameters + + default: return [:] } } @@ -158,7 +107,7 @@ public extension Network { queryParameters: [HTTPQueryParam: String] = [:], headers: [HTTPHeader: String] = [:], x25519PublicKey: String - ) throws -> Destination { + ) -> Destination { return .server(info: ServerInfo( method: method, server: server, @@ -174,7 +123,7 @@ public extension Network { headers: [HTTPHeader: String] = [:], x25519PublicKey: String, fileName: String? - ) throws -> Destination { + ) -> Destination { return .serverUpload( info: ServerInfo( method: .post, @@ -194,11 +143,10 @@ public extension Network { x25519PublicKey: String, fileName: String? ) throws -> Destination { - return .serverDownload(info: ServerInfo( + return try .serverDownload(info: ServerInfo( method: .get, url: url, server: nil, - pathAndParamsString: nil, headers: headers, x25519PublicKey: x25519PublicKey )) @@ -225,7 +173,7 @@ public extension Network { // MARK: - Convenience - internal static func generatePathsAndParams(endpoint: E, queryParameters: [HTTPQueryParam: String]) -> String { + internal static func generatePathWithParams(endpoint: E, queryParameters: [HTTPQueryParam: String]) -> String { return [ "/\(endpoint.path)", queryParameters @@ -237,17 +185,6 @@ public extension Network { .joined(separator: "?") } - internal func withGeneratedUrl(for endpoint: E) throws -> Destination { - switch self { - case .server(let info): return .server(info: try info.updated(for: endpoint)) - case .serverUpload(let info, let fileName): - return .serverUpload(info: try info.updated(for: endpoint), fileName: fileName) - case .serverDownload(let info): return .serverDownload(info: try info.updated(for: endpoint)) - - default: return self - } - } - // MARK: - Equatable public static func == (lhs: Destination, rhs: Destination) -> Bool { @@ -258,17 +195,8 @@ public extension Network { lhsSwarmPublicKey == rhsSwarmPublicKey ) - case (.randomSnode(let lhsSwarmPublicKey, let lhsRetryCount), .randomSnode(let rhsSwarmPublicKey, let rhsRetryCount)): - return ( - lhsSwarmPublicKey == rhsSwarmPublicKey && - lhsRetryCount == rhsRetryCount - ) - - case (.randomSnodeLatestNetworkTimeTarget(let lhsSwarmPublicKey, let lhsRetryCount, _), .randomSnodeLatestNetworkTimeTarget(let rhsSwarmPublicKey, let rhsRetryCount, _)): - return ( - lhsSwarmPublicKey == rhsSwarmPublicKey && - lhsRetryCount == rhsRetryCount - ) + case (.randomSnode(let lhsSwarmPublicKey), .randomSnode(let rhsSwarmPublicKey)): + return (lhsSwarmPublicKey == rhsSwarmPublicKey) case (.server(let lhsInfo), .server(let rhsInfo)): return (lhsInfo == rhsInfo) diff --git a/SessionNetworkingKit/Types/Network.swift b/SessionNetworkingKit/Types/Network.swift index a11afeb54c..6fc5c232f3 100644 --- a/SessionNetworkingKit/Types/Network.swift +++ b/SessionNetworkingKit/Types/Network.swift @@ -11,10 +11,110 @@ import SessionUtilitiesKit public extension Singleton { static let network: SingletonConfig = Dependencies.create( identifier: "network", - createInstance: { dependencies in LibSessionNetwork(using: dependencies) } + createInstance: { dependencies, _ in LibSessionNetwork(singlePathMode: false, using: dependencies) } ) } +// MARK: - NetworkType + +public protocol NetworkType { + var isSuspended: Bool { get async } + var hardfork: Int { get async } + var softfork: Int { get async } + var networkTimeOffsetMs: Int64 { get async } + + nonisolated var networkStatus: AsyncStream { get } + @available(*, deprecated, message: "Should try to refactor the code to use proper async/await") + nonisolated var syncState: NetworkSyncState { get } + + func getActivePaths() async throws -> [LibSession.Path] + func getSwarm(for swarmPublicKey: String) async throws -> Set + func getRandomNodes(count: Int) async throws -> Set + + @available(*, deprecated, message: "We want to shift from Combine to Async/Await when possible") + nonisolated func send( + endpoint: E, + destination: Network.Destination, + body: Data?, + category: Network.RequestCategory, + requestTimeout: TimeInterval, + overallTimeout: TimeInterval? + ) -> AnyPublisher<(ResponseInfoType, Data?), Error> + + func send( + endpoint: E, + destination: Network.Destination, + body: Data?, + category: Network.RequestCategory, + requestTimeout: TimeInterval, + overallTimeout: TimeInterval? + ) async throws -> (info: ResponseInfoType, value: Data?) + + func checkClientVersion(ed25519SecretKey: [UInt8]) async throws -> (info: ResponseInfoType, value: Network.FileServer.AppVersionResponse) + + func resetNetworkStatus() async + func setNetworkStatus(status: NetworkStatus) async + func setNetworkInfo(networkTimeOffsetMs: Int64, hardfork: Int, softfork: Int) async + func suspendNetworkAccess() async + func resumeNetworkAccess(autoReconnect: Bool) async + func finishCurrentObservations() async + func clearCache() async +} + +public extension NetworkType { + func resumeNetworkAccess() async { + await resumeNetworkAccess(autoReconnect: true) + } + + func checkClientVersion(ed25519SecretKey: [UInt8]) async throws -> Network.FileServer.AppVersionResponse { + return try await checkClientVersion(ed25519SecretKey: ed25519SecretKey).value + } +} + +/// We manually handle thread-safety using the `NSLock` so can ensure this is `Sendable` +public final class NetworkSyncState: @unchecked Sendable { + private let lock = NSLock() + private let _dependencies: Dependencies + private var _hardfork: Int + private var _softfork: Int + private var _networkTimeOffsetMs: Int64 + private var _isSuspended: Bool + + public init( + hardfork: Int, + softfork: Int, + networkTimeOffsetMs: Int64 = 0, + isSuspended: Bool = false, + using dependencies: Dependencies + ) { + self._dependencies = dependencies + self._hardfork = hardfork + self._softfork = softfork + self._networkTimeOffsetMs = networkTimeOffsetMs + self._isSuspended = isSuspended + } + + internal var dependencies: Dependencies { lock.withLock { _dependencies } } + public var hardfork: Int { lock.withLock { _hardfork } } + public var softfork: Int { lock.withLock { _softfork } } + public var networkTimeOffsetMs: Int64 { lock.withLock { _networkTimeOffsetMs } } + public var isSuspended: Bool { lock.withLock { _isSuspended } } + + func update( + hardfork: Int? = nil, + softfork: Int? = nil, + networkTimeOffsetMs: Int64? = nil, + isSuspended: Bool? = nil + ) { + lock.withLock { + self._hardfork = (hardfork ?? self._hardfork) + self._softfork = (softfork ?? self._softfork) + self._networkTimeOffsetMs = (networkTimeOffsetMs ?? self._networkTimeOffsetMs) + self._isSuspended = (isSuspended ?? self._isSuspended) + } + } +} + // MARK: - Network Constants public class Network { @@ -35,19 +135,3 @@ public enum NetworkStatus { case connected case disconnected } - -// MARK: - NetworkType - -public protocol NetworkType { - func getSwarm(for swarmPublicKey: String) -> AnyPublisher, Error> - func getRandomNodes(count: Int) -> AnyPublisher, Error> - - func send( - _ body: Data?, - to destination: Network.Destination, - requestTimeout: TimeInterval, - requestAndPathBuildTimeout: TimeInterval? - ) -> AnyPublisher<(ResponseInfoType, Data?), Error> - - func checkClientVersion(ed25519SecretKey: [UInt8]) -> AnyPublisher<(ResponseInfoType, Network.FileServer.AppVersionResponse), Error> -} diff --git a/SessionNetworkingKit/Types/PreparedRequest+Sending.swift b/SessionNetworkingKit/Types/PreparedRequest+Sending.swift index a49bae9cd0..5f11523ecd 100644 --- a/SessionNetworkingKit/Types/PreparedRequest+Sending.swift +++ b/SessionNetworkingKit/Types/PreparedRequest+Sending.swift @@ -7,26 +7,60 @@ import SessionUtilitiesKit public extension Network.PreparedRequest { func send(using dependencies: Dependencies) -> AnyPublisher<(ResponseInfoType, R), Error> { return dependencies[singleton: .network] - .send(body, to: destination, requestTimeout: requestTimeout, requestAndPathBuildTimeout: requestAndPathBuildTimeout) + .send( + endpoint: endpoint, + destination: destination, + body: body, + category: category, + requestTimeout: requestTimeout, + overallTimeout: overallTimeout + ) .decoded(with: self, using: dependencies) .retry(retryCount, using: dependencies) .handleEvents( - receiveSubscription: { _ in self.subscriptionHandler?() }, receiveOutput: self.outputEventHandler, - receiveCompletion: self.completionEventHandler, - receiveCancel: self.cancelEventHandler + receiveCompletion: self.completionEventHandler ) .eraseToAnyPublisher() } -} - -public extension Optional { - func send(using dependencies: Dependencies) -> AnyPublisher<(ResponseInfoType, R), Error> where Wrapped == Network.PreparedRequest { - guard let instance: Wrapped = self else { - return Fail(error: NetworkError.invalidPreparedRequest) - .eraseToAnyPublisher() + + func send(using dependencies: Dependencies) async throws -> (info: ResponseInfoType, value: R) { + /// Need to calculate a `finalRetryCount` to ensure the request is sent at least once + var lastError: Error? + let finalRetryCount: Int = (retryCount < 0 ? 1 : retryCount + 1) + + for _ in 0.. R { + return try await send(using: dependencies).value } } diff --git a/SessionNetworkingKit/Types/PreparedRequest.swift b/SessionNetworkingKit/Types/PreparedRequest.swift index 67c1697324..62db515a79 100644 --- a/SessionNetworkingKit/Types/PreparedRequest.swift +++ b/SessionNetworkingKit/Types/PreparedRequest.swift @@ -13,27 +13,32 @@ public extension Network { fileprivate let info: ResponseInfoType fileprivate let originalData: Any fileprivate let convertedData: R + + public init(info: ResponseInfoType, originalData: Any, convertedData: R) { + self.info = info + self.originalData = originalData + self.convertedData = convertedData + } } - public let body: Data? + public let endpoint: (any EndpointType) public let destination: Destination + public let body: Data? + public let category: RequestCategory + public let requestTimeout: TimeInterval + public let overallTimeout: TimeInterval? + public let retryCount: Int public let additionalSignatureData: Any? public let originalType: Decodable.Type public let responseType: R.Type - public let retryCount: Int - public let requestTimeout: TimeInterval - public let requestAndPathBuildTimeout: TimeInterval? public let cachedResponse: CachedResponse? fileprivate let responseConverter: ((ResponseInfoType, Any) throws -> R) - public let subscriptionHandler: (() -> Void)? public let outputEventHandler: (((CachedResponse)) -> Void)? public let completionEventHandler: ((Subscribers.Completion) -> Void)? - public let cancelEventHandler: (() -> Void)? // The following types are needed for `BatchRequest` handling public let method: HTTPMethod public let path: String - public let endpoint: (any EndpointType) public let endpointName: String public let headers: [HTTPHeader: String] public let batchEndpoints: [any EndpointType] @@ -51,9 +56,6 @@ public extension Network { request: Request, responseType: R.Type, requireAllBatchResponses: Bool = true, - retryCount: Int = 0, - requestTimeout: TimeInterval = Network.defaultTimeout, - requestAndPathBuildTimeout: TimeInterval? = nil, using dependencies: Dependencies ) throws where R: Decodable { try self.init( @@ -61,9 +63,6 @@ public extension Network { responseType: responseType, additionalSignatureData: NoSignature.null, requireAllBatchResponses: requireAllBatchResponses, - retryCount: retryCount, - requestTimeout: requestTimeout, - requestAndPathBuildTimeout: requestAndPathBuildTimeout, using: dependencies ) } @@ -73,9 +72,6 @@ public extension Network { responseType: R.Type, additionalSignatureData: S?, requireAllBatchResponses: Bool = true, - retryCount: Int = 0, - requestTimeout: TimeInterval = Network.defaultTimeout, - requestAndPathBuildTimeout: TimeInterval? = nil, using dependencies: Dependencies ) throws where R: Decodable { let batchRequests: [Network.BatchRequest.Child]? = (request.body as? BatchRequestChildRetrievable)?.requests @@ -90,14 +86,16 @@ public extension Network { } .flatMap { $0 }) - self.body = try request.bodyData(using: dependencies) + self.endpoint = request.endpoint self.destination = request.destination + self.body = try request.bodyData(using: dependencies) + self.category = request.category + self.requestTimeout = request.requestTimeout + self.overallTimeout = request.overallTimeout + self.retryCount = request.retryCount self.additionalSignatureData = additionalSignatureData self.originalType = R.self self.responseType = responseType - self.retryCount = retryCount - self.requestTimeout = requestTimeout - self.requestAndPathBuildTimeout = requestAndPathBuildTimeout self.cachedResponse = nil // When we are making a batch request we also want to call though any sub request event @@ -204,7 +202,6 @@ public extension Network { } } }() - self.subscriptionHandler = nil self.completionEventHandler = { guard let subRequestEventHandlers: [((Subscribers.Completion) -> Void)] = batchRequests? @@ -216,21 +213,14 @@ public extension Network { // individual subRequest results here return { result in subRequestEventHandlers.forEach { $0(result) } } }() - self.cancelEventHandler = { - guard - let subRequestEventHandlers: [(() -> Void)] = batchRequests? - .compactMap({ $0.request.cancelEventHandler }), - !subRequestEventHandlers.isEmpty - else { return nil } - - return { subRequestEventHandlers.forEach { $0() } } - }() // The following data is needed in this type for handling batch requests self.method = request.destination.method - self.endpoint = request.endpoint self.endpointName = E.name - self.path = request.destination.urlPathAndParamsString + self.path = Destination.generatePathWithParams( + endpoint: endpoint, + queryParameters: request.destination.queryParameters + ) self.headers = request.destination.headers self.batchEndpoints = batchEndpoints @@ -271,22 +261,21 @@ public extension Network { } fileprivate init( - body: Data?, + endpoint: (any EndpointType), destination: Destination, + body: Data?, + category: Network.RequestCategory, + requestTimeout: TimeInterval, + overallTimeout: TimeInterval?, + retryCount: Int, additionalSignatureData: Any?, originalType: U.Type, responseType: R.Type, - retryCount: Int, - requestTimeout: TimeInterval, - requestAndPathBuildTimeout: TimeInterval?, cachedResponse: CachedResponse?, responseConverter: @escaping (ResponseInfoType, Any) throws -> R, - subscriptionHandler: (() -> Void)?, outputEventHandler: ((CachedResponse) -> Void)?, completionEventHandler: ((Subscribers.Completion) -> Void)?, - cancelEventHandler: (() -> Void)?, method: HTTPMethod, - endpoint: (any EndpointType), endpointName: String, headers: [HTTPHeader: String], path: String, @@ -300,24 +289,23 @@ public extension Network { b64: String?, bytes: [UInt8]? ) { - self.body = body + self.endpoint = endpoint self.destination = destination + self.body = body + self.category = category + self.requestTimeout = requestTimeout + self.overallTimeout = overallTimeout + self.retryCount = retryCount self.additionalSignatureData = additionalSignatureData self.originalType = originalType self.responseType = responseType - self.retryCount = retryCount - self.requestTimeout = requestTimeout - self.requestAndPathBuildTimeout = requestAndPathBuildTimeout self.cachedResponse = cachedResponse self.responseConverter = responseConverter - self.subscriptionHandler = subscriptionHandler self.outputEventHandler = outputEventHandler self.completionEventHandler = completionEventHandler - self.cancelEventHandler = cancelEventHandler // The following data is needed in this type for handling batch requests self.method = method - self.endpoint = endpoint self.endpointName = endpointName self.headers = headers self.path = path @@ -331,6 +319,26 @@ public extension Network { self.b64 = b64 self.bytes = bytes } + + // MARK: - Functions + + public func generateUrl() throws -> URL { + switch destination { + case .server(let info), .serverUpload(let info, _), .serverDownload(let info): + let pathWithParams: String = Destination.generatePathWithParams( + endpoint: endpoint, + queryParameters: info.queryParameters + ) + + guard let url: URL = URL(string: "\(info.server)\(pathWithParams)") else { + throw NetworkError.invalidURL + } + + return url + + default: throw NetworkError.invalidURL + } + } } } @@ -345,7 +353,6 @@ public protocol ErasedPreparedRequest { var erasedResponseConverter: ((ResponseInfoType, Any) throws -> Any) { get } var erasedOutputEventHandler: ((ResponseInfoType, Any, Any) -> Void)? { get } var completionEventHandler: ((Subscribers.Completion) -> Void)? { get } - var cancelEventHandler: (() -> Void)? { get } func batchRequestEndpoint(of type: E.Type) -> E? func encodeForBatchRequest(to encoder: Encoder) throws @@ -413,13 +420,13 @@ extension Network.PreparedRequest: ErasedPreparedRequest { } public func encodeForBatchRequest(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: Network.BatchRequest.Child.CodingKeys.self) + switch batchRequestVariant { case .unsupported: Log.critical("Attempted to encode unsupported request type \(endpointName) as a batch subrequest") case .sogs: - var container: KeyedEncodingContainer = encoder.container(keyedBy: Network.BatchRequest.Child.CodingKeys.self) - // Exclude request signature headers (not used for sub-requests) let excludedSubRequestHeaders: [HTTPHeader] = excludedSubRequestHeaders let batchRequestHeaders: [HTTPHeader: String] = headers @@ -436,9 +443,9 @@ extension Network.PreparedRequest: ErasedPreparedRequest { try container.encodeIfPresent(bytes, forKey: .bytes) case .storageServer: - var container: SingleValueEncodingContainer = encoder.singleValueContainer() + try container.encode(endpoint.path, forKey: .method) + try jsonKeyedBodyEncoder?(&container, .params) - try jsonBodyEncoder?(&container) } } } @@ -453,22 +460,21 @@ public extension Network.PreparedRequest { let signedDestination: Network.Destination = try requestSigner(self, dependencies) return Network.PreparedRequest( - body: body, + endpoint: endpoint, destination: signedDestination, + body: body, + category: category, + requestTimeout: requestTimeout, + overallTimeout: overallTimeout, + retryCount: retryCount, additionalSignatureData: additionalSignatureData, originalType: originalType, responseType: responseType, - retryCount: retryCount, - requestTimeout: requestTimeout, - requestAndPathBuildTimeout: requestAndPathBuildTimeout, cachedResponse: cachedResponse, responseConverter: responseConverter, - subscriptionHandler: subscriptionHandler, outputEventHandler: outputEventHandler, completionEventHandler: completionEventHandler, - cancelEventHandler: cancelEventHandler, method: method, - endpoint: endpoint, endpointName: endpointName, headers: signedDestination.headers, path: path, @@ -500,14 +506,16 @@ public extension Network.PreparedRequest { } return Network.PreparedRequest( - body: body, + endpoint: endpoint, destination: destination, + body: body, + category: category, + requestTimeout: requestTimeout, + overallTimeout: overallTimeout, + retryCount: retryCount, additionalSignatureData: additionalSignatureData, originalType: originalType, responseType: O.self, - retryCount: retryCount, - requestTimeout: requestTimeout, - requestAndPathBuildTimeout: requestAndPathBuildTimeout, cachedResponse: cachedResponse.map { data in (try? responseConverter(data.info, data.convertedData)) .map { convertedData in @@ -519,7 +527,6 @@ public extension Network.PreparedRequest { } }, responseConverter: responseConverter, - subscriptionHandler: subscriptionHandler, outputEventHandler: self.outputEventHandler.map { eventHandler in { data in guard let validResponse: R = try? originalConverter(data.info, data.originalData) else { @@ -534,9 +541,7 @@ public extension Network.PreparedRequest { } }, completionEventHandler: completionEventHandler, - cancelEventHandler: cancelEventHandler, method: method, - endpoint: endpoint, endpointName: endpointName, headers: headers, path: path, @@ -553,23 +558,9 @@ public extension Network.PreparedRequest { } func handleEvents( - receiveSubscription: (() -> Void)? = nil, receiveOutput: (((ResponseInfoType, R)) -> Void)? = nil, - receiveCompletion: ((Subscribers.Completion) -> Void)? = nil, - receiveCancel: (() -> Void)? = nil + receiveCompletion: ((Subscribers.Completion) -> Void)? = nil ) -> Network.PreparedRequest { - let subscriptionHandler: (() -> Void)? = { - switch (self.subscriptionHandler, receiveSubscription) { - case (.none, .none): return nil - case (.some(let eventHandler), .none): return eventHandler - case (.none, .some(let eventHandler)): return eventHandler - case (.some(let originalEventHandler), .some(let eventHandler)): - return { - originalEventHandler() - eventHandler() - } - } - }() let outputEventHandler: ((CachedResponse) -> Void)? = { switch (self.outputEventHandler, receiveOutput) { case (.none, .none): return nil @@ -598,36 +589,23 @@ public extension Network.PreparedRequest { } } }() - let cancelEventHandler: (() -> Void)? = { - switch (self.cancelEventHandler, receiveCancel) { - case (.none, .none): return nil - case (.some(let eventHandler), .none): return eventHandler - case (.none, .some(let eventHandler)): return eventHandler - case (.some(let originalEventHandler), .some(let eventHandler)): - return { - originalEventHandler() - eventHandler() - } - } - }() return Network.PreparedRequest( - body: body, + endpoint: endpoint, destination: destination, + body: body, + category: category, + requestTimeout: requestTimeout, + overallTimeout: overallTimeout, + retryCount: retryCount, additionalSignatureData: additionalSignatureData, originalType: originalType, responseType: responseType, - retryCount: retryCount, - requestTimeout: requestTimeout, - requestAndPathBuildTimeout: requestAndPathBuildTimeout, cachedResponse: cachedResponse, responseConverter: responseConverter, - subscriptionHandler: subscriptionHandler, outputEventHandler: outputEventHandler, completionEventHandler: completionEventHandler, - cancelEventHandler: cancelEventHandler, method: method, - endpoint: endpoint, endpointName: endpointName, headers: headers, path: path, @@ -653,29 +631,28 @@ public extension Network.PreparedRequest { using dependencies: Dependencies ) throws -> Network.PreparedRequest where R: Codable { return Network.PreparedRequest( - body: nil, + endpoint: endpoint, destination: try .cached( response: cachedResponse, using: dependencies ), + body: nil, + category: .standard, + requestTimeout: 0, + overallTimeout: nil, + retryCount: 0, additionalSignatureData: nil, originalType: R.self, responseType: R.self, - retryCount: 0, - requestTimeout: 0, - requestAndPathBuildTimeout: nil, cachedResponse: Network.PreparedRequest.CachedResponse( info: Network.ResponseInfo(code: 0, headers: [:]), originalData: cachedResponse, convertedData: cachedResponse ), responseConverter: { _, _ in cachedResponse }, - subscriptionHandler: nil, outputEventHandler: nil, completionEventHandler: nil, - cancelEventHandler: nil, method: .get, - endpoint: endpoint, endpointName: E.name, headers: [:], path: "", @@ -714,6 +691,57 @@ public extension Decodable { } } +public extension Network.PreparedRequest { + func decode( + info: ResponseInfoType, + data: Data?, + using dependencies: Dependencies + ) throws -> (originalData: Any, convertedData: R) { + // Depending on the 'originalType' we need to process the response differently + let targetData: Any = try { + switch originalType { + case let erasedBatchResponse as ErasedBatchResponseMap.Type: + let response: Network.BatchResponse = try Network.BatchResponse.decodingResponses( + from: data, + as: batchResponseTypes, + requireAllResults: requireAllBatchResponses, + using: dependencies + ) + + return try erasedBatchResponse.from( + batchEndpoints: batchEndpoints, + response: response + ) + + case is Network.BatchResponse.Type: + return try Network.BatchResponse.decodingResponses( + from: data, + as: batchResponseTypes, + requireAllResults: requireAllBatchResponses, + using: dependencies + ) + + case is NoResponse.Type: return NoResponse() + case is Optional.Type: return data as Any + case is Data.Type: return try data ?? { throw NetworkError.parsingFailed }() + + case is _OptionalProtocol.Type: + guard let data: Data = data else { return data as Any } + + return try originalType.decoded(from: data, using: dependencies) + + default: + guard let data: Data = data else { throw NetworkError.parsingFailed } + + return try originalType.decoded(from: data, using: dependencies) + } + }() + + // Generate and return the converted data + return (targetData, try responseConverter(info, targetData)) + } +} + public extension Publisher where Output == (ResponseInfoType, Data?), Failure == Error { func decoded( with preparedRequest: Network.PreparedRequest, diff --git a/SessionNetworkingKit/Types/ProxiedContentDownloader.swift b/SessionNetworkingKit/Types/ProxiedContentDownloader.swift index 6bff42eac9..11856cb605 100644 --- a/SessionNetworkingKit/Types/ProxiedContentDownloader.swift +++ b/SessionNetworkingKit/Types/ProxiedContentDownloader.swift @@ -17,7 +17,7 @@ public enum ProxiedContentRequestPriority: Equatable { public extension Singleton { static let proxiedContentDownloader: SingletonConfig = Dependencies.create( identifier: "proxiedContentDownloader", - createInstance: { dependencies in + createInstance: { dependencies, _ in ProxiedContentDownloader( downloadFolderName: "proxiedContent", using: dependencies diff --git a/SessionNetworkingKit/Types/Request.swift b/SessionNetworkingKit/Types/Request.swift index f25efccaa9..9104a4b86a 100644 --- a/SessionNetworkingKit/Types/Request.swift +++ b/SessionNetworkingKit/Types/Request.swift @@ -40,16 +40,29 @@ public struct Request { /// is custom handling for certain data types public let body: T? + public let category: Network.RequestCategory + public let requestTimeout: TimeInterval + public let overallTimeout: TimeInterval? + public let retryCount: Int + // MARK: - Initialization public init( endpoint: Endpoint, destination: Network.Destination, - body: T? = nil - ) throws { + body: T? = nil, + category: Network.RequestCategory = .standard, + requestTimeout: TimeInterval = Network.defaultTimeout, + overallTimeout: TimeInterval? = nil, + retryCount: Int = 0 + ) { self.endpoint = endpoint - self.destination = try destination.withGeneratedUrl(for: endpoint) + self.destination = destination self.body = body + self.category = category + self.requestTimeout = requestTimeout + self.overallTimeout = overallTimeout + self.retryCount = retryCount } // MARK: - Internal Methods @@ -80,3 +93,24 @@ public struct Request { } } } + +public extension Request where T == NoBody { + init( + endpoint: Endpoint, + destination: Network.Destination, + category: Network.RequestCategory = .standard, + requestTimeout: TimeInterval = Network.defaultTimeout, + overallTimeout: TimeInterval? = nil, + retryCount: Int = 0 + ) { + self = Request( + endpoint: endpoint, + destination: destination, + body: nil, + category: category, + requestTimeout: requestTimeout, + overallTimeout: overallTimeout, + retryCount: retryCount + ) + } +} diff --git a/SessionNetworkingKit/Types/RequestCategory.swift b/SessionNetworkingKit/Types/RequestCategory.swift index e69de29bb2..a21651e845 100644 --- a/SessionNetworkingKit/Types/RequestCategory.swift +++ b/SessionNetworkingKit/Types/RequestCategory.swift @@ -0,0 +1,33 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +public extension Network { + enum RequestCategory: Int, Codable, CaseIterable { + case standard + case upload + case download + case invalid + } +} + +extension Network.RequestCategory { + public var libSessionValue: SESSION_NETWORK_REQUEST_CATEGORY { + switch self { + case .standard: return SESSION_NETWORK_REQUEST_CATEGORY_STANDARD + case .upload: return SESSION_NETWORK_REQUEST_CATEGORY_UPLOAD + case .download: return SESSION_NETWORK_REQUEST_CATEGORY_DOWNLOAD + case .invalid: return SESSION_NETWORK_REQUEST_CATEGORY_STANDARD + } + } + + public init(_ libSessionValue: SESSION_NETWORK_REQUEST_CATEGORY) { + switch libSessionValue { + case SESSION_NETWORK_REQUEST_CATEGORY_STANDARD: self = .standard + case SESSION_NETWORK_REQUEST_CATEGORY_UPLOAD: self = .upload + case SESSION_NETWORK_REQUEST_CATEGORY_DOWNLOAD: self = .download + default: self = .standard + } + } +} diff --git a/SessionNetworkingKit/Types/SwarmDrainBehaviour.swift b/SessionNetworkingKit/Types/SwarmDrainBehaviour.swift deleted file mode 100644 index 89466fb0c9..0000000000 --- a/SessionNetworkingKit/Types/SwarmDrainBehaviour.swift +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtilitiesKit - -public enum SwarmDrainBehaviour { - case alwaysRandom - case limitedReuse( - count: UInt, - targetSnode: LibSession.Snode?, - targetUseCount: Int, - usedSnodes: Set, - swarmHash: Int - ) - - public static func limitedReuse(count: UInt) -> SwarmDrainBehaviour { - guard count > 1 else { return .alwaysRandom } - - return .limitedReuse(count: count, targetSnode: nil, targetUseCount: 0, usedSnodes: [], swarmHash: 0) - } - - // MARK: - Convenience - - func use(snode: LibSession.Snode, from swarm: Set) -> SwarmDrainBehaviour { - switch self { - case .alwaysRandom: return .alwaysRandom - case .limitedReuse(let count, let targetSnode, let targetUseCount, let usedSnodes, _): - // If we are using a new snode then reset everything - guard targetSnode == snode else { - return .limitedReuse( - count: count, - targetSnode: snode, - targetUseCount: 1, - usedSnodes: usedSnodes.inserting(snode), - swarmHash: swarm.hashValue - ) - } - - // Increment the use count and clear the target if it's been used too many times - let updatedUseCount: Int = (targetUseCount + 1) - - return .limitedReuse( - count: count, - targetSnode: (updatedUseCount < count ? snode : nil), - targetUseCount: updatedUseCount, - usedSnodes: usedSnodes, - swarmHash: swarm.hashValue - ) - } - } - - public func clearTargetSnode() -> SwarmDrainBehaviour { - switch self { - case .alwaysRandom: return .alwaysRandom - case .limitedReuse(let count, _, _, let usedSnodes, let swarmHash): - return .limitedReuse( - count: count, - targetSnode: nil, - targetUseCount: 0, - usedSnodes: usedSnodes, - swarmHash: swarmHash - ) - } - } - - public func reset() -> SwarmDrainBehaviour { - switch self { - case .alwaysRandom: return .alwaysRandom - case .limitedReuse(let count, _, _, _, _): - return .limitedReuse( - count: count, - targetSnode: nil, - targetUseCount: 0, - usedSnodes: [], - swarmHash: 0 - ) - } - } -} - -// MARK: - Convenience - -public extension ThreadSafeObject where Value == SwarmDrainBehaviour { - static var alwaysRandom: ThreadSafeObject { ThreadSafeObject(.alwaysRandom) } - static func limitedReuse(count: UInt) -> ThreadSafeObject { - return ThreadSafeObject(.limitedReuse(count: count)) - } -} diff --git a/SessionNetworkingKit/Types/SwarmDrainer.swift b/SessionNetworkingKit/Types/SwarmDrainer.swift new file mode 100644 index 0000000000..c1e94637c6 --- /dev/null +++ b/SessionNetworkingKit/Types/SwarmDrainer.swift @@ -0,0 +1,141 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +public actor SwarmDrainer { + public enum Strategy: Sendable { + /// Select a new random node for each retry attempt + case alwaysRandom + + /// Reuse the same node a number of times before picking a new one + case limitedReuse(count: UInt) + } + + /// The behaviour that should occur when attempting to retrieve the next snode after the swarm has been drained + /// according to the `Stragegy` + public enum AfterDrain: Sendable { + case throwError + case resetState + } + + public struct LogDetails { + let cat: Log.Category? + let name: String? + + public init(cat: Log.Category?, name: String?) { + self.cat = cat + self.name = name + } + + fileprivate func log(_ message: String) { + switch cat { + case .some(let cat): Log.info(cat, "\(name.map { "\($0) " } ?? "")\(message)") + case .none: Log.info("\(name.map { "\($0) " } ?? "")\(message)") + } + } + } + + private let dependencies: Dependencies + private let strategy: Strategy + private let nextRetrievalAfterDrain: AfterDrain + private let logDetails: LogDetails? + + private var swarm: Set + private var remainingSnodes: Set + private var swarmHash: Int + private var targetSnode: LibSession.Snode? + private var targetUseCount: Int + + // MARK: - Initialization + + public init( + swarm: Set = [], + strategy: Strategy = .alwaysRandom, + nextRetrievalAfterDrain: AfterDrain = .throwError, + logDetails: LogDetails? = nil, + using dependencies: Dependencies + ) { + self.dependencies = dependencies + self.strategy = strategy + self.nextRetrievalAfterDrain = nextRetrievalAfterDrain + self.logDetails = logDetails + + self.swarm = swarm + self.remainingSnodes = swarm + self.swarmHash = swarm.hashValue + self.targetSnode = nil + self.targetUseCount = 0 + } + + // MARK: - Functions + + public func updateSwarmIfNeeded(_ swarm: Set) { + guard swarmHash != swarm.hashValue else { return } + + self.swarm = swarm + self.remainingSnodes = swarm + self.swarmHash = swarm.hashValue + self.targetSnode = nil + self.targetUseCount = 0 + } + + public func selectNextNode() throws -> LibSession.Snode { + /// If the swarm was changed then reset the state + if self.swarmHash != swarm.hashValue { + self.resetState() + } + + /// If we have already drained the swarm then we need to behave as per the specified `nextRetrievalAfterDrain` behaviour + if self.remainingSnodes.isEmpty { + switch nextRetrievalAfterDrain { + case .throwError: throw StorageServerError.ranOutOfRandomSnodes(nil) + case .resetState: + logDetails?.log("drained the swarm, resetting state.") + self.resetState() + } + } + + switch self.strategy { + case .alwaysRandom: + /// Just pop a random element + guard let snode: LibSession.Snode = dependencies.popRandomElement(&self.remainingSnodes) else { + throw StorageServerError.ranOutOfRandomSnodes(nil) + } + + return snode + + case .limitedReuse(let maxUseCount): + if let target: LibSession.Snode = self.targetSnode { + /// If we have more retries then just keep the same target + if self.targetUseCount < maxUseCount { + self.targetUseCount += 1 + return target + } + + /// Otherwise log that we are switching + logDetails?.log("switching from \(target) to next snode.") + } + + self.targetSnode = nil + self.targetUseCount = 0 + + /// Select the next node + guard let newTarget: LibSession.Snode = dependencies.popRandomElement(&self.remainingSnodes) else { + throw StorageServerError.ranOutOfRandomSnodes(nil) + } + + self.targetSnode = newTarget + self.targetUseCount = 1 + + return newTarget + } + } + + private func resetState() { + self.remainingSnodes = swarm + self.swarmHash = swarm.hashValue + self.targetSnode = nil + self.targetUseCount = 0 + } +} diff --git a/SessionNetworkingKit/Types/UpdatableTimestamp.swift b/SessionNetworkingKit/Types/UpdatableTimestamp.swift deleted file mode 100644 index 4847a9445f..0000000000 --- a/SessionNetworkingKit/Types/UpdatableTimestamp.swift +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -public protocol UpdatableTimestamp { - func with(timestampMs: UInt64) -> Self -} diff --git a/SessionNetworkingKit/Types/ValidatableResponse.swift b/SessionNetworkingKit/Types/ValidatableResponse.swift index 7aaa0de95a..0460cc19eb 100644 --- a/SessionNetworkingKit/Types/ValidatableResponse.swift +++ b/SessionNetworkingKit/Types/ValidatableResponse.swift @@ -58,7 +58,7 @@ internal extension ValidatableResponse { Self.requiredSuccessfulResponses < 0 && successPercentage >= abs(1 / CGFloat(Self.requiredSuccessfulResponses)) ) - else { throw SnodeAPIError.responseFailedValidation } + else { throw StorageServerError.responseFailedValidation } return validResultMap } diff --git a/SessionNetworkingKit/Utilities/Dependencies+Network.swift b/SessionNetworkingKit/Utilities/Dependencies+Network.swift new file mode 100644 index 0000000000..90d72f0302 --- /dev/null +++ b/SessionNetworkingKit/Utilities/Dependencies+Network.swift @@ -0,0 +1,50 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +public extension Dependencies { + var networkStatusUpdates: AsyncStream { + if #available(iOS 16.0, *) { + self.stream(singleton: .network).switchMap { $0.networkStatus } + } + else { + self[singleton: .network].networkStatus + } + } + + var currentNetworkStatus: NetworkStatus { + get async { await networkStatusUpdates.first(defaultValue: .unknown) } + } + + nonisolated func networkOffsetTimestampMs() -> T { + return timestampNowMsWithOffset( + offsetMs: self[singleton: .network].syncState.networkTimeOffsetMs + ) + } + + func networkOffsetTimestampMs() async -> T { + return await timestampNowMsWithOffset( + offsetMs: self[singleton: .network].networkTimeOffsetMs + ) + } + + /// Asynchronously waits until the network status is `connected`. + /// + /// **Note:** Since this observes the `networkStatusUpdates` it handles cases where the `network` instance is replaced + /// (eg. switching from Onion Requests to Lokinet) and will continue waiting until the *new* network instance reports a connected status. + func waitUntilConnected(onWillStartWaiting: (() async -> Void)? = nil) async throws { + /// Get the current `networkStatus`, if we are already connected then we can just stop immediately + guard await currentNetworkStatus != .connected else { return } + + /// If we need to wait then inform the caller just in case they need to do something first + await onWillStartWaiting?() + + /// Wait for the a `network` instance to report a `connected` status + _ = await networkStatusUpdates.first(where: { $0 == .connected }) + + if #unavailable(iOS 16.0) { + throw NetworkError.invalidState + } + } +} diff --git a/SessionNetworkingKitTests/Models/SnodeRequestSpec.swift b/SessionNetworkingKitTests/Models/SnodeRequestSpec.swift deleted file mode 100644 index 0405d71e7c..0000000000 --- a/SessionNetworkingKitTests/Models/SnodeRequestSpec.swift +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Combine -import SessionUtilitiesKit - -import Quick -import Nimble - -@testable import SessionNetworkingKit - -class SnodeRequestSpec: QuickSpec { - override class func spec() { - // MARK: Configuration - - @TestState var dependencies: TestDependencies! = TestDependencies() - @TestState var batchRequest: Network.BatchRequest! - - // MARK: - a SnodeRequest - describe("a SnodeRequest") { - // MARK: -- when encoding a Network.BatchRequest storage server type endpoint - context("when encoding a Network.BatchRequest storage server type endpoint") { - // MARK: ---- successfully encodes a SnodeRequest body - it("successfully encodes a SnodeRequest body") { - batchRequest = Network.BatchRequest( - requestsKey: .requests, - requests: [ - try! Network.PreparedRequest( - request: Request, TestEndpoint>( - endpoint: .endpoint, - destination: try! .server( - method: .post, - server: "testServer", - x25519PublicKey: "05\(TestConstants.publicKey)" - ), - body: SnodeRequest( - endpoint: .sendMessage, - body: TestType(stringValue: "testValue") - ) - ), - responseType: NoResponse.self, - requestTimeout: 0, - using: dependencies - ) - ] - ) - - let requestData: Data? = try? JSONEncoder().encode(batchRequest) - let requestJson: [String: [[String: Any]]]? = requestData - .map { try? JSONSerialization.jsonObject(with: $0) as? [String: [[String: Any]]] } - let request: [String: Any]? = requestJson?["requests"]?.first - expect(request?["method"] as? String).to(equal("store")) - expect(request?["params"] as? [String: String]).to(equal(["stringValue": "testValue"])) - } - } - } - } -} - -// MARK: - Test Types - -fileprivate enum TestEndpoint: EndpointType { - case endpoint - - static var name: String { "TestEndpoint" } - static var batchRequestVariant: Network.BatchRequest.Child.Variant { .storageServer } - static var excludedSubRequestHeaders: [HTTPHeader] { [] } - - var path: String { return "endpoint" } -} - -fileprivate struct TestType: Codable, Equatable { - let stringValue: String -} diff --git a/SessionNetworkingKitTests/SOGS/Crypto/CryptoSOGSAPISpec.swift b/SessionNetworkingKitTests/SOGS/Crypto/CryptoSOGSAPISpec.swift index d141b5549f..bb4f5f3aba 100644 --- a/SessionNetworkingKitTests/SOGS/Crypto/CryptoSOGSAPISpec.swift +++ b/SessionNetworkingKitTests/SOGS/Crypto/CryptoSOGSAPISpec.swift @@ -8,18 +8,20 @@ import Nimble @testable import SessionNetworkingKit -class CryptoSOGSAPISpec: QuickSpec { +class CryptoSOGSAPISpec: AsyncSpec { override class func spec() { // MARK: Configuration @TestState var dependencies: TestDependencies! = TestDependencies() - @TestState(singleton: .crypto, in: dependencies) var crypto: Crypto! = Crypto(using: dependencies) - @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( - initialSetup: { cache in - cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) - cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) - } - ) + @TestState var crypto: Crypto! = Crypto(using: dependencies) + @TestState var mockGeneralCache: MockGeneralCache! = .create(using: dependencies) + + beforeEach { + dependencies.set(singleton: .crypto, to: crypto) + + try await mockGeneralCache.defaultInitialSetup() + dependencies.set(cache: .general, to: mockGeneralCache) + } // MARK: - Crypto for SOGSAPI describe("Crypto for SOGSAPI") { diff --git a/SessionNetworkingKitTests/SOGS/Models/SOGSMessageSpec.swift b/SessionNetworkingKitTests/SOGS/Models/SOGSMessageSpec.swift index 315f6e0e33..5facf6e189 100644 --- a/SessionNetworkingKitTests/SOGS/Models/SOGSMessageSpec.swift +++ b/SessionNetworkingKitTests/SOGS/Models/SOGSMessageSpec.swift @@ -2,13 +2,14 @@ import Foundation import SessionUtilitiesKit +import TestUtilities import Quick import Nimble @testable import SessionNetworkingKit -class SOGSMessageSpec: QuickSpec { +class SOGSMessageSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -27,9 +28,13 @@ class SOGSMessageSpec: QuickSpec { """ @TestState var messageData: Data! = messageJson.data(using: .utf8)! @TestState var dependencies: TestDependencies! = TestDependencies() - @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto() + @TestState var mockCrypto: MockCrypto! = .create(using: dependencies) @TestState var decoder: JSONDecoder! = JSONDecoder(using: dependencies) + beforeEach { + dependencies.set(singleton: .crypto, to: mockCrypto) + } + // MARK: - a SOGSMessage describe("a SOGSMessage") { // MARK: -- when decoding @@ -198,7 +203,7 @@ class SOGSMessageSpec: QuickSpec { // MARK: -------- succeeds if it succeeds verification it("succeeds if it succeeds verification") { - mockCrypto + try await mockCrypto .when { $0.verify(.signature(message: .any, publicKey: .any, signature: .any)) } .thenReturn(true) @@ -210,14 +215,14 @@ class SOGSMessageSpec: QuickSpec { // MARK: -------- provides the correct values as parameters it("provides the correct values as parameters") { - mockCrypto + try await mockCrypto .when { $0.verify(.signature(message: .any, publicKey: .any, signature: .any)) } .thenReturn(true) _ = try? decoder.decode(Network.SOGS.Message.self, from: messageData) - expect(mockCrypto) - .to(call(matchingParameters: .all) { + await mockCrypto + .verify { $0.verify( .signature( message: Data(base64Encoded: "VGVzdERhdGE=")!.bytes, @@ -225,12 +230,13 @@ class SOGSMessageSpec: QuickSpec { signature: Data(base64Encoded: "VGVzdFNpZ25hdHVyZQ==")!.bytes ) ) - }) + } + .wasCalled(exactly: 1) } // MARK: -------- throws if it fails verification it("throws if it fails verification") { - mockCrypto + try await mockCrypto .when { $0.verify(.signature(message: .any, publicKey: .any, signature: .any)) } .thenReturn(false) @@ -245,7 +251,7 @@ class SOGSMessageSpec: QuickSpec { context("that is unblinded") { // MARK: -------- succeeds if it succeeds verification it("succeeds if it succeeds verification") { - mockCrypto + try await mockCrypto .when { $0.verify(.signatureXed25519(.any, curve25519PublicKey: .any, data: .any)) } .thenReturn(true) @@ -257,14 +263,14 @@ class SOGSMessageSpec: QuickSpec { // MARK: -------- provides the correct values as parameters it("provides the correct values as parameters") { - mockCrypto + try await mockCrypto .when { $0.verify(.signatureXed25519(.any, curve25519PublicKey: .any, data: .any)) } .thenReturn(true) _ = try? decoder.decode(Network.SOGS.Message.self, from: messageData) - expect(mockCrypto) - .to(call(matchingParameters: .all) { + await mockCrypto + .verify { $0.verify( .signatureXed25519( Data(base64Encoded: "VGVzdFNpZ25hdHVyZQ==")!, @@ -272,12 +278,13 @@ class SOGSMessageSpec: QuickSpec { data: Data(base64Encoded: "VGVzdERhdGE=")! ) ) - }) + } + .wasCalled(exactly: 1) } // MARK: -------- throws if it fails verification it("throws if it fails verification") { - mockCrypto + try await mockCrypto .when { $0.verify(.signatureXed25519(.any, curve25519PublicKey: .any, data: .any)) } .thenReturn(false) diff --git a/SessionNetworkingKitTests/SOGS/SOGSAPISpec.swift b/SessionNetworkingKitTests/SOGS/SOGSAPISpec.swift index 342be00b57..096b31a46c 100644 --- a/SessionNetworkingKitTests/SOGS/SOGSAPISpec.swift +++ b/SessionNetworkingKitTests/SOGS/SOGSAPISpec.swift @@ -4,13 +4,14 @@ import Foundation import Combine import GRDB import SessionUtilitiesKit +import TestUtilities import Quick import Nimble @testable import SessionNetworkingKit -class SOGSAPISpec: QuickSpec { +class SOGSAPISpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -18,63 +19,62 @@ class SOGSAPISpec: QuickSpec { dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) dependencies.forceSynchronous = true } - @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork() - @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( - initialSetup: { crypto in - crypto - .when { $0.generate(.hash(message: .any, key: .any, length: .any)) } - .thenReturn([]) - crypto - .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } - .thenReturn( - KeyPair( - publicKey: Data(hex: TestConstants.publicKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ) - ) - crypto - .when { $0.generate(.signatureBlind15(message: .any, serverPublicKey: .any, ed25519SecretKey: .any)) } - .thenReturn("TestSogsSignature".bytes) - crypto - .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } - .thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes)) - crypto - .when { $0.generate(.signatureXed25519(data: .any, curve25519PrivateKey: .any)) } - .thenReturn("TestStandardSignature".bytes) - crypto - .when { $0.generate(.randomBytes(16)) } - .thenReturn(Array(Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!)) - crypto - .when { $0.generate(.randomBytes(24)) } - .thenReturn(Array(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!)) - crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } - .thenReturn( - KeyPair( - publicKey: Array(Data(hex: TestConstants.edPublicKey)), - secretKey: Array(Data(hex: TestConstants.edSecretKey)) - ) - ) - crypto - .when { $0.generate(.x25519(ed25519Pubkey: .any)) } - .thenReturn(Array(Data(hex: TestConstants.publicKey))) - crypto - .when { $0.generate(.x25519(ed25519Seckey: .any)) } - .thenReturn(Array(Data(hex: TestConstants.privateKey))) - } - ) - @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( - initialSetup: { cache in - cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) - cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) - cache - .when { $0.ed25519Seed } - .thenReturn(Array(Array(Data(hex: TestConstants.edSecretKey)).prefix(upTo: 32))) - } - ) + @TestState var mockNetwork: MockNetwork! = .create(using: dependencies) + @TestState var mockCrypto: MockCrypto! = .create(using: dependencies) + @TestState var mockGeneralCache: MockGeneralCache! = .create(using: dependencies) @TestState var disposables: [AnyCancellable]! = [] @TestState var error: Error? + beforeEach { + try await mockGeneralCache.defaultInitialSetup() + dependencies.set(cache: .general, to: mockGeneralCache) + + try await mockCrypto + .when { $0.generate(.hash(message: .any, key: .any, length: .any)) } + .thenReturn([]) + try await mockCrypto + .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } + .thenReturn( + KeyPair( + publicKey: Data(hex: TestConstants.publicKey).bytes, + secretKey: Data(hex: TestConstants.edSecretKey).bytes + ) + ) + try await mockCrypto + .when { $0.generate(.signatureBlind15(message: .any, serverPublicKey: .any, ed25519SecretKey: .any)) } + .thenReturn("TestSogsSignature".bytes) + try await mockCrypto + .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } + .thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes)) + try await mockCrypto + .when { $0.generate(.signatureXed25519(data: .any, curve25519PrivateKey: .any)) } + .thenReturn("TestStandardSignature".bytes) + try await mockCrypto + .when { $0.generate(.randomBytes(16)) } + .thenReturn(Array(Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!)) + try await mockCrypto + .when { $0.generate(.randomBytes(24)) } + .thenReturn(Array(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!)) + try await mockCrypto + .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .thenReturn( + KeyPair( + publicKey: Array(Data(hex: TestConstants.edPublicKey)), + secretKey: Array(Data(hex: TestConstants.edSecretKey)) + ) + ) + try await mockCrypto + .when { $0.generate(.x25519(ed25519Pubkey: .any)) } + .thenReturn(Array(Data(hex: TestConstants.publicKey))) + try await mockCrypto + .when { $0.generate(.x25519(ed25519Seckey: .any)) } + .thenReturn(Array(Data(hex: TestConstants.privateKey))) + dependencies.set(singleton: .crypto, to: mockCrypto) + + try await mockNetwork.defaultInitialSetup(using: dependencies) + dependencies.set(singleton: .network, to: mockNetwork) + } + // MARK: - a SOGSAPI describe("a SOGSAPI") { // MARK: -- when preparing a poll request @@ -620,8 +620,17 @@ class SOGSAPISpec: QuickSpec { // MARK: ---- processes a valid response correctly it("processes a valid response correctly") { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockCapabilitiesAndRoomResponse) var response: (info: ResponseInfoType, data: Network.SOGS.CapabilitiesAndRoomResponse)? @@ -641,7 +650,7 @@ class SOGSAPISpec: QuickSpec { ) }.toNot(throwError()) - preparedRequest + preparedRequest? .send(using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } @@ -655,8 +664,17 @@ class SOGSAPISpec: QuickSpec { 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(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockCapabilitiesAndBanResponse) var response: (info: ResponseInfoType, data: Network.SOGS.CapabilitiesAndRoomResponse)? @@ -676,7 +694,7 @@ class SOGSAPISpec: QuickSpec { ) }.toNot(throwError()) - preparedRequest + preparedRequest? .send(using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } @@ -688,8 +706,17 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ errors when not given a capabilities response it("errors when not given a capabilities response") { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockBanAndRoomResponse) var response: (info: ResponseInfoType, data: Network.SOGS.CapabilitiesAndRoomResponse)? @@ -709,7 +736,7 @@ class SOGSAPISpec: QuickSpec { ) }.toNot(throwError()) - preparedRequest + preparedRequest? .send(using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } @@ -792,8 +819,17 @@ class SOGSAPISpec: QuickSpec { // MARK: ---- processes a valid response correctly it("processes a valid response correctly") { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockCapabilitiesAndRoomsResponse) var response: (info: ResponseInfoType, data: Network.SOGS.CapabilitiesAndRoomsResponse)? @@ -812,7 +848,7 @@ class SOGSAPISpec: QuickSpec { ) }.toNot(throwError()) - preparedRequest + preparedRequest? .send(using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } @@ -826,8 +862,17 @@ class SOGSAPISpec: QuickSpec { 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(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn( MockNetwork.batchResponseData(with: [ (Network.SOGS.Endpoint.capabilities, Network.SOGS.CapabilitiesResponse.mockBatchSubResponse()), @@ -854,7 +899,7 @@ class SOGSAPISpec: QuickSpec { ) }.toNot(throwError()) - preparedRequest + preparedRequest? .send(using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } @@ -866,8 +911,17 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ errors when not given a capabilities response it("errors when not given a capabilities response") { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockBanAndRoomsResponse) var response: (info: ResponseInfoType, data: Network.SOGS.CapabilitiesAndRoomsResponse)? @@ -886,7 +940,7 @@ class SOGSAPISpec: QuickSpec { ) }.toNot(throwError()) - preparedRequest + preparedRequest? .send(using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } @@ -958,7 +1012,7 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ fails to sign if there is no ed25519SecretKey it("fails to sign if there is no ed25519SecretKey") { - mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) + try await mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) expect { preparedRequest = try Network.SOGS.preparedSend( @@ -984,7 +1038,7 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ fails to sign if there is ed25519Seed it("fails to sign if there is ed25519Seed") { - mockGeneralCache.when { $0.ed25519Seed }.thenReturn([]) + try await mockGeneralCache.when { $0.ed25519Seed }.thenReturn([]) expect { preparedRequest = try Network.SOGS.preparedSend( @@ -1010,7 +1064,7 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ fails to sign if no signature is generated it("fails to sign if no signature is generated") { - mockCrypto + try await mockCrypto .when { $0.generate(.signatureXed25519(data: .any, curve25519PrivateKey: .any)) } .thenReturn(nil) @@ -1068,7 +1122,7 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ fails to sign if there is no ed25519SecretKey it("fails to sign if there is no ed25519SecretKey") { - mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) + try await mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) expect { preparedRequest = try Network.SOGS.preparedSend( @@ -1094,7 +1148,7 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ fails to sign if there is no ed25519Seed it("fails to sign if there is no ed25519Seed") { - mockGeneralCache.when { $0.ed25519Seed }.thenReturn([]) + try await mockGeneralCache.when { $0.ed25519Seed }.thenReturn([]) expect { preparedRequest = try Network.SOGS.preparedSend( @@ -1120,7 +1174,7 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ fails to sign if no signature is generated it("fails to sign if no signature is generated") { - mockCrypto + try await mockCrypto .when { $0.generate(.signatureBlind15(message: .any, serverPublicKey: .any, ed25519SecretKey: .any)) } .thenReturn(nil) @@ -1233,7 +1287,7 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ fails to sign if there is no ed25519SecretKey it("fails to sign if there is no ed25519SecretKey") { - mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) + try await mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) expect { preparedRequest = try Network.SOGS.preparedMessageUpdate( @@ -1258,7 +1312,7 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ fails to sign if there is no ed25519Seed it("fails to sign if there is no ed25519Seed") { - mockGeneralCache.when { $0.ed25519Seed }.thenReturn([]) + try await mockGeneralCache.when { $0.ed25519Seed }.thenReturn([]) expect { preparedRequest = try Network.SOGS.preparedMessageUpdate( @@ -1283,7 +1337,7 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ fails to sign if no signature is generated it("fails to sign if no signature is generated") { - mockCrypto + try await mockCrypto .when { $0.generate(.signatureXed25519(data: .any, curve25519PrivateKey: .any)) } .thenReturn(nil) @@ -1339,7 +1393,7 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ fails to sign if there is no ed25519SecretKey it("fails to sign if there is no ed25519SecretKey") { - mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) + try await mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) expect { preparedRequest = try Network.SOGS.preparedMessageUpdate( @@ -1364,7 +1418,7 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ fails to sign if there is no ed25519Seed it("fails to sign if there is no ed25519Seed") { - mockGeneralCache.when { $0.ed25519Seed }.thenReturn([]) + try await mockGeneralCache.when { $0.ed25519Seed }.thenReturn([]) expect { preparedRequest = try Network.SOGS.preparedMessageUpdate( @@ -1389,7 +1443,7 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ fails to sign if no signature is generated it("fails to sign if no signature is generated") { - mockCrypto + try await mockCrypto .when { $0.generate(.signatureBlind15(message: .any, serverPublicKey: .any, ed25519SecretKey: .any)) } .thenReturn(nil) @@ -2074,7 +2128,7 @@ class SOGSAPISpec: QuickSpec { // MARK: ---- fails when there is no ed25519SecretKey it("fails when there is no ed25519SecretKey") { - mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) + try await mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) expect { preparedRequest = try Network.SOGS.preparedRooms( @@ -2128,7 +2182,7 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ fails when the signature is not generated it("fails when the signature is not generated") { - mockCrypto + try await mockCrypto .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } .thenThrow(CryptoError.failedToGenerateOutput) @@ -2185,7 +2239,7 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ fails when the blindedKeyPair is not generated it("fails when the blindedKeyPair is not generated") { - mockCrypto + try await mockCrypto .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } .thenReturn(nil) @@ -2208,7 +2262,7 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ fails when the sogsSignature is not generated it("fails when the sogsSignature is not generated") { - mockCrypto + try await mockCrypto .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } .thenReturn(nil) @@ -2236,8 +2290,17 @@ class SOGSAPISpec: QuickSpec { @TestState var preparedRequest: Network.PreparedRequest<[Network.SOGS.Room]>? beforeEach { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn(MockNetwork.response(type: [Network.SOGS.Room].self)) } @@ -2354,15 +2417,14 @@ private extension Network.Destination { switch self { case .cached: return nil case .snode(_, let swarmPublicKey): return swarmPublicKey - case .randomSnode(let swarmPublicKey, _), .randomSnodeLatestNetworkTimeTarget(let swarmPublicKey, _, _): - return swarmPublicKey + case .randomSnode(let swarmPublicKey): return swarmPublicKey case .server(let info), .serverDownload(let info), .serverUpload(let info, _): return info.x25519PublicKey } } var testHeaders: [HTTPHeader: String]? { switch self { - case .cached, .snode, .randomSnode, .randomSnodeLatestNetworkTimeTarget: return nil + case .cached, .snode, .randomSnode: return nil case .server(let info), .serverDownload(let info), .serverUpload(let info, _): return info.headers } } diff --git a/SessionNetworkingKitTests/Types/BatchRequestSpec.swift b/SessionNetworkingKitTests/Types/BatchRequestSpec.swift index 05554a2be1..4e746eb3db 100644 --- a/SessionNetworkingKitTests/Types/BatchRequestSpec.swift +++ b/SessionNetworkingKitTests/Types/BatchRequestSpec.swift @@ -22,9 +22,9 @@ class BatchRequestSpec: QuickSpec { context("when encoding") { // MARK: ---- correctly strips specified headers from sub requests it("correctly strips specified headers from sub requests") { - let httpRequest: Request = try! Request( + let httpRequest: Request = Request( endpoint: .endpoint1, - destination: try! .server( + destination: .server( server: "testServer", queryParameters: [:], headers: [ @@ -33,7 +33,8 @@ class BatchRequestSpec: QuickSpec { ], x25519PublicKey: "05\(TestConstants.publicKey)" ), - body: nil + body: nil, + requestTimeout: 0 ) request = Network.BatchRequest( @@ -41,7 +42,6 @@ class BatchRequestSpec: QuickSpec { try! Network.PreparedRequest( request: httpRequest, responseType: NoResponse.self, - requestTimeout: 0, using: dependencies ) ] @@ -58,9 +58,9 @@ class BatchRequestSpec: QuickSpec { // MARK: ---- does not strip unspecified headers from sub requests it("does not strip unspecified headers from sub requests") { - let httpRequest: Request = try! Request( + let httpRequest: Request = Request( endpoint: .endpoint1, - destination: try! .server( + destination: .server( server: "testServer", queryParameters: [:], headers: [ @@ -69,14 +69,14 @@ class BatchRequestSpec: QuickSpec { ], x25519PublicKey: "05\(TestConstants.publicKey)" ), - body: nil + body: nil, + requestTimeout: 0 ) request = Network.BatchRequest( requests: [ try! Network.PreparedRequest( request: httpRequest, responseType: NoResponse.self, - requestTimeout: 0, using: dependencies ) ] @@ -99,16 +99,16 @@ class BatchRequestSpec: QuickSpec { try! Network.PreparedRequest( request: Request( endpoint: .endpoint1, - destination: try! .server( + destination: .server( server: "testServer", queryParameters: [:], headers: [:], x25519PublicKey: "05\(TestConstants.publicKey)" ), - body: "testBody" + body: "testBody", + requestTimeout: 0 ), responseType: NoResponse.self, - requestTimeout: 0, using: dependencies ) ] @@ -129,16 +129,16 @@ class BatchRequestSpec: QuickSpec { try! Network.PreparedRequest( request: Request<[UInt8], TestEndpoint1>( endpoint: .endpoint1, - destination: try! .server( + destination: .server( server: "testServer", queryParameters: [:], headers: [:], x25519PublicKey: "05\(TestConstants.publicKey)" ), - body: [1, 2, 3] + body: [1, 2, 3], + requestTimeout: 0 ), responseType: NoResponse.self, - requestTimeout: 0, using: dependencies ) ] @@ -159,16 +159,16 @@ class BatchRequestSpec: QuickSpec { try! Network.PreparedRequest( request: Request( endpoint: .endpoint1, - destination: try! .server( + destination: .server( server: "testServer", queryParameters: [:], headers: [:], x25519PublicKey: "05\(TestConstants.publicKey)" ), - body: TestType(stringValue: "testValue") + body: TestType(stringValue: "testValue"), + requestTimeout: 0 ), responseType: NoResponse.self, - requestTimeout: 0, using: dependencies ) ] @@ -193,16 +193,16 @@ class BatchRequestSpec: QuickSpec { try! Network.PreparedRequest( request: Request( endpoint: .endpoint2, - destination: try! .server( + destination: .server( server: "testServer", queryParameters: [:], headers: [:], x25519PublicKey: "05\(TestConstants.publicKey)" ), - body: "TestMessage".data(using: .utf8)!.base64EncodedString() + body: "TestMessage".data(using: .utf8)!.base64EncodedString(), + requestTimeout: 0 ), responseType: NoResponse.self, - requestTimeout: 0, using: dependencies ) ] @@ -213,7 +213,7 @@ class BatchRequestSpec: QuickSpec { .map { try? JSONSerialization.jsonObject(with: $0) as? [String: [[String: Any]]] } let requests: [[String: Any]]? = requestJson?["requests"] expect(requests?.count).to(equal(1)) - expect(requests?.first?.count).to(equal(0)) + expect(requests?.first?["method"] as? String).to(equal("endpoint2")) } // MARK: ---- ignores a byte body @@ -224,16 +224,16 @@ class BatchRequestSpec: QuickSpec { try! Network.PreparedRequest( request: Request<[UInt8], TestEndpoint2>( endpoint: .endpoint2, - destination: try! .server( + destination: .server( server: "testServer", queryParameters: [:], headers: [:], x25519PublicKey: "05\(TestConstants.publicKey)" ), - body: [1, 2, 3] + body: [1, 2, 3], + requestTimeout: 0 ), responseType: NoResponse.self, - requestTimeout: 0, using: dependencies ) ] @@ -244,7 +244,7 @@ class BatchRequestSpec: QuickSpec { .map { try? JSONSerialization.jsonObject(with: $0) as? [String: [[String: Any]]] } let requests: [[String: Any]]? = requestJson?["requests"] expect(requests?.count).to(equal(1)) - expect(requests?.first?.count).to(equal(0)) + expect(requests?.first?["method"] as? String).to(equal("endpoint2")) } // MARK: ---- successfully encodes a JSON body @@ -255,16 +255,16 @@ class BatchRequestSpec: QuickSpec { try! Network.PreparedRequest( request: Request( endpoint: .endpoint2, - destination: try! .server( + destination: .server( server: "testServer", queryParameters: [:], headers: [:], x25519PublicKey: "05\(TestConstants.publicKey)" ), - body: TestType(stringValue: "testValue") + body: TestType(stringValue: "testValue"), + requestTimeout: 0 ), responseType: NoResponse.self, - requestTimeout: 0, using: dependencies ) ] @@ -275,7 +275,8 @@ class BatchRequestSpec: QuickSpec { .map { try? JSONSerialization.jsonObject(with: $0) as? [String: [[String: Any]]] } let requests: [[String: Any]]? = requestJson?["requests"] expect(requests?.count).to(equal(1)) - expect(requests?.first as? [String: String]) + expect(requests?.first?["method"] as? String).to(equal("endpoint2")) + expect(requests?.first?["params"] as? [String: String]) .to(equal(["stringValue": "testValue"])) } } diff --git a/SessionNetworkingKitTests/Types/DestinationSpec.swift b/SessionNetworkingKitTests/Types/DestinationSpec.swift index e226a25f21..ba5e6cc188 100644 --- a/SessionNetworkingKitTests/Types/DestinationSpec.swift +++ b/SessionNetworkingKitTests/Types/DestinationSpec.swift @@ -15,7 +15,7 @@ class DestinationSpec: QuickSpec { context("when generating a path") { // MARK: ---- adds a leading forward slash to the endpoint path it("adds a leading forward slash to the endpoint path") { - let result: String = Network.Destination.generatePathsAndParams( + let result: String = Network.Destination.generatePathWithParams( endpoint: TestEndpoint.test1, queryParameters: [:] ) @@ -25,7 +25,7 @@ class DestinationSpec: QuickSpec { // MARK: ---- creates a valid URL with no query parameters it("creates a valid URL with no query parameters") { - let result: String = Network.Destination.generatePathsAndParams( + let result: String = Network.Destination.generatePathWithParams( endpoint: TestEndpoint.test1, queryParameters: [:] ) @@ -35,7 +35,7 @@ class DestinationSpec: QuickSpec { // MARK: ---- creates a valid URL when query parameters are provided it("creates a valid URL when query parameters are provided") { - let result: String = Network.Destination.generatePathsAndParams( + let result: String = Network.Destination.generatePathWithParams( endpoint: TestEndpoint.test1, queryParameters: [ .testParam: "123" @@ -45,20 +45,6 @@ class DestinationSpec: QuickSpec { expect(result).to(equal("/test1?testParam=123")) } } - - // MARK: -- for a server - context("for a server") { - // MARK: ---- throws an error if the generated URL is invalid - it("throws an error if the generated URL is invalid") { - expect { - _ = try Network.Destination.server( - server: "ftp:// test Server", - x25519PublicKey: "" - ).withGeneratedUrl(for: TestEndpoint.testParams("test", 123)) - } - .to(throwError(NetworkError.invalidURL)) - } - } } } } diff --git a/SessionNetworkingKitTests/Types/PreparedRequestSendingSpec.swift b/SessionNetworkingKitTests/Types/PreparedRequestSendingSpec.swift index a1ad6f438b..208dc39f30 100644 --- a/SessionNetworkingKitTests/Types/PreparedRequestSendingSpec.swift +++ b/SessionNetworkingKitTests/Types/PreparedRequestSendingSpec.swift @@ -3,49 +3,61 @@ import Foundation import Combine import SessionUtilitiesKit +import TestUtilities import Quick import Nimble @testable import SessionNetworkingKit -class PreparedRequestSendingSpec: QuickSpec { +class PreparedRequestSendingSpec: AsyncSpec { override class func spec() { // MARK: Configuration @TestState var dependencies: TestDependencies! = TestDependencies { dependencies in dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) } - @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork() - @TestState var preparedRequest: Network.PreparedRequest! = { - let request = try! Request( + @TestState var mockNetwork: MockNetwork! = .create(using: dependencies) + @TestState var preparedRequest: Network.PreparedRequest! + @TestState var error: Error? + @TestState var disposables: [AnyCancellable]! = [] + + beforeEach { + let request: Request = Request( endpoint: .endpoint1, - destination: try! .server( + destination: .server( method: .post, server: "testServer", x25519PublicKey: "" ), body: nil ) - - return try! Network.PreparedRequest( + preparedRequest = try Network.PreparedRequest( request: request, responseType: Int.self, - retryCount: 0, - requestTimeout: 10, using: dependencies ) - }() - @TestState var error: Error? - @TestState var disposables: [AnyCancellable]! = [] + + try await mockNetwork.defaultInitialSetup(using: dependencies) + dependencies.set(singleton: .network, to: mockNetwork) + } // MARK: - a PreparedRequest sending Onion Requests describe("a PreparedRequest sending Onion Requests") { // MARK: -- when sending context("when sending") { beforeEach { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn(MockNetwork.response(with: 1)) } @@ -64,21 +76,6 @@ class PreparedRequestSendingSpec: QuickSpec { expect(error).to(beNil()) } - // MARK: ---- returns an error when the prepared request is null - it("returns an error when the prepared request is null") { - var response: (info: ResponseInfoType, data: Int)? - - preparedRequest = nil - preparedRequest - .send(using: dependencies) - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error).to(matchError(NetworkError.invalidPreparedRequest)) - expect(response).to(beNil()) - } - // MARK: ------ can return a cached response it("can return a cached response") { var response: (info: ResponseInfoType, data: Int)? @@ -102,25 +99,11 @@ class PreparedRequestSendingSpec: QuickSpec { // MARK: ---- and handling events context("and handling events") { - @TestState var didReceiveSubscription: Bool! = false - @TestState var didReceiveCancel: Bool! = false @TestState var receivedOutput: (ResponseInfoType, Int)? = nil @TestState var receivedCompletion: Subscribers.Completion? = nil - @TestState var multiDidReceiveSubscription: [Bool]! = [] + @TestState var multiReceivedOutput: [(ResponseInfoType, Int)]! = [] @TestState var multiReceivedCompletion: [Subscribers.Completion]! = [] - // MARK: ------ calls receiveSubscription correctly - it("calls receiveSubscription correctly") { - preparedRequest - .handleEvents( - receiveSubscription: { didReceiveSubscription = true } - ) - .send(using: dependencies) - .sinkAndStore(in: &disposables) - - expect(didReceiveSubscription).to(beTrue()) - } - // MARK: ------ calls receiveOutput correctly it("calls receiveOutput correctly") { preparedRequest @@ -145,32 +128,17 @@ class PreparedRequestSendingSpec: QuickSpec { expect(receivedCompletion).toNot(beNil()) } - // MARK: ------ calls receiveCancel correctly - it("calls receiveCancel correctly") { - preparedRequest - .handleEvents( - receiveCancel: { didReceiveCancel = true } - ) - .send(using: dependencies) - .handleEvents( - receiveSubscription: { $0.cancel() } - ) - .sinkAndStore(in: &disposables) - - expect(didReceiveCancel).to(beTrue()) - } - // MARK: ------ calls multiple callbacks without issue it("calls multiple callbacks without issue") { preparedRequest .handleEvents( - receiveSubscription: { didReceiveSubscription = true }, + receiveOutput: { info, output in receivedOutput = (info, output) }, receiveCompletion: { result in receivedCompletion = result } ) .send(using: dependencies) .sinkAndStore(in: &disposables) - expect(didReceiveSubscription).to(beTrue()) + expect(receivedOutput).toNot(beNil()) expect(receivedCompletion).toNot(beNil()) } @@ -178,21 +146,21 @@ class PreparedRequestSendingSpec: QuickSpec { it("supports multiple handleEvents calls") { preparedRequest .handleEvents( - receiveSubscription: { multiDidReceiveSubscription.append(true) }, + receiveOutput: { info, output in multiReceivedOutput.append((info, output)) }, receiveCompletion: { result in multiReceivedCompletion.append(result) } ) .handleEvents( - receiveSubscription: { multiDidReceiveSubscription.append(true) }, + receiveOutput: { info, output in multiReceivedOutput.append((info, output)) }, receiveCompletion: { result in multiReceivedCompletion.append(result) } ) .handleEvents( - receiveSubscription: { multiDidReceiveSubscription.append(true) }, + receiveOutput: { info, output in multiReceivedOutput.append((info, output)) }, receiveCompletion: { result in multiReceivedCompletion.append(result) } ) .send(using: dependencies) .sinkAndStore(in: &disposables) - expect(multiDidReceiveSubscription).to(equal([true, true, true])) + expect(multiReceivedOutput.count).to(equal(3)) expect(multiReceivedCompletion.count).to(equal(3)) } } @@ -278,13 +246,11 @@ class PreparedRequestSendingSpec: QuickSpec { preparedRequest .map { _, output -> String in "\(output)" } .handleEvents( - receiveSubscription: { didReceiveSubscription = true }, receiveCompletion: { result in receivedCompletion = result } ) .send(using: dependencies) .sinkAndStore(in: &disposables) - expect(didReceiveSubscription).to(beTrue()) expect(receivedCompletion).toNot(beNil()) } } @@ -293,26 +259,26 @@ class PreparedRequestSendingSpec: QuickSpec { context("a batch request") { // MARK: ---- with a BatchResponseMap context("with a BatchResponseMap") { - @TestState var subRequest1: Request! = try! Request( + @TestState var subRequest1: Request! = Request( endpoint: TestEndpoint.endpoint1, - destination: try! .server( + destination: .server( method: .post, server: "testServer", x25519PublicKey: "" ) ) - @TestState var subRequest2: Request! = try! Request( + @TestState var subRequest2: Request! = Request( endpoint: TestEndpoint.endpoint2, - destination: try! .server( + destination: .server( method: .post, server: "testServer", x25519PublicKey: "" ) ) @TestState var preparedBatchRequest: Network.PreparedRequest>! = { - let request = try! Request( + let request = Request( endpoint: TestEndpoint.batch, - destination: try! .server( + destination: .server( method: .post, server: "testServer", x25519PublicKey: "" @@ -322,15 +288,11 @@ class PreparedRequestSendingSpec: QuickSpec { try! Network.PreparedRequest( request: subRequest1, responseType: TestType.self, - retryCount: 0, - requestTimeout: 10, using: dependencies ), try! Network.PreparedRequest( request: subRequest2, responseType: TestType.self, - retryCount: 0, - requestTimeout: 10, using: dependencies ) ] @@ -340,8 +302,6 @@ class PreparedRequestSendingSpec: QuickSpec { return try! Network.PreparedRequest( request: request, responseType: Network.BatchResponseMap.self, - retryCount: 0, - requestTimeout: 10, using: dependencies ) }() @@ -351,8 +311,17 @@ class PreparedRequestSendingSpec: QuickSpec { @TestState var receivedCompletion: Subscribers.Completion? = nil beforeEach { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + try await mockNetwork + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } .thenReturn( MockNetwork.batchResponseData(with: [ (endpoint: TestEndpoint.endpoint1, data: TestType.mockBatchSubResponse()), @@ -391,9 +360,9 @@ class PreparedRequestSendingSpec: QuickSpec { // MARK: ------ supports transformations on subrequests it("supports transformations on subrequests") { preparedBatchRequest = { - let request = try! Request( + let request = Request( endpoint: TestEndpoint.batch, - destination: try! .server( + destination: .server( method: .post, server: "testServer", x25519PublicKey: "" @@ -403,16 +372,12 @@ class PreparedRequestSendingSpec: QuickSpec { try! Network.PreparedRequest( request: subRequest1, responseType: TestType.self, - retryCount: 0, - requestTimeout: 10, using: dependencies ) .map { _, _ in "Test" }, try! Network.PreparedRequest( request: subRequest2, responseType: TestType.self, - retryCount: 0, - requestTimeout: 10, using: dependencies ) ] @@ -422,8 +387,6 @@ class PreparedRequestSendingSpec: QuickSpec { return try! Network.PreparedRequest( request: request, responseType: Network.BatchResponseMap.self, - retryCount: 0, - requestTimeout: 10, using: dependencies ) }() @@ -447,22 +410,20 @@ class PreparedRequestSendingSpec: QuickSpec { it("works with the event handling") { preparedBatchRequest .handleEvents( - receiveSubscription: { didReceiveSubscription = true }, receiveCompletion: { result in receivedCompletion = result } ) .send(using: dependencies) .sinkAndStore(in: &disposables) - expect(didReceiveSubscription).to(beTrue()) expect(receivedCompletion).toNot(beNil()) } // MARK: ------ supports event handling on sub requests it("supports event handling on sub requests") { preparedBatchRequest = { - let request = try! Request( + let request = Request( endpoint: TestEndpoint.batch, - destination: try! .server( + destination: .server( method: .post, server: "testServer", x25519PublicKey: "" @@ -472,8 +433,6 @@ class PreparedRequestSendingSpec: QuickSpec { try! Network.PreparedRequest( request: subRequest1, responseType: TestType.self, - retryCount: 0, - requestTimeout: 10, using: dependencies ) .handleEvents( @@ -482,8 +441,6 @@ class PreparedRequestSendingSpec: QuickSpec { try! Network.PreparedRequest( request: subRequest2, responseType: TestType.self, - retryCount: 0, - requestTimeout: 10, using: dependencies ) ] @@ -493,8 +450,6 @@ class PreparedRequestSendingSpec: QuickSpec { return try! Network.PreparedRequest( request: request, responseType: Network.BatchResponseMap.self, - retryCount: 0, - requestTimeout: 10, using: dependencies ) }() @@ -533,7 +488,8 @@ fileprivate enum TestEndpoint: EndpointType { } fileprivate struct TestType: Codable, Equatable, Mocked { - static var mock: TestType { TestType(intValue: 100, stringValue: "Test", optionalStringValue: nil) } + public static var any: TestType { TestType(intValue: .any, stringValue: .any, optionalStringValue: .any) } + public static var mock: TestType { TestType(intValue: 100, stringValue: "Test", optionalStringValue: nil) } let intValue: Int let stringValue: String diff --git a/SessionNetworkingKitTests/Types/PreparedRequestSpec.swift b/SessionNetworkingKitTests/Types/PreparedRequestSpec.swift index 7b949b5fde..9e78fa3db9 100644 --- a/SessionNetworkingKitTests/Types/PreparedRequestSpec.swift +++ b/SessionNetworkingKitTests/Types/PreparedRequestSpec.swift @@ -24,9 +24,9 @@ class PreparedRequestSpec: QuickSpec { describe("a PreparedRequest") { // MARK: -- generates the request correctly it("generates the request correctly") { - request = try! Request( + request = Request( endpoint: .endpoint, - destination: try! .server( + destination: .server( method: .post, server: "testServer", queryParameters: [:], @@ -36,12 +36,15 @@ class PreparedRequestSpec: QuickSpec { ], x25519PublicKey: "" ), - body: nil + body: nil, + category: .upload, + requestTimeout: 123, + overallTimeout: 1234, + retryCount: 3 ) preparedRequest = try! Network.PreparedRequest( request: request, responseType: TestType.self, - requestTimeout: 10, using: dependencies ) @@ -51,13 +54,17 @@ class PreparedRequestSpec: QuickSpec { "TestCustomHeader": "TestCustom", HTTPHeader.testHeader: "Test" ])) + expect(preparedRequest.category).to(equal(.upload)) + expect(preparedRequest.requestTimeout).to(equal(123)) + expect(preparedRequest.overallTimeout).to(equal(1234)) + expect(preparedRequest.retryCount).to(equal(3)) } // MARK: -- does not strip excluded subrequest headers it("does not strip excluded subrequest headers") { - request = try! Request( + request = Request( endpoint: .endpoint, - destination: try! .server( + destination: .server( method: .post, server: "testServer", queryParameters: [:], @@ -72,13 +79,37 @@ class PreparedRequestSpec: QuickSpec { preparedRequest = try! Network.PreparedRequest( request: request, responseType: TestType.self, - requestTimeout: 10, using: dependencies ) expect(TestEndpoint.excludedSubRequestHeaders).to(equal([HTTPHeader.testHeader])) expect(preparedRequest.headers.keys).to(contain([HTTPHeader.testHeader])) } + + // MARK: ---- throws an error if the generated URL is invalid + it("throws an error if the generated URL is invalid") { + request = Request( + endpoint: .testParams("test", 123), + destination: .server( + method: .post, + server: "ftp:// test Server", + queryParameters: [:], + headers: [ + "TestCustomHeader": "TestCustom", + HTTPHeader.testHeader: "Test" + ], + x25519PublicKey: "" + ), + body: nil + ) + preparedRequest = try! Network.PreparedRequest( + request: request, + responseType: TestType.self, + using: dependencies + ) + + expect { try preparedRequest.generateUrl() }.to(throwError(NetworkError.invalidURL)) + } } // MARK: - a Decodable @@ -160,6 +191,7 @@ fileprivate extension HTTPHeader { fileprivate enum TestEndpoint: EndpointType { case endpoint + case testParams(String, Int) static var name: String { "TestEndpoint" } static var batchRequestVariant: Network.BatchRequest.Child.Variant { .storageServer } diff --git a/SessionNetworkingKitTests/Types/RequestSpec.swift b/SessionNetworkingKitTests/Types/RequestSpec.swift index 0b951cf1d3..9f0f49d5d2 100644 --- a/SessionNetworkingKitTests/Types/RequestSpec.swift +++ b/SessionNetworkingKitTests/Types/RequestSpec.swift @@ -21,9 +21,9 @@ class RequestSpec: QuickSpec { describe("a Request") { // MARK: -- is initialized with the correct default values it("is initialized with the correct default values") { - let request: Request = try! Request( + let request: Request = Request( endpoint: .test1, - destination: try! .server( + destination: .server( server: "testServer", x25519PublicKey: "" ) @@ -36,9 +36,9 @@ class RequestSpec: QuickSpec { // MARK: ---- sets all the values correctly it("sets all the values correctly") { - let request: Request = try! Request( + let request: Request = Request( endpoint: .test1, - destination: try! .server( + destination: .server( method: .delete, server: "testServer", headers: [ @@ -48,9 +48,10 @@ class RequestSpec: QuickSpec { ) ) - expect(request.destination.url?.absoluteString).to(equal("testServer/test1")) + expect(request.endpoint).to(equal(.test1)) + expect(request.destination.server).to(equal("testServer")) + expect(request.destination.queryParameters).to(equal([:])) expect(request.destination.method.rawValue).to(equal("DELETE")) - expect(request.destination.urlPathAndParamsString).to(equal("/test1")) expect(request.destination.headers).to(equal(["TestHeader": "test"])) expect(request.body).to(beNil()) } @@ -59,9 +60,9 @@ class RequestSpec: QuickSpec { context("with a base64 string body") { // MARK: ------ successfully encodes the body it("successfully encodes the body") { - let request: Request = try! Request( + let request: Request = Request( endpoint: .test1, - destination: try! .server( + destination: .server( server: "testServer", x25519PublicKey: "" ), @@ -76,9 +77,9 @@ class RequestSpec: QuickSpec { // MARK: ------ throws an error if the body is not base64 encoded it("throws an error if the body is not base64 encoded") { - let request: Request = try! Request( + let request: Request = Request( endpoint: .test1, - destination: try! .server( + destination: .server( server: "testServer", x25519PublicKey: "" ), @@ -96,9 +97,9 @@ class RequestSpec: QuickSpec { context("with a byte body") { // MARK: ------ successfully encodes the body it("successfully encodes the body") { - let request: Request<[UInt8], TestEndpoint> = try! Request( + let request: Request<[UInt8], TestEndpoint> = Request( endpoint: .test1, - destination: try! .server( + destination: .server( server: "testServer", x25519PublicKey: "" ), @@ -115,9 +116,9 @@ class RequestSpec: QuickSpec { context("with a JSON body") { // MARK: ------ successfully encodes the body it("successfully encodes the body") { - let request: Request = try! Request( + let request: Request = Request( endpoint: .test1, - destination: try! .server( + destination: .server( server: "testServer", x25519PublicKey: "" ), @@ -135,9 +136,9 @@ class RequestSpec: QuickSpec { // MARK: ------ successfully encodes no body it("successfully encodes no body") { - let request: Request = try! Request( + let request: Request = Request( endpoint: .test1, - destination: try! .server( + destination: .server( server: "testServer", x25519PublicKey: "" ), diff --git a/SessionNetworkingKitTests/_TestUtilities/MockNetwork.swift b/SessionNetworkingKitTests/_TestUtilities/MockNetwork.swift index eff21161ac..51bb625a2d 100644 --- a/SessionNetworkingKitTests/_TestUtilities/MockNetwork.swift +++ b/SessionNetworkingKitTests/_TestUtilities/MockNetwork.swift @@ -3,52 +3,118 @@ import Foundation import Combine import SessionUtilitiesKit +import TestUtilities @testable import SessionNetworkingKit // MARK: - MockNetwork -class MockNetwork: Mock, NetworkType { +class MockNetwork: NetworkType, Mockable { + public var handler: MockHandler + + required init(handler: MockHandler) { + self.handler = handler + } + + required init(handlerForBuilder: any MockFunctionHandler) { + self.handler = MockHandler(forwardingHandler: handlerForBuilder) + } + var requestData: RequestData? - func getSwarm(for swarmPublicKey: String) -> AnyPublisher, Error> { - return mock(args: [swarmPublicKey]) + var isSuspended: Bool { handler.mock() } + var hardfork: Int { handler.mock() } + var softfork: Int { handler.mock() } + var networkTimeOffsetMs: Int64 { handler.mock() } + var networkStatus: AsyncStream { handler.mock() } + var syncState: NetworkSyncState { handler.mock() } + + func getActivePaths() async throws -> [LibSession.Path] { + return try handler.mockThrowing() + } + + func getSwarm(for swarmPublicKey: String) async throws -> Set { + return try handler.mockThrowing(args: [swarmPublicKey]) } - func getRandomNodes(count: Int) -> AnyPublisher, Error> { - return mock(args: [count]) + func getRandomNodes(count: Int) async throws -> Set { + return try handler.mockThrowing(args: [count]) } - func send( - _ body: Data?, - to destination: Network.Destination, + func send( + endpoint: E, + destination: Network.Destination, + body: Data?, + category: Network.RequestCategory, requestTimeout: TimeInterval, - requestAndPathBuildTimeout: TimeInterval? + overallTimeout: TimeInterval? ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { requestData = RequestData( + method: destination.method, + headers: destination.headers, + path: endpoint.path, + queryParameters: destination.queryParameters, body: body, + category: category, + requestTimeout: requestTimeout, + overallTimeout: overallTimeout + ) + + return handler.mock(args: [endpoint, destination, body, category, requestTimeout, overallTimeout]) + } + + func send( + endpoint: E, + destination: Network.Destination, + body: Data?, + category: Network.RequestCategory, + requestTimeout: TimeInterval, + overallTimeout: TimeInterval? + ) async throws -> (info: ResponseInfoType, value: Data?) { + requestData = RequestData( method: destination.method, - pathAndParamsString: destination.urlPathAndParamsString, headers: destination.headers, - x25519PublicKey: { - switch destination { - case .server(let info), .serverUpload(let info, _), .serverDownload(let info): return info.x25519PublicKey - case .snode(_, let swarmPublicKey): return swarmPublicKey - case .randomSnode(let swarmPublicKey, _), .randomSnodeLatestNetworkTimeTarget(let swarmPublicKey, _, _): - return swarmPublicKey - - case .cached: return nil - } - }(), + path: endpoint.path, + queryParameters: destination.queryParameters, + body: body, + category: category, requestTimeout: requestTimeout, - requestAndPathBuildTimeout: requestAndPathBuildTimeout + overallTimeout: overallTimeout ) - return mock(args: [body, destination, requestTimeout, requestAndPathBuildTimeout]) + return try handler.mockThrowing(args: [endpoint, destination, body, category, requestTimeout, overallTimeout]) + } + + func checkClientVersion(ed25519SecretKey: [UInt8]) async throws -> (info: ResponseInfoType, value: Network.FileServer.AppVersionResponse) { + return try handler.mockThrowing(args: [ed25519SecretKey]) + } + + func resetNetworkStatus() async { + handler.mockNoReturn() + } + + func setNetworkStatus(status: NetworkStatus) async { + handler.mockNoReturn(args: [status]) + } + + func setNetworkInfo(networkTimeOffsetMs: Int64, hardfork: Int, softfork: Int) async { + handler.mockNoReturn(args: [networkTimeOffsetMs, hardfork, softfork]) } - func checkClientVersion(ed25519SecretKey: [UInt8]) -> AnyPublisher<(ResponseInfoType, Network.FileServer.AppVersionResponse), Error> { - return mock(args: [ed25519SecretKey]) + func suspendNetworkAccess() async { + handler.mockNoReturn() + } + + func resumeNetworkAccess(autoReconnect: Bool) async { + handler.mockNoReturn(args: [autoReconnect]) + } + + func finishCurrentObservations() async { + handler.mockNoReturn() + } + + func clearCache() async { + handler.mockNoReturn() } } @@ -96,11 +162,42 @@ extension MockNetwork { static func errorResponse() -> AnyPublisher<(ResponseInfoType, Data?), Error> { return Fail(error: TestError.mock).eraseToAnyPublisher() } + + static func response(info: MockResponseInfo = .mock, with value: T) -> (ResponseInfoType, Data?) { + return (info, try? JSONEncoder().with(outputFormatting: .sortedKeys).encode(value)) + } + + static func response(info: MockResponseInfo = .mock, type: T.Type) -> (ResponseInfoType, Data?) { + return response(info: info, with: T.mock) + } + + static func response(info: MockResponseInfo = .mock, type: Array.Type) -> (ResponseInfoType, Data?) { + return response(info: info, with: [T.mock]) + } + + static func batchResponseData( + info: MockResponseInfo = .mock, + with value: [(endpoint: E, data: Data)] + ) -> (ResponseInfoType, Data?) { + let data: Data = "[\(value.map { String(data: $0.data, encoding: .utf8)! }.joined(separator: ","))]" + .data(using: .utf8)! + + return (info, data) + } + + static func response(info: MockResponseInfo = .mock, data: Data) -> (ResponseInfoType, Data?) { + return (info, data) + } + + static func nullResponse(info: MockResponseInfo = .mock) -> (ResponseInfoType, Data?) { + return (info, nil) + } } // MARK: - MockResponseInfo struct MockResponseInfo: ResponseInfoType, Mocked { + static let any: MockResponseInfo = MockResponseInfo(requestData: .any, code: .any, headers: .any) static let mock: MockResponseInfo = MockResponseInfo(requestData: .mock, code: 200, headers: [:]) let requestData: RequestData @@ -115,23 +212,35 @@ struct MockResponseInfo: ResponseInfoType, Mocked { } struct RequestData: Codable, Mocked { + static let any: RequestData = RequestData( + method: .get, + headers: .any, + path: .any, + queryParameters: .any, + body: .any, + category: .standard, + requestTimeout: .any, + overallTimeout: .any + ) static let mock: RequestData = RequestData( - body: nil, method: .get, - pathAndParamsString: "", headers: [:], - x25519PublicKey: nil, + path: "/mock", + queryParameters: [:], + body: nil, + category: .standard, requestTimeout: 0, - requestAndPathBuildTimeout: nil + overallTimeout: nil ) - let body: Data? let method: HTTPMethod - let pathAndParamsString: String let headers: [HTTPHeader: String] - let x25519PublicKey: String? + let path: String + let queryParameters: [HTTPQueryParam: String] + let body: Data? + let category: Network.RequestCategory let requestTimeout: TimeInterval - let requestAndPathBuildTimeout: TimeInterval? + let overallTimeout: TimeInterval? } // MARK: - Network.BatchSubResponse Encoding Convenience @@ -149,24 +258,105 @@ extension Encodable where Self: Codable { } } -extension Mocked where Self: Codable { +public extension Mocked where Self: Codable { static func mockBatchSubResponse() -> Data { return mock.batchSubResponse() } } -extension Array where Element: Mocked, Element: Codable { +public extension Array where Element: Mocked, Element: Codable { static func mockBatchSubResponse() -> Data { return [Element.mock].batchSubResponse() } } // MARK: - Endpoint enum MockEndpoint: EndpointType, Mocked { + static var any: MockEndpoint = .anyValue static var mockValue: MockEndpoint = .mock + static var skipTypeMatchForAnyComparison: Bool { true } + case anyValue case mock static var name: String { "MockEndpoint" } static var batchRequestVariant: Network.BatchRequest.Child.Variant { .storageServer } static var excludedSubRequestHeaders: [HTTPHeader] { [] } - var path: String { return "mock" } + var path: String { + switch self { + case .anyValue: return "__MOCKED_ANY_ENDPOINT_VALUE__" + case .mock: return "mock" + } + } +} + +// MARK: - Convenience + +extension MockNetwork { + func removeRequestMocks() async { + await self.removeMocksFor { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } + } + + func defaultInitialSetup(using dependencies: Dependencies) async throws { + try await self.when { await $0.isSuspended }.thenReturn(false) + try await self.when { await $0.hardfork }.thenReturn(2) + try await self.when { await $0.softfork }.thenReturn(11) + try await self.when { await $0.networkTimeOffsetMs }.thenReturn(0) + try await self.when { $0.networkStatus }.thenReturn(.singleValue(value: .connected)) + try await self + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } + .thenReturn(MockNetwork.errorResponse()) + try await self + .when { $0.syncState } + .thenReturn(NetworkSyncState( + hardfork: 2, + softfork: 11, + isSuspended: false, + using: dependencies + )) + try await self + .when { try await $0.getSwarm(for: .any) } + .thenReturn([ + LibSession.Snode( + ed25519PubkeyHex: TestConstants.edPublicKey, + ip: "1.1.1.1", + httpsPort: 10, + quicPort: 1, + version: "2.11.0", + swarmId: 1 + ), + LibSession.Snode( + ed25519PubkeyHex: TestConstants.edPublicKey, + ip: "1.1.1.1", + httpsPort: 20, + quicPort: 2, + version: "2.11.0", + swarmId: 1 + ), + LibSession.Snode( + ed25519PubkeyHex: TestConstants.edPublicKey, + ip: "1.1.1.1", + httpsPort: 30, + quicPort: 3, + version: "2.11.0", + swarmId: 1 + ) + ]) + } } diff --git a/SessionNetworkingKitTests/_TestUtilities/MockSnodeAPICache.swift b/SessionNetworkingKitTests/_TestUtilities/MockSnodeAPICache.swift deleted file mode 100644 index 90be7933ed..0000000000 --- a/SessionNetworkingKitTests/_TestUtilities/MockSnodeAPICache.swift +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -import Foundation -import Combine -import SessionUtilitiesKit - -@testable import SessionNetworkingKit - -class MockSnodeAPICache: Mock, SnodeAPICacheType { - var hardfork: Int { - get { return mock() } - set { mockNoReturn(args: [newValue]) } - } - - var softfork: Int { - get { return mock() } - set { mockNoReturn(args: [newValue]) } - } - - var clockOffsetMs: Int64 { mock() } - - func currentOffsetTimestampMs() -> T where T: Numeric { - return mock(generics: [T.self]) - } - - func setClockOffsetMs(_ clockOffsetMs: Int64) { - mockNoReturn(args: [clockOffsetMs]) - } -} - -// MARK: - Convenience - -extension Mock where T == SnodeAPICacheType { - func defaultInitialSetup() { - self.when { $0.hardfork }.thenReturn(0) - self.when { $0.hardfork = .any }.thenReturn(()) - self.when { $0.softfork }.thenReturn(0) - self.when { $0.softfork = .any }.thenReturn(()) - self.when { $0.clockOffsetMs }.thenReturn(0) - self.when { $0.setClockOffsetMs(.any) }.thenReturn(()) - self.when { $0.currentOffsetTimestampMs() }.thenReturn(Double(1234567890000)) - self.when { $0.currentOffsetTimestampMs() }.thenReturn(Int(1234567890000)) - self.when { $0.currentOffsetTimestampMs() }.thenReturn(Int64(1234567890000)) - self.when { $0.currentOffsetTimestampMs() }.thenReturn(UInt64(1234567890000)) - } -} diff --git a/SessionNetworkingKitTests/_TestUtilities/Mocked+SNK.swift b/SessionNetworkingKitTests/_TestUtilities/Mocked+SNK.swift new file mode 100644 index 0000000000..30008bb403 --- /dev/null +++ b/SessionNetworkingKitTests/_TestUtilities/Mocked+SNK.swift @@ -0,0 +1,230 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import TestUtilities + +@testable import SessionNetworkingKit + +extension NoResponse: @retroactive Mocked { + public static let any: NoResponse = NoResponse() + public static let mock: NoResponse = NoResponse() +} + +extension Network.BatchSubResponse: @retroactive Mocked where T: Mocked { + public static var any: Network.BatchSubResponse { + Network.BatchSubResponse( + code: .any, + headers: .any, + body: T.any, + failedToParseBody: .any + ) + } + public static var mock: Network.BatchSubResponse { + Network.BatchSubResponse( + code: 200, + headers: [:], + body: T.mock, + failedToParseBody: false + ) + } +} + +extension Network.BatchSubResponse { + static func mockArrayValue(type: M.Type) -> Network.BatchSubResponse> { + return Network.BatchSubResponse( + code: 200, + headers: [:], + body: [M.mock], + failedToParseBody: false + ) + } +} + +extension Network.Destination: @retroactive Mocked { + public static var any: Network.Destination = Network.Destination.server( + server: .any, + headers: .any, + x25519PublicKey: .any + ) + public static var mock: Network.Destination = Network.Destination.server( + server: "testServer", + headers: [:], + x25519PublicKey: "" + ) +} + +extension Network.RequestCategory: @retroactive Mocked { + public static var any: Network.RequestCategory = .invalid + public static var mock: Network.RequestCategory = .standard +} + +extension Network.SOGS.CapabilitiesResponse: @retroactive Mocked { + public static var any: Network.SOGS.CapabilitiesResponse = Network.SOGS.CapabilitiesResponse( + capabilities: .any, + missing: .any + ) + public static var mock: Network.SOGS.CapabilitiesResponse = Network.SOGS.CapabilitiesResponse( + capabilities: [], + missing: nil + ) +} + +extension Network.SOGS.Room: @retroactive Mocked { + public static var any: Network.SOGS.Room = Network.SOGS.Room( + token: .any, + name: .any, + roomDescription: .any, + infoUpdates: .any, + messageSequence: .any, + created: .any, + activeUsers: .any, + activeUsersCutoff: .any, + imageId: .any, + pinnedMessages: .any, + admin: .any, + globalAdmin: .any, + admins: .any, + hiddenAdmins: .any, + moderator: .any, + globalModerator: .any, + moderators: .any, + hiddenModerators: .any, + read: .any, + defaultRead: .any, + defaultAccessible: .any, + write: .any, + defaultWrite: .any, + upload: .any, + defaultUpload: .any + ) + + public static var mock: Network.SOGS.Room = Network.SOGS.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 Network.SOGS.RoomPollInfo: @retroactive Mocked { + public static var any: Network.SOGS.RoomPollInfo = Network.SOGS.RoomPollInfo( + token: .any, + activeUsers: .any, + admin: .any, + globalAdmin: .any, + moderator: .any, + globalModerator: .any, + read: .any, + defaultRead: .any, + defaultAccessible: .any, + write: .any, + defaultWrite: .any, + upload: .any, + defaultUpload: .any, + details: .any + ) + public static var mock: Network.SOGS.RoomPollInfo = Network.SOGS.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: .mock + ) +} + +extension Network.SOGS.Message: @retroactive Mocked { + public static var any: Network.SOGS.Message = Network.SOGS.Message( + id: .any, + sender: .any, + posted: .any, + edited: .any, + deleted: .any, + seqNo: .any, + whisper: .any, + whisperMods: .any, + whisperTo: .any, + base64EncodedData: .any, + base64EncodedSignature: .any, + reactions: .any + ) + public static var mock: Network.SOGS.Message = Network.SOGS.Message( + id: 100, + sender: TestConstants.blind15PublicKey, + posted: 1, + edited: nil, + deleted: nil, + seqNo: 1, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: nil, + base64EncodedSignature: nil, + reactions: nil + ) +} + +extension Network.SOGS.SendDirectMessageResponse: @retroactive Mocked { + public static var any: Network.SOGS.SendDirectMessageResponse = Network.SOGS.SendDirectMessageResponse( + id: .any, + sender: .any, + recipient: .any, + posted: .any, + expires: .any + ) + public static var mock: Network.SOGS.SendDirectMessageResponse = Network.SOGS.SendDirectMessageResponse( + id: 1, + sender: TestConstants.blind15PublicKey, + recipient: "testRecipient", + posted: 1122, + expires: 2233 + ) +} + +extension Network.SOGS.DirectMessage: @retroactive Mocked { + public static var any: Network.SOGS.DirectMessage = Network.SOGS.DirectMessage( + id: .any, + sender: .any, + recipient: .any, + posted: .any, + expires: .any, + base64EncodedMessage: .any + ) + public static var mock: Network.SOGS.DirectMessage = Network.SOGS.DirectMessage( + id: 101, + sender: TestConstants.blind15PublicKey, + recipient: "testRecipient", + posted: 1212, + expires: 2323, + base64EncodedMessage: "TestMessage".data(using: .utf8)!.base64EncodedString() + ) +} diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index cf343cc500..3e0980d716 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -61,7 +61,7 @@ public class NSENotificationPresenter: NotificationsManagerType { mutedUntil: TimeInterval? ) {} - public func notificationUserInfo(threadId: String, threadVariant: SessionThread.Variant) -> [String: Any] { + public func notificationUserInfo(threadId: String, threadVariant: SessionThread.Variant) -> [String: AnyHashable] { return [ NotificationUserInfoKey.isFromRemote: true, NotificationUserInfoKey.threadId: threadId, diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 54fc5cd9a9..55551f2b29 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -100,7 +100,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension } /// Setup Version Info and Network - dependencies.warmCache(cache: .appVersion) + dependencies.warm(cache: .appVersion) /// Configure the different targets SNUtilitiesKit.configure( @@ -205,7 +205,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension serverHash: info.metadata.hash, serverTimestampMs: info.metadata.createdTimestampMs, serverExpirationTimestamp: ( - (TimeInterval(dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + SnodeReceivedMessage.defaultExpirationMs) / 1000) + (TimeInterval(dependencies.networkOffsetTimestampMs() + Network.StorageServer.Message.defaultExpirationMs) / 1000) ) ), using: dependencies @@ -279,7 +279,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension private func handleConfigMessage( _ notification: ProcessedNotification, swarmPublicKey: String, - namespace: Network.SnodeAPI.Namespace, + namespace: Network.StorageServer.Namespace, serverHash: String, serverTimestampMs: Int64, data: Data @@ -312,11 +312,11 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension /// for a poll to return do { try dependencies[singleton: .extensionHelper].saveMessage( - SnodeReceivedMessage( + Network.StorageServer.Message( snode: nil, publicKey: notification.info.metadata.accountId, namespace: notification.info.metadata.namespace, - rawMessage: GetMessagesResponse.RawMessage( + rawMessage: Network.StorageServer.GetMessagesResponse.RawMessage( base64EncodedDataString: notification.info.data.base64EncodedString(), expirationMs: notification.info.metadata.expirationTimestampMs, hash: notification.info.metadata.hash, @@ -555,7 +555,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension let userEdKeyPair: KeyPair = dependencies[singleton: .crypto].generate( .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) ) - else { throw SnodeAPIError.noKeyPair } + else { throw CryptoError.keyGenerationFailed } Log.info(.calls, "Sending end call message because there is an ongoing call.") /// Update the `CallMessage.state` value so the correct notification logic can occur @@ -842,11 +842,11 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension } try dependencies[singleton: .extensionHelper].saveMessage( - SnodeReceivedMessage( + Network.StorageServer.Message( snode: nil, publicKey: notification.info.metadata.accountId, namespace: notification.info.metadata.namespace, - rawMessage: GetMessagesResponse.RawMessage( + rawMessage: Network.StorageServer.GetMessagesResponse.RawMessage( base64EncodedDataString: notification.info.data.base64EncodedString(), expirationMs: notification.info.metadata.expirationTimestampMs, hash: notification.info.metadata.hash, diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index b1f420316f..a63ccbffb6 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -41,11 +41,9 @@ final class ShareNavController: UINavigationController { } guard !SNUtilitiesKit.isRunningTests else { return } - - dependencies.warmCache(cache: .appVersion) - AppSetup.setupEnvironment( - appSpecificBlock: { [dependencies] in + Task(priority: .userInitiated) { [weak self, dependencies] in + do { // stringlint:ignore_start if !Log.loggerExists(withPrefix: "SessionShareExtension") { Log.setup(with: Logger( @@ -58,45 +56,37 @@ final class ShareNavController: UINavigationController { } // stringlint:ignore_stop - // Setup LibSession - dependencies.warmCache(cache: .libSessionNetwork) + var backgroundTask: SessionBackgroundTask? = SessionBackgroundTask(label: #function, using: dependencies) + try await AppSetup.performSetup(using: dependencies) + try await AppSetup.performDatabaseMigrations(using: dependencies) + try await AppSetup.postMigrationSetup(using: dependencies) - // Configure the different targets - SNUtilitiesKit.configure( - networkMaxFileSize: Network.maxFileSize, - maxValidImageDimention: ImageDataManager.DataSource.maxValidDimension, - using: dependencies - ) - SNMessagingKit.configure(using: dependencies) - }, - migrationsCompletion: { [weak self, dependencies] result in - switch result { - case .failure: Log.error("Failed to complete migrations") - case .success: - DispatchQueue.main.async { - /// Because the `SessionUIKit` target doesn't depend on the `SessionUtilitiesKit` dependency (it shouldn't - /// need to since it should just be UI) but since the theme settings are stored in the database we need to pass these through - /// to `SessionUIKit` and expose a mechanism to save updated settings - this is done here (once the migrations complete) - SNUIKit.configure( - with: SAESNUIKitConfig(using: dependencies), - themeSettings: dependencies.mutate(cache: .libSession) { cache -> ThemeSettings in - ( - cache.get(.theme), - cache.get(.themePrimaryColor), - cache.get(.themeMatchSystemDayNightCycle) - ) - } + /// The 'if' is only there to prevent the "variable never read" warning from showing + if backgroundTask != nil { backgroundTask = nil } + + let maybeUserMetadata: ExtensionHelper.UserMetadata? = dependencies[singleton: .extensionHelper] + .loadUserMetadata() + + await MainActor.run { [weak self] in + /// `SessionUIKit` is isolated from the other targets (since it should only contain UI code) but the theme settings are + /// stored in `libSession` so we need to pass these through and expose a mechanism to save updated settings - this + /// is done here + SNUIKit.configure( + with: SAESNUIKitConfig(using: dependencies), + themeSettings: dependencies.mutate(cache: .libSession) { cache -> ThemeSettings in + ( + cache.get(.theme), + cache.get(.themePrimaryColor), + cache.get(.themeMatchSystemDayNightCycle) ) - - let maybeUserMetadata: ExtensionHelper.UserMetadata? = dependencies[singleton: .extensionHelper] - .loadUserMetadata() - - self?.versionMigrationsDidComplete(userMetadata: maybeUserMetadata) } + ) + + self?.versionMigrationsDidComplete(userMetadata: maybeUserMetadata) } - }, - using: dependencies - ) + } + catch { Log.error("Failed to complete migrations") } + } // We don't need to use "screen protection" in the SAE. NotificationCenter.default.addObserver( @@ -551,8 +541,11 @@ final class ShareNavController: UINavigationController { if let data = image.pngData() { let tempFilePath: String = dependencies[singleton: .fileManager].temporaryFilePath(fileExtension: "png") // stringlint:ignore do { - let url = NSURL.fileURL(withPath: tempFilePath) - try data.write(to: url) + let url: URL = URL(fileURLWithPath: tempFilePath) + try dependencies[singleton: .fileManager].write( + data: data, + to: URL(fileURLWithPath: tempFilePath) + ) resolver( Result.success( @@ -780,7 +773,7 @@ private struct SAESNUIKitConfig: SNUIKit.ConfigType { func navBarSessionIcon() -> NavBarSessionIcon { switch (dependencies[feature: .serviceNetwork], dependencies[feature: .forceOffline]) { case (.mainnet, false): return NavBarSessionIcon() - case (.testnet, _), (.mainnet, true): + case (.testnet, _), (.devnet, _), (.mainnet, true): return NavBarSessionIcon( showDebugUI: true, serviceNetworkTitle: dependencies[feature: .serviceNetwork].title, diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 60295606b9..9d793bc48e 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -124,9 +124,11 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView // When the thread picker disappears it means the user has left the screen (this will be called // whether the user has sent the message or cancelled sending) - viewModel.dependencies.mutate(cache: .libSessionNetwork) { $0.suspendNetworkAccess() } - viewModel.dependencies[singleton: .storage].suspendDatabaseAccess() - Log.flush() + Task { [dependencies = viewModel.dependencies] in + await dependencies[singleton: .network].suspendNetworkAccess() + dependencies[singleton: .storage].suspendDatabaseAccess() + Log.flush() + } } // MARK: Layout @@ -263,187 +265,178 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView shareNavController?.dismiss(animated: true, completion: nil) - ModalActivityIndicatorViewController.present(fromViewController: shareNavController!, canCancel: false, message: "sending".localized()) { [dependencies = viewModel.dependencies] activityIndicator in - dependencies[singleton: .storage].resumeDatabaseAccess() - dependencies.mutate(cache: .libSessionNetwork) { $0.resumeNetworkAccess() } - - /// When we prepare the message we set the timestamp to be the `dependencies[cache: .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 - /// before we create the interaction + ModalActivityIndicatorViewController.present(fromViewController: shareNavController!, canCancel: false, message: "sending".localized()) { [weak self, dependencies = viewModel.dependencies] activityIndicator in var sharedInteractionId: Int64? - dependencies[singleton: .network] - .getSwarm(for: swarmPublicKey) - .tryFlatMapWithRandomSnode(using: dependencies) { snode in - try Network.SnodeAPI - .preparedGetNetworkTime(from: snode, using: dependencies) - .send(using: dependencies) - } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .flatMapStorageWritePublisher(using: dependencies) { db, _ -> (Message, Message.Destination, Int64?, AuthenticationMethod, [Network.PreparedRequest<(attachment: Attachment, fileId: String)>]) in - guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { - throw MessageSenderError.noThread - } + Task { [weak self] in + dependencies[singleton: .storage].resumeDatabaseAccess() + await dependencies[singleton: .network].resumeNetworkAccess() + + do { + typealias MessageData = ( + message: Message, + destination: Message.Destination, + interactionId: Int64?, + authMethod: AuthenticationMethod, + preparedUploads: [Network.PreparedRequest<(attachment: Attachment, fileId: String)>] + ) + + /// Get the latest network time before sending (to reduce the chance that the request will fail due to the device clock + /// being out of sync with the network, or Disappearing Messages will have issues due to the discrepancy) + let swarm: Set = try await dependencies[singleton: .network] + .getSwarm(for: swarmPublicKey) + let snode: LibSession.Snode = try await SwarmDrainer(swarm: swarm, using: dependencies) + .selectNextNode() + try await Network.StorageServer.getNetworkTime(from: snode, using: dependencies) - // Update the thread to be visible (if it isn't already) - if !thread.shouldBeVisible || thread.pinnedPriority == LibSession.hiddenPriority { - try SessionThread.updateVisibility( + let data: MessageData = try await dependencies[singleton: .storage].writeAsync { db in + guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { + throw MessageSenderError.noThread + } + + // Update the thread to be visible (if it isn't already) + if !thread.shouldBeVisible || thread.pinnedPriority == LibSession.hiddenPriority { + try SessionThread.updateVisibility( + db, + threadId: threadId, + isVisible: true, + additionalChanges: [SessionThread.Columns.isDraft.set(to: false)], + using: dependencies + ) + } + + // Create the interaction + let sentTimestampMs: Int64 = dependencies.networkOffsetTimestampMs() + let destinationDisappearingMessagesConfiguration: DisappearingMessagesConfiguration? = try? DisappearingMessagesConfiguration + .filter(id: threadId) + .filter(DisappearingMessagesConfiguration.Columns.isEnabled == true) + .fetchOne(db) + let interaction: Interaction = try Interaction( + threadId: threadId, + threadVariant: threadVariant, + authorId: userSessionId.hexString, + variant: .standardOutgoing, + body: body, + timestampMs: sentTimestampMs, + hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: body, using: dependencies), + expiresInSeconds: destinationDisappearingMessagesConfiguration?.expiresInSeconds(), + expiresStartedAtMs: destinationDisappearingMessagesConfiguration?.initialExpiresStartedAtMs( + sentTimestampMs: Double(sentTimestampMs) + ), + linkPreviewUrl: (isSharingUrl ? attachments.first?.linkPreviewDraft?.urlString : nil), + using: dependencies + ).inserted(db) + sharedInteractionId = interaction.id + + 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, + type: .jpeg, + using: dependencies + )? + .inserted(db) + .id, + using: dependencies + ).insert(db) + } + + // Process any attachments + try AttachmentUploader.process( + db, + attachments: AttachmentUploader.prepare( + attachments: finalAttachments, + using: dependencies + ), + for: interactionId + ) + + // Using the same logic as the `MessageSendJob` retrieve + let authMethod: AuthenticationMethod = try Authentication.with( db, threadId: threadId, - isVisible: true, - additionalChanges: [SessionThread.Columns.isDraft.set(to: false)], + threadVariant: threadVariant, using: dependencies ) - } - - // Create the interaction - let sentTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - let destinationDisappearingMessagesConfiguration: DisappearingMessagesConfiguration? = try? DisappearingMessagesConfiguration - .filter(id: threadId) - .filter(DisappearingMessagesConfiguration.Columns.isEnabled == true) - .fetchOne(db) - let interaction: Interaction = try Interaction( - threadId: threadId, - threadVariant: threadVariant, - authorId: userSessionId.hexString, - variant: .standardOutgoing, - body: body, - timestampMs: sentTimestampMs, - hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: body, using: dependencies), - expiresInSeconds: destinationDisappearingMessagesConfiguration?.expiresInSeconds(), - expiresStartedAtMs: destinationDisappearingMessagesConfiguration?.initialExpiresStartedAtMs( - sentTimestampMs: Double(sentTimestampMs) - ), - linkPreviewUrl: (isSharingUrl ? attachments.first?.linkPreviewDraft?.urlString : nil), - using: dependencies - ).inserted(db) - sharedInteractionId = interaction.id - - 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, - type: .jpeg, + let attachmentState: MessageSendJob.AttachmentState = try MessageSendJob + .fetchAttachmentState(db, interactionId: interactionId) + let preparedUploads: [Network.PreparedRequest<(attachment: Attachment, fileId: String)>] = try Attachment + .filter(ids: attachmentState.allAttachmentIds) + .fetchAll(db) + .map { attachment in + try AttachmentUploader.preparedUpload( + attachment: attachment, + logCategory: nil, + authMethod: authMethod, using: dependencies - )? - .inserted(db) - .id, - using: dependencies - ).insert(db) + ) + } + let visibleMessage: VisibleMessage = VisibleMessage.from(db, interaction: interaction) + let destination: Message.Destination = try Message.Destination.from( + db, + threadId: threadId, + threadVariant: threadVariant + ) + + return (visibleMessage, destination, interaction.id, authMethod, preparedUploads) } - // Process any attachments - try AttachmentUploader.process( - db, - attachments: AttachmentUploader.prepare( - attachments: finalAttachments, - using: dependencies - ), - for: interactionId - ) + /// Perform any uploads + var uploadResults: [(Attachment, String)] = [] + for uploadRequest in data.preparedUploads { + uploadResults.append(try await uploadRequest.send(using: dependencies)) + + } - // Using the same logic as the `MessageSendJob` retrieve - let authMethod: AuthenticationMethod = try Authentication.with( - db, - threadId: threadId, - threadVariant: threadVariant, + /// Send the message + let sentMessage: Message = try await MessageSender.preparedSend( + message: data.message, + to: data.destination, + namespace: data.destination.defaultNamespace, + interactionId: data.interactionId, + attachments: uploadResults, + authMethod: data.authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) - let attachmentState: MessageSendJob.AttachmentState = try MessageSendJob - .fetchAttachmentState(db, interactionId: interactionId) - let preparedUploads: [Network.PreparedRequest<(attachment: Attachment, fileId: String)>] = try Attachment - .filter(ids: attachmentState.allAttachmentIds) - .fetchAll(db) - .map { attachment in - try AttachmentUploader.preparedUpload( - attachment: attachment, - logCategory: nil, - authMethod: authMethod, - using: dependencies - ) - } - let visibleMessage: VisibleMessage = VisibleMessage.from(db, interaction: interaction) - let destination: Message.Destination = try Message.Destination.from( - db, - threadId: threadId, - threadVariant: threadVariant - ) + .send(using: dependencies) - return (visibleMessage, destination, interaction.id, authMethod, preparedUploads) - } - .flatMap { (message: Message, destination: Message.Destination, interactionId: Int64?, authMethod: AuthenticationMethod, preparedUploads: [Network.PreparedRequest<(attachment: Attachment, fileId: String)>]) -> AnyPublisher<(Message, Message.Destination, Int64?, AuthenticationMethod, [(Attachment, String)]), Error> in - guard !preparedUploads.isEmpty else { - return Just((message, destination, interactionId, authMethod, [])) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - return Publishers - .MergeMany(preparedUploads.map { $0.send(using: dependencies) }) - .collect() - .map { results in (message, destination, interactionId, authMethod, results.map { _, value in value }) } - .eraseToAnyPublisher() - } - .tryFlatMap { message, destination, interactionId, authMethod, attachments -> AnyPublisher<(Message, [Attachment]), Error> in - try MessageSender - .preparedSend( - message: message, - to: destination, - namespace: destination.defaultNamespace, - interactionId: interactionId, - attachments: attachments, - authMethod: authMethod, - onEvent: MessageSender.standardEventHandling(using: dependencies), - using: dependencies - ) - .send(using: dependencies) - .map { _, message in - (message, attachments.map { attachment, _ in attachment }) - } - .eraseToAnyPublisher() - } - .handleEvents( - receiveOutput: { _, attachments in - guard !attachments.isEmpty else { return } - - /// Need to actually save the uploaded attachments now that we are done - dependencies[singleton: .storage].write { db in - attachments.forEach { attachment in + /// Need to actually save the uploaded attachments now that we are done + if !uploadResults.isEmpty { + try? await dependencies[singleton: .storage].writeAsync { db in + uploadResults.forEach { attachment, _ in try? attachment.upsert(db) } } } - ) - .receive(on: DispatchQueue.main) - .sinkUntilComplete( - receiveCompletion: { [weak self] result in - dependencies.mutate(cache: .libSessionNetwork) { $0.suspendNetworkAccess() } - dependencies[singleton: .storage].suspendDatabaseAccess() - Log.flush() - activityIndicator.dismiss { } - - switch result { - case .finished: self?.shareNavController?.shareViewWasCompleted( - threadId: threadId, - interactionId: sharedInteractionId - ) - case .failure(let error): self?.shareNavController?.shareViewFailed(error: error) - } - } - ) + + self?.shareNavController?.shareViewWasCompleted( + threadId: threadId, + interactionId: sharedInteractionId + ) + } + catch { + self?.shareNavController?.shareViewFailed(error: error) + } + + /// Shut everything down again + await dependencies[singleton: .network].suspendNetworkAccess() + dependencies[singleton: .storage].suspendDatabaseAccess() + Log.flush() + activityIndicator.dismiss { } + } } } diff --git a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift index beaaa7ed26..e1706debc5 100644 --- a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift @@ -19,28 +19,11 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: AsyncSpec { dependencies.forceSynchronous = true dependencies[singleton: .scheduler] = .immediate } - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + @TestState var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), - migrations: SNMessagingKit.migrations, - using: dependencies, - initialData: { db in - try SessionThread( - id: "TestId", - variant: .contact, - creationDateTimestamp: 0 - ).insert(db) - } - ) - @TestState(singleton: .jobRunner, in: dependencies) var mockJobRunner: MockJobRunner! = MockJobRunner( - initialSetup: { jobRunner in - jobRunner - .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } - .thenReturn(nil) - jobRunner - .when { $0.upsert(.any, job: .any, canStartJob: .any) } - .thenReturn(nil) - } + using: dependencies ) + @TestState var mockJobRunner: MockJobRunner! = .create(using: dependencies) @TestState var viewModel: ThreadDisappearingMessagesSettingsViewModel! = ThreadDisappearingMessagesSettingsViewModel( threadId: "TestId", threadVariant: .contact, @@ -59,6 +42,28 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: AsyncSpec { ) ] + beforeEach { + try await mockStorage.perform( + migrations: SNMessagingKit.migrations + ) + try await mockStorage.writeAsync { db in + try SessionThread( + id: "TestId", + variant: .contact, + creationDateTimestamp: 0 + ).insert(db) + } + dependencies.set(singleton: .storage, to: mockStorage) + + try await mockJobRunner + .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } + .thenReturn(nil) + try await mockJobRunner + .when { $0.upsert(.any, job: .any, canStartJob: .any) } + .thenReturn(nil) + dependencies.set(singleton: .jobRunner, to: mockJobRunner) + } + // MARK: - a ThreadDisappearingMessagesSettingsViewModel describe("a ThreadDisappearingMessagesSettingsViewModel") { // MARK: -- has the correct title diff --git a/SessionTests/Conversations/Settings/ThreadNotificationSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadNotificationSettingsViewModelSpec.swift index 8d2532b0b0..19985d3e6e 100644 --- a/SessionTests/Conversations/Settings/ThreadNotificationSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadNotificationSettingsViewModelSpec.swift @@ -8,6 +8,7 @@ import SessionUIKit import SessionNetworkingKit import SessionMessagingKit import SessionUtilitiesKit +import TestUtilities @testable import Session @@ -19,50 +20,56 @@ class ThreadNotificationSettingsViewModelSpec: AsyncSpec { dependencies.forceSynchronous = true dependencies[singleton: .scheduler] = .immediate } - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + @TestState var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), - migrations: SNMessagingKit.migrations, - using: dependencies, - initialData: { db in + using: dependencies + ) + @TestState var mockJobRunner: MockJobRunner! = .create(using: dependencies) + @TestState var mockNotificationsManager: MockNotificationsManager! = .create(using: dependencies) + @TestState var viewModel: ThreadNotificationSettingsViewModel! + @TestState var cancellables: [AnyCancellable]! + + beforeEach { + try await mockStorage.perform( + migrations: SNMessagingKit.migrations + ) + try await mockStorage.writeAsync { db in try SessionThread( id: "TestId", variant: .contact, creationDateTimestamp: 0 ).insert(db) } - ) - @TestState(singleton: .jobRunner, in: dependencies) var mockJobRunner: MockJobRunner! = MockJobRunner( - initialSetup: { jobRunner in - jobRunner - .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } - .thenReturn(nil) - jobRunner - .when { $0.upsert(.any, job: .any, canStartJob: .any) } - .thenReturn(nil) - } - ) - @TestState(singleton: .notificationsManager, in: dependencies) var mockNotificationsManager: MockNotificationsManager! = MockNotificationsManager( - initialSetup: { $0.defaultInitialSetup() } - ) - @TestState var viewModel: ThreadNotificationSettingsViewModel! = TestState.create { - await ThreadNotificationSettingsViewModel( + dependencies.set(singleton: .storage, to: mockStorage) + + try await mockJobRunner + .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } + .thenReturn(nil) + try await mockJobRunner + .when { $0.upsert(.any, job: .any, canStartJob: .any) } + .thenReturn(nil) + dependencies.set(singleton: .jobRunner, to: mockJobRunner) + + try await mockNotificationsManager.defaultInitialSetup() + dependencies.set(singleton: .notificationsManager, to: mockNotificationsManager) + + viewModel = await ThreadNotificationSettingsViewModel( threadId: "TestId", threadVariant: .contact, threadOnlyNotifyForMentions: nil, threadMutedUntilTimestamp: nil, using: dependencies ) + cancellables = [ + viewModel.tableDataPublisher + .receive(on: ImmediateScheduler.shared) + .sink( + receiveCompletion: { _ in }, + receiveValue: { viewModel.updateTableData($0) } + ) + ] } - @TestState var cancellables: [AnyCancellable]! = [ - viewModel.tableDataPublisher - .receive(on: ImmediateScheduler.shared) - .sink( - receiveCompletion: { _ in }, - receiveValue: { viewModel.updateTableData($0) } - ) - ] - // MARK: - a ThreadNotificationSettingsViewModel describe("a ThreadNotificationSettingsViewModel") { beforeEach { @@ -420,15 +427,16 @@ class ThreadNotificationSettingsViewModelSpec: AsyncSpec { it("saves the updated settings") { await MainActor.run { [footerButtonInfo] in footerButtonInfo?.onTap() } - await expect(mockNotificationsManager) - .toEventually(call(.exactly(times: 1), matchingParameters: .all) { + await mockNotificationsManager + .verify { $0.updateSettings( threadId: "TestId", threadVariant: .contact, mentionsOnly: false, mutedUntil: Date.distantFuture.timeIntervalSince1970 ) - }) + } + .wasCalled(exactly: 1, timeout: .milliseconds(100)) } } } diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index 77514dedf8..512cb0813e 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -7,6 +7,7 @@ import Nimble import SessionUIKit import SessionNetworkingKit import SessionUtilitiesKit +import TestUtilities @testable import SessionUIKit @testable import SessionMessagingKit @@ -25,65 +26,18 @@ class ThreadSettingsViewModelSpec: AsyncSpec { @TestState var communityId: String! = "testserver.testRoom" @TestState var dependencies: TestDependencies! = TestDependencies { dependencies in dependencies[singleton: .scheduler] = .immediate + dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) dependencies.forceSynchronous = true } - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + @TestState var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), - migrations: SNMessagingKit.migrations, - using: dependencies, - initialData: { db in - try Identity( - variant: .x25519PublicKey, - data: Data(hex: TestConstants.publicKey) - ).insert(db) - try Profile(id: userPubkey, name: "TestMe").insert(db) - try Profile(id: user2Pubkey, name: "TestUser").insert(db) - } - ) - @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( - initialSetup: { cache in - cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) - } - ) - @TestState(singleton: .jobRunner, in: dependencies) var mockJobRunner: MockJobRunner! = MockJobRunner( - initialSetup: { jobRunner in - jobRunner - .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } - .thenReturn(nil) - jobRunner - .when { $0.upsert(.any, job: .any, canStartJob: .any) } - .thenReturn(nil) - jobRunner - .when { $0.jobInfoFor(jobs: .any, state: .any, variant: .any) } - .thenReturn([:]) - } - ) - @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( - initialSetup: { $0.defaultInitialSetup() } - ) - @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( - initialSetup: { crypto in - crypto - .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } - .thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes)) - } - ) - @TestState(cache: .snodeAPI, in: dependencies) var mockSnodeAPICache: MockSnodeAPICache! = MockSnodeAPICache( - initialSetup: { cache in - var timestampMs: Int64 = 1234567890000 - - cache.when { $0.clockOffsetMs }.thenReturn(0) - cache - .when { $0.currentOffsetTimestampMs() } - .thenReturn { _, _ in - /// **Note:** We need to increment this value every time it's accessed because otherwise any functions which - /// insert multiple `Interaction` values can end up running into unique constraint conflicts due to the timestamp - /// being identical between different interactions - timestampMs += 1 - return timestampMs - } - } + using: dependencies ) + @TestState var mockGeneralCache: MockGeneralCache! = .create(using: dependencies) + @TestState var mockJobRunner: MockJobRunner! = .create(using: dependencies) + @TestState var mockLibSessionCache: MockLibSessionCache! = .create(using: dependencies) + @TestState var mockCrypto: MockCrypto! = .create(using: dependencies) + @TestState var mockNetwork: MockNetwork! = .create(using: dependencies) @TestState var threadVariant: SessionThread.Variant! = .contact @TestState var didTriggerSearchCallbackTriggered: Bool! = false @TestState var viewModel: ThreadSettingsViewModel! @@ -117,7 +71,65 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ) .store(in: &disposables) } - + + beforeEach { + try await mockGeneralCache.defaultInitialSetup() + dependencies.set(cache: .general, to: mockGeneralCache) + + try await mockLibSessionCache.defaultInitialSetup() + dependencies.set(cache: .libSession, to: mockLibSessionCache) + + try await mockStorage.perform(migrations: SNMessagingKit.migrations) + try await mockStorage.writeAsync { db in + try Identity( + variant: .x25519PublicKey, + data: Data(hex: TestConstants.publicKey) + ).insert(db) + try Profile(id: userPubkey, name: "TestMe").insert(db) + try Profile(id: user2Pubkey, name: "TestUser").insert(db) + } + dependencies.set(singleton: .storage, to: mockStorage) + + try await mockJobRunner + .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } + .thenReturn(nil) + try await mockJobRunner + .when { $0.upsert(.any, job: .any, canStartJob: .any) } + .thenReturn(nil) + try await mockJobRunner + .when { $0.jobInfoFor(jobs: .any, state: .any, variant: .any) } + .thenReturn([:]) + dependencies.set(singleton: .jobRunner, to: mockJobRunner) + + try await mockCrypto + .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } + .thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes)) + dependencies.set(singleton: .crypto, to: mockCrypto) + + var networkOffset: Int64 = 0 + try await mockNetwork.when { await $0.networkTimeOffsetMs }.thenReturn { _ in + /// **Note:** We need to increment this value every time it's accessed because otherwise any functions which + /// insert multiple `Interaction` values can end up running into unique constraint conflicts due to the timestamp + /// being identical between different interactions + networkOffset += 1 + return networkOffset + } + try await mockNetwork.when { $0.syncState }.thenReturn { _ in + /// **Note:** We need to increment this value every time it's accessed because otherwise any functions which + /// insert multiple `Interaction` values can end up running into unique constraint conflicts due to the timestamp + /// being identical between different interactions + networkOffset += 1 + + return NetworkSyncState( + hardfork: 2, + softfork: 11, + networkTimeOffsetMs: networkOffset, + using: dependencies + ) + } + dependencies.set(singleton: .network, to: mockNetwork) + } + // MARK: - a ThreadSettingsViewModel describe("a ThreadSettingsViewModel") { beforeEach { @@ -741,8 +753,8 @@ class ThreadSettingsViewModelSpec: AsyncSpec { onChange2?("TestNewGroupName", "") await modal?.confirmationPressed() - await expect(mockJobRunner) - .toEventually(call(matchingParameters: .all) { + await mockJobRunner + .verify { $0.add( .any, job: Job( @@ -755,7 +767,7 @@ class ThreadSettingsViewModelSpec: AsyncSpec { message: try GroupUpdateInfoChangeMessage( changeType: .name, updatedName: "TestNewGroupName", - sentTimestampMs: UInt64(1234567890002), + sentTimestampMs: UInt64(1234567890001), authMethod: Authentication.groupAdmin( groupSessionId: SessionId(.group, hex: groupPubkey), ed25519SecretKey: [1, 2, 3] @@ -768,36 +780,39 @@ class ThreadSettingsViewModelSpec: AsyncSpec { dependantJob: nil, canStartJob: false ) - }) + } + .wasCalled(exactly: 1, timeout: .milliseconds(100)) } // MARK: -------- triggers a libSession change it("triggers a libSession change") { - mockLibSessionCache + try await mockLibSessionCache .when { $0.isAdmin(groupSessionId: .any) } .thenReturn(true) onChange2?("Test", "TestNewGroupDescription") await modal?.confirmationPressed() - await expect(mockLibSessionCache) - .toEventually(call(matchingParameters: .all) { + await mockLibSessionCache + .verify { try $0.performAndPushChange( .any, for: .userGroups, sessionId: SessionId(.standard, hex: userPubkey), change: { _ in } ) - }) - expect(mockLibSessionCache) - .to(call(matchingParameters: .all) { + } + .wasCalled(exactly: 1, timeout: .milliseconds(100)) + await mockLibSessionCache + .verify { try $0.performAndPushChange( .any, for: .groupInfo, sessionId: SessionId(.group, hex: groupPubkey), change: { _ in } ) - }) + } + .wasCalled(exactly: 1) } } } diff --git a/SessionTests/Database/DatabaseSpec.swift b/SessionTests/Database/DatabaseSpec.swift index c9b824b196..8008b8194c 100644 --- a/SessionTests/Database/DatabaseSpec.swift +++ b/SessionTests/Database/DatabaseSpec.swift @@ -7,12 +7,13 @@ import Nimble import SessionUtil import SessionUIKit import SessionNetworkingKit +import TestUtilities @testable import Session @testable import SessionMessagingKit @testable import SessionUtilitiesKit -class DatabaseSpec: QuickSpec { +class DatabaseSpec: AsyncSpec { fileprivate static let ignoredTables: Set = [ "sqlite_sequence", "grdb_migrations", "*_fts*" ] @@ -22,21 +23,16 @@ class DatabaseSpec: QuickSpec { // MARK: Configuration @TestState var dependencies: TestDependencies! = TestDependencies() - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + @TestState var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), using: dependencies ) - @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( - initialSetup: { cache in - cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) - } - ) - @TestState(cache: .libSession, in: dependencies) var libSessionCache: LibSession.Cache! = LibSession.Cache( + @TestState var mockFileManager: MockFileManager! = .create(using: dependencies) + @TestState var mockGeneralCache: MockGeneralCache! = .create(using: dependencies) + @TestState var libSessionCache: LibSession.Cache! = LibSession.Cache( userSessionId: SessionId(.standard, hex: TestConstants.publicKey), using: dependencies ) - @TestState var initialResult: Result! = nil - @TestState var finalResult: Result! = nil let allMigrations: [Migration.Type] = SNMessagingKit.migrations let dynamicTests: [MigrationTest] = MigrationTest.extractTests(allMigrations) @@ -63,29 +59,32 @@ class DatabaseSpec: QuickSpec { snapshotCache.removeAll() } + beforeEach { + dependencies.set(singleton: .storage, to: mockStorage) + + try await mockGeneralCache.defaultInitialSetup() + dependencies.set(cache: .general, to: mockGeneralCache) + + try await mockFileManager.defaultInitialSetup() + dependencies.set(singleton: .fileManager, to: mockFileManager) + + dependencies.set(cache: .libSession, to: libSessionCache) + } + // MARK: - a Database describe("a Database") { // MARK: -- can be created from an empty state it("can be created from an empty state") { - mockStorage.perform( - migrations: allMigrations, - async: false, - onProgressUpdate: nil, - onComplete: { result in initialResult = result } - ) - - expect(initialResult).to(beSuccess()) + expect { + try await mockStorage.perform(migrations: allMigrations) + }.toNot(throwError()) } // MARK: -- can still parse the database table types it("can still parse the database table types") { - mockStorage.perform( - migrations: allMigrations, - async: false, - onProgressUpdate: nil, - onComplete: { result in initialResult = result } - ) - expect(initialResult).to(beSuccess()) + await expect { + try await mockStorage.perform(migrations: allMigrations) + }.toEventuallyNot(throwError()) // Generate dummy data (fetching below won't do anything) expect(try MigrationTest.generateDummyData(mockStorage, nullsWherePossible: false)) @@ -102,13 +101,9 @@ class DatabaseSpec: QuickSpec { // MARK: -- can still parse the database types setting null where possible it("can still parse the database types setting null where possible") { - mockStorage.perform( - migrations: allMigrations, - async: false, - onProgressUpdate: nil, - onComplete: { result in initialResult = result } - ) - expect(initialResult).to(beSuccess()) + await expect { + try await mockStorage.perform(migrations: allMigrations) + }.toEventuallyNot(throwError()) // Generate dummy data (fetching below won't do anything) expect(try MigrationTest.generateDummyData(mockStorage, nullsWherePossible: true)) @@ -126,7 +121,7 @@ class DatabaseSpec: QuickSpec { // MARK: -- can migrate from X to Y dynamicTests.forEach { test in it("can migrate from \(test.initialMigrationIdentifier) to \(test.finalMigrationIdentifier)") { - let initialStateResult: Result = { + let initialStateResult: Result = await { if let cachedResult: Result = snapshotCache[test.initialMigrationIdentifier] { return cachedResult } @@ -137,16 +132,7 @@ class DatabaseSpec: QuickSpec { customWriter: dbQueue, using: dependencies ) - - // Generate dummy data (otherwise structural issues or invalid foreign keys won't error) - var initialResult: Result! - storage.perform( - migrations: test.initialMigrations, - async: false, - onProgressUpdate: nil, - onComplete: { result in initialResult = result } - ) - try initialResult.get() + try await storage.perform(migrations: test.initialMigrations) // Generate dummy data (otherwise structural issues or invalid foreign keys won't error) try MigrationTest.generateDummyData(storage, nullsWherePossible: false) @@ -173,19 +159,14 @@ class DatabaseSpec: QuickSpec { mockStorage = SynchronousStorage(customWriter: testDb, using: dependencies) // Peform the target migrations to ensure the migrations themselves worked correctly - mockStorage.perform( - migrations: test.migrationsToTest, - async: false, - onProgressUpdate: nil, - onComplete: { result in finalResult = result } - ) - - switch finalResult { - case .success: break - case .failure(let error): - fail("Failed to migrate from '\(test.initialMigrationIdentifier)' to '\(test.finalMigrationIdentifier)'. Error: \(error)") - case .none: - fail("Failed to migrate from '\(test.initialMigrationIdentifier)' to '\(test.finalMigrationIdentifier)'. Error: No result") + do { + try await mockStorage.perform( + migrations: test.migrationsToTest, + onProgressUpdate: nil + ) + } + catch { + fail("Failed to migrate from '\(test.initialMigrationIdentifier)' to '\(test.finalMigrationIdentifier)'. Error: \(error)") } } } diff --git a/SessionTests/Onboarding/OnboardingSpec.swift b/SessionTests/Onboarding/OnboardingSpec.swift index f060ee7e98..fb6c3c07ea 100644 --- a/SessionTests/Onboarding/OnboardingSpec.swift +++ b/SessionTests/Onboarding/OnboardingSpec.swift @@ -7,6 +7,7 @@ import Nimble import SessionUtil import SessionUIKit import SessionUtilitiesKit +import TestUtilities @testable import Session @testable import SessionNetworkingKit @@ -22,85 +23,70 @@ class OnboardingSpec: AsyncSpec { dependencies.uuid = .mock dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) } - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + @TestState var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), - migrations: SNMessagingKit.migrations, using: dependencies ) - @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( - initialSetup: { crypto in - crypto - .when { $0.generate(.x25519(ed25519Pubkey: .any)) } - .thenReturn(Array(Data(hex: TestConstants.publicKey))) - crypto - .when { $0.generate(.x25519(ed25519Seckey: .any)) } - .thenReturn(Array(Data(hex: TestConstants.privateKey))) - crypto - .when { $0.generate(.randomBytes(.any)) } - .thenReturn(Data([1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8])) - crypto - .when { $0.generate(.ed25519Seed(ed25519SecretKey: .any)) } - .thenReturn(Data([ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, - 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, - 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, - 1, 2 - ])) - crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } - .thenReturn( - KeyPair( - publicKey: Array(Data(hex: TestConstants.edPublicKey)), - secretKey: Array(Data(hex: TestConstants.edSecretKey)) - ) + @TestState var mockCrypto: MockCrypto! = .create(using: dependencies) + @TestState var mockGeneralCache: MockGeneralCache! = .create(using: dependencies) + @TestState var mockLibSessionCache: MockLibSessionCache! = .create(using: dependencies) + @TestState var mockUserDefaults: MockUserDefaults! = .create(using: dependencies) + @TestState var mockNetwork: MockNetwork! = .create(using: dependencies) + @TestState var mockExtensionHelper: MockExtensionHelper! = .create(using: dependencies) + @TestState var disposables: [AnyCancellable]! = [] + @TestState var manager: Onboarding.Manager! + + beforeEach { + try await mockGeneralCache.defaultInitialSetup() + dependencies.set(cache: .general, to: mockGeneralCache) + + try await mockLibSessionCache.defaultInitialSetup() + try await mockLibSessionCache + .when { + $0.profile( + contactId: .any, + threadId: .any, + threadVariant: .any, + visibleMessage: .any ) - crypto - .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } - .thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes)) - } - ) - @TestState(cache: .libSession, in: dependencies) var mockLibSession: MockLibSessionCache! = MockLibSessionCache( - initialSetup: { cache in - cache.defaultInitialSetup() - cache - .when { - $0.profile( - contactId: .any, - threadId: .any, - threadVariant: .any, - visibleMessage: .any - ) - } - .thenReturn(nil) - } - ) - @TestState(defaults: .standard, in: dependencies) var mockUserDefaults: MockUserDefaults! = MockUserDefaults( - initialSetup: { defaults in - defaults.when { $0.bool(forKey: UserDefaults.BoolKey.isMainAppActive.rawValue) }.thenReturn(true) - defaults.when { $0.bool(forKey: UserDefaults.BoolKey.isUsingFullAPNs.rawValue) }.thenReturn(false) - defaults.when { $0.integer(forKey: .any) }.thenReturn(2) - defaults.when { $0.set(true, forKey: .any) }.thenReturn(()) - defaults.when { $0.set(false, forKey: .any) }.thenReturn(()) - } - ) - @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( - initialSetup: { cache in - cache.when { $0.userExists }.thenReturn(true) - cache.when { $0.setSecretKey(ed25519SecretKey: .any) }.thenReturn(()) - cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) - cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) - } - ) - @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( - initialSetup: { network in - network.when { $0.getSwarm(for: .any) }.thenReturn([ - LibSession.Snode( - ip: "1.2.3.4", - quicPort: 1234, - ed25519PubkeyHex: "1234" + } + .thenReturn(nil) + dependencies.set(cache: .libSession, to: mockLibSessionCache) + + try await mockStorage.perform(migrations: SNMessagingKit.migrations) + dependencies.set(singleton: .storage, to: mockStorage) + + try await mockCrypto + .when { $0.generate(.x25519(ed25519Pubkey: .any)) } + .thenReturn(Array(Data(hex: TestConstants.publicKey))) + try await mockCrypto + .when { $0.generate(.x25519(ed25519Seckey: .any)) } + .thenReturn(Array(Data(hex: TestConstants.privateKey))) + try await mockCrypto + .when { $0.generate(.randomBytes(.any)) } + .thenReturn(Data([1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8])) + try await mockCrypto + .when { $0.generate(.ed25519Seed(ed25519SecretKey: .any)) } + .thenReturn(Data([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, + 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, + 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, + 1, 2 + ])) + try await mockCrypto + .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .thenReturn( + KeyPair( + publicKey: Array(Data(hex: TestConstants.edPublicKey)), + secretKey: Array(Data(hex: TestConstants.edSecretKey)) ) - ]) - + ) + try await mockCrypto + .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } + .thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes)) + dependencies.set(singleton: .crypto, to: mockCrypto) + + let pendingPushes: LibSession.PendingPushes? = { let cache: LibSession.Cache = LibSession.Cache( userSessionId: SessionId(.standard, hex: TestConstants.publicKey), using: dependencies @@ -112,120 +98,151 @@ class OnboardingSpec: AsyncSpec { groupEd25519SecretKey: nil ) try? cache.updateProfile(displayName: "TestPolledName") - let pendingPushes: LibSession.PendingPushes? = try? cache.pendingPushes( - swarmPublicKey: cache.userSessionId.hexString - ) - network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn(MockNetwork.batchResponseData( - with: [ - ( - Network.SnodeAPI.Endpoint.getMessages, - GetMessagesResponse( - messages: (pendingPushes? - .pushData - .first { $0.variant == .userProfile }? - .data - .enumerated() - .map { index, data in - GetMessagesResponse.RawMessage( - base64EncodedDataString: data.base64EncodedString(), - expirationMs: nil, - hash: "\(index)", - timestampMs: 1234567890 - ) - } ?? []), - more: false, - hardForkVersion: [2, 2], - timeOffset: 0 - - ).batchSubResponse() - ) - ] - )) - } - ) - @TestState(singleton: .extensionHelper, in: dependencies) var mockExtensionHelper: MockExtensionHelper! = MockExtensionHelper( - initialSetup: { helper in - helper - .when { $0.replicate(dump: .any, replaceExisting: .any) } - .thenReturn(()) - helper - .when { - try $0.saveUserMetadata( - sessionId: .any, - ed25519SecretKey: .any, - unreadCount: .any + return try? cache.pendingPushes(swarmPublicKey: cache.userSessionId.hexString) + }() + + try await mockNetwork.defaultInitialSetup(using: dependencies) + await mockNetwork.removeRequestMocks() + await mockNetwork.removeMocksFor { try await $0.getSwarm(for: .any) } + try await mockNetwork.when { try await $0.getSwarm(for: .any) }.thenReturn([ + LibSession.Snode( + ed25519PubkeyHex: "1234", + ip: "1.2.3.4", + httpsPort: 1233, + quicPort: 1234, + version: "2.11.0", + swarmId: 1 + ) + ]) + try await mockNetwork + .when { + try await $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + category: .any, + requestTimeout: .any, + overallTimeout: .any + ) + } + .thenReturn(MockNetwork.batchResponseData( + with: [ + ( + Network.StorageServer.Endpoint.getMessages, + Network.StorageServer.GetMessagesResponse( + messages: (pendingPushes? + .pushData + .first { $0.variant == .userProfile }? + .data + .enumerated() + .map { index, data in + Network.StorageServer.GetMessagesResponse.RawMessage( + base64EncodedDataString: data.base64EncodedString(), + expirationMs: nil, + hash: "\(index)", + timestampMs: 1234567890 + ) + } ?? []), + more: false, + hardForkVersion: [2, 2], + timeOffset: 0 + + ).batchSubResponse() ) - } - .thenReturn(()) - } - ) - @TestState(cache: .snodeAPI, in: dependencies) var mockSnodeAPICache: MockSnodeAPICache! = MockSnodeAPICache( - initialSetup: { $0.defaultInitialSetup() } - ) - @TestState var disposables: [AnyCancellable]! = [] - @TestState var cache: Onboarding.Cache! + ] + )) + dependencies.set(singleton: .network, to: mockNetwork) + + try await mockUserDefaults.defaultInitialSetup() + try await mockUserDefaults + .when { $0.bool(forKey: UserDefaults.BoolKey.isMainAppActive.rawValue) } + .thenReturn(true) + try await mockUserDefaults + .when { $0.bool(forKey: UserDefaults.BoolKey.isUsingFullAPNs.rawValue) } + .thenReturn(false) + try await mockUserDefaults.when { $0.integer(forKey: .any) }.thenReturn(2) + dependencies.set(defaults: .standard, to: mockUserDefaults) + + try await mockExtensionHelper + .when { $0.replicate(dump: .any, replaceExisting: .any) } + .thenReturn(()) + try await mockExtensionHelper + .when { + try $0.saveUserMetadata( + sessionId: .any, + ed25519SecretKey: .any, + unreadCount: .any + ) + } + .thenReturn(()) + dependencies.set(singleton: .extensionHelper, to: mockExtensionHelper) + } // MARK: - an Onboarding Cache - Initialization describe("an Onboarding Cache when initialising") { beforeEach { - mockLibSession + try await mockLibSessionCache .when { $0.profile(contactId: .any, threadId: .any, threadVariant: .any, visibleMessage: .any) } .thenReturn(nil) } justBeforeEach { - cache = Onboarding.Cache( + manager = Onboarding.Manager( flow: .restore, using: dependencies ) + try await manager.loadInitialState() } // MARK: -- stores the initialFlow it("stores the initialFlow") { - Onboarding.Flow.allCases.forEach { flow in - cache = Onboarding.Cache( + for flow in Onboarding.Flow.allCases { + manager = Onboarding.Manager( flow: flow, using: dependencies ) - expect(cache.initialFlow).to(equal(flow)) + await expect { await manager.initialFlow }.to(equal(flow)) } } // MARK: -- without a stored secret key context("without a stored secret key") { beforeEach { - mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) - mockCrypto + try await mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) + try await mockCrypto .when { $0.generate(.ed25519KeyPair(seed: .any)) } .thenReturn(KeyPair(publicKey: [1, 2, 3], secretKey: [4, 5, 6])) - mockCrypto + try await mockCrypto .when { $0.generate(.x25519(ed25519Pubkey: .any)) } .thenReturn([3, 2, 1]) - mockCrypto + try await mockCrypto .when { $0.generate(.x25519(ed25519Seckey: .any)) } .thenReturn([6, 5, 4]) } // MARK: ---- generates new key pairs it("generates new key pairs") { - expect(cache.ed25519KeyPair.publicKey.toHexString()).to(equal("010203")) - expect(cache.ed25519KeyPair.secretKey.toHexString()).to(equal("040506")) - expect(cache.x25519KeyPair.publicKey.toHexString()).to(equal("030201")) - expect(cache.x25519KeyPair.secretKey.toHexString()).to(equal("060504")) - expect(cache.userSessionId).to(equal(SessionId(.standard, hex: "030201"))) + await expect { await manager.ed25519KeyPair.publicKey.toHexString() } + .toEventually(equal("010203")) + await expect { await manager.ed25519KeyPair.secretKey.toHexString() } + .toEventually(equal("040506")) + await expect { await manager.x25519KeyPair.publicKey.toHexString() } + .toEventually(equal("030201")) + await expect { await manager.x25519KeyPair.secretKey.toHexString() } + .toEventually(equal("060504")) + await expect { await manager.userSessionId } + .toEventually(equal(SessionId(.standard, hex: "030201"))) } } // MARK: -- with a stored secret key context("with a stored secret key") { beforeEach { - mockGeneralCache + try await mockGeneralCache .when { $0.ed25519SecretKey } .thenReturn(Array(Data(hex: TestConstants.edSecretKey))) - mockCrypto + try await mockCrypto .when { $0.generate(.ed25519KeyPair(seed: .any)) } .thenReturn( KeyPair( @@ -237,104 +254,107 @@ class OnboardingSpec: AsyncSpec { // MARK: ---- does not generate a seed it("does not generate a seed") { - expect(cache.seed.isEmpty).to(beTrue()) + await expect { await manager.seed.isEmpty }.to(beTrue()) } // MARK: ---- loads the ed25519 key pair from the database it("loads the ed25519 key pair from the database") { - expect(cache.ed25519KeyPair.publicKey.toHexString()) + await expect { await manager.ed25519KeyPair.publicKey.toHexString() } .to(equal(TestConstants.edPublicKey)) - expect(cache.ed25519KeyPair.secretKey.toHexString()) + await expect { await manager.ed25519KeyPair.secretKey.toHexString() } .to(equal(TestConstants.edSecretKey)) } // MARK: ---- generates the x25519KeyPair from the loaded ed25519 key pair it("generates the x25519KeyPair from the loaded ed25519 key pair") { - expect(mockCrypto).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.generate(.x25519(ed25519Pubkey: Array(Data(hex: TestConstants.edPublicKey)))) - }) - expect(mockCrypto).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.generate(.x25519(ed25519Seckey: Array(Data(hex: TestConstants.edSecretKey)))) - }) + await mockCrypto + .verify { $0.generate(.x25519(ed25519Pubkey: Array(Data(hex: TestConstants.edPublicKey)))) } + .wasCalled(exactly: 1) + await mockCrypto + .verify { $0.generate(.x25519(ed25519Seckey: Array(Data(hex: TestConstants.edSecretKey)))) } + .wasCalled(exactly: 1) - expect(cache.x25519KeyPair.publicKey.toHexString()) - .to(equal(TestConstants.publicKey)) - expect(cache.x25519KeyPair.secretKey.toHexString()) + await expect { await manager.x25519KeyPair.publicKey.toHexString() } + .to(equal(TestConstants.publicKey)) + await expect { await manager.x25519KeyPair.secretKey.toHexString() } .to(equal(TestConstants.privateKey)) } // MARK: ---- generates the sessionId from the generated x25519PublicKey it("generates the sessionId from the generated x25519PublicKey") { - expect(cache.userSessionId) + await expect { await manager.userSessionId } .to(equal(SessionId(.standard, hex: TestConstants.publicKey))) } // MARK: ---- and failing to generate an x25519KeyPair context("and failing to generate an x25519KeyPair") { beforeEach { - mockCrypto.removeMocksFor { $0.generate(.ed25519KeyPair(seed: .any)) } - mockCrypto.removeMocksFor { $0.generate(.ed25519Seed(ed25519SecretKey: .any)) } - mockCrypto + await mockCrypto.removeMocksFor { $0.generate(.ed25519KeyPair(seed: .any)) } + await mockCrypto.removeMocksFor { $0.generate(.ed25519Seed(ed25519SecretKey: .any)) } + try await mockCrypto .when { try $0.tryGenerate(.ed25519Seed(ed25519SecretKey: .any)) } - .thenThrow(MockError.mockedData) - mockCrypto + .thenThrow(MockError.mock) + try await mockCrypto .when { $0.generate(.ed25519KeyPair( seed: Array(Data(hex: TestConstants.edSecretKey)) )) } .thenReturn(KeyPair(publicKey: [1, 2, 3], secretKey: [9, 8, 7])) - mockCrypto + try await mockCrypto .when { - $0.generate(.ed25519KeyPair( - seed: [1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8] - )) } + $0.generate(.ed25519KeyPair(seed: [ + 1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ])) } .thenReturn(KeyPair(publicKey: [1, 2, 3], secretKey: [4, 5, 6])) - mockCrypto + try await mockCrypto .when { $0.generate(.x25519(ed25519Pubkey: [1, 2, 3])) } .thenReturn([4, 3, 2, 1]) - mockCrypto + try await mockCrypto .when { $0.generate(.x25519(ed25519Seckey: [4, 5, 6])) } .thenReturn([7, 6, 5, 4]) - mockCrypto + try await mockCrypto .when { $0.generate(.x25519(ed25519Pubkey: [9, 8, 7])) } .thenReturn(nil) } // MARK: ------ generates new credentials it("generates new credentials") { - expect(cache.state).to(equal(.noUserInvalidKeyPair)) - expect(cache.seed).to(equal(Data([1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8]))) - expect(cache.ed25519KeyPair.publicKey.toHexString()).to(equal("010203")) - expect(cache.ed25519KeyPair.secretKey.toHexString()).to(equal("040506")) - expect(cache.x25519KeyPair.publicKey.toHexString()).to(equal("04030201")) - expect(cache.x25519KeyPair.secretKey.toHexString()).to(equal("07060504")) - expect(cache.userSessionId).to(equal(SessionId(.standard, hex: "04030201"))) + await expect { await manager.state.first() }.to(equal(.noUserInvalidKeyPair)) + await expect { await manager.seed } + .to(equal(Data([1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8]))) + await expect { await manager.ed25519KeyPair.publicKey.toHexString() }.to(equal("010203")) + await expect { await manager.ed25519KeyPair.secretKey.toHexString() }.to(equal("040506")) + await expect { await manager.x25519KeyPair.publicKey.toHexString() }.to(equal("04030201")) + await expect { await manager.x25519KeyPair.secretKey.toHexString() }.to(equal("07060504")) + await expect { await manager.userSessionId }.to(equal(SessionId(.standard, hex: "04030201"))) } // MARK: ------ goes into an invalid state when generating a seed fails it("goes into an invalid state when generating a seed fails") { - mockCrypto.when { $0.generate(.randomBytes(.any)) }.thenReturn(nil as Data?) - cache = Onboarding.Cache( + try await mockCrypto.when { $0.generate(.randomBytes(.any)) }.thenReturn(nil as Data?) + manager = Onboarding.Manager( flow: .restore, using: dependencies ) - expect(cache.state).to(equal(.noUserInvalidSeedGeneration)) + try await manager.loadInitialState() + await expect{ await manager.state.first() }.to(equal(.noUserInvalidSeedGeneration)) } // MARK: ------ does not load the useAPNs flag from user defaults it("does not load the useAPNs flag from user defaults") { - expect(mockUserDefaults).toNot(call { $0.bool(forKey: .any) }) + await mockUserDefaults.verify { $0.bool(forKey: .any) }.wasNotCalled() } } // MARK: ---- and an existing display name context("and an existing display name") { beforeEach { - mockUserDefaults + try await mockUserDefaults .when { $0.bool(forKey: UserDefaults.BoolKey.isUsingFullAPNs.rawValue) } .thenReturn(true) - mockLibSession + try await mockLibSessionCache .when { $0.profile( contactId: .any, @@ -348,62 +368,65 @@ class OnboardingSpec: AsyncSpec { // MARK: ------ loads from libSession it("loads from libSession") { - expect(mockLibSession).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.profile( - contactId: "05\(TestConstants.publicKey)", - threadId: nil, - threadVariant: nil, - visibleMessage: nil - ) - }) + await mockLibSessionCache + .verify { + $0.profile( + contactId: "05\(TestConstants.publicKey)", + threadId: nil, + threadVariant: nil, + visibleMessage: nil + ) + } + .wasCalled(exactly: 1) } // MARK: ------ stores the loaded displayName it("stores the loaded displayName") { - expect(cache.displayName).to(equal("TestProfileName")) + await expect{ await manager.displayName.first() }.to(equal("TestProfileName")) } // MARK: ------ loads the useAPNs setting from user defaults it("loads the useAPNs setting from user defaults") { - expect(mockUserDefaults).to(call { $0.bool(forKey: .any) }) - expect(cache.useAPNS).to(beTrue()) + await mockUserDefaults.verify { $0.bool(forKey: .any) }.wasCalled() + await expect{ await manager.useAPNS }.to(beTrue()) } // MARK: ------ after generating new credentials context("after generating new credentials") { beforeEach { - mockCrypto.removeMocksFor { $0.generate(.ed25519KeyPair(seed: .any)) } - mockCrypto.removeMocksFor { $0.generate(.ed25519Seed(ed25519SecretKey: .any)) } - mockCrypto + await mockCrypto.removeMocksFor { $0.generate(.ed25519KeyPair(seed: .any)) } + await mockCrypto.removeMocksFor { $0.generate(.ed25519Seed(ed25519SecretKey: .any)) } + try await mockCrypto .when { try $0.tryGenerate(.ed25519Seed(ed25519SecretKey: .any)) } - .thenThrow(MockError.mockedData) - mockCrypto + .thenThrow(MockError.mock) + try await mockCrypto .when { $0.generate(.ed25519KeyPair( seed: Array(Data(hex: TestConstants.edSecretKey)) )) } .thenReturn(KeyPair(publicKey: [1, 2, 3], secretKey: [9, 8, 7])) - mockCrypto + try await mockCrypto .when { - $0.generate(.ed25519KeyPair( - seed: [1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8] - )) } + $0.generate(.ed25519KeyPair(seed: [ + 1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ])) } .thenReturn(KeyPair(publicKey: [1, 2, 3], secretKey: [4, 5, 6])) - mockCrypto + try await mockCrypto .when { $0.generate(.x25519(ed25519Pubkey: [1, 2, 3])) } .thenReturn([4, 3, 2, 1]) - mockCrypto + try await mockCrypto .when { $0.generate(.x25519(ed25519Seckey: [4, 5, 6])) } .thenReturn([7, 6, 5, 4]) - mockCrypto + try await mockCrypto .when { $0.generate(.x25519(ed25519Pubkey: [9, 8, 7])) } .thenReturn(nil) } // MARK: -------- has an empty display name it("has an empty display name") { - expect(cache.displayName).to(equal("")) + await expect { await manager.displayName.first() }.to(beNil()) } } } @@ -411,20 +434,20 @@ class OnboardingSpec: AsyncSpec { // MARK: ---- and a missing display name context("and a missing display name") { beforeEach { - mockUserDefaults + try await mockUserDefaults .when { $0.bool(forKey: UserDefaults.BoolKey.isUsingFullAPNs.rawValue) } .thenReturn(true) } // MARK: ------ has an empty display name it("has an empty display name") { - expect(cache.displayName).to(equal("")) + await expect { await manager.displayName.first() }.to(equal("")) } // MARK: ------ loads the useAPNs setting from user defaults it("loads the useAPNs setting from user defaults") { - expect(mockUserDefaults).to(call { $0.bool(forKey: .any) }) - expect(cache.useAPNS).to(beTrue()) + await mockUserDefaults.verify { $0.bool(forKey: .any) }.wasCalled() + await expect { await manager.useAPNS }.to(beTrue()) } } } @@ -433,7 +456,7 @@ class OnboardingSpec: AsyncSpec { // MARK: - an Onboarding Cache - Seed Data describe("an Onboarding Cache when setting seed data") { beforeEach { - mockCrypto + try await mockCrypto .when { $0.generate(.ed25519KeyPair(seed: .any)) } .thenReturn( KeyPair( @@ -444,120 +467,141 @@ class OnboardingSpec: AsyncSpec { } justBeforeEach { - cache = Onboarding.Cache( + manager = Onboarding.Manager( flow: .register, using: dependencies ) - cache.displayNamePublisher.sinkAndStore(in: &disposables) - try? cache.setSeedData(Data(hex: TestConstants.edKeySeed).prefix(upTo: 16)) + try await manager.loadInitialState() + try await manager.setSeedData(Data(hex: TestConstants.edKeySeed).prefix(upTo: 16)) } // MARK: -- throws if the seed is the wrong length it("throws if the seed is the wrong length") { - expect { try cache.setSeedData(Data([1, 2, 3])) } - .to(throwError(CryptoError.invalidSeed)) + await expect { try await manager.setSeedData(Data([1, 2, 3])) } + .toEventually(throwError(CryptoError.invalidSeed)) } // MARK: -- stores the seed it("stores the seed") { - expect(cache.seed).to(equal(Data(hex: TestConstants.edKeySeed).prefix(upTo: 16))) + await expect { await manager.seed } + .toEventually(equal(Data(hex: TestConstants.edKeySeed).prefix(upTo: 16))) } // MARK: -- stores the generated identity it("stores the generated identity") { - expect(cache.ed25519KeyPair.publicKey.toHexString()) - .to(equal(TestConstants.edPublicKey)) - expect(cache.ed25519KeyPair.secretKey.toHexString()) - .to(equal(TestConstants.edSecretKey)) - expect(cache.x25519KeyPair.publicKey.toHexString()) - .to(equal(TestConstants.publicKey)) - expect(cache.x25519KeyPair.secretKey.toHexString()) - .to(equal(TestConstants.privateKey)) - expect(cache.userSessionId) - .to(equal(SessionId(.standard, hex: TestConstants.publicKey))) + await expect { await manager.ed25519KeyPair.publicKey.toHexString() } + .toEventually(equal(TestConstants.edPublicKey)) + await expect { await manager.ed25519KeyPair.secretKey.toHexString() } + .toEventually(equal(TestConstants.edSecretKey)) + await expect { await manager.x25519KeyPair.publicKey.toHexString() } + .toEventually(equal(TestConstants.publicKey)) + await expect { await manager.x25519KeyPair.secretKey.toHexString() } + .toEventually(equal(TestConstants.privateKey)) + await expect { await manager.userSessionId } + .toEventually(equal(SessionId(.standard, hex: TestConstants.publicKey))) } // MARK: -- polls for the userProfile config it("polls for the userProfile config") { - let base64EncodedDataString: String = "eyJtZXRob2QiOiJiYXRjaCIsInBhcmFtcyI6eyJyZXF1ZXN0cyI6W3sibWV0aG9kIjoicmV0cmlldmUiLCJwYXJhbXMiOnsibGFzdF9oYXNoIjoiIiwibWF4X3NpemUiOi0xLCJuYW1lc3BhY2UiOjIsInB1YmtleSI6IjA1ODg2NzJjY2I5N2Y0MGJiNTcyMzg5ODkyMjZjZjQyOWI1NzViYTM1NTQ0M2Y0N2JjNzZjNWFiMTQ0YTk2YzY1YiIsInB1YmtleV9lZDI1NTE5IjoiYmFjNmU3MWVmZDdkZmE0YTgzYzk4ZWQyNGYyNTRhYjJjMjY3ZjljY2RiMTcyYTUyODBhMDQ0NGFkMjRlODljYyIsInNpZ25hdHVyZSI6IlZHVnpkRk5wWjI1aGRIVnlaUT09IiwidGltZXN0YW1wIjoxMjM0NTY3ODkwMDAwfX1dfX0=" - - await expect(mockNetwork) - .toEventually(call(.exactly(times: 1), matchingParameters: .atLeast(3)) { - $0.send( - Data(base64Encoded: base64EncodedDataString), - to: Network.Destination.snode( + await mockNetwork + .verify { + try await $0.send( + endpoint: Network.StorageServer.Endpoint.batch, + destination: Network.Destination.snode( LibSession.Snode( + ed25519PubkeyHex: "1234", ip: "1.2.3.4", + httpsPort: 1233, quicPort: 1234, - ed25519PubkeyHex: "" + version: "2.11.0", + swarmId: 1 ), swarmPublicKey: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b" ), + body: try JSONEncoder(using: dependencies).encode( + Network.BatchRequest( + requestsKey: .requests, + requests: [ + try Network.StorageServer.preparedGetMessages( + namespace: .configUserProfile, + snode: LibSession.Snode( + ed25519PubkeyHex: "1234", + ip: "1.2.3.4", + httpsPort: 1233, + quicPort: 1234, + version: "2.11.0", + swarmId: 1 + ), + lastHash: nil, + maxSize: -1, + authMethod: Authentication.standard( + sessionId: SessionId(.standard, hex: TestConstants.publicKey), + ed25519PublicKey: Array(Data(hex: TestConstants.edPublicKey)), + ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)) + ), + using: dependencies + ) + ] + ) + ), + category: .standard, requestTimeout: 10, - requestAndPathBuildTimeout: nil + overallTimeout: nil ) - }) - } - - // MARK: -- the display name to be set to the successful result - it("the display name to be set to the successful result") { - await expect(cache.displayName).toEventually(equal("TestPolledName")) + } + .wasCalled(exactly: 1, timeout: .milliseconds(100)) } - // MARK: -- the publisher to emit the display name - it("the publisher to emit the display name") { - var value: String? - cache - .displayNamePublisher - .sink( - receiveCompletion: { _ in }, - receiveValue: { value = $0 } - ) - .store(in: &disposables) - await expect(value).toEventually(equal("TestPolledName")) + // MARK: -- the display name stream to output the correct value + it("the display name stream to output the correct value") { + await expect { await manager.displayName.first() }.toEventually(equal("TestPolledName")) } } // MARK: - an Onboarding Cache - Setting values describe("an Onboarding Cache when setting values") { justBeforeEach { - cache = Onboarding.Cache( + manager = Onboarding.Manager( flow: .register, using: dependencies ) + try await manager.loadInitialState() } // MARK: -- stores the useAPNs setting it("stores the useAPNs setting") { - expect(cache.useAPNS).to(beFalse()) - cache.setUseAPNS(true) - expect(cache.useAPNS).to(beTrue()) + await expect { await manager.useAPNS }.toEventually(beFalse()) + await manager.setUseAPNS(true) + await expect { await manager.useAPNS }.toEventually(beTrue()) } // MARK: -- stores the display name it("stores the display name") { - expect(cache.displayName).to(equal("")) - cache.setDisplayName("TestName") - expect(cache.displayName).to(equal("TestName")) + await expect { await manager.displayName.first() }.toEventually(equal("")) + await manager.setDisplayName("TestName") + await expect { await manager.displayName.first() }.toEventually(equal("TestName")) } } // MARK: - an Onboarding Cache - Complete Registration describe("an Onboarding Cache when completing registration") { justBeforeEach { - cache = Onboarding.Cache( + try await mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) + + manager = Onboarding.Manager( flow: .register, using: dependencies ) - cache.setDisplayName("TestCompleteName") - cache.completeRegistration() + try await manager.loadInitialState() + await manager.setDisplayName("TestCompleteName") + await manager.completeRegistration() } // MARK: -- stores the ed25519 secret key in the general cache it("stores the ed25519 secret key in the general cache") { - expect(mockGeneralCache).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.setSecretKey(ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey))) - }) + await mockGeneralCache + .verify { $0.setSecretKey(ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey))) } + .wasCalled(exactly: 1) } // MARK: -- stores a new libSession cache instance @@ -665,21 +709,71 @@ class OnboardingSpec: AsyncSpec { let result: [ConfigDump]? = mockStorage.read { db in try ConfigDump.fetchAll(db) } - let expectedData: Data? = Data(base64Encoded: "ZDE6IWkxZTE6JDEwNDpkMTojaTFlMTomZDE6K2ktMWUxOm4xNjpUZXN0Q29tcGxldGVOYW1lZTE6PGxsaTBlMzI66hc7V77KivGMNRmnu/acPnoF0cBJ+pVYNB2Ou0iwyWVkZWVlMTo9ZDE6KzA6MTpuMDplZTE6KGxlMTopbGUxOipkZTE6K2RlZQ==") - expect(result).to(equal([ - ConfigDump( - variant: .userProfile, - sessionId: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b", - data: (expectedData ?? Data()), - timestampMs: 1234567890000 - ) - ])) + try require(result).to(haveCount(1)) + expect(result![0].variant).to(equal(.userProfile)) + expect(result![0].sessionId).to(equal(SessionId(.standard, hex: TestConstants.publicKey))) + expect(result![0].timestampMs).to(equal(1234567890000)) + + /// The data now contains a `now` timestamp so won't be an exact match anymore, but we _can_ check to ensure + /// the rest of the data matches and that the timestamps are close enough to `now` + /// + /// **Note:** The data contains non-ASCII content so we can't do a straight conversion unfortunately + let resultData: Data = result![0].data + let prefixData: Data = "d1:!i1e1:$144:d1:#i1e1:&d1:+i-1e1:Ti".data(using: .ascii)! + let infixData: Data = "e1:n16:TestCompleteName1:ti".data(using: .ascii)! + let suffixData: Data = "ee1: = resultData.range(of: prefixData), + let infixRange: Range = resultData + .range(of: infixData, in: prefixRange.upperBound.. = resultData + .range(of: suffixData, in: infixRange.upperBound.. = prefixRange.upperBound.. = infixRange.upperBound.. 0 else { - return DataModel.defaultPriceString - } + guard networkStakedUSD > 0 else { return defaultPriceString } + return "$\(networkStakedUSD.formatted(format: .currency(decimal: false))) USD" } public var networkStakedUSDAbbreviatedString: String { - guard networkStakedUSD > 0 else { - return DataModel.defaultPriceString - } + guard networkStakedUSD > 0 else { return defaultPriceString } + return "$\(networkStakedUSD.formatted(format: .abbreviatedCurrency(decimalPlaces: 1))) USD" } public let stakingRewardPool: Double? @@ -100,23 +98,27 @@ public extension SessionNetworkScreenContent { } return "$\(marketCap.formatted(format: .abbreviatedCurrency(decimalPlaces: 1))) USD" } + public let totalTargetConversations: Int // Last update time public let lastUpdatedTimestampMs: Int64? + // MARK: - Initialization + public init( - snodesInCurrentSwarm: Int = 0, - snodesInTotal: Int = 0, - contractAddress: String? = nil, - tokenUSD: Double? = nil, - priceTimestampMs: Int64 = 0, - stakingRequirement: Double = 0, - networkSize: Int = 0, - networkStakedTokens: Double = 0, - networkStakedUSD: Double = 0, - stakingRewardPool: Double? = nil, - marketCapUSD: Double? = nil, - lastUpdatedTimestampMs: Int64? = nil + snodesInCurrentSwarm: Int, + snodesInTotal: Int, + contractAddress: String?, + tokenUSD: Double?, + priceTimestampMs: Int64, + stakingRequirement: Double, + networkSize: Int, + networkStakedTokens: Double, + networkStakedUSD: Double, + stakingRewardPool: Double?, + marketCapUSD: Double?, + totalTargetConversations: Int, + lastUpdatedTimestampMs: Int64? ) { self.snodesInCurrentSwarm = snodesInCurrentSwarm self.snodesInTotal = snodesInTotal @@ -129,10 +131,11 @@ public extension SessionNetworkScreenContent { self.networkStakedUSD = networkStakedUSD self.stakingRewardPool = stakingRewardPool self.marketCapUSD = marketCapUSD + self.totalTargetConversations = totalTargetConversations self.lastUpdatedTimestampMs = lastUpdatedTimestampMs } - public static func == (lhs: DataModel, rhs: DataModel) -> Bool { + public static func == (lhs: State, rhs: State) -> Bool { let isSnodeInfoEqual: Bool = ( lhs.snodesInCurrentSwarm == rhs.snodesInCurrentSwarm && lhs.snodesInTotal == rhs.snodesInTotal @@ -153,16 +156,23 @@ public extension SessionNetworkScreenContent { lhs.marketCapUSD == rhs.marketCapUSD ) + let convoCountEqual: Bool = lhs.totalTargetConversations == rhs.totalTargetConversations let isUpdateTimeEqual: Bool = lhs.lastUpdatedTimestampMs == rhs.lastUpdatedTimestampMs - return isSnodeInfoEqual && isTokenInfoDataEqual && isNetworkInfoDataEqual && isUpdateTimeEqual + return ( + isSnodeInfoEqual && + isTokenInfoDataEqual && + isNetworkInfoDataEqual && + convoCountEqual && + isUpdateTimeEqual + ) } } } // MARK: - Convenience -extension SessionNetworkScreenContent.DataModel { +extension SessionNetworkScreenContent.State { public func with( snodesInCurrentSwarm: Int? = nil, snodesInTotal: Int? = nil, @@ -175,9 +185,10 @@ extension SessionNetworkScreenContent.DataModel { networkStakedUSD: Double? = nil, stakingRewardPool: Double? = nil, marketCapUSD: Double? = nil, + totalTargetConversations: Int? = nil, lastUpdatedTimestampMs: Int64? = nil - ) -> SessionNetworkScreenContent.DataModel { - return SessionNetworkScreenContent.DataModel( + ) -> SessionNetworkScreenContent.State { + return SessionNetworkScreenContent.State( snodesInCurrentSwarm: snodesInCurrentSwarm ?? self.snodesInCurrentSwarm, snodesInTotal: snodesInTotal ?? self.snodesInTotal, contractAddress: contractAddress ?? self.contractAddress, @@ -189,6 +200,7 @@ extension SessionNetworkScreenContent.DataModel { networkStakedUSD: networkStakedUSD ?? self.networkStakedUSD, stakingRewardPool: stakingRewardPool ?? self.stakingRewardPool, marketCapUSD: marketCapUSD ?? self.marketCapUSD, + totalTargetConversations: totalTargetConversations ?? self.totalTargetConversations, lastUpdatedTimestampMs: lastUpdatedTimestampMs ?? self.lastUpdatedTimestampMs ) } diff --git a/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift b/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift index fee96e74f8..46382aa18c 100644 --- a/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift +++ b/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift @@ -34,7 +34,7 @@ public struct SessionNetworkScreen 0 { - Image("connection_\(dataModel.snodesInCurrentSwarm)") + } else if state.snodesInCurrentSwarm > 0 { + Image("connection_\(state.snodesInCurrentSwarm)") .renderingMode(.template) .foregroundColor(themeColor: .textPrimary) - Image("snodes_\(dataModel.snodesInCurrentSwarm)") + Image("snodes_\(state.snodesInCurrentSwarm)") .renderingMode(.template) .foregroundColor(themeColor: .primary) .shadow(themeColor: .settings_glowingBackground, radius: 10) @@ -271,9 +271,9 @@ extension SessionNetworkScreen { AdaptiveText( textOptions: [ - dataModel.tokenUSDString, - dataModel.tokenUSDNoCentsString, - dataModel.tokenUSDAbbreviatedString + state.tokenUSDString, + state.tokenUSDNoCentsString, + state.tokenUSDAbbreviatedString ], isLoading: isRefreshing ) @@ -332,7 +332,7 @@ extension SessionNetworkScreen { if isRefreshing || !lastRefreshWasSuccessful { ProgressView() } else { - Text("\(dataModel.snodesInCurrentSwarm)") + Text("\(state.snodesInCurrentSwarm)") .font(.Headings.H3) .foregroundColor(themeColor: .sessionButton_text) .lineLimit(1) @@ -374,9 +374,9 @@ extension SessionNetworkScreen { AdaptiveText( textOptions: [ - dataModel.snodesInTotalString, - dataModel.snodesInTotalAbbreviatedString, - dataModel.snodesInTotalAbbreviatedNoDecimalString + state.snodesInTotalString, + state.snodesInTotalAbbreviatedString, + state.snodesInTotalAbbreviatedNoDecimalString ], isLoading: isRefreshing || !lastRefreshWasSuccessful ) @@ -411,7 +411,7 @@ extension SessionNetworkScreen { ) .minimumScaleFactor(0.5) - Text(isRefreshing ? "loading".localized() : dataModel.networkStakedTokensString) + Text(isRefreshing ? "loading".localized() : state.networkStakedTokensString) .font(.Headings.H5) .foregroundColor(themeColor: .sessionButton_text) .lineLimit(1) @@ -423,8 +423,8 @@ extension SessionNetworkScreen { AdaptiveText( textOptions: [ - dataModel.networkStakedUSDString, - dataModel.networkStakedUSDAbbreviatedString + state.networkStakedUSDString, + state.networkStakedUSDAbbreviatedString ], isLoading: isRefreshing ) @@ -433,7 +433,7 @@ extension SessionNetworkScreen { uiKit: Fonts.Body.custom(Values.smallFontSize) ) .foregroundColor(themeColor: .textSecondary) - .loadingStyle(.text(SessionNetworkScreenContent.DataModel.defaultPriceString)) + .loadingStyle(.text(SessionNetworkScreenContent.defaultPriceString)) .fixedSize() } .padding(.horizontal, Values.mediumSmallSpacing) @@ -457,7 +457,7 @@ extension SessionNetworkScreen { ZStack { Text( Constants.session_network_data_price - .put(key: "date_time", value: dataModel.priceTimeString) // stringlint:ignore + .put(key: "date_time", value: state.priceTimeString) // stringlint:ignore .localized() ) .font(.Body.smallRegular) @@ -496,7 +496,7 @@ extension SessionNetworkScreen { extension SessionNetworkScreen { struct SessionTokenSection: View { - @Binding var dataModel: SessionNetworkScreenContent.DataModel + @Binding var state: SessionNetworkScreenContent.State @Binding var isRefreshing: Bool var linkOutAction: () -> () @@ -549,7 +549,7 @@ extension SessionNetworkScreen { alignment: .leading, spacing: Values.veryLargeSpacing ) { - Text(isRefreshing ? "loading".localized() : dataModel.stakingRewardPoolString) + Text(isRefreshing ? "loading".localized() : state.stakingRewardPoolString) .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) .lineLimit(1) @@ -560,8 +560,8 @@ extension SessionNetworkScreen { AdaptiveText( textOptions: [ - dataModel.marketCapString, - dataModel.marketCapAbbreviatedString + state.marketCapString, + state.marketCapAbbreviatedString ], isLoading: isRefreshing ) @@ -613,20 +613,20 @@ extension SessionNetworkScreen { #if DEBUG extension SessionNetworkScreenContent { class PreviewViewModel: ViewModelType { - var dataModel: DataModel + var state: State var isRefreshing: Bool var lastRefreshWasSuccessful: Bool var errorString: String? var lastUpdatedTimeString: String? init( - dataModel: DataModel, + state: State, isRefreshing: Bool, lastRefreshWasSuccessful: Bool, errorString: String? = nil, lastUpdatedTimeString: String? = nil ) { - self.dataModel = dataModel + self.state = state self.isRefreshing = isRefreshing self.lastRefreshWasSuccessful = lastRefreshWasSuccessful self.errorString = errorString @@ -644,7 +644,7 @@ extension SessionNetworkScreenContent { #Preview { SessionNetworkScreen( viewModel: SessionNetworkScreenContent.PreviewViewModel( - dataModel: SessionNetworkScreenContent.DataModel( + state: SessionNetworkScreenContent.State( snodesInCurrentSwarm: 6, snodesInTotal: 2254, contractAddress: "0x7D7fD4E91834A96cD9Fb2369E7f4EB72383bbdEd", @@ -656,6 +656,7 @@ extension SessionNetworkScreenContent { networkStakedUSD: 34_278_323_684.940723, stakingRewardPool: 40_010_040, marketCapUSD: 216_442_438_046.91196, + totalTargetConversations: 6, lastUpdatedTimestampMs: 1745817920000 ), isRefreshing: false, diff --git a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift index ca7cea95c0..093f91e2ea 100644 --- a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift +++ b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift @@ -161,6 +161,25 @@ public extension Publisher { // MARK: - Convenience +private final class SubscriptionManager { + static let shared: SubscriptionManager = SubscriptionManager() + + private let lock: NSLock = NSLock() + private var subscriptions: [UUID: AnyCancellable] = [:] + + func store(_ subscription: AnyCancellable, for id: UUID) { + lock.lock() + defer { lock.unlock() } + subscriptions[id] = subscription + } + + func release(for id: UUID) { + lock.lock() + defer { lock.unlock() } + subscriptions[id] = nil + } +} + public extension Publisher { func sink(into subject: PassthroughSubject?, includeCompletions: Bool = false) -> AnyCancellable { guard let targetSubject: PassthroughSubject = subject else { return AnyCancellable {} } @@ -173,18 +192,18 @@ public extension Publisher { receiveCompletion: ((Subscribers.Completion) -> Void)? = nil, receiveValue: ((Output) -> Void)? = nil ) { - var retainCycle: Cancellable? = nil - retainCycle = self + let id: UUID = UUID() + let cancellable: AnyCancellable = self .sink( receiveCompletion: { result in receiveCompletion?(result) - // Redundant but without reading 'retainCycle' it will warn that the variable - // isn't used - if retainCycle != nil { retainCycle = nil } + SubscriptionManager.shared.release(for: id) }, receiveValue: (receiveValue ?? { _ in }) ) + + SubscriptionManager.shared.store(cancellable, for: id) } } @@ -204,3 +223,15 @@ extension AnyPublisher: @retroactive ExpressibleByArrayLiteral where Output: Ran self = Just(Output(elements)).setFailureType(to: Failure.self).eraseToAnyPublisher() } } + +public extension AnyPublisher where Failure == Error { + static func lazy(_ closure: @escaping () throws -> Output) -> Self { + return Deferred { + Future { promise in + do { promise(.success(try closure())) } + catch { promise(.failure(error)) } + } + } + .eraseToAnyPublisher() + } +} diff --git a/SessionUtilitiesKit/Crypto/Crypto.swift b/SessionUtilitiesKit/Crypto/Crypto.swift index 8ab0c151f8..25c98f6590 100644 --- a/SessionUtilitiesKit/Crypto/Crypto.swift +++ b/SessionUtilitiesKit/Crypto/Crypto.swift @@ -9,7 +9,7 @@ import Foundation public extension Singleton { static let crypto: SingletonConfig = Dependencies.create( identifier: "crypto", - createInstance: { dependencies in Crypto(using: dependencies) } + createInstance: { dependencies, _ in Crypto(using: dependencies) } ) } diff --git a/SessionUtilitiesKit/Database/Models/Identity.swift b/SessionUtilitiesKit/Database/Models/Identity.swift index 945e0c7d3f..d374164ec0 100644 --- a/SessionUtilitiesKit/Database/Models/Identity.swift +++ b/SessionUtilitiesKit/Database/Models/Identity.swift @@ -124,7 +124,7 @@ public extension Identity { ) }, completion: { result in - let (hasStoredXKeyPair, hasStoredEdKeyPair) = ((try? result.successOrThrow()) ?? (false, false)) + let (hasStoredXKeyPair, hasStoredEdKeyPair) = ((try? result.get()) ?? (false, false)) // stringlint:ignore_start let dbStates: [String] = [ diff --git a/SessionUtilitiesKit/Database/Models/Job.swift b/SessionUtilitiesKit/Database/Models/Job.swift index 1d6a0df046..ac92a12f58 100644 --- a/SessionUtilitiesKit/Database/Models/Job.swift +++ b/SessionUtilitiesKit/Database/Models/Job.swift @@ -11,25 +11,6 @@ public protocol UniqueHashable { public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "job" } - internal static let dependencyForeignKey = ForeignKey([Columns.id], to: [JobDependencies.Columns.dependantId]) - public static let dependantJobDependency = hasMany( - JobDependencies.self, - using: JobDependencies.jobForeignKey - ) - public static let dependancyJobDependency = hasMany( - JobDependencies.self, - using: JobDependencies.dependantForeignKey - ) - internal static let jobsThisJobDependsOn = hasMany( - Job.self, - through: dependantJobDependency, - using: JobDependencies.dependant - ) - internal static let jobsThatDependOnThisJob = hasMany( - Job.self, - through: dependancyJobDependency, - using: JobDependencies.job - ) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -244,22 +225,6 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, /// a job directly which may need some special behaviour) public let transientData: Any? - /// The other jobs which this job is dependant on - /// - /// **Note:** When completing a job the dependencies **MUST** be cleared before the job is - /// deleted or it will automatically delete any dependant jobs - public var dependencies: QueryInterfaceRequest { - request(for: Job.jobsThisJobDependsOn) - } - - /// The other jobs which depend on this job - /// - /// **Note:** When completing a job the dependencies **MUST** be cleared before the job is - /// deleted or it will automatically delete any dependant jobs - public var dependantJobs: QueryInterfaceRequest { - request(for: Job.jobsThatDependOnThisJob) - } - // MARK: - Initialization fileprivate init( @@ -609,7 +574,9 @@ extension Job { } if !includeJobsWithDependencies { - query = query.having(Job.jobsThisJobDependsOn.isEmpty) + let dependencySubquery: QueryInterfaceRequest = JobDependencies + .filter(JobDependencies.Columns.jobId == Job.Columns.id) + query = query.filter(!dependencySubquery.exists()) } return query diff --git a/SessionUtilitiesKit/Database/Models/JobDependencies.swift b/SessionUtilitiesKit/Database/Models/JobDependencies.swift index ab929636f4..7dcf662a31 100644 --- a/SessionUtilitiesKit/Database/Models/JobDependencies.swift +++ b/SessionUtilitiesKit/Database/Models/JobDependencies.swift @@ -5,10 +5,6 @@ import GRDB public struct JobDependencies: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "jobDependencies" } - internal static let jobForeignKey = ForeignKey([Columns.jobId], to: [Job.Columns.id]) - internal static let dependantForeignKey = ForeignKey([Columns.dependantId], to: [Job.Columns.id]) - public static let job = belongsTo(Job.self, using: jobForeignKey) - public static let dependant = hasOne(Job.self, using: Job.dependencyForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { @@ -36,14 +32,4 @@ public struct JobDependencies: Codable, Equatable, Hashable, FetchableRecord, Pe self.jobId = jobId self.dependantId = dependantId } - - // MARK: - Relationships - - public var job: QueryInterfaceRequest { - request(for: JobDependencies.job) - } - - public var dependant: QueryInterfaceRequest { - request(for: JobDependencies.dependant) - } } diff --git a/SessionUtilitiesKit/Database/Models/KeyValueStore.swift b/SessionUtilitiesKit/Database/Models/KeyValueStore.swift index 539f2c15c2..f6a7ee08cb 100644 --- a/SessionUtilitiesKit/Database/Models/KeyValueStore.swift +++ b/SessionUtilitiesKit/Database/Models/KeyValueStore.swift @@ -70,77 +70,54 @@ extension KeyValueStore { // MARK: - Keys public extension KeyValueStore { - struct BoolKey: RawRepresentable, ExpressibleByStringLiteral, Hashable { + protocol Key: RawRepresentable, ExpressibleByStringLiteral, Hashable { + var rawValue: String { get } + init(_ rawValue: String) + } + + struct BoolKey: Key { public let rawValue: String - public init(_ rawValue: String) { self.rawValue = rawValue } - public init?(rawValue: String) { self.rawValue = rawValue } - public init(stringLiteral value: String) { self.init(value) } - public init(unicodeScalarLiteral value: String) { self.init(value) } - public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } } - struct DateKey: RawRepresentable, ExpressibleByStringLiteral, Hashable { + struct DateKey: Key { public let rawValue: String - public init(_ rawValue: String) { self.rawValue = rawValue } - public init?(rawValue: String) { self.rawValue = rawValue } - public init(stringLiteral value: String) { self.init(value) } - public init(unicodeScalarLiteral value: String) { self.init(value) } - public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } } - struct DoubleKey: RawRepresentable, ExpressibleByStringLiteral, Hashable { + struct DoubleKey: Key { public let rawValue: String - public init(_ rawValue: String) { self.rawValue = rawValue } - public init?(rawValue: String) { self.rawValue = rawValue } - public init(stringLiteral value: String) { self.init(value) } - public init(unicodeScalarLiteral value: String) { self.init(value) } - public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } } - struct IntKey: RawRepresentable, ExpressibleByStringLiteral, Hashable { + struct IntKey: Key { public let rawValue: String - public init(_ rawValue: String) { self.rawValue = rawValue } - public init?(rawValue: String) { self.rawValue = rawValue } - public init(stringLiteral value: String) { self.init(value) } - public init(unicodeScalarLiteral value: String) { self.init(value) } - public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } } - struct Int64Key: RawRepresentable, ExpressibleByStringLiteral, Hashable { + struct Int64Key: Key { public let rawValue: String - public init(_ rawValue: String) { self.rawValue = rawValue } - public init?(rawValue: String) { self.rawValue = rawValue } - public init(stringLiteral value: String) { self.init(value) } - public init(unicodeScalarLiteral value: String) { self.init(value) } - public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } } - struct StringKey: RawRepresentable, ExpressibleByStringLiteral, Hashable { + struct StringKey: Key { public let rawValue: String - public init(_ rawValue: String) { self.rawValue = rawValue } - public init?(rawValue: String) { self.rawValue = rawValue } - public init(stringLiteral value: String) { self.init(value) } - public init(unicodeScalarLiteral value: String) { self.init(value) } - public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } } - struct EnumKey: RawRepresentable, ExpressibleByStringLiteral, Hashable { + struct EnumKey: Key { public let rawValue: String - public init(_ rawValue: String) { self.rawValue = rawValue } - public init?(rawValue: String) { self.rawValue = rawValue } - public init(stringLiteral value: String) { self.init(value) } - public init(unicodeScalarLiteral value: String) { self.init(value) } - public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } } } +public extension KeyValueStore.Key { + init?(rawValue: String) { self.init(rawValue) } + init(stringLiteral value: String) { self.init(value) } + init(unicodeScalarLiteral value: String) { self.init(value) } + init(extendedGraphemeClusterLiteral value: String) { self.init(value) } +} + // MARK: - GRDB Interactions public extension ObservingDatabase { @@ -170,30 +147,45 @@ public extension ObservingDatabase { // Default to false if it doesn't exist (self[key.rawValue]?.unsafeValue(as: Bool.self) ?? false) } - set { self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue) } + set { + self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue) + self.addEvent(newValue, forKey: .keyValue(key)) + } } subscript(key: KeyValueStore.DoubleKey) -> Double? { get { self[key.rawValue]?.value(as: Double.self) } - set { self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue) } + set { + self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue) + self.addEvent(newValue, forKey: .keyValue(key)) + } } subscript(key: KeyValueStore.IntKey) -> Int? { get { self[key.rawValue]?.value(as: Int.self) } - set { self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue) } + set { + self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue) + self.addEvent(newValue, forKey: .keyValue(key)) + } } subscript(key: KeyValueStore.Int64Key) -> Int64? { get { self[key.rawValue]?.value(as: Int64.self) } - set { self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue) } + set { + self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue) + self.addEvent(newValue, forKey: .keyValue(key)) + } } subscript(key: KeyValueStore.StringKey) -> String? { get { self[key.rawValue]?.value(as: String.self) } - set { self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue) } + set { + self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue) + self.addEvent(newValue, forKey: .keyValue(key)) + } } - subscript(key: KeyValueStore.EnumKey) -> T? where T.RawValue == Int { + subscript(key: KeyValueStore.EnumKey) -> T? where T.RawValue == Int, T: Hashable { get { guard let rawValue: Int = self[key.rawValue]?.value(as: Int.self) else { return nil @@ -201,10 +193,13 @@ public extension ObservingDatabase { return T(rawValue: rawValue) } - set { self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue?.rawValue) } + set { + self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue?.rawValue) + self.addEvent(newValue, forKey: .keyValue(key)) + } } - subscript(key: KeyValueStore.EnumKey) -> T? where T.RawValue == String { + subscript(key: KeyValueStore.EnumKey) -> T? where T.RawValue == String, T: Hashable { get { guard let rawValue: String = self[key.rawValue]?.value(as: String.self) else { return nil @@ -212,7 +207,10 @@ public extension ObservingDatabase { return T(rawValue: rawValue) } - set { self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue?.rawValue) } + set { + self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue?.rawValue) + self.addEvent(newValue, forKey: .keyValue(key)) + } } /// Value will be stored as a timestamp in seconds since 1970 @@ -227,48 +225,56 @@ public extension ObservingDatabase { key: key.rawValue, value: newValue.map { $0.timeIntervalSince1970 } ) + self.addEvent(newValue, forKey: .keyValue(key)) } } func setting(key: KeyValueStore.BoolKey, to newValue: Bool) -> KeyValueStore? { let result: KeyValueStore? = KeyValueStore(key: key.rawValue, value: newValue) self[key.rawValue] = result + self.addEvent(newValue, forKey: .keyValue(key)) return result } func setting(key: KeyValueStore.DoubleKey, to newValue: Double?) -> KeyValueStore? { let result: KeyValueStore? = KeyValueStore(key: key.rawValue, value: newValue) self[key.rawValue] = result + self.addEvent(newValue, forKey: .keyValue(key)) return result } func setting(key: KeyValueStore.IntKey, to newValue: Int?) -> KeyValueStore? { let result: KeyValueStore? = KeyValueStore(key: key.rawValue, value: newValue) self[key.rawValue] = result + self.addEvent(newValue, forKey: .keyValue(key)) return result } func setting(key: KeyValueStore.Int64Key, to newValue: Int64?) -> KeyValueStore? { let result: KeyValueStore? = KeyValueStore(key: key.rawValue, value: newValue) self[key.rawValue] = result + self.addEvent(newValue, forKey: .keyValue(key)) return result } func setting(key: KeyValueStore.StringKey, to newValue: String?) -> KeyValueStore? { let result: KeyValueStore? = KeyValueStore(key: key.rawValue, value: newValue) self[key.rawValue] = result + self.addEvent(newValue, forKey: .keyValue(key)) return result } - func setting(key: KeyValueStore.EnumKey, to newValue: T?) -> KeyValueStore? where T.RawValue == Int { + func setting(key: KeyValueStore.EnumKey, to newValue: T?) -> KeyValueStore? where T.RawValue == Int, T: Hashable { let result: KeyValueStore? = KeyValueStore(key: key.rawValue, value: newValue?.rawValue) self[key.rawValue] = result + self.addEvent(newValue, forKey: .keyValue(key)) return result } - func setting(key: KeyValueStore.EnumKey, to newValue: T?) -> KeyValueStore? where T.RawValue == String { + func setting(key: KeyValueStore.EnumKey, to newValue: T?) -> KeyValueStore? where T.RawValue == String, T: Hashable { let result: KeyValueStore? = KeyValueStore(key: key.rawValue, value: newValue?.rawValue) self[key.rawValue] = result + self.addEvent(newValue, forKey: .keyValue(key)) return result } @@ -276,6 +282,25 @@ public extension ObservingDatabase { func setting(key: KeyValueStore.DateKey, to newValue: Date?) -> KeyValueStore? { let result: KeyValueStore? = KeyValueStore(key: key.rawValue, value: newValue.map { $0.timeIntervalSince1970 }) self[key.rawValue] = result + self.addEvent(newValue, forKey: .keyValue(key)) return result } } + +// MARK: - ObservationManager + +public extension ObservableKey { + fileprivate static func keyValue(_ key: String) -> ObservableKey { ObservableKey(key, .keyValue) } + + static func keyValue(_ key: KeyValueStore.BoolKey) -> ObservableKey { keyValue(key.rawValue) } + static func keyValue(_ key: KeyValueStore.DateKey) -> ObservableKey { keyValue(key.rawValue) } + static func keyValue(_ key: KeyValueStore.DoubleKey) -> ObservableKey { keyValue(key.rawValue) } + static func keyValue(_ key: KeyValueStore.IntKey) -> ObservableKey { keyValue(key.rawValue) } + static func keyValue(_ key: KeyValueStore.Int64Key) -> ObservableKey { keyValue(key.rawValue) } + static func keyValue(_ key: KeyValueStore.StringKey) -> ObservableKey { keyValue(key.rawValue) } + static func keyValue(_ key: KeyValueStore.EnumKey) -> ObservableKey { keyValue(key.rawValue) } +} + +public extension GenericObservableKey { + static let keyValue: GenericObservableKey = "keyValue" +} diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 09817afb74..a23ca84a09 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -16,11 +16,11 @@ import Darwin public extension Singleton { static let storage: SingletonConfig = Dependencies.create( identifier: "storage", - createInstance: { dependencies in Storage(using: dependencies) } + createInstance: { dependencies, _ in Storage(using: dependencies) } ) static let scheduler: SingletonConfig = Dependencies.create( identifier: "scheduler", - createInstance: { _ in AsyncValueObservationScheduler.async(onQueue: .main) } + createInstance: { _, _ in AsyncValueObservationScheduler.async(onQueue: .main) } ) } @@ -255,15 +255,12 @@ open class Storage { public func perform( migrations: [Migration.Type], - async: Bool = true, - onProgressUpdate: ((CGFloat, TimeInterval) -> ())?, - onComplete: @escaping (Result) -> () - ) { + onProgressUpdate: ((CGFloat, TimeInterval) -> ())? = nil + ) async throws { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { let error: Error = (startupError ?? StorageError.startupFailed) Log.error(.storage, "Startup failed with error: \(error)") - onComplete(.failure(error)) - return + throw error } // Setup and run any required migrations @@ -277,7 +274,7 @@ open class Storage { // Determine which migrations need to be performed and gather the relevant settings needed to // inform the app of progress/states - let completedMigrations: [String] = (try? dbWriter.read { db in try migrator.completedMigrations(db) }) + let completedMigrations: [String] = (try? await dbWriter.read { [migrator] db in try migrator.completedMigrations(db) }) .defaulting(to: []) let unperformedMigrations: [Migration.Type] = migrations .reduce(into: []) { result, next in @@ -304,57 +301,15 @@ open class Storage { ) let totalProgress: CGFloat = (completedExpectedDuration / totalMinExpectedDuration) - DispatchQueue.main.async { + Task { @MainActor in onProgressUpdate?(totalProgress, totalMinExpectedDuration) } } - let migrationCompleted: (Result) -> () = { [weak self, migrator, dbWriter, dependencies] result in - // Make sure to transition the progress updater to 100% for the final migration (just - // in case the migration itself didn't update to 100% itself) - if let lastMigrationIdentifier: String = unperformedMigrations.last?.identifier { - MigrationExecution.current?.progressUpdater(lastMigrationIdentifier, 1) - } - - self?.hasCompletedMigrations = true - - // Output any events tracked during the migration and trigger any `postCommitActions` which - // should occur - if let events: [ObservedEvent] = MigrationExecution.current?.observedEvents { - dependencies.notifyAsync(events: events) - } - - if let actions: [String: () -> Void] = MigrationExecution.current?.postCommitActions { - actions.values.forEach { $0() } - } - - // 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 - // the 'write' functions anyway) - switch result { - case .success: break - case .failure(DatabaseError.SQLITE_ABORT): break - case .failure(let error): - let completedMigrations: [String] = (try? dbWriter - .read { db in try migrator.completedMigrations(db) }) - .defaulting(to: []) - let failedMigrationName: String = migrator.migrations - .filter { !completedMigrations.contains($0) } - .first - .defaulting(to: "Unknown") - Log.critical(.migration, "Migration '\(failedMigrationName)' failed with error: \(error)") - } - - onComplete(result) - } // if there aren't any migrations to run then just complete immediately (this way the migrator // doesn't try to execute on the DBWrite thread so returning from the background can't get blocked // due to some weird endless process running) - guard !unperformedMigrations.isEmpty else { - migrationCompleted(.success(())) - return - } + guard !unperformedMigrations.isEmpty else { return } // Create the `MigrationContext` let migrationContext: MigrationExecution.Context = MigrationExecution.Context(progressUpdater: progressUpdater) @@ -364,22 +319,58 @@ open class Storage { migrationContext.progressUpdater(firstMigrationIdentifier, 0) } - MigrationExecution.$current.withValue(migrationContext) { - // Note: The non-async migration should only be used for unit tests - guard async else { return migrationCompleted(Result(catching: { try migrator.migrate(dbWriter) })) } - - migrator.asyncMigrate(dbWriter) { [dependencies] result in - let finalResult: Result = { + return try await withCheckedThrowingContinuation { [weak self, migrator, dependencies] continuation in + MigrationExecution.$current.withValue(migrationContext) { [weak self, migrator, dependencies] in + migrator.asyncMigrate(dbWriter) { [weak self, migrator, dependencies] result in + let finalResult: Result = { + switch result { + case .failure(let error): return .failure(error) + case .success: return .success(()) + } + }() + + // Make sure to transition the progress updater to 100% for the final migration (just + // in case the migration itself didn't update to 100% itself) + if let lastMigrationIdentifier: String = unperformedMigrations.last?.identifier { + MigrationExecution.current?.progressUpdater(lastMigrationIdentifier, 1) + } + + self?.hasCompletedMigrations = true + + // Output any events tracked during the migration and trigger any `postCommitActions` which + // should occur + if let events: [ObservedEvent] = MigrationExecution.current?.observedEvents { + dependencies.notifyAsync(events: events) + } + + if let actions: [String: () -> Void] = MigrationExecution.current?.postCommitActions { + actions.values.forEach { $0() } + } + + /// 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 the `write` functions anyway) switch result { - case .failure(let error): return .failure(error) - case .success: return .success(()) + case .success: break + case .failure(DatabaseError.SQLITE_ABORT): break + case .failure(let error): + Task { [migrator] in + let completedMigrations: [String] = (try? await dbWriter + .read { [migrator] db in try migrator.completedMigrations(db) }) + .defaulting(to: []) + let failedMigrationName: String = migrator.migrations + .filter { !completedMigrations.contains($0) } + .first + .defaulting(to: "Unknown") + Log.critical(.migration, "Migration '\(failedMigrationName)' failed with error: \(error)") + } + } + + /// Resume the continuation + switch result { + case .failure(let error): continuation.resume(throwing: error) + case .success: continuation.resume() } - }() - - // Note: We need to dispatch this to the next run toop to prevent blocking if the callback - // performs subsequent database operations - DispatchQueue.global(qos: .userInitiated).async(using: dependencies) { - migrationCompleted(finalResult) } } } @@ -592,7 +583,7 @@ open class Storage { /// Do this outside of the actually db operation as it's more for debugging queries running on the main thread /// than trying to slow the query itself - if !SNUtilitiesKit.isRunningTests && dependencies[feature: .forceSlowDatabaseQueries] { + if dependencies[feature: .forceSlowDatabaseQueries] { try await Task.sleep(for: .seconds(1)) } @@ -601,8 +592,10 @@ open class Storage { try await dbWriter.read(trackedOperation) ) - /// Trigger the observations - dependencies.notifyAsync(events: output.events) + /// Trigger the observations in a detached task so we don't block + Task.detached { [dependencies] in + await dependencies.notify(events: output.events) + } return (output.result, output.postCommitActions) } @@ -804,8 +797,7 @@ open class Storage { addCall(info) defer { removeCall(info) } - return try await Storage.performOperation(info, dbWriter, operation, dependencies) - .successOrThrow() + return try await Storage.performOperation(info, dbWriter, operation, dependencies).get() } private func addCall(_ call: CallInfo) { @@ -1322,7 +1314,7 @@ public protocol IdentifiableTransactionObserver: TransactionObserver { private extension Storage { actor PublisherBridge { - private weak var subject: PassthroughSubject? + private var subject: PassthroughSubject? private var isFinished: Bool = false init(subject: PassthroughSubject) { @@ -1332,7 +1324,8 @@ private extension Storage { func send(result: Result, info: CallInfo) { guard !isFinished, let subject = self.subject else { return } - isFinished = true + self.isFinished = true + self.subject = nil switch result { case .success(let value): @@ -1349,7 +1342,8 @@ private extension Storage { func cancel() { guard !isFinished else { return } - isFinished = true + self.isFinished = true + self.subject = nil } } } diff --git a/SessionUtilitiesKit/Dependency Injection/CacheConfig.swift b/SessionUtilitiesKit/Dependency Injection/CacheConfig.swift index 3bf93e05b1..466dadb84a 100644 --- a/SessionUtilitiesKit/Dependency Injection/CacheConfig.swift +++ b/SessionUtilitiesKit/Dependency Injection/CacheConfig.swift @@ -15,13 +15,13 @@ public protocol ImmutableCacheType {} public class CacheConfig: Cache { public let identifier: String - public let createInstance: (Dependencies) -> M + public let createInstance: (Dependencies, Dependencies.Key) -> M public let mutableInstance: (M) -> MutableCacheType public let immutableInstance: (M) -> I fileprivate init( identifier: String, - createInstance: @escaping (Dependencies) -> M, + createInstance: @escaping (Dependencies, Dependencies.Key) -> M, mutableInstance: @escaping (M) -> MutableCacheType, immutableInstance: @escaping (M) -> I ) { @@ -37,7 +37,7 @@ public class CacheConfig: Cache { public extension Dependencies { static func create( identifier: String, - createInstance: @escaping (Dependencies) -> M, + createInstance: @escaping (Dependencies, Dependencies.Key) -> M, mutableInstance: @escaping (M) -> MutableCacheType, immutableInstance: @escaping (M) -> I ) -> CacheConfig { diff --git a/SessionUtilitiesKit/Dependency Injection/Dependencies.swift b/SessionUtilitiesKit/Dependency Injection/Dependencies.swift index 4c6578106a..5af78640ec 100644 --- a/SessionUtilitiesKit/Dependency Injection/Dependencies.swift +++ b/SessionUtilitiesKit/Dependency Injection/Dependencies.swift @@ -5,7 +5,7 @@ import Foundation import Combine -public class Dependencies { +public class Dependencies: FeatureStorageType { static let userInfoKey: CodingUserInfoKey = CodingUserInfoKey(rawValue: "session.dependencies.codingOptions")! /// The `isRTLRetriever` is handled differently from normal dependencies because it's not really treated as such (it's more of @@ -13,15 +13,15 @@ public class Dependencies { @ThreadSafeObject private static var cachedIsRTLRetriever: (requiresMainThread: Bool, retriever: () -> Bool) = (false, { false }) @ThreadSafeObject private var storage: DependencyStorage = DependencyStorage() - private typealias DependencyChange = (Dependencies.DependencyStorage.Key, DependencyStorage.Value?) - private let dependencyChangeStream: CancellationAwareAsyncStream = CancellationAwareAsyncStream() + fileprivate typealias DependencyChange = (Dependencies.Key, DependencyStorage.Value?) + fileprivate let dependencyChangeStream: CancellationAwareAsyncStream = CancellationAwareAsyncStream() // MARK: - Subscript Access public subscript(singleton singleton: SingletonConfig) -> S { getOrCreate(singleton) } public subscript(cache cache: CacheConfig) -> I { getOrCreate(cache).immutable(cache: cache, using: self) } public subscript(defaults defaults: UserDefaultsConfig) -> UserDefaultsType { getOrCreate(defaults) } - public subscript(feature feature: FeatureConfig) -> T { getOrCreate(feature).currentValue(using: self) } + public subscript(feature feature: FeatureConfig) -> T { getOrCreate(feature).currentValue(in: self) } // MARK: - Global Values, Timing and Async Handling @@ -37,6 +37,17 @@ public class Dependencies { public var fixedTime: Int { 0 } public var forceSynchronous: Bool { false } + public func timestampNowMsWithOffset(offsetMs: Int64) -> T { + let timestampNowMs: Int64 = (Int64(floor(dateNow.timeIntervalSince1970 * 1000)) + offsetMs) + + guard let convertedTimestampNowMs: T = T(exactly: timestampNowMs) else { + Log.critical("Failed to convert the timestamp to the desired type: \(type(of: T.self)).") + return 0 + } + + return convertedTimestampNowMs + } + // MARK: - Initialization public static func createEmpty() -> Dependencies { return Dependencies() } @@ -58,7 +69,8 @@ public class Dependencies { /// This code path should never happen (and is essentially invalid if it does) but in order to avoid neeing to return /// a nullable type or force-casting this is how we need to do things) Log.critical("Failed to convert erased cache value for '\(cache.identifier)' to expected type: \(M.self)") - let fallbackValue: M = cache.createInstance(self) + let key: Dependencies.Key = Dependencies.Key.Variant.cache.key(cache.identifier) + let fallbackValue: M = cache.createInstance(self, key) return mutation(fallbackValue) } @@ -75,7 +87,8 @@ public class Dependencies { /// This code path should never happen (and is essentially invalid if it does) but in order to avoid neeing to return /// a nullable type or force-casting this is how we need to do things) Log.critical("Failed to convert erased cache value for '\(cache.identifier)' to expected type: \(M.self)") - let fallbackValue: M = cache.createInstance(self) + let key: Dependencies.Key = Dependencies.Key.Variant.cache.key(cache.identifier) + let fallbackValue: M = cache.createInstance(self, key) return try mutation(fallbackValue) } @@ -117,7 +130,17 @@ public class Dependencies { // MARK: - Instance management - public func warmCache(cache: CacheConfig) { + public func has(singleton: SingletonConfig) -> Bool { + let key: Dependencies.Key = Key.Variant.singleton.key(singleton.identifier) + + return (_storage.performMap({ $0.instances[key]?.value(as: S.self) }) != nil) + } + + public func warm(singleton: SingletonConfig) { + _ = getOrCreate(singleton) + } + + public func warm(cache: CacheConfig) { _ = getOrCreate(cache) } @@ -130,6 +153,10 @@ public class Dependencies { setValue(value, typedStorage: .cache(value), key: cache.identifier) } + public func remove(singleton: SingletonConfig) { + removeValue(singleton.identifier, of: .singleton) + } + public func remove(cache: CacheConfig) { removeValue(cache.identifier, of: .cache) } @@ -138,7 +165,8 @@ public class Dependencies { _cachedIsRTLRetriever.set(to: (requiresMainThread, isRTLRetriever)) } - private func waitUntilInitialised(targetKey: Dependencies.DependencyStorage.Key) async throws { + @available(iOS 16.0, *) + private func waitUntilInitialised(targetKey: Dependencies.Key) async throws { /// If we already have an instance (which isn't a `NoopDependency`) then no need to observe the stream guard !_storage.performMap({ $0.instances[targetKey]?.isNoop == false }) else { return } @@ -150,12 +178,27 @@ public class Dependencies { } } + @available(iOS 16.0, *) public func waitUntilInitialised(singleton: SingletonConfig) async throws { - try await waitUntilInitialised(targetKey: DependencyStorage.Key.Variant.singleton.key(singleton.identifier)) + try await waitUntilInitialised(targetKey: Key.Variant.singleton.key(singleton.identifier)) } + @available(iOS 16.0, *) public func waitUntilInitialised(cache: CacheConfig) async throws { - try await waitUntilInitialised(targetKey: DependencyStorage.Key.Variant.cache.key(cache.identifier)) + try await waitUntilInitialised(targetKey: Key.Variant.cache.key(cache.identifier)) + } + + // MARK: - FeatureStorageType + + public var hardfork: Int { self[defaults: .standard, key: .hardfork] } + public var softfork: Int { self[defaults: .standard, key: .hardfork] } + + public func rawFeatureValue(forKey defaultName: String) -> Any? { + return self[defaults: .appGroup].object(forKey: defaultName) + } + + public func storeFeatureValue(_ value: Any?, forKey defaultName: String) { + return self[defaults: .appGroup].set(value, forKey: defaultName) } } @@ -163,9 +206,11 @@ public class Dependencies { private extension ThreadSafeObject { func immutable(cache: CacheConfig, using dependencies: Dependencies) -> I { + let key: Dependencies.Key = Dependencies.Key.Variant.cache.key(cache.identifier) + return cache.immutableInstance( (self.wrappedValue as? M) ?? - cache.createInstance(dependencies) + cache.createInstance(dependencies, key) ) } } @@ -174,8 +219,7 @@ private extension ThreadSafeObject { public extension Dependencies { func hasSet(feature: FeatureConfig) -> Bool { - let key: Dependencies.DependencyStorage.Key = DependencyStorage.Key.Variant.feature - .key(feature.identifier) + let key: Dependencies.Key = Key.Variant.feature.key(feature.identifier) /// Use a `readLock` to check if a value has been set guard @@ -187,16 +231,15 @@ public extension Dependencies { } func set(feature: FeatureConfig, to updatedFeature: T?) { - let key: Dependencies.DependencyStorage.Key = DependencyStorage.Key.Variant.feature - .key(feature.identifier) + let key: Dependencies.Key = Key.Variant.feature.key(feature.identifier) let typedValue: DependencyStorage.Value? = _storage.performMap { $0.instances[key] } /// Update the cached & in-memory values let instance: Feature = ( typedValue?.value(as: Feature.self) ?? - feature.createInstance(self) + feature.createInstance(self, key) ) - instance.setValue(to: updatedFeature, using: self) + instance.setValue(to: updatedFeature, in: self) setValue(instance, typedStorage: .feature(instance), key: feature.identifier) /// Notify observers @@ -207,20 +250,17 @@ public extension Dependencies { } func reset(feature: FeatureConfig) { - let key: Dependencies.DependencyStorage.Key = DependencyStorage.Key.Variant.feature - .key(feature.identifier) + let key: Dependencies.Key = Key.Variant.feature.key(feature.identifier) /// Reset the cached and in-memory values _storage.perform { storage in storage.instances[key]? .value(as: Feature.self)? - .setValue(to: nil, using: self) + .setValue(to: nil, in: self) } removeValue(feature.identifier, of: .feature) /// Notify observers - - Task { await dependencyChangeStream.send((key, nil)) } notifyAsync(events: [ ObservedEvent(key: .feature(feature), value: nil), ObservedEvent(key: .featureGroup(feature), value: nil) @@ -228,7 +268,9 @@ public extension Dependencies { } func defaultValue(feature: FeatureConfig) -> T? { - return feature.createInstance(self).defaultOption + let key: Dependencies.Key = Dependencies.Key.Variant.feature.key(feature.identifier) + + return feature.createInstance(self, key).defaultOption } } @@ -240,33 +282,35 @@ public enum DependenciesError: Error { // MARK: - Storage Management +public extension Dependencies { + struct Key: Hashable, CustomStringConvertible { + public enum Variant: String { + case singleton + case cache + case userDefaults + case feature + + public func key(_ identifier: String) -> Key { + return Key(identifier, of: self) + } + } + + public let identifier: String + public let variant: Variant + public var description: String { "\(variant): \(identifier)" } + + fileprivate init(_ identifier: String, of variant: Variant) { + self.identifier = identifier + self.variant = variant + } + } +} + private extension Dependencies { class DependencyStorage { var initializationLocks: [Key: NSLock] = [:] var instances: [Key: Value] = [:] - struct Key: Hashable, CustomStringConvertible { - enum Variant: String { - case singleton - case cache - case userDefaults - case feature - - func key(_ identifier: String) -> Key { - return Key(identifier, of: self) - } - } - - let identifier: String - let variant: Variant - var description: String { "\(variant): \(identifier)" } - - init(_ identifier: String, of variant: Variant) { - self.identifier = identifier - self.variant = variant - } - } - enum Value { case singleton(Any) case cache(ThreadSafeObject) @@ -303,30 +347,38 @@ private extension Dependencies { } private func getOrCreate(_ singleton: SingletonConfig) -> S { + let key: Dependencies.Key = Dependencies.Key.Variant.singleton.key(singleton.identifier) + return getOrCreateInstance( identifier: singleton.identifier, - constructor: .singleton { singleton.createInstance(self) } + constructor: .singleton { singleton.createInstance(self, key) } ) } private func getOrCreate(_ cache: CacheConfig) -> ThreadSafeObject { + let key: Dependencies.Key = Dependencies.Key.Variant.cache.key(cache.identifier) + return getOrCreateInstance( identifier: cache.identifier, - constructor: .cache { ThreadSafeObject(cache.mutableInstance(cache.createInstance(self))) } + constructor: .cache { ThreadSafeObject(cache.mutableInstance(cache.createInstance(self, key))) } ) } private func getOrCreate(_ defaults: UserDefaultsConfig) -> UserDefaultsType { + let key: Dependencies.Key = Dependencies.Key.Variant.userDefaults.key(defaults.identifier) + return getOrCreateInstance( identifier: defaults.identifier, - constructor: .userDefaults { defaults.createInstance(self) } + constructor: .userDefaults { defaults.createInstance(self, key) } ) } private func getOrCreate(_ feature: FeatureConfig) -> Feature { + let key: Dependencies.Key = Dependencies.Key.Variant.feature.key(feature.identifier) + return getOrCreateInstance( identifier: feature.identifier, - constructor: .feature { feature.createInstance(self) } + constructor: .feature { feature.createInstance(self, key) } ) } @@ -338,7 +390,7 @@ private extension Dependencies { identifier: String, constructor: DependencyStorage.Constructor ) -> Value { - let key: Dependencies.DependencyStorage.Key = constructor.variant.key(identifier) + let key: Dependencies.Key = constructor.variant.key(identifier) /// If we already have an instance then just return that (need to get a `writeLock` here because accessing values on a class /// isn't thread safe so we need to block during access) @@ -378,7 +430,7 @@ private extension Dependencies { /// Convenience method to store a dependency instance in memory in a thread-safe way @discardableResult private func setValue(_ value: T, typedStorage: DependencyStorage.Value, key: String) -> T { - let finalKey: DependencyStorage.Key = typedStorage.distinctKey(for: key) + let finalKey: Key = typedStorage.distinctKey(for: key) let result: T = _storage.performUpdateAndMap { storage in storage.instances[finalKey] = typedStorage return (storage, value) @@ -394,8 +446,8 @@ private extension Dependencies { } /// Convenience method to remove a dependency instance from memory in a thread-safe way - private func removeValue(_ key: String, of variant: DependencyStorage.Key.Variant) { - let finalKey: DependencyStorage.Key = variant.key(key) + private func removeValue(_ key: String, of variant: Key.Variant) { + let finalKey: Key = variant.key(key) _storage.performUpdate { storage in storage.instances.removeValue(forKey: finalKey) return storage @@ -409,7 +461,7 @@ private extension Dependencies { private extension Dependencies.DependencyStorage { struct Constructor { - let variant: Key.Variant + let variant: Dependencies.Key.Variant let create: () -> (typedStorage: Dependencies.DependencyStorage.Value, value: T) static func singleton(_ constructor: @escaping () -> T) -> Constructor { @@ -445,3 +497,42 @@ private extension Dependencies.DependencyStorage { } } } + +// MARK: - Async/Await + +public extension Dependencies { + @available(iOS 16.0, *) + private func stream(key: Dependencies.Key, initialValueRetriever: (@escaping () -> T?)) -> AsyncStream { + return dependencyChangeStream.stream + .filter { changedKey, _ in changedKey == key } + .compactMap { _, changedValue in changedValue?.value(as: T.self) } + .prepend(initialValueRetriever()) + .asAsyncStream() + } + + @available(iOS 16.0, *) + func stream(key: Dependencies.Key, of type: T.Type) -> AsyncStream { + return stream(key: key, initialValueRetriever: { nil }) + } + + @available(iOS 16.0, *) + func stream(singleton: SingletonConfig) -> AsyncStream { + let key = Dependencies.Key.Variant.singleton.key(singleton.identifier) + + return stream(key: key, initialValueRetriever: { [weak self] in self?[singleton: singleton] }) + } + + @available(iOS 16.0, *) + func stream(cache: CacheConfig) -> AsyncStream { + let key = Dependencies.Key.Variant.cache.key(cache.identifier) + + return stream(key: key, initialValueRetriever: { [weak self] in self?[cache: cache] }) + } + + @available(iOS 16.0, *) + func stream(feature: FeatureConfig) -> AsyncStream { + let key = Dependencies.Key.Variant.feature.key(feature.identifier) + + return stream(key: key, initialValueRetriever: { [weak self] in self?[feature: feature] }) + } +} diff --git a/SessionUtilitiesKit/Dependency Injection/FeatureConfig.swift b/SessionUtilitiesKit/Dependency Injection/FeatureConfig.swift index 30440e19ab..2763084b00 100644 --- a/SessionUtilitiesKit/Dependency Injection/FeatureConfig.swift +++ b/SessionUtilitiesKit/Dependency Injection/FeatureConfig.swift @@ -11,7 +11,7 @@ public class FeatureStorage {} public class FeatureConfig: FeatureStorage { public let identifier: String public let groupIdentifier: String? - public let createInstance: (Dependencies) -> Feature + public let createInstance: (Dependencies, Dependencies.Key) -> Feature /// `fileprivate` to hide when accessing via `dependencies[feature: ]` fileprivate init( @@ -22,7 +22,7 @@ public class FeatureConfig: FeatureStorage { ) { self.identifier = identifier self.groupIdentifier = groupIdentifier - self.createInstance = { _ in + self.createInstance = { _, _ in Feature( identifier: identifier, defaultOption: defaultOption, diff --git a/SessionUtilitiesKit/Dependency Injection/SingletonConfig.swift b/SessionUtilitiesKit/Dependency Injection/SingletonConfig.swift index 5841512610..e83fb4edf6 100644 --- a/SessionUtilitiesKit/Dependency Injection/SingletonConfig.swift +++ b/SessionUtilitiesKit/Dependency Injection/SingletonConfig.swift @@ -10,12 +10,12 @@ public class Singleton {} public class SingletonConfig: Singleton { public let identifier: String - public let createInstance: (Dependencies) -> S + public let createInstance: (Dependencies, Dependencies.Key) -> S /// `fileprivate` to hide when accessing via `dependencies[singleton: ]` fileprivate init( identifier: String, - createInstance: @escaping (Dependencies) -> S + createInstance: @escaping (Dependencies, Dependencies.Key) -> S ) { self.identifier = identifier self.createInstance = createInstance @@ -27,7 +27,7 @@ public class SingletonConfig: Singleton { public extension Dependencies { static func create( identifier: String, - createInstance: @escaping (Dependencies) -> S + createInstance: @escaping (Dependencies, Dependencies.Key) -> S ) -> SingletonConfig { return SingletonConfig( identifier: identifier, diff --git a/SessionUtilitiesKit/Dependency Injection/UserDefaultsConfig.swift b/SessionUtilitiesKit/Dependency Injection/UserDefaultsConfig.swift index cdfed9af2d..53d74d92fc 100644 --- a/SessionUtilitiesKit/Dependency Injection/UserDefaultsConfig.swift +++ b/SessionUtilitiesKit/Dependency Injection/UserDefaultsConfig.swift @@ -10,12 +10,12 @@ public class UserDefaultsStorage {} public class UserDefaultsConfig: UserDefaultsStorage { public let identifier: String - public let createInstance: (Dependencies) -> UserDefaultsType + public let createInstance: (Dependencies, Dependencies.Key) -> UserDefaultsType /// `fileprivate` to hide when accessing via `dependencies[defaults: ]` fileprivate init( identifier: String, - createInstance: @escaping (Dependencies) -> UserDefaultsType + createInstance: @escaping (Dependencies, Dependencies.Key) -> UserDefaultsType ) { self.identifier = identifier self.createInstance = createInstance @@ -27,7 +27,7 @@ public class UserDefaultsConfig: UserDefaultsStorage { public extension Dependencies { static func create( identifier: String, - createInstance: @escaping (Dependencies) -> UserDefaultsType + createInstance: @escaping (Dependencies, Dependencies.Key) -> UserDefaultsType ) -> UserDefaultsConfig { return UserDefaultsConfig( identifier: identifier, diff --git a/SessionUtilitiesKit/General/AppContext.swift b/SessionUtilitiesKit/General/AppContext.swift index cf47d1f0c3..45cd1e4b97 100644 --- a/SessionUtilitiesKit/General/AppContext.swift +++ b/SessionUtilitiesKit/General/AppContext.swift @@ -7,7 +7,7 @@ import UIKit public extension Singleton { static let appContext: SingletonConfig = Dependencies.create( identifier: "appContext", - createInstance: { _ in NoopAppContext() } + createInstance: { _, _ in NoopAppContext() } ) } diff --git a/SessionUtilitiesKit/General/Feature+ServiceNetwork.swift b/SessionUtilitiesKit/General/Feature+ServiceNetwork.swift deleted file mode 100644 index 7ae5469b20..0000000000 --- a/SessionUtilitiesKit/General/Feature+ServiceNetwork.swift +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. -// -// stringlint:disable - -import Foundation - -// MARK: - FeatureStorage - -public extension FeatureStorage { - static let serviceNetwork: FeatureConfig = Dependencies.create( - identifier: "serviceNetwork", - defaultOption: .mainnet - ) -} - -// MARK: - ServiceNetwork Feature - -public enum ServiceNetwork: Int, Sendable, FeatureOption, CaseIterable { - case mainnet = 1 - case testnet = 2 - - // MARK: - Feature Option - - public static var defaultOption: ServiceNetwork = .mainnet - - public var title: String { - switch self { - case .mainnet: return "Mainnet" - case .testnet: return "Testnet" - } - } - - public var subtitle: String? { - switch self { - case .mainnet: return "This is the production service node network." - case .testnet: return "This is the test service node network, it should be used for testing features which are currently in development and may be unstable." - } - } -} diff --git a/SessionUtilitiesKit/General/Feature.swift b/SessionUtilitiesKit/General/Feature.swift index 0e512347e0..8fd4802c67 100644 --- a/SessionUtilitiesKit/General/Feature.swift +++ b/SessionUtilitiesKit/General/Feature.swift @@ -161,14 +161,14 @@ public struct Feature: FeatureType { return (dependencies[defaults: .appGroup].object(forKey: identifier) != nil) } - internal func currentValue(using dependencies: Dependencies) -> T { + internal func currentValue(in featureStorage: any FeatureStorageType) -> T { let maybeSelectedOption: T? = { // `Int` defaults to `0` and `Bool` defaults to `false` so rather than those (in case we want // a default value that isn't `0` or `false` which might be considered valid cases) we check // if an entry exists and return `nil` if not before retrieving an `Int` representation of // the value and converting to the desired type - guard dependencies[defaults: .appGroup].object(forKey: identifier) != nil else { return nil } - guard let selectedOption: T.RawValue = dependencies[defaults: .appGroup].object(forKey: identifier) as? T.RawValue else { + guard featureStorage.rawFeatureValue(forKey: identifier) != nil else { return nil } + guard let selectedOption: T.RawValue = featureStorage.rawFeatureValue(forKey: identifier) as? T.RawValue else { Log.error("Unable to retrieve feature option for \(identifier) due to incorrect storage type") return nil } @@ -181,11 +181,12 @@ public struct Feature: FeatureType { guard let selectedOption: T = maybeSelectedOption, selectedOption.isValidOption else { func automaticChangeConditionMet(_ condition: ChangeCondition) -> Bool { switch condition { - case .after(let timestamp): return (dependencies.dateNow.timeIntervalSince1970 >= timestamp) + case .after(let timestamp): + return (featureStorage.dateNow.timeIntervalSince1970 >= timestamp) case .afterFork(let hard, let soft): - let currentHardFork: Int = dependencies[defaults: .standard, key: .hardfork] - let currentSoftFork: Int = dependencies[defaults: .standard, key: .softfork] + let currentHardFork: Int = featureStorage.hardfork + let currentSoftFork: Int = featureStorage.softfork let currentVersion: Version = Version(major: currentHardFork, minor: currentSoftFork, patch: 0) let requiredVersion: Version = Version(major: hard, minor: soft, patch: 0) @@ -218,11 +219,22 @@ public struct Feature: FeatureType { return selectedOption } - internal func setValue(to updatedValue: T?, using dependencies: Dependencies) { - dependencies[defaults: .appGroup].set(updatedValue?.rawValue, forKey: identifier) + internal func setValue(to updatedValue: T?, in featureStorage: any FeatureStorageType) { + featureStorage.storeFeatureValue(updatedValue?.rawValue, forKey: identifier) } } +// MARK: - Feature Storage + +public protocol FeatureStorageType { + var hardfork: Int { get } + var softfork: Int { get } + var dateNow: Date { get } + + func rawFeatureValue(forKey defaultName: String) -> Any? + func storeFeatureValue(_ value: Any?, forKey defaultName: String) +} + // MARK: - Convenience public struct FeatureValue { diff --git a/SessionUtilitiesKit/General/General.swift b/SessionUtilitiesKit/General/General.swift index 1c7f764c4e..ac66bf6e4f 100644 --- a/SessionUtilitiesKit/General/General.swift +++ b/SessionUtilitiesKit/General/General.swift @@ -8,7 +8,7 @@ import GRDB public extension Cache { static let general: CacheConfig = Dependencies.create( identifier: "general", - createInstance: { dependencies in General.Cache(using: dependencies) }, + createInstance: { dependencies, _ in General.Cache(using: dependencies) }, mutableInstance: { $0 }, immutableInstance: { $0 } ) diff --git a/SessionUtilitiesKit/General/Logging.swift b/SessionUtilitiesKit/General/Logging.swift index 67b529d8ec..c23eb69c65 100644 --- a/SessionUtilitiesKit/General/Logging.swift +++ b/SessionUtilitiesKit/General/Logging.swift @@ -43,7 +43,7 @@ public enum Log { level: Log.Level, categories: [Category], message: String, - file: StaticString, + file: String, function: StaticString, line: UInt ) @@ -71,6 +71,42 @@ public enum Log { case .default: return "default" } } + + var emoji: String { + switch self { + case .off, .default: return "" + case .verbose: return "💙" + case .debug: return "💚" + case .info: return "💛" + case .warn: return "🧡" + case .error: return "❤️" + case .critical: return "🔥" + } + } + + var ddLevel: DDLogLevel { + switch self { + case .off, .default: return .off + case .verbose: return .verbose + case .debug: return .warning + case .info: return .info + case .warn: return .warning + case .error: return .error + case .critical: return .error + } + } + + var ddFlag: DDLogFlag { + switch self { + case .off, .default: return .verbose + case .verbose: return .verbose + case .debug: return .debug + case .info: return .info + case .warn: return .warning + case .error: return .error + case .critical: return .error + } + } } public struct Group: Hashable { @@ -255,126 +291,126 @@ public enum Log { // FIXME: Would be nice to properly require a category for all logs public static func verbose( _ msg: String, - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { custom(.verbose, [], msg, file: file, function: function, line: line) } public static func verbose( _ cat: Category , _ msg: String, - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { custom(.verbose, [cat], msg, file: file, function: function, line: line) } public static func verbose( _ cats: [Category], _ msg: String, - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { custom(.verbose, cats, msg, file: file, function: function, line: line) } public static func debug( _ msg: String, - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { custom(.debug, [], msg, file: file, function: function, line: line) } public static func debug( _ cat: Category, _ msg: String, - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { custom(.debug, [cat], msg, file: file, function: function, line: line) } public static func debug( _ cats: [Category], _ msg: String, - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { custom(.debug, cats, msg, file: file, function: function, line: line) } public static func info( _ msg: String, - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { custom(.info, [], msg, file: file, function: function, line: line) } public static func info( _ cat: Category, _ msg: String, - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { custom(.info, [cat], msg, file: file, function: function, line: line) } public static func info( _ cats: [Category], _ msg: String, - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { custom(.info, cats, msg, file: file, function: function, line: line) } public static func warn( _ msg: String, - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { custom(.warn, [], msg, file: file, function: function, line: line) } public static func warn( _ cat: Category, _ msg: String, - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { custom(.warn, [cat], msg, file: file, function: function, line: line) } public static func warn( _ cats: [Category], _ msg: String, - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { custom(.warn, cats, msg, file: file, function: function, line: line) } public static func error( _ msg: String, - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { custom(.error, [], msg, file: file, function: function, line: line) } public static func error( _ cat: Category, _ msg: String, - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { custom(.error, [cat], msg, file: file, function: function, line: line) } public static func error( _ cats: [Category], _ msg: String, - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { custom(.error, cats, msg, file: file, function: function, line: line) } public static func critical( _ msg: String, - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { custom(.critical, [], msg, file: file, function: function, line: line) } public static func critical( _ cat: Category, _ msg: String, - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { custom(.critical, [cat], msg, file: file, function: function, line: line) } public static func critical( _ cats: [Category], _ msg: String, - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { custom(.critical, cats, msg, file: file, function: function, line: line) } @@ -382,7 +418,7 @@ public enum Log { public static func assert( _ condition: Bool, _ message: @autoclosure () -> String = String(), - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { @@ -397,7 +433,7 @@ public enum Log { } public static func assertOnMainThread( - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { @@ -412,7 +448,7 @@ public enum Log { } public static func assertNotOnMainThread( - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { @@ -430,7 +466,7 @@ public enum Log { _ level: Level, _ categories: [Category], _ message: String, - file: StaticString = #fileID, + file: String = #fileID, function: StaticString = #function, line: UInt = #line ) { @@ -459,7 +495,7 @@ public protocol LoggerType: Actor { _ level: Log.Level, _ categories: [Log.Category], _ message: String, - file: StaticString, + file: String, function: StaticString, line: UInt ) @@ -687,7 +723,7 @@ public actor Logger: LoggerType { _ level: Log.Level, _ categories: [Log.Category], _ message: String, - file: StaticString, + file: String, function: StaticString, line: UInt ) { @@ -753,16 +789,26 @@ public actor Logger: LoggerType { return updatedText } let ddLogMessage: String = "\(logPrefix) ".appending(cleanedMessage) - let consoleLogMessage: String = "\(logPrefix)[\(level)] ".appending(cleanedMessage) + let consoleLogMessage: String = "\(logPrefix)[\(level)|\(file):\(line)] ".appending(cleanedMessage) - switch level { - case .off, .default: return - case .verbose: DDLogVerbose("💙 \(ddLogMessage)", file: file, function: function, line: line) - case .debug: DDLogDebug("💚 \(ddLogMessage)", file: file, function: function, line: line) - case .info: DDLogInfo("💛 \(ddLogMessage)", file: file, function: function, line: line) - case .warn: DDLogWarn("🧡 \(ddLogMessage)", file: file, function: function, line: line) - case .error: DDLogError("❤️ \(ddLogMessage)", file: file, function: function, line: line) - case .critical: DDLogError("🔥 \(ddLogMessage)", file: file, function: function, line: line) + /// Log the message via `DDLog` if logging is on + if level != .off && level != .default { + DDLog.log( + asynchronous: asyncLoggingEnabled, + message: DDLogMessage( + format: "", + formatted: "\(level.emoji) \(ddLogMessage)", + level: level.ddLevel, + flag: level.ddFlag, + context: 0, + file: file, + function: String(describing: function), + line: line, + tag: nil, + options: [.dontCopyMessage], + timestamp: nil + ) + ) } let mainCategory: String = (categories.first?.rawValue ?? "General") diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index e3549638e3..e9f66d611f 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -11,7 +11,7 @@ import GRDB public extension Singleton { static let jobRunner: SingletonConfig = Dependencies.create( identifier: "jobRunner", - createInstance: { dependencies in JobRunner(using: dependencies) } + createInstance: { dependencies, _ in JobRunner(using: dependencies) } ) } @@ -61,10 +61,12 @@ public protocol JobRunnerType: AnyObject { // MARK: - JobRunnerType Convenience public extension JobRunnerType { - func allJobInfo() -> [Int64: JobRunner.JobInfo] { return jobInfoFor(jobs: nil, state: .any, variant: nil) } + func allJobInfo() -> [Int64: JobRunner.JobInfo] { + return jobInfoFor(jobs: nil, state: .anyState, variant: nil) + } func jobInfoFor(jobs: [Job]) -> [Int64: JobRunner.JobInfo] { - return jobInfoFor(jobs: jobs, state: .any, variant: nil) + return jobInfoFor(jobs: jobs, state: .anyState, variant: nil) } func jobInfoFor(jobs: [Job], state: JobRunner.JobState) -> [Int64: JobRunner.JobInfo] { @@ -80,7 +82,7 @@ public extension JobRunnerType { } func jobInfoFor(variant: Job.Variant) -> [Int64: JobRunner.JobInfo] { - return jobInfoFor(jobs: nil, state: .any, variant: variant) + return jobInfoFor(jobs: nil, state: .anyState, variant: variant) } func isCurrentlyRunning(_ job: Job?) -> Bool { @@ -91,7 +93,7 @@ public extension JobRunnerType { func hasJob( of variant: Job.Variant? = nil, - inState state: JobRunner.JobState = .any, + inState state: JobRunner.JobState = .anyState, with jobDetails: T ) -> Bool { guard @@ -116,7 +118,7 @@ public extension JobRunnerType { } func afterJob(_ job: Job?) -> AnyPublisher { - return afterJob(job, state: .any) + return afterJob(job, state: .anyState) } } @@ -167,7 +169,7 @@ public final class JobRunner: JobRunnerType { 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 static let anyState: JobState = [ .pending, .running ] } public enum JobResult: Equatable { @@ -904,6 +906,8 @@ public final class JobRunner: JobRunnerType { } public func scheduleRecurringJobsIfNeeded() { + guard dependencies[singleton: .appContext].isMainApp else { return } + let scheduleInfo: [ScheduleInfo] = registeredRecurringJobs let variants: Set = Set(scheduleInfo.map { $0.variant }) let maybeExistingJobs: [Job]? = dependencies[singleton: .storage].read { db in @@ -1703,7 +1707,12 @@ public final class JobQueue: Hashable { updates: { [dependencies] db -> [Job] in /// 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] = try job.dependantJobs.fetchAll(db) + let dependantJobIds: Set = try JobDependencies + .select(.jobId) + .filter(JobDependencies.Columns.dependantId == job.id) + .asRequest(of: Int64.self) + .fetchSet(db) + let dependantJobs: [Job] = try Job.fetchAll(db, ids: dependantJobIds) switch job.behaviour { case .runOnce, .runOnceNextLaunch, .runOnceAfterConfigSyncIgnoringPermanentFailure: @@ -1822,15 +1831,19 @@ public final class JobQueue: Hashable { // Get the max failure count for the job (a value of '-1' means it will retry indefinitely) let maxFailureCount: Int = (executorMap[job.variant]?.maxFailureCount ?? 0) let nextRunTimestamp: TimeInterval = (dependencies.dateNow.timeIntervalSince1970 + JobRunner.getRetryInterval(for: job)) - var dependantJobIds: [Int64] = [] + var dependantJobIds: Set = [] var failureText: String = "failed due to error: \(error)" dependencies[singleton: .storage].write { db in /// Retrieve a list of dependant jobs so we can clear them from the queue - dependantJobIds = try job.dependantJobs - .select(.id) + + /// 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) + dependantJobIds = try JobDependencies + .select(.jobId) + .filter(JobDependencies.Columns.dependantId == job.id) .asRequest(of: Int64.self) - .fetchAll(db) + .fetchSet(db) /// Delete/update the failed jobs and any dependencies let updatedFailureCount: UInt = (job.failureCount + 1) @@ -1849,9 +1862,7 @@ public final class JobQueue: Hashable { // If the job permanently failed or we have performed all of our retry attempts // then delete the job and all of it's dependant jobs (it'll probably never succeed) - _ = try job.dependantJobs - .deleteAll(db) - + _ = try Job.deleteAll(db, ids: dependantJobIds) _ = try job.delete(db) return } @@ -1868,7 +1879,8 @@ public final class JobQueue: Hashable { // Update the failureCount and nextRunTimestamp on dependant jobs as well (update the // 'nextRunTimestamp' value to be 1ms later so when the queue gets regenerated they'll // come after the dependency) - try job.dependantJobs + try Job + .filter(ids: dependantJobIds) .updateAll( db, Job.Columns.failureCount.set(to: updatedFailureCount), diff --git a/SessionUtilitiesKit/LibSession/LibSession.swift b/SessionUtilitiesKit/LibSession/LibSession.swift index e787e7b4db..6e09c77ab1 100644 --- a/SessionUtilitiesKit/LibSession/LibSession.swift +++ b/SessionUtilitiesKit/LibSession/LibSession.swift @@ -29,30 +29,60 @@ extension LibSession { Log.Category.create("config", defaultLevel: .info) Log.Category.create("network", defaultLevel: .info) - /// Subscribe for log level changes (this wil' emit an initial event which we can use to set the default log level) - ObservationBuilder.observe(.featureGroup(.allLogLevels), using: dependencies) { [dependencies] _ in - let currentLogLevels: [Log.Category: Log.Level] = dependencies[feature: .allLogLevels] - .currentValues(using: dependencies) - let currentGroupLogLevels: [Log.Group: Log.Level] = dependencies[feature: .allLogLevels] - .currentValues(using: dependencies) - let targetDefault: Log.Level? = min( - (currentLogLevels[.default] ?? .off), - (currentGroupLogLevels[.libSession] ?? .off) - ) - let cDefaultLevel: LOG_LEVEL = (targetDefault?.libSession ?? LOG_LEVEL_OFF) - session_logger_set_level_default(cDefaultLevel) - session_logger_reset_level(cDefaultLevel) - - /// Update all explicit log levels (we don't want to register a listener for each individual one so just re-apply all) - /// - /// If the conversation to the libSession `LOG_LEVEL` fails then it means we should use the default log level - currentLogLevels.forEach { (category: Log.Category, level: Log.Level) in - guard - let cCat: [CChar] = category.rawValue.cString(using: .utf8), - let cLogLevel: LOG_LEVEL = level.libSession - else { return } + /// Subscribe for log level changes (this will emit an initial event which we can use to set the default log level) + Task { + if #available(iOS 16.0, *) { + for await _ in dependencies.stream(feature: .allLogLevels) { + let currentLogLevels: [Log.Category: Log.Level] = dependencies[feature: .allLogLevels] + .currentValues(using: dependencies) + let currentGroupLogLevels: [Log.Group: Log.Level] = dependencies[feature: .allLogLevels] + .currentValues(using: dependencies) + let targetDefault: Log.Level? = min( + (currentLogLevels[.default] ?? .off), + (currentGroupLogLevels[.libSession] ?? .off) + ) + let cDefaultLevel: LOG_LEVEL = (targetDefault?.libSession ?? LOG_LEVEL_OFF) + session_logger_set_level_default(cDefaultLevel) + session_logger_reset_level(cDefaultLevel) + + /// Update all explicit log levels (we don't want to register a listener for each individual one so just re-apply all) + /// + /// If the conversation to the libSession `LOG_LEVEL` fails then it means we should use the default log level + currentLogLevels.forEach { (category: Log.Category, level: Log.Level) in + guard + let cCat: [CChar] = category.rawValue.cString(using: .utf8), + let cLogLevel: LOG_LEVEL = level.libSession + else { return } + + session_logger_set_level(cCat, cLogLevel) + } + } + } + else { + /// iOS 15 doesn't support observing dependency streams so just set the values once + let currentLogLevels: [Log.Category: Log.Level] = dependencies[feature: .allLogLevels] + .currentValues(using: dependencies) + let currentGroupLogLevels: [Log.Group: Log.Level] = dependencies[feature: .allLogLevels] + .currentValues(using: dependencies) + let targetDefault: Log.Level? = min( + (currentLogLevels[.default] ?? .off), + (currentGroupLogLevels[.libSession] ?? .off) + ) + let cDefaultLevel: LOG_LEVEL = (targetDefault?.libSession ?? LOG_LEVEL_OFF) + session_logger_set_level_default(cDefaultLevel) + session_logger_reset_level(cDefaultLevel) - session_logger_set_level(cCat, cLogLevel) + /// Update all explicit log levels (we don't want to register a listener for each individual one so just re-apply all) + /// + /// If the conversation to the libSession `LOG_LEVEL` fails then it means we should use the default log level + currentLogLevels.forEach { (category: Log.Category, level: Log.Level) in + guard + let cCat: [CChar] = category.rawValue.cString(using: .utf8), + let cLogLevel: LOG_LEVEL = level.libSession + else { return } + + session_logger_set_level(cCat, cLogLevel) + } } } @@ -70,44 +100,71 @@ extension LibSession { /// /// We want to simplify the message because our logging already includes category and timestamp information: /// `[+{lifetime}s] {message}` - let processedMessage: String = { - let trimmedMsg = msg.trimmingCharacters(in: .whitespacesAndNewlines) - - guard - let timestampRegex: NSRegularExpression = LibSession.timestampRegex, - let messageStartRegex: NSRegularExpression = LibSession.messageStartRegex - else { return trimmedMsg } - - let fullRange = NSRange(trimmedMsg.startIndex.. Void] = [:] @@ -15,15 +16,20 @@ public class ObservingDatabase { /// The observation mechanism works via the `Storage` wrapper so if we create a new `ObservingDatabase` outside of that /// mechanism the observed events won't be emitted - public static func create(_ db: Database, using dependencies: Dependencies) -> ObservingDatabase { - return ObservingDatabase(db, using: dependencies) + public static func create(_ db: Database, id: UUID = UUID(), using dependencies: Dependencies) -> ObservingDatabase { + return ObservingDatabase(db, id: id, using: dependencies) } - private init(_ db: Database, using dependencies: Dependencies) { + private init(_ db: Database, id: UUID, using dependencies: Dependencies) { self.dependencies = dependencies + self.id = id self.originalDb = db } + public static func == (lhs: ObservingDatabase, rhs: ObservingDatabase) -> Bool { + return lhs.id == rhs.id + } + // MARK: - Functions public func addEvent(_ event: ObservedEvent) { diff --git a/SessionUtilitiesKit/Media/DataSource.swift b/SessionUtilitiesKit/Media/DataSource.swift index 84fcddb595..ee09320fc4 100644 --- a/SessionUtilitiesKit/Media/DataSource.swift +++ b/SessionUtilitiesKit/Media/DataSource.swift @@ -160,7 +160,11 @@ public class DataSourceValue: DataSource { } public func write(to path: String) throws { - try data.write(to: URL(fileURLWithPath: path), options: .atomic) + try dependencies[singleton: .fileManager].write( + data: data, + to: URL(fileURLWithPath: path), + options: .atomic + ) } public static func == (lhs: DataSourceValue, rhs: DataSourceValue) -> Bool { diff --git a/SessionUtilitiesKit/Observations/ObservationManager.swift b/SessionUtilitiesKit/Observations/ObservationManager.swift index e0b8388818..91ea507001 100644 --- a/SessionUtilitiesKit/Observations/ObservationManager.swift +++ b/SessionUtilitiesKit/Observations/ObservationManager.swift @@ -8,7 +8,7 @@ import UIKit.UIApplication public extension Singleton { static let observationManager: SingletonConfig = Dependencies.create( identifier: "observationManager", - createInstance: { dependencies in ObservationManager(using: dependencies) } + createInstance: { dependencies, _ in ObservationManager(using: dependencies) } ) } @@ -35,7 +35,9 @@ public actor ObservationManager { result.append( NotificationCenter.default.addObserver(forName: next.key, object: nil, queue: .current) { [dependencies] _ in - dependencies.notifyAsync(key: .appLifecycle(value)) + Task { [dependencies] in + await dependencies.notify(key: .appLifecycle(value)) + } } ) } @@ -100,14 +102,38 @@ public extension ObservationManager { // MARK: - Convenience public extension Dependencies { + func notify( + priority: ObservationManager.Priority = .standard, + events: [ObservedEvent?] + ) async { + guard let events: [ObservedEvent] = events.compactMap({ $0 }).nullIfEmpty else { return } + + await self[singleton: .observationManager].notify(priority: priority, events: events) + } + + func notify( + priority: ObservationManager.Priority = .standard, + key: ObservableKey?, + value: T? + ) async { + guard let event: ObservedEvent = key.map({ ObservedEvent(key: $0, value: value) }) else { return } + + await notify(priority: priority, events: [event]) + } + + func notify( + priority: ObservationManager.Priority = .standard, + key: ObservableKey + ) async { + await notify(priority: priority, events: [ObservedEvent(key: key, value: nil)]) + } + @discardableResult func notifyAsync( priority: ObservationManager.Priority = .standard, events: [ObservedEvent?] ) -> Task { - guard let events: [ObservedEvent] = events.compactMap({ $0 }).nullIfEmpty else { return Task {} } - - return Task(priority: priority.taskPriority) { [observationManager = self[singleton: .observationManager]] in - await observationManager.notify(priority: priority, events: events) + return Task(priority: priority.taskPriority) { [weak self] in + await self?.notify(priority: priority, events: events) } } @@ -116,9 +142,7 @@ public extension Dependencies { key: ObservableKey?, value: T? ) -> Task { - guard let event: ObservedEvent = key.map({ ObservedEvent(key: $0, value: value) }) else { return Task {} } - - return notifyAsync(priority: priority, events: [event]) + return notifyAsync(priority: priority, events: [key.map { ObservedEvent(key: $0, value: value) }]) } @discardableResult func notifyAsync( diff --git a/SessionUtilitiesKit/Types/AppVersion.swift b/SessionUtilitiesKit/Types/AppVersion.swift index 6bd597dc35..a2c3eb5e21 100644 --- a/SessionUtilitiesKit/Types/AppVersion.swift +++ b/SessionUtilitiesKit/Types/AppVersion.swift @@ -9,7 +9,7 @@ import UIKit public extension Cache { static let appVersion: CacheConfig = Dependencies.create( identifier: "appVersion", - createInstance: { dependencies in AppVersion(using: dependencies) }, + createInstance: { dependencies, _ in AppVersion(using: dependencies) }, mutableInstance: { $0 }, immutableInstance: { $0 } ) diff --git a/SessionUtilitiesKit/Types/BackgroundTaskManager.swift b/SessionUtilitiesKit/Types/BackgroundTaskManager.swift index a7a1139af9..4dffaea37c 100644 --- a/SessionUtilitiesKit/Types/BackgroundTaskManager.swift +++ b/SessionUtilitiesKit/Types/BackgroundTaskManager.swift @@ -7,7 +7,7 @@ import UIKit public extension Singleton { static let backgroundTaskManager: SingletonConfig = Dependencies.create( identifier: "backgroundTaskManager", - createInstance: { dependencies in SessionBackgroundTaskManager(using: dependencies) } + createInstance: { dependencies, _ in SessionBackgroundTaskManager(using: dependencies) } ) } diff --git a/SessionUtilitiesKit/Types/CancellationAwareAsyncStream.swift b/SessionUtilitiesKit/Types/CancellationAwareAsyncStream.swift index 42a56bace5..bc61c6d85c 100644 --- a/SessionUtilitiesKit/Types/CancellationAwareAsyncStream.swift +++ b/SessionUtilitiesKit/Types/CancellationAwareAsyncStream.swift @@ -14,19 +14,15 @@ public actor CancellationAwareAsyncStream: CancellationAwareS // MARK: - Functions public func send(_ newValue: Element) async { - lifecycleManager.send(newValue) + await lifecycleManager.send(newValue) } public func finishCurrentStreams() async { - lifecycleManager.finishCurrentStreams() + await lifecycleManager.finishCurrentStreams() } - public func beforeYield(to continuation: AsyncStream.Continuation) async { - // No-op - no initial value - } - - public func makeTrackedStream() -> AsyncStream { - lifecycleManager.makeTrackedStream().stream + public func _makeTrackedStream() async -> AsyncStream { + await lifecycleManager.makeTrackedStream().stream } } @@ -38,11 +34,8 @@ public protocol CancellationAwareStreamType: Actor { func send(_ newValue: Element) async func finishCurrentStreams() async - /// This function gets called when a stream is initially created but before the inner stream is created, it shouldn't be called directly - func beforeYield(to continuation: AsyncStream.Continuation) async - /// This is an internal function which shouldn't be called directly - func makeTrackedStream() async -> AsyncStream + func _makeTrackedStream() async -> AsyncStream } public extension CancellationAwareStreamType { @@ -52,11 +45,9 @@ public extension CancellationAwareStreamType { /// thread-safe internally and `Element` is `Sendable` so it's verified to be safe to send concurrently nonisolated var stream: AsyncStream { AsyncStream { continuation in - let bridgingTask = Task { - await self.beforeYield(to: continuation) - - let internalStream: AsyncStream = await self.makeTrackedStream() - + let bridgingTask: Task = Task { + let internalStream: AsyncStream = await _makeTrackedStream() + for await element in internalStream { continuation.yield(element) } diff --git a/SessionUtilitiesKit/Types/CurrentValueAsyncStream.swift b/SessionUtilitiesKit/Types/CurrentValueAsyncStream.swift index b2f9f13e1a..d6eb4c3ce1 100644 --- a/SessionUtilitiesKit/Types/CurrentValueAsyncStream.swift +++ b/SessionUtilitiesKit/Types/CurrentValueAsyncStream.swift @@ -18,18 +18,17 @@ public actor CurrentValueAsyncStream: CancellationAwareStream public func send(_ newValue: Element) async { currentValue = newValue - lifecycleManager.send(newValue) + await lifecycleManager.send(newValue) } public func finishCurrentStreams() async { - lifecycleManager.finishCurrentStreams() + await lifecycleManager.finishCurrentStreams() } - public func beforeYield(to continuation: AsyncStream.Continuation) async { + public func _makeTrackedStream() async -> AsyncStream { + let (stream, continuation, _) = await lifecycleManager.makeTrackedStream() continuation.yield(currentValue) - } - - public func makeTrackedStream() -> AsyncStream { - lifecycleManager.makeTrackedStream().stream + + return stream } } diff --git a/SessionUtilitiesKit/Types/FileManager.swift b/SessionUtilitiesKit/Types/FileManager.swift index 6c49165f86..2763722366 100644 --- a/SessionUtilitiesKit/Types/FileManager.swift +++ b/SessionUtilitiesKit/Types/FileManager.swift @@ -3,13 +3,14 @@ // stringlint:disable import Foundation +import UIKit.UIImage // MARK: - Singleton public extension Singleton { static let fileManager: SingletonConfig = Dependencies.create( identifier: "fileManager", - createInstance: { dependencies in SessionFileManager(using: dependencies) } + createInstance: { dependencies, _ in SessionFileManager(using: dependencies) } ) } @@ -29,6 +30,7 @@ public protocol FileManagerType { func protectFileOrFolder(at path: String, fileProtectionType: FileProtectionType) throws func fileSize(of path: String) -> UInt64? func temporaryFilePath(fileExtension: String?) -> String + func write(data: Data, to url: URL, options: Data.WritingOptions) throws func write(data: Data, toTemporaryFileWithExtension fileExtension: String?) throws -> String? // MARK: - Forwarded NSFileManager @@ -46,6 +48,7 @@ public protocol FileManagerType { func fileExists(atPath: String) -> Bool func fileExists(atPath: String, isDirectory: UnsafeMutablePointer?) -> Bool func contents(atPath: String) -> Data? + func imageContents(atPath: String) -> UIImage? func contentsOfDirectory(at url: URL) throws -> [URL] func contentsOfDirectory(atPath path: String) throws -> [String] func isDirectoryEmpty(at url: URL) -> Bool @@ -75,6 +78,10 @@ public extension FileManagerType { try protectFileOrFolder(at: path, fileProtectionType: .completeUntilFirstUserAuthentication) } + func write(data: Data, to url: URL) throws { + try write(data: data, to: url, options: []) + } + func enumerator(at url: URL, includingPropertiesForKeys keys: [URLResourceKey]?) -> FileManager.DirectoryEnumerator? { return enumerator(at: url, includingPropertiesForKeys: keys, options: [], errorHandler: nil) } @@ -257,6 +264,10 @@ public class SessionFileManager: FileManagerType { .path } + public func write(data: Data, to url: URL, options: Data.WritingOptions) throws { + try data.write(to: url, options: options) + } + public func write(data: Data, toTemporaryFileWithExtension fileExtension: String?) throws -> String? { let tempFilePath: String = temporaryFilePath(fileExtension: fileExtension) @@ -301,6 +312,10 @@ public class SessionFileManager: FileManagerType { return fileManager.contents(atPath: atPath) } + public func imageContents(atPath: String) -> UIImage? { + return UIImage(contentsOfFile: atPath) + } + public func contentsOfDirectory(at url: URL) throws -> [URL] { return try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) } diff --git a/SessionUtilitiesKit/Types/FlatMapLatestActor.swift b/SessionUtilitiesKit/Types/FlatMapLatestActor.swift new file mode 100644 index 0000000000..375c64a217 --- /dev/null +++ b/SessionUtilitiesKit/Types/FlatMapLatestActor.swift @@ -0,0 +1,91 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension AsyncSequence where Element: Sendable { + /// Transforms elements from an upstream sequence into a new sequence, observing only the values from the most recently + /// transformed sequence. + /// + /// This is a more intuitively named alias for `flatMapLatest`. + /// + /// When the upstream sequence produces a new element, the operator cancels observation of the previously produced inner + /// sequence and **switches** to observing the new one. + /// + /// **Note:** Internally this function uses an `Actor` to safely manage the state of switching between inner streams, this is + /// especially needed on iOS 15 as in early versions of async/await in Swift there were race conditions which could crash. + func switchMap(_ transform: @escaping @Sendable (Element) async -> S) -> AsyncStream where S.Element: Sendable { + flatMapLatest(transform) + } + + /// Transforms the elements of an async sequence into a new async sequence, flattening the result. + /// + /// This operator is equivalent to `switchMap` or `flatMapLatest` in other reactive frameworks - when the upstream sequence + /// produces a new element, the operator cancels the observation of the previously produced inner sequence and switches to + /// observing the new one. + /// + /// This is particularly useful for "stream of streams" scenarios, like observing a value on a dependency that can itself be replaced. + /// + /// - Parameter transform: An async, throwing closure that takes an element from the upstream sequence and returns a + /// new `AsyncSequence` to be observed. + /// - Returns: An `AsyncStream` that emits elements from the latest inner sequence. + /// + /// **Note:** Internally this function uses an `Actor` to safely manage the state of switching between inner streams, this is + /// especially needed on iOS 15 as in early versions of async/await in Swift there were race conditions which could crash. + func flatMapLatest( + _ transform: @escaping @Sendable (Element) async -> S + ) -> AsyncStream where S.Element: Sendable { + return AsyncStream { continuation in + let actor = FlatMapLatestActor(continuation: continuation, transform: transform) + let outerTask = Task { + do { + for try await element in self { + await actor.switchTo(element: element) + } + + await actor.finish() + } catch { + await actor.finish(with: error) + } + } + + continuation.onTermination = { @Sendable _ in + outerTask.cancel() + } + } + } +} + +private actor FlatMapLatestActor where UpstreamElement: Sendable, InnerSequence.Element: Sendable { + private let continuation: AsyncStream.Continuation + private let transform: @Sendable (UpstreamElement) async -> InnerSequence + private var currentInnerTask: Task? + + init( + continuation: AsyncStream.Continuation, + transform: @escaping @Sendable (UpstreamElement) async -> InnerSequence + ) { + self.continuation = continuation + self.transform = transform + } + + func switchTo(element: UpstreamElement) async { + currentInnerTask?.cancel() + currentInnerTask = Task { + let innerSequence = await transform(element) + + for try await innerElement in innerSequence { + let result = continuation.yield(innerElement) + + switch result { + case .terminated: return + default: break + } + } + } + } + + func finish(with error: Error? = nil) { + currentInnerTask?.cancel() + continuation.finish() + } +} diff --git a/SessionUtilitiesKit/Types/KeychainStorage.swift b/SessionUtilitiesKit/Types/KeychainStorage.swift index 49a62e3e14..605aa9e840 100644 --- a/SessionUtilitiesKit/Types/KeychainStorage.swift +++ b/SessionUtilitiesKit/Types/KeychainStorage.swift @@ -10,7 +10,7 @@ import KeychainSwift public extension Singleton { static let keychain: SingletonConfig = Dependencies.create( identifier: "keychain", - createInstance: { dependencies in KeychainStorage(using: dependencies) } + createInstance: { dependencies, _ in KeychainStorage(using: dependencies) } ) } diff --git a/SessionUtilitiesKit/Types/StreamLifecycleManager.swift b/SessionUtilitiesKit/Types/StreamLifecycleManager.swift index 444e431525..0b89bf5bad 100644 --- a/SessionUtilitiesKit/Types/StreamLifecycleManager.swift +++ b/SessionUtilitiesKit/Types/StreamLifecycleManager.swift @@ -2,8 +2,7 @@ import Foundation -public final class StreamLifecycleManager: @unchecked Sendable { - private let lock: NSLock = NSLock() +public actor StreamLifecycleManager: @unchecked Sendable { private var continuations: [UUID: AsyncStream.Continuation] = [:] // MARK: - Initialization @@ -11,51 +10,50 @@ public final class StreamLifecycleManager: @unchecked Sendabl public init() {} deinit { - finishCurrentStreams() + Task { [continuations = self.continuations] in + for continuation in continuations.values { + continuation.finish() + } + } + } + + // MARK: - Internal Functions + + private func removeContinuation(id: UUID) { + continuations.removeValue(forKey: id) } // MARK: - Functions - func makeTrackedStream() -> (stream: AsyncStream, id: UUID) { - let (stream, continuation) = AsyncStream.makeStream(of: Element.self) + func makeTrackedStream() -> (stream: AsyncStream, continuation: AsyncStream.Continuation, id: UUID) { let id: UUID = UUID() - - lock.withLock { continuations[id] = continuation } - - continuation.onTermination = { @Sendable [self] _ in - self.finishStream(id: id) + let (stream, continuation) = AsyncStream.makeStream(of: Element.self) + continuation.onTermination = { @Sendable [weak self] _ in + Task { await self?.removeContinuation(id: id) } } - return (stream, id) + continuations[id] = continuation + + return (stream, continuation, id) } func send(_ value: Element) { - /// Capture current continuations before sending to avoid deadlocks where yielding could result in a new continuation being - /// added while the lock is held - let currentContinuations: [UUID: AsyncStream.Continuation] = lock.withLock { continuations } - - for continuation in currentContinuations.values { + for continuation in continuations.values { continuation.yield(value) } } func finishStream(id: UUID) { - lock.withLock { - if let continuation: AsyncStream.Continuation = continuations.removeValue(forKey: id) { - continuation.finish() - } + if let continuation: AsyncStream.Continuation = continuations.removeValue(forKey: id) { + continuation.finish() } } func finishCurrentStreams() { - let currentContinuations: [UUID: AsyncStream.Continuation] = lock.withLock { - let continuationsToFinish: [UUID: AsyncStream.Continuation] = continuations - continuations.removeAll() - return continuationsToFinish - } - - for continuation in currentContinuations.values { + for continuation in continuations.values { continuation.finish() } + + continuations.removeAll() } } diff --git a/SessionUtilitiesKit/Types/UserDefaultsType.swift b/SessionUtilitiesKit/Types/UserDefaultsType.swift index 968b66c2e4..0399e430eb 100644 --- a/SessionUtilitiesKit/Types/UserDefaultsType.swift +++ b/SessionUtilitiesKit/Types/UserDefaultsType.swift @@ -5,10 +5,10 @@ import Foundation public extension UserDefaultsStorage { - static var standard: UserDefaultsConfig = Dependencies.create(identifier: "standard") { _ in + static var standard: UserDefaultsConfig = Dependencies.create(identifier: "standard") { _, _ in UserDefaults.standard } - static var appGroup: UserDefaultsConfig = Dependencies.create(identifier: UserDefaults.applicationGroup) { _ in + static var appGroup: UserDefaultsConfig = Dependencies.create(identifier: UserDefaults.applicationGroup) { _, _ in UserDefaults(suiteName: UserDefaults.applicationGroup)! } } @@ -72,8 +72,12 @@ public extension UserDefaults { var allKeys: [String] { Array(self.dictionaryRepresentation().keys) } static func removeAll(using dependencies: Dependencies) { - UserDefaultsStorage.standard.createInstance(dependencies).removeAll() - UserDefaultsStorage.appGroup.createInstance(dependencies).removeAll() + let standardKey: Dependencies.Key = Dependencies.Key.Variant.userDefaults + .key(UserDefaultsStorage.standard.identifier) + let appGroupKey: Dependencies.Key = Dependencies.Key.Variant.userDefaults + .key(UserDefaultsStorage.appGroup.identifier) + UserDefaultsStorage.standard.createInstance(dependencies, standardKey).removeAll() + UserDefaultsStorage.appGroup.createInstance(dependencies, appGroupKey).removeAll() } func removeAll() { diff --git a/SessionUtilitiesKit/Utilities/AsyncSequence+Utilities.swift b/SessionUtilitiesKit/Utilities/AsyncSequence+Utilities.swift index 3992824965..69ca0d77ff 100644 --- a/SessionUtilitiesKit/Utilities/AsyncSequence+Utilities.swift +++ b/SessionUtilitiesKit/Utilities/AsyncSequence+Utilities.swift @@ -1,5 +1,51 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +import Foundation + +public extension AsyncSequence { + func asAsyncStream() -> AsyncStream { + AsyncStream { continuation in + let task: Task = Task { + for try await element in self { + continuation.yield(element) + } + + continuation.finish() + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + /// Returns a new async sequence that emits the given initial element before emitting the elements from the upstream sequence + func prepend(_ initialElement: Element?) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + if let initialElement { + continuation.yield(initialElement) + } + + let observationTask: Task = Task { + do { + for try await element in self { + continuation.yield(element) + } + } + catch { + continuation.finish(throwing: error) + } + + continuation.finish() + } + + continuation.onTermination = { @Sendable _ in + observationTask.cancel() + } + } + } +} + public extension AsyncSequence where Element: Equatable { func removeDuplicates() -> AsyncThrowingStream { return AsyncThrowingStream { continuation in diff --git a/SessionUtilitiesKit/Utilities/AsyncStream+Utilities.swift b/SessionUtilitiesKit/Utilities/AsyncStream+Utilities.swift new file mode 100644 index 0000000000..812463b7a5 --- /dev/null +++ b/SessionUtilitiesKit/Utilities/AsyncStream+Utilities.swift @@ -0,0 +1,13 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension AsyncStream { + func first() async -> Element? { + return await first(where: { _ in true }) + } + + func first(defaultValue: Element) async -> Element { + return (await first(where: { _ in true }) ?? defaultValue) + } +} diff --git a/SessionUtilitiesKit/Utilities/MutableIdentifiable.swift b/SessionUtilitiesKit/Utilities/MutableIdentifiable.swift deleted file mode 100644 index a6d5e8fd9f..0000000000 --- a/SessionUtilitiesKit/Utilities/MutableIdentifiable.swift +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -public protocol MutableIdentifiable: Identifiable { - mutating func setId(_ id: ID) -} diff --git a/SessionUtilitiesKit/Utilities/Result+Utilities.swift b/SessionUtilitiesKit/Utilities/Result+Utilities.swift index 69b3d09444..380e0e26bd 100644 --- a/SessionUtilitiesKit/Utilities/Result+Utilities.swift +++ b/SessionUtilitiesKit/Utilities/Result+Utilities.swift @@ -16,11 +16,4 @@ public extension Result where Failure == Error { return self } - - func successOrThrow() throws -> Success { - switch self { - case .success(let value): return value - case .failure(let error): throw error - } - } } diff --git a/SessionUtilitiesKit/Utilities/Task+Utilities.swift b/SessionUtilitiesKit/Utilities/Task+Utilities.swift index 2509ecd539..70448e3edc 100644 --- a/SessionUtilitiesKit/Utilities/Task+Utilities.swift +++ b/SessionUtilitiesKit/Utilities/Task+Utilities.swift @@ -9,4 +9,19 @@ public extension Task where Success == Never, Failure == Never { let nanosecondsToSleep: UInt64 = (UInt64(interval.milliseconds) * 1_000_000) try await Task.sleep(nanoseconds: nanosecondsToSleep) } + + static func sleep( + for interval: DispatchTimeInterval, + checkingEvery checkInterval: DispatchTimeInterval = .milliseconds(100), + until condition: () async -> Bool + ) async throws { + var currentWaitDuration: Int = 0 + + while currentWaitDuration < interval.milliseconds { + guard await !condition() else { return } + + try await Task.sleep(for: checkInterval) + currentWaitDuration += checkInterval.milliseconds + } + } } diff --git a/SessionUtilitiesKitTests/Database/Models/IdentitySpec.swift b/SessionUtilitiesKitTests/Database/Models/IdentitySpec.swift index 5101300fc7..f709a2822b 100644 --- a/SessionUtilitiesKitTests/Database/Models/IdentitySpec.swift +++ b/SessionUtilitiesKitTests/Database/Models/IdentitySpec.swift @@ -8,17 +8,21 @@ import Nimble @testable import SessionUtilitiesKit -class IdentitySpec: QuickSpec { +class IdentitySpec: AsyncSpec { override class func spec() { // MARK: Configuration @TestState var dependencies: TestDependencies! = TestDependencies() - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + @TestState var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), - migrations: [_001_SUK_InitialSetupMigration.self], using: dependencies ) + beforeEach { + try await mockStorage.perform(migrations: [_001_SUK_InitialSetupMigration.self]) + dependencies.set(singleton: .storage, to: mockStorage) + } + // MARK: - an Identity describe("an Identity") { // MARK: -- correctly retrieves the user key pair diff --git a/SessionUtilitiesKitTests/General/GeneralCacheSpec.swift b/SessionUtilitiesKitTests/General/GeneralCacheSpec.swift index 4eaa6a0480..120d6e8a95 100644 --- a/SessionUtilitiesKitTests/General/GeneralCacheSpec.swift +++ b/SessionUtilitiesKitTests/General/GeneralCacheSpec.swift @@ -1,32 +1,34 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. import Foundation +import TestUtilities import Quick import Nimble @testable import SessionUtilitiesKit -class GeneralCacheSpec: QuickSpec { +class GeneralCacheSpec: AsyncSpec { override class func spec() { // MARK: Configuration @TestState var dependencies: TestDependencies! = TestDependencies() - @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( - initialSetup: { crypto in - crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } - .thenReturn( - KeyPair( - publicKey: Array(Data(hex: TestConstants.edPublicKey)), - secretKey: Array(Data(hex: TestConstants.edSecretKey)) - ) + @TestState var mockCrypto: MockCrypto! = .create(using: dependencies) + + beforeEach { + try await mockCrypto + .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .thenReturn( + KeyPair( + publicKey: Array(Data(hex: TestConstants.edPublicKey)), + secretKey: Array(Data(hex: TestConstants.edSecretKey)) ) - crypto - .when { $0.generate(.x25519(ed25519Pubkey: .any)) } - .thenReturn(Array(Data(hex: TestConstants.publicKey))) - } - ) + ) + try await mockCrypto + .when { $0.generate(.x25519(ed25519Pubkey: .any)) } + .thenReturn(Array(Data(hex: TestConstants.publicKey))) + dependencies.set(singleton: .crypto, to: mockCrypto) + } // MARK: - a General Cache describe("a General Cache") { @@ -57,7 +59,7 @@ class GeneralCacheSpec: QuickSpec { // MARK: -- remains invalid when given a seckey that is too short it("remains invalid when given a seckey that is too short") { - mockCrypto.when { $0.generate(.ed25519KeyPair(seed: .any)) }.thenReturn(nil) + try await mockCrypto.when { $0.generate(.ed25519KeyPair(seed: .any)) }.thenReturn(nil) let cache: General.Cache = General.Cache(using: dependencies) cache.setSecretKey(ed25519SecretKey: [1, 2, 3]) @@ -68,7 +70,7 @@ class GeneralCacheSpec: QuickSpec { // MARK: -- remains invalid when ed key pair generation fails it("remains invalid when ed key pair generation fails") { - mockCrypto.when { $0.generate(.ed25519KeyPair(seed: .any)) }.thenReturn(nil) + try await mockCrypto.when { $0.generate(.ed25519KeyPair(seed: .any)) }.thenReturn(nil) let cache: General.Cache = General.Cache(using: dependencies) cache.setSecretKey(ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey))) @@ -79,7 +81,7 @@ class GeneralCacheSpec: QuickSpec { // MARK: -- remains invalid when x25519 pubkey generation fails it("remains invalid when x25519 pubkey generation fails") { - mockCrypto.when { $0.generate(.x25519(ed25519Pubkey: .any)) }.thenReturn(nil) + try await mockCrypto.when { $0.generate(.x25519(ed25519Pubkey: .any)) }.thenReturn(nil) let cache: General.Cache = General.Cache(using: dependencies) cache.setSecretKey(ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey))) diff --git a/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift b/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift index bfd9d50009..dac0ddd16a 100644 --- a/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift +++ b/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift @@ -3,13 +3,14 @@ import Foundation import Combine import GRDB +import TestUtilities import Quick import Nimble @testable import SessionUtilitiesKit -class JobRunnerSpec: QuickSpec { +class JobRunnerSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -45,16 +46,11 @@ class JobRunnerSpec: QuickSpec { dependencies.dateNow = Date(timeIntervalSince1970: 0) dependencies.forceSynchronous = true } - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + @TestState var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), - migrations: [ - _001_SUK_InitialSetupMigration.self, - _012_AddJobPriority.self, - _020_AddJobUniqueHash.self - ], using: dependencies ) - @TestState(singleton: .jobRunner, in: dependencies) var jobRunner: JobRunnerType! = JobRunner( + @TestState var jobRunner: JobRunnerType! = JobRunner( isTestingJobRunner: true, executors: [ .messageSend: TestJob.self, @@ -64,6 +60,19 @@ class JobRunnerSpec: QuickSpec { using: dependencies ) + beforeEach { + try await mockStorage.perform( + migrations: [ + _001_SUK_InitialSetupMigration.self, + _012_AddJobPriority.self, + _020_AddJobUniqueHash.self + ] + ) + dependencies.set(singleton: .storage, to: mockStorage) + + dependencies.set(singleton: .jobRunner, to: jobRunner) + } + // MARK: - a JobRunner describe("a JobRunner") { afterEach { @@ -1778,7 +1787,7 @@ fileprivate struct TestDetails: Codable { } fileprivate struct InvalidDetails: Codable { - func encode(to encoder: Encoder) throws { throw MockError.mockedData } + func encode(to encoder: Encoder) throws { throw MockError.mock } } fileprivate enum TestJob: JobExecutor { @@ -1816,8 +1825,8 @@ fileprivate enum TestJob: JobExecutor { switch details.result { case .success: success(job, true) - case .failure: failure(job, MockError.mockedData, false) - case .permanentFailure: failure(job, MockError.mockedData, true) + case .failure: failure(job, MockError.mock, false) + case .permanentFailure: failure(job, MockError.mock, true) case .deferred: deferred(updatedJob) } } diff --git a/SessionUtilitiesKitTests/_TestUtilities/MockAppContext.swift b/SessionUtilitiesKitTests/_TestUtilities/MockAppContext.swift new file mode 100644 index 0000000000..cfb68fb9a7 --- /dev/null +++ b/SessionUtilitiesKitTests/_TestUtilities/MockAppContext.swift @@ -0,0 +1,48 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUtilitiesKit +import TestUtilities + +class MockAppContext: AppContext, Mockable { + public var handler: MockHandler + + required init(handler: MockHandler) { + self.handler = handler + } + + required init(handlerForBuilder: any MockFunctionHandler) { + self.handler = MockHandler(forwardingHandler: handlerForBuilder) + } + + var mainWindow: UIWindow? { handler.mock() } + var frontMostViewController: UIViewController? { handler.mock() } + + var isValid: Bool { handler.mock() } + var appLaunchTime: Date { handler.mock() } + var isMainApp: Bool { handler.mock() } + var isMainAppAndActive: Bool { handler.mock() } + var isShareExtension: Bool { handler.mock() } + var reportedApplicationState: UIApplication.State { handler.mock() } + var backgroundTimeRemaining: TimeInterval { handler.mock() } + + // Override the extension functions + var isInBackground: Bool { handler.mock() } + var isAppForegroundAndActive: Bool { handler.mock() } + + func setMainWindow(_ mainWindow: UIWindow) { + handler.mockNoReturn(args: [mainWindow]) + } + + func ensureSleepBlocking(_ shouldBeBlocking: Bool, blockingObjects: [Any]) { + handler.mockNoReturn(args: [shouldBeBlocking, blockingObjects]) + } + + func beginBackgroundTask(expirationHandler: @escaping () -> ()) -> UIBackgroundTaskIdentifier { + return handler.mock(args: [expirationHandler]) + } + + func endBackgroundTask(_ backgroundTaskIdentifier: UIBackgroundTaskIdentifier) { + handler.mockNoReturn(args: [backgroundTaskIdentifier]) + } +} diff --git a/SessionUtilitiesKitTests/_TestUtilities/MockCrypto.swift b/SessionUtilitiesKitTests/_TestUtilities/MockCrypto.swift new file mode 100644 index 0000000000..16a75762a7 --- /dev/null +++ b/SessionUtilitiesKitTests/_TestUtilities/MockCrypto.swift @@ -0,0 +1,25 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit +import TestUtilities + +class MockCrypto: CryptoType, Mockable { + nonisolated let handler: MockHandler + + required init(handler: MockHandler) { + self.handler = handler + } + + required init(handlerForBuilder: any MockFunctionHandler) { + self.handler = MockHandler(forwardingHandler: handlerForBuilder) + } + + func tryGenerate(_ generator: Crypto.Generator) throws -> R { + return try handler.mockThrowing(funcName: "generate<\(R.self)>(\(generator.id))", args: generator.args) + } + + func verify(_ verification: Crypto.Verification) -> Bool { + return handler.mock(funcName: "verify(\(verification.id))", args: verification.args) + } +} diff --git a/SessionUtilitiesKitTests/_TestUtilities/MockFileManager.swift b/SessionUtilitiesKitTests/_TestUtilities/MockFileManager.swift new file mode 100644 index 0000000000..62f81b50ad --- /dev/null +++ b/SessionUtilitiesKitTests/_TestUtilities/MockFileManager.swift @@ -0,0 +1,167 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import UIKit.UIImage +import SessionUtilitiesKit +import TestUtilities + +class MockFileManager: FileManagerType, Mockable { + public var handler: MockHandler + + required init(handler: MockHandler) { + self.handler = handler + } + + required init(handlerForBuilder: any MockFunctionHandler) { + self.handler = MockHandler(forwardingHandler: handlerForBuilder) + } + + var temporaryDirectory: String { handler.mock() } + var documentsDirectoryPath: String { handler.mock() } + var appSharedDataDirectoryPath: String { handler.mock() } + var temporaryDirectoryAccessibleAfterFirstAuth: String { handler.mock() } + + func clearOldTemporaryDirectories() { handler.mockNoReturn() } + + func ensureDirectoryExists(at path: String, fileProtectionType: FileProtectionType) throws { + try handler.mockThrowingNoReturn(args: [path, fileProtectionType]) + } + + + func protectFileOrFolder(at path: String, fileProtectionType: FileProtectionType) throws { + try handler.mockThrowingNoReturn(args: [path, fileProtectionType]) + } + + func fileSize(of path: String) -> UInt64? { + return handler.mock(args: [path]) + } + + func temporaryFilePath(fileExtension: String?) -> String { + return handler.mock(args: [fileExtension]) + } + + func write(data: Data, to url: URL, options: Data.WritingOptions) throws { + try handler.mockThrowingNoReturn(args: [data, url, options]) + } + + func write(data: Data, toTemporaryFileWithExtension fileExtension: String?) throws -> String? { + return try handler.mockThrowing(args: [data, fileExtension]) + } + + // MARK: - Forwarded NSFileManager + + var currentDirectoryPath: String { handler.mock() } + + func urls(for directory: FileManager.SearchPathDirectory, in domains: FileManager.SearchPathDomainMask) -> [URL] { + return handler.mock(args: [directory, domains]) + } + + func enumerator( + at url: URL, + includingPropertiesForKeys: [URLResourceKey]?, + options: FileManager.DirectoryEnumerationOptions, + errorHandler: ((URL, Error) -> Bool)? + ) -> FileManager.DirectoryEnumerator? { + return handler.mock(args: [url, includingPropertiesForKeys, options, errorHandler]) + } + + func fileExists(atPath: String) -> Bool { return handler.mock(args: [atPath]) } + func fileExists(atPath: String, isDirectory: UnsafeMutablePointer?) -> Bool { + return handler.mock(args: [atPath, isDirectory]) + } + + func contents(atPath: String) -> Data? { return handler.mock(args: [atPath]) } + func imageContents(atPath: String) -> UIImage? { return handler.mock(args: [atPath]) } + func contentsOfDirectory(at url: URL) throws -> [URL] { return try handler.mockThrowing(args: [url]) } + func contentsOfDirectory(atPath path: String) throws -> [String] { return try handler.mockThrowing(args: [path]) } + func isDirectoryEmpty(at url: URL) -> Bool { return handler.mock(args: [url]) } + func isDirectoryEmpty(atPath path: String) -> Bool { return handler.mock(args: [path]) } + + func createFile(atPath: String, contents: Data?, attributes: [FileAttributeKey : Any]?) -> Bool { + return handler.mock(args: [atPath, contents, attributes]) + } + + func createDirectory(atPath: String, withIntermediateDirectories: Bool, attributes: [FileAttributeKey: Any]?) throws { + try handler.mockThrowingNoReturn(args: [atPath, withIntermediateDirectories, attributes]) + } + + func createDirectory(at url: URL, withIntermediateDirectories: Bool, attributes: [FileAttributeKey: Any]?) throws { + try handler.mockThrowingNoReturn(args: [url, withIntermediateDirectories, attributes]) + } + + func copyItem(atPath: String, toPath: String) throws { return try handler.mockThrowing(args: [atPath, toPath]) } + func copyItem(at fromUrl: URL, to toUrl: URL) throws { return try handler.mockThrowing(args: [fromUrl, toUrl]) } + func moveItem(atPath: String, toPath: String) throws { return try handler.mockThrowing(args: [atPath, toPath]) } + func moveItem(at fromUrl: URL, to toUrl: URL) throws { return try handler.mockThrowing(args: [fromUrl, toUrl]) } + func replaceItem( + atPath originalItemPath: String, + withItemAtPath newItemPath: String, + backupItemName: String?, + options: FileManager.ItemReplacementOptions + ) throws -> String? { + return try handler.mockThrowing(args: [originalItemPath, newItemPath, backupItemName, options]) + } + func replaceItemAt( + _ originalItemURL: URL, + withItemAt newItemURL: URL, + backupItemName: String?, + options: FileManager.ItemReplacementOptions + ) throws -> URL? { + return try handler.mockThrowing(args: [originalItemURL, newItemURL, backupItemName, options]) + } + func removeItem(atPath: String) throws { return try handler.mockThrowing(args: [atPath]) } + + func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] { + return try handler.mockThrowing(args: [path]) + } + + func setAttributes(_ attributes: [FileAttributeKey: Any], ofItemAtPath path: String) throws { + try handler.mockThrowingNoReturn(args: [attributes, path]) + } +} + +// MARK: - Convenience + +extension MockFileManager { + func defaultInitialSetup() async throws { + try await self.when { $0.appSharedDataDirectoryPath }.thenReturn("/test") + try await self.when { try $0.ensureDirectoryExists(at: .any, fileProtectionType: .any) }.thenReturn(()) + try await self.when { try $0.protectFileOrFolder(at: .any, fileProtectionType: .any) }.thenReturn(()) + try await self.when { try $0.write(data: .any, to: .any, options: .any) }.thenReturn(()) + try await self.when { $0.fileExists(atPath: .any) }.thenReturn(false) + try await self.when { $0.fileExists(atPath: .any, isDirectory: .any) }.thenReturn(false) + try await self.when { $0.temporaryFilePath(fileExtension: .any) }.thenReturn("tmpFile") + try await self.when { $0.createFile(atPath: .any, contents: .any, attributes: .any) }.thenReturn(true) + try await self.when { try $0.setAttributes(.any, ofItemAtPath: .any) }.thenReturn(()) + try await self.when { try $0.moveItem(atPath: .any, toPath: .any) }.thenReturn(()) + try await self.when { + _ = try $0.replaceItem( + atPath: .any, + withItemAtPath: .any, + backupItemName: .any, + options: .any + ) + }.thenReturn(nil) + try await self.when { + _ = try $0.replaceItemAt( + .any, + withItemAt: .any, + backupItemName: .any, + options: .any + ) + }.thenReturn(nil) + try await self.when { try $0.removeItem(atPath: .any) }.thenReturn(()) + try await self.when { $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3])) + try await self.when { $0.imageContents(atPath: .any) }.thenReturn(UIImage(data: TestConstants.validImageData)) + try await self.when { try $0.contentsOfDirectory(at: .any) }.thenReturn([]) + try await self.when { try $0.contentsOfDirectory(atPath: .any) }.thenReturn([]) + try await self.when { + try $0.createDirectory( + atPath: .any, + withIntermediateDirectories: .any, + attributes: .any + ) + }.thenReturn(()) + try await self.when { $0.isDirectoryEmpty(atPath: .any) }.thenReturn(true) + } +} diff --git a/SessionUtilitiesKitTests/_TestUtilities/MockGeneralCache.swift b/SessionUtilitiesKitTests/_TestUtilities/MockGeneralCache.swift new file mode 100644 index 0000000000..16fb1517bb --- /dev/null +++ b/SessionUtilitiesKitTests/_TestUtilities/MockGeneralCache.swift @@ -0,0 +1,65 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUtilitiesKit +import TestUtilities + +class MockGeneralCache: GeneralCacheType, Mockable { + public var handler: MockHandler + + required init(handler: MockHandler) { + self.handler = handler + } + + required init(handlerForBuilder: any MockFunctionHandler) { + self.handler = MockHandler(forwardingHandler: handlerForBuilder) + } + + var userExists: Bool { + get { return handler.mock() } + set { handler.mockNoReturn(args: [newValue]) } + } + + var sessionId: SessionId { + get { return handler.mock() } + set { handler.mockNoReturn(args: [newValue]) } + } + + var ed25519Seed: [UInt8] { + get { return handler.mock() } + set { handler.mockNoReturn(args: [newValue]) } + } + + var ed25519SecretKey: [UInt8] { + get { return handler.mock() } + set { handler.mockNoReturn(args: [newValue]) } + } + + var recentReactionTimestamps: [Int64] { + get { return (handler.mock() ?? []) } + set { handler.mockNoReturn(args: [newValue]) } + } + + var contextualActionLookupMap: [Int: [String: [Int: Any]]] { + get { return (handler.mock() ?? [:]) } + set { handler.mockNoReturn(args: [newValue]) } + } + + func setSecretKey(ed25519SecretKey: [UInt8]) { + handler.mockNoReturn(args: [ed25519SecretKey]) + } +} + +// MARK: - Convenience + +extension MockGeneralCache { + func defaultInitialSetup() async throws { + try await self.when { $0.userExists }.thenReturn(true) + try await self.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) + try await self.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) + try await self.when { $0.setSecretKey(ed25519SecretKey: .any) }.thenReturn(()) + try await self + .when { $0.ed25519Seed } + .thenReturn(Array(Array(Data(hex: TestConstants.edSecretKey)).prefix(upTo: 32))) + } +} diff --git a/_SharedTestUtilities/MockJobRunner.swift b/SessionUtilitiesKitTests/_TestUtilities/MockJobRunner.swift similarity index 61% rename from _SharedTestUtilities/MockJobRunner.swift rename to SessionUtilitiesKitTests/_TestUtilities/MockJobRunner.swift index 7f6ccdb393..c6f84f4119 100644 --- a/_SharedTestUtilities/MockJobRunner.swift +++ b/SessionUtilitiesKitTests/_TestUtilities/MockJobRunner.swift @@ -2,23 +2,33 @@ import Foundation import Combine -import GRDB +import TestUtilities @testable import SessionUtilitiesKit -class MockJobRunner: Mock, JobRunnerType { +class MockJobRunner: JobRunnerType, Mockable { + public var handler: MockHandler + + required init(handler: MockHandler) { + self.handler = handler + } + + required init(handlerForBuilder: any MockFunctionHandler) { + self.handler = MockHandler(forwardingHandler: handlerForBuilder) + } + // MARK: - Configuration func setExecutor(_ executor: JobExecutor.Type, for variant: Job.Variant) { - mockNoReturn(args: [executor, variant]) + handler.mockNoReturn(args: [executor, variant]) } func canStart(queue: JobQueue?) -> Bool { - return mock(args: [queue]) + return handler.mock(args: [queue]) } func afterBlockingQueue(callback: @escaping () -> ()) { - mockNoReturn() + handler.mockNoReturn() } func queue(for variant: Job.Variant) -> DispatchQueue? { DispatchQueue.main } @@ -26,11 +36,11 @@ class MockJobRunner: Mock, JobRunnerType { // MARK: - State Management func jobInfoFor(jobs: [Job]?, state: JobRunner.JobState, variant: Job.Variant?) -> [Int64: JobRunner.JobInfo] { - return mock(args: [jobs, state, variant]) + return handler.mock(args: [jobs, state, variant]) } func deferCount(for jobId: Int64?, of variant: Job.Variant) -> Int { - return mock(args: [jobId, variant]) + return handler.mock(args: [jobId, variant]) } func appDidFinishLaunching() {} @@ -38,46 +48,46 @@ class MockJobRunner: Mock, JobRunnerType { func startNonBlockingQueues() {} func stopAndClearPendingJobs(exceptForVariant: Job.Variant?, onComplete: ((Bool) -> ())?) { - mockNoReturn(args: [exceptForVariant, onComplete]) + handler.mockNoReturn(args: [exceptForVariant, onComplete]) onComplete?(false) } // MARK: - Job Scheduling @discardableResult func add(_ db: ObservingDatabase, job: Job?, dependantJob: Job?, canStartJob: Bool) -> Job? { - return mock(args: [job, dependantJob, canStartJob], untrackedArgs: [db]) + return handler.mock(args: [db, job, dependantJob, canStartJob]) } func upsert(_ db: ObservingDatabase, job: Job?, canStartJob: Bool) -> Job? { - return mock(args: [job, canStartJob], untrackedArgs: [db]) + return handler.mock(args: [db, job, canStartJob]) } func insert(_ db: ObservingDatabase, job: Job?, before otherJob: Job) -> (Int64, Job)? { - return mock(args: [job, otherJob], untrackedArgs: [db]) + return handler.mock(args: [db, job, otherJob]) } func enqueueDependenciesIfNeeded(_ jobs: [Job]) { - mockNoReturn(args: [jobs]) + handler.mockNoReturn(args: [jobs]) } func afterJob(_ job: Job?, state: JobRunner.JobState) -> AnyPublisher { - mock(args: [job, state]) + handler.mock(args: [job, state]) } func manuallyTriggerResult(_ job: Job?, result: JobRunner.JobResult) { - mockNoReturn(args: [job, result]) + handler.mockNoReturn(args: [job, result]) } func removePendingJob(_ job: Job?) { - mockNoReturn(args: [job]) + handler.mockNoReturn(args: [job]) } func registerRecurringJobs(scheduleInfo: [JobRunner.ScheduleInfo]) { - mockNoReturn(args: [scheduleInfo]) + handler.mockNoReturn(args: [scheduleInfo]) } func scheduleRecurringJobsIfNeeded() { - mockNoReturn() + handler.mockNoReturn() } } diff --git a/SessionUtilitiesKitTests/_TestUtilities/MockKeychain.swift b/SessionUtilitiesKitTests/_TestUtilities/MockKeychain.swift new file mode 100644 index 0000000000..3964cad6e9 --- /dev/null +++ b/SessionUtilitiesKitTests/_TestUtilities/MockKeychain.swift @@ -0,0 +1,57 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit +import TestUtilities + +class MockKeychain: KeychainStorageType, Mockable { + public var handler: MockHandler + + required init(handler: MockHandler) { + self.handler = handler + } + + required init(handlerForBuilder: any MockFunctionHandler) { + self.handler = MockHandler(forwardingHandler: handlerForBuilder) + } + + func string(forKey key: KeychainStorage.StringKey) throws -> String { + return try handler.mockThrowing(args: [key]) + } + + func set(string: String, forKey key: KeychainStorage.StringKey) throws { + return try handler.mockThrowing(args: [key]) + } + + func remove(key: KeychainStorage.StringKey) throws { + return try handler.mockThrowing(args: [key]) + } + + func data(forKey key: KeychainStorage.DataKey) throws -> Data { + return try handler.mockThrowing(args: [key]) + } + + func set(data: Data, forKey key: KeychainStorage.DataKey) throws { + return try handler.mockThrowing(args: [key]) + } + + func remove(key: KeychainStorage.DataKey) throws { + return try handler.mockThrowing(args: [key]) + } + + func removeAll() throws { try handler.mockThrowingNoReturn() } + + func migrateLegacyKeyIfNeeded(legacyKey: String, legacyService: String?, toKey key: KeychainStorage.DataKey) throws { + try handler.mockThrowingNoReturn(args: [legacyKey, legacyService, key]) + } + + func getOrGenerateEncryptionKey( + forKey key: KeychainStorage.DataKey, + length: Int, + cat: Log.Category, + legacyKey: String?, + legacyService: String? + ) throws -> Data { + return try handler.mockThrowing(args: [key, length, cat, legacyKey, legacyService]) + } +} diff --git a/_SharedTestUtilities/MockLogger.swift b/SessionUtilitiesKitTests/_TestUtilities/MockLogger.swift similarity index 97% rename from _SharedTestUtilities/MockLogger.swift rename to SessionUtilitiesKitTests/_TestUtilities/MockLogger.swift index 744491bde1..28c343e415 100644 --- a/_SharedTestUtilities/MockLogger.swift +++ b/SessionUtilitiesKitTests/_TestUtilities/MockLogger.swift @@ -29,7 +29,7 @@ public actor MockLogger: LoggerType { _ level: Log.Level, _ categories: [Log.Category], _ message: String, - file: StaticString, + file: String, function: StaticString, line: UInt ) { diff --git a/SessionUtilitiesKitTests/_TestUtilities/MockUserDefaults.swift b/SessionUtilitiesKitTests/_TestUtilities/MockUserDefaults.swift new file mode 100644 index 0000000000..5904a10e0c --- /dev/null +++ b/SessionUtilitiesKitTests/_TestUtilities/MockUserDefaults.swift @@ -0,0 +1,71 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit +import TestUtilities + +class MockUserDefaults: UserDefaultsType, Mockable { + public var handler: MockHandler + + required init(handler: MockHandler) { + self.handler = handler + } + + required init(handlerForBuilder: any MockFunctionHandler) { + self.handler = MockHandler(forwardingHandler: handlerForBuilder) + } + + var allKeys: [String] { handler.mock() } + + func object(forKey defaultName: String) -> Any? { return handler.mock(args: [defaultName]) } + func string(forKey defaultName: String) -> String? { return handler.mock(args: [defaultName]) } + func array(forKey defaultName: String) -> [Any]? { return handler.mock(args: [defaultName]) } + func dictionary(forKey defaultName: String) -> [String: Any]? { return handler.mock(args: [defaultName]) } + func data(forKey defaultName: String) -> Data? { return handler.mock(args: [defaultName]) } + func stringArray(forKey defaultName: String) -> [String]? { return handler.mock(args: [defaultName]) } + func integer(forKey defaultName: String) -> Int { return (handler.mock(args: [defaultName]) ?? 0) } + func float(forKey defaultName: String) -> Float { return (handler.mock(args: [defaultName]) ?? 0) } + func double(forKey defaultName: String) -> Double { return (handler.mock(args: [defaultName]) ?? 0) } + func bool(forKey defaultName: String) -> Bool { return (handler.mock(args: [defaultName]) ?? false) } + func url(forKey defaultName: String) -> URL? { return handler.mock(args: [defaultName]) } + + func set(_ value: Any?, forKey defaultName: String) { + handler.mockNoReturn(generics: [Any.self], args: [value, defaultName]) + } + func set(_ value: Int, forKey defaultName: String) { + handler.mockNoReturn(generics: [Int.self], args: [value, defaultName]) + } + + func set(_ value: Float, forKey defaultName: String) { + handler.mockNoReturn(generics: [Float.self], args: [value, defaultName]) + } + + func set(_ value: Double, forKey defaultName: String) { + handler.mockNoReturn(generics: [Double.self], args: [value, defaultName]) + } + + func set(_ value: Bool, forKey defaultName: String) { + handler.mockNoReturn(generics: [Bool.self], args: [value, defaultName]) + } + + func set(_ url: URL?, forKey defaultName: String) { + handler.mockNoReturn(generics: [URL?.self], args: [url, defaultName]) + } + + func removeObject(forKey defaultName: String) { + handler.mockNoReturn(args: [defaultName]) + } + + func removeAll() { handler.mockNoReturn() } +} + +extension MockUserDefaults { + func defaultInitialSetup() async throws { + try await self.when { $0.set(anyAny(), forKey: .any) }.thenReturn(()) + try await self.when { $0.set(Int.any, forKey: .any) }.thenReturn(()) + try await self.when { $0.set(Float.any, forKey: .any) }.thenReturn(()) + try await self.when { $0.set(Double.any, forKey: .any) }.thenReturn(()) + try await self.when { $0.set(Bool.any, forKey: .any) }.thenReturn(()) + try await self.when { $0.set(URL.any, forKey: .any) }.thenReturn(()) + } +} diff --git a/SessionUtilitiesKitTests/_TestUtilities/Mocked+SUK.swift b/SessionUtilitiesKitTests/_TestUtilities/Mocked+SUK.swift new file mode 100644 index 0000000000..d4de4375f6 --- /dev/null +++ b/SessionUtilitiesKitTests/_TestUtilities/Mocked+SUK.swift @@ -0,0 +1,107 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import GRDB +import TestUtilities + +@testable import SessionUtilitiesKit + +extension SessionId: @retroactive Mocked { + public static let any: SessionId = SessionId(.standard, publicKey: [255, 255, 255, 255, 255]) + public static let mock: SessionId = SessionId(.standard, publicKey: [ + 1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8, + 1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8 + ]) +} + +extension Dependencies { + static var any: Dependencies { + TestDependencies { dependencies in + dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) + dependencies.forceSynchronous = true + } + } +} + +extension ObservingDatabase: @retroactive Mocked, @retroactive ArgumentDescribing { + public static var any: Self { + var result: Database! + try! DatabaseQueue().read { result = $0 } + return ObservingDatabase.create(result!, id: .any, using: .any) as! Self + } + public static var mock: Self { + var result: Database! + try! DatabaseQueue().read { result = $0 } + return ObservingDatabase.create(result!, id: .mock, using: .any) as! Self + } + + public var summary: String? { "ObservingDatabase(\(id)" } +} + +extension ObservableKey: @retroactive Mocked { + public static var any: ObservableKey = "__MOCKED_ANY_KEY_VALUE__" + public static var mock: ObservableKey = "mockObservableKey" +} + +extension ObservedEvent: @retroactive Mocked { + public static var any: ObservedEvent { return ObservedEvent(key: "__MOCKED_ANY_KEY_VALUE__", value: nil) } + public static var mock: ObservedEvent = ObservedEvent(key: "mock", value: nil) +} + +extension KeyPair: @retroactive Mocked { + public static var any: KeyPair = KeyPair(publicKey: Array(Data.any), secretKey: Array(Data.any)) + public static var mock: KeyPair = KeyPair( + publicKey: Data(hex: TestConstants.publicKey).bytes, + secretKey: Data(hex: TestConstants.edSecretKey).bytes + ) +} + +extension Job: @retroactive Mocked { + public static var any: Job = Job(variant: .any) + public static var mock: Job = Job(variant: .mock) +} + +extension Job.Variant: @retroactive Mocked { + public static var any: Job.Variant = ._legacy_notifyPushServer + public static var mock: Job.Variant = .messageSend +} + +extension JobRunner.JobResult: @retroactive Mocked { + public static var any: JobRunner.JobResult = .failed(MockError.any, false) + public static var mock: JobRunner.JobResult = .succeeded +} + +extension JobRunner.JobState: @retroactive Mocked { + public static var any: JobRunner.JobState = JobRunner.JobState(rawValue: .any) + public static var mock: JobRunner.JobState = .pending +} + +extension Log.Category: @retroactive Mocked { + public static var any: Log.Category = .create(.any, defaultLevel: .debug) + public static var mock: Log.Category = .create("mock", defaultLevel: .debug) +} + +extension Setting.BoolKey: @retroactive Mocked { + public static var any: Setting.BoolKey = "__MOCKED_ANY_BOOL_KEY_VALUE__" + public static var mock: Setting.BoolKey = "mockBool" +} + +extension Setting.EnumKey: @retroactive Mocked { + public static var any: Setting.EnumKey = "__MOCKED_ANY_ENUM_KEY_VALUE__" + public static var mock: Setting.EnumKey = "mockEnum" +} + +// MARK: - Encodable Convenience + +extension Mocked where Self: Encodable { + func encoded(using dependencies: Dependencies) -> Data { + try! JSONEncoder(using: dependencies).with(outputFormatting: .sortedKeys).encode(self) + } +} + +extension Array where Element: Encodable { + func encoded(using dependencies: Dependencies) -> Data { + try! JSONEncoder(using: dependencies).with(outputFormatting: .sortedKeys).encode(self) + } +} diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift index 1e6e22dfc1..8fc20cfba9 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift @@ -283,7 +283,7 @@ public class ImageEditorModel { unitCropRect: CGRect, using dependencies: Dependencies ) -> UIImage? { - guard let srcImage = UIImage(contentsOfFile: imagePath) else { + guard let srcImage = dependencies[singleton: .fileManager].imageContents(atPath: imagePath) else { Log.error("[ImageEditorModel] Could not load image") return nil } diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift deleted file mode 100644 index 1a3f2f7558..0000000000 --- a/SignalUtilitiesKit/Utilities/AppSetup.swift +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import SessionUIKit -import SessionNetworkingKit -import SessionMessagingKit -import SessionUtilitiesKit - -public enum AppSetup { - public static func setupEnvironment( - requestId: String? = nil, - appSpecificBlock: (() -> ())? = nil, - migrationProgressChanged: ((CGFloat, TimeInterval) -> ())? = nil, - migrationsCompletion: @escaping (Result) -> (), - using dependencies: Dependencies - ) { - var backgroundTask: SessionBackgroundTask? = SessionBackgroundTask(label: #function, using: dependencies) - - DispatchQueue.global(qos: .userInitiated).async { - // Order matters here. - // - // All of these "singletons" should have any dependencies used in their - // initializers injected. - dependencies[singleton: .backgroundTaskManager].startObservingNotifications() - - // Attachments can be stored to NSTemporaryDirectory() - // If you receive a media message while the device is locked, the download will fail if - // the temporary directory is NSFileProtectionComplete - try? dependencies[singleton: .fileManager].protectFileOrFolder( - at: NSTemporaryDirectory(), - fileProtectionType: .completeUntilFirstUserAuthentication - ) - - SessionEnvironment.shared = SessionEnvironment( - audioSession: OWSAudioSession(), - proximityMonitoringManager: OWSProximityMonitoringManagerImpl(using: dependencies), - windowManager: OWSWindowManager(default: ()) - ) - appSpecificBlock?() - - runPostSetupMigrations( - requestId: requestId, - backgroundTask: backgroundTask, - migrationProgressChanged: migrationProgressChanged, - migrationsCompletion: migrationsCompletion, - using: dependencies - ) - - // The 'if' is only there to prevent the "variable never read" warning from showing - if backgroundTask != nil { backgroundTask = nil } - } - } - - public static func runPostSetupMigrations( - requestId: String? = nil, - backgroundTask: SessionBackgroundTask? = nil, - migrationProgressChanged: ((CGFloat, TimeInterval) -> ())? = nil, - migrationsCompletion: @escaping (Result) -> (), - using dependencies: Dependencies - ) { - var backgroundTask: SessionBackgroundTask? = (backgroundTask ?? SessionBackgroundTask(label: #function, using: dependencies)) - - dependencies[singleton: .storage].perform( - migrations: SNMessagingKit.migrations, - onProgressUpdate: migrationProgressChanged, - onComplete: { originalResult in - // Now that the migrations are complete there are a few more states which need - // to be setup - typealias UserInfo = ( - sessionId: SessionId, - ed25519SecretKey: [UInt8], - dumpSessionIds: Set, - unreadCount: Int? - ) - dependencies[singleton: .storage].readAsync( - retrieve: { db -> UserInfo? in - guard let ed25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { - return nil - } - - /// Cache the users session id so we don't need to fetch it from the database every time - dependencies.mutate(cache: .general) { - $0.setSecretKey(ed25519SecretKey: ed25519KeyPair.secretKey) - } - - /// Load the `libSession` state into memory - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let cache: LibSession.Cache = LibSession.Cache( - userSessionId: userSessionId, - using: dependencies - ) - cache.loadState(db, requestId: requestId) - dependencies.set(cache: .libSession, to: cache) - - return ( - userSessionId, - ed25519KeyPair.secretKey, - cache.allDumpSessionIds, - try? Interaction.fetchAppBadgeUnreadCount(db, using: dependencies) - ) - }, - completion: { result in - switch result { - case .failure, .success(.none): break - case .success(.some(let userInfo)): - /// Save the `UserMetadata` and replicate `ConfigDump` data if needed - try? dependencies[singleton: .extensionHelper].saveUserMetadata( - sessionId: userInfo.sessionId, - ed25519SecretKey: userInfo.ed25519SecretKey, - unreadCount: userInfo.unreadCount - ) - - Task.detached(priority: .medium) { - dependencies[singleton: .extensionHelper].replicateAllConfigDumpsIfNeeded( - userSessionId: userInfo.sessionId, - allDumpSessionIds: userInfo.dumpSessionIds - ) - } - } - - /// Ensure any recurring jobs are properly scheduled - dependencies[singleton: .jobRunner].scheduleRecurringJobsIfNeeded() - - /// Callback that the migrations have completed - migrationsCompletion(originalResult) - - /// The 'if' is only there to prevent the "variable never read" warning from showing - if backgroundTask != nil { backgroundTask = nil } - } - ) - } - ) - } -} diff --git a/TestUtilities/ArgumentDescribing.swift b/TestUtilities/ArgumentDescribing.swift new file mode 100644 index 0000000000..2ccdc7d8ec --- /dev/null +++ b/TestUtilities/ArgumentDescribing.swift @@ -0,0 +1,235 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation + +public protocol ArgumentDescribing { + var summary: String? { get } +} + +internal func summary(for argument: Any?) -> String { + guard let argument: Any = argument else { return "nil" } + + /// Custom handle the `any` types + if isAnyValue(argument) { + return "" + } + + /// Otherwise generate a summary + return generateSummary(for: argument) +} + +private func isEquatableMatch(lhs: E, rhs: Any) -> Bool { + if let rhs = rhs as? E { + return lhs == rhs + } + + return false +} + +internal func isAnyValue(_ value: Any) -> Bool { + func open(value: T) -> Bool { + /// Try a single equality check (this could result in a false positive because the value is a legitimate value) + if let mockedEquatable = value as? any Equatable { + return isEquatableMatch(lhs: mockedEquatable, rhs: T.any) + } + + /// Compare using the `summary` as a fallback + return generateSummary(for: value) == generateSummary(for: T.any) + } + + if let mockedValue = value as? any Mocked { + return open(value: mockedValue) + } + + return false +} + +private func generateSummary(for argument: Any?) -> String { + guard let argument: Any = argument else { return "nil" } + + /// Then handle any `ArgumentDescribing` values + if let customSummary: String = (argument as? ArgumentDescribing)?.summary { + return customSummary + } + + switch argument { + case let string as String: return string.debugDescription + case let array as [Any]: return "[\(array.map { summary(for: $0) }.joined(separator: ", "))]" + + case let dict as [String: Any]: + if dict.isEmpty { return "[:]" } + + let sortedValues: [String] = dict + .map { key, value in "\(summary(for: key)):\(summary(for: value))" } + .sorted() + return "[\(sortedValues.joined(separator: ", "))]" + + case let data as Data: return "Data(base64Encoded: \(data.base64EncodedString()))" + default: return recursiveSummary(for: argument) + } +} + +private func recursiveSummary(for subject: Any) -> String { + let mirror: Mirror = Mirror(reflecting: subject) + let typeName: String = String(describing: Swift.type(of: subject)) + + /// Fall back to a simple description for types with no custom representation (eg. primitives) + guard let displayStyle: Mirror.DisplayStyle = mirror.displayStyle else { + return String(describing: subject) + } + + switch displayStyle { + case .struct, .class: + /// If there are no properties, just print the type name + guard !mirror.children.isEmpty else { return typeName } + + let properties: String = mirror.children + .compactMap { child -> String? in + guard let label: String = child.label else { + return nil + } + + return "\(label): \(summary(for: child.value))" + } + .sorted() + .joined(separator: ", ") + + return "\(typeName)(\(properties))" + + case .enum: + // Handle associated values first + if let child: Mirror.Child = mirror.children.first { + if let label: String = child.label { + /// Enum case with one or more named associated values + let properties: String = mirror.children + .compactMap { child -> String? in + guard let label: String = child.label else { + return nil + } + + return "\(label): \(summary(for: child.value))" + } + .sorted() + .joined(separator: ", ") + + return ".\(label)(\(properties))" + } + + /// Enum case with one or more unnamed associated values + let values: String = mirror.children + .map { summary(for: $0.value) } + .joined(separator: ", ") + + return ".\(mirror.subjectType).\(subject)(\(values))" + } + + /// Simple enum case with no associated value + return ".\(subject)" + + case .tuple: + let elements: String = mirror.children + .map { child -> String in + if let label: String = child.label { + return "\(label): \(summary(for: child.value))" + } + + return summary(for: child.value) + } + .joined(separator: ", ") + + return "(\(elements))" + + /// For other collections like Set, fall back to the default but sort any dictionary content by keys + default: return sortDictionariesInReflectedString(String(describing: subject)) + } +} + +private func sortDictionariesInReflectedString(_ input: String) -> String { + // Regular expression to match the headers dictionary + let pattern = "\\[(.+?)\\]" + let regex = try! NSRegularExpression(pattern: pattern, options: []) + + var result = "" + var lastRange = input.startIndex.. String { + var pairs: [(String, String)] = [] + var currentKey = "" + var currentValue = "" + var inQuotes = false + var parsingKey = true + var nestedLevel = 0 + + for char in dictionaryString { + switch char { + case "\"": + inQuotes.toggle() + if nestedLevel > 0 { + currentKey.append(char) + continue + } + + case ":": + if !inQuotes && nestedLevel == 0 { + parsingKey = false + continue + } + + case ",": + if !inQuotes && nestedLevel == 0 { + pairs.append((currentKey.trimmingCharacters(in: .whitespaces), currentValue.trimmingCharacters(in: .whitespaces))) + currentKey = "" + currentValue = "" + parsingKey = true + continue + } + + case "[", "{": nestedLevel += (parsingKey ? 0 : 1) + case "]", "}": nestedLevel -= (parsingKey ? 0 : 1) + default: break + } + + switch parsingKey { + case true: currentKey.append(char) + case false: currentValue.append(char) + } + } + + // Add the last pair if exists + if !currentKey.isEmpty || !currentValue.isEmpty { + pairs.append((currentKey.trimmingCharacters(in: .whitespaces), currentValue.trimmingCharacters(in: .whitespaces))) + } + + // Sort pairs by key + let sortedPairs = pairs.sorted { $0.0 < $1.0 } + + // Join sorted pairs back into a string + return sortedPairs.map { "\($0): \($1)" }.joined(separator: ", ") +} diff --git a/TestUtilities/MockError.swift b/TestUtilities/MockError.swift new file mode 100644 index 0000000000..6598de8291 --- /dev/null +++ b/TestUtilities/MockError.swift @@ -0,0 +1,61 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation + +public enum MockError: Error, CustomStringConvertible { + case any + case mock + case noStubFound(function: String, args: [Any?]) + case noMatchingStubFound(function: String, expectedArgs: [Any?], mockedArgs: [[Any?]]) + case stubbedValueIsWrongType(function: String, expected: Any.Type, actual: Any.Type?) + case invalidWhenBlock(message: String) + + public var shouldLogFailure: Bool { + switch self { + case .noStubFound, .noMatchingStubFound: return true + default: return false + } + } + + public var description: String { + switch self { + case .any: return "AnyError" + case .mock: return "MockError: This error should never be thrown." + case .noStubFound(let function, let args): + let argsDescription: String = args.map { summary(for: $0) }.joined(separator: ", ") + + return "MockError: No stub found for '\(function)(\(argsDescription))'. Use .when { ... } to provide a return value or action." + + case .noMatchingStubFound(let function, let expectedArgs, let mockedArgs): + guard !expectedArgs.isEmpty && !mockedArgs.isEmpty else { + return MockError.noStubFound(function: function, args: []).description + } + + var errorDescription: String = "MockError: A stub for \(function) was found, but the parameters didn't match." + + errorDescription += "\n\nCalled with parameters:" + let args: String = expectedArgs.map { summary(for: $0) }.joined(separator: ", ") + errorDescription += "\n- [\(args)]" + + let callDescriptions: String = mockedArgs + .map { args in + let argString: String = args.map { summary(for: $0) }.joined(separator: ", ") + + return "- [\(argString)]" + } + .joined(separator: "\n") + errorDescription += "\n\nAll stubs:\n\(callDescriptions)" + + return errorDescription + + case .stubbedValueIsWrongType(let function, let expected, let actual): + return "MockError: A stub for \(function) was found, but its return value is the wrong type. Expected '\(expected)', but found '\(actual.map { "\($0)" } ?? "nil")'." + + case .invalidWhenBlock(let message): + return "MockError: Invalid `when` block. \(message)" + } + } +} + diff --git a/TestUtilities/MockFallbackRegistry.swift b/TestUtilities/MockFallbackRegistry.swift new file mode 100644 index 0000000000..28f7950c87 --- /dev/null +++ b/TestUtilities/MockFallbackRegistry.swift @@ -0,0 +1,44 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public final class MockFallbackRegistry { + internal static let shared: MockFallbackRegistry = MockFallbackRegistry() + private var fallbacks: [ObjectIdentifier: () -> Any] = [:] + private let lock: NSLock = NSLock() + + private init() {} + + // MARK: - Public Functions + + public static func register(for type: T.Type, provider: @escaping () -> T) { + shared.registerFallback(for: type, provider: provider) + } + + // MARK: - Internal Functions + + internal func registerFallback(for type: T.Type, provider: @escaping () -> T) { + lock.lock() + defer { lock.unlock() } + + let typeId: ObjectIdentifier = ObjectIdentifier(T.self) + fallbacks[typeId] = provider + } + + internal func makeFallback(for type: T.Type) -> T? { + lock.lock() + defer { lock.unlock() } + + let typeId: ObjectIdentifier = ObjectIdentifier(T.self) + + if let provider = fallbacks[typeId], let value: T = provider() as? T { + return value + } + + if let mockedType = T.self as? any Mocked.Type, let value = mockedType.mock as? T { + return value + } + + return nil + } +} diff --git a/TestUtilities/MockFunction.swift b/TestUtilities/MockFunction.swift new file mode 100644 index 0000000000..94d9596789 --- /dev/null +++ b/TestUtilities/MockFunction.swift @@ -0,0 +1,33 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +internal final class MockFunction { + let name: String + let generics: [Any.Type] + let arguments: [Any?] + let returnValue: Any? + let dynamicReturnValueRetriever: (([Any?]) -> Any?)? + let returnError: (any Error)? + let actions: [([Any?]) -> Void] + + var asCall: RecordedCall { RecordedCall(name: name, generics: generics, arguments: arguments) } + + init( + name: String, + generics: [Any.Type], + arguments: [Any?], + returnValue: Any?, + dynamicReturnValueRetriever: (([Any?]) -> Any?)?, + returnError: Error?, + actions: [([Any?]) -> Void] + ) { + self.name = name + self.generics = generics + self.arguments = arguments + self.returnValue = returnValue + self.dynamicReturnValueRetriever = dynamicReturnValueRetriever + self.returnError = returnError + self.actions = actions + } +} diff --git a/TestUtilities/MockFunctionBuilder.swift b/TestUtilities/MockFunctionBuilder.swift new file mode 100644 index 0000000000..36d0ab108e --- /dev/null +++ b/TestUtilities/MockFunctionBuilder.swift @@ -0,0 +1,104 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +internal protocol Buildable { + func build() async throws -> MockFunction +} + +public class MockFunctionBuilder { + private let handler: MockHandler + private let callBlock: (inout T) async throws -> R + private let dummyProvider: (any MockFunctionHandler) -> T + + private var capturedFunctionName: String? + private var capturedGenerics: [Any.Type] = [] + private var capturedArguments: [Any?] = [] + + private var returnValue: Any? + private var dynamicReturnValueRetriever: (([Any?]) -> Any?)? + private var returnError: Error? + private var actions: [([Any?]) -> Void] = [] + + public init( + handler: MockHandler, + callBlock: @escaping (inout T) async throws -> R, + dummyProvider: @escaping (any MockFunctionHandler) -> T + ) { + self.handler = handler + self.callBlock = callBlock + self.dummyProvider = dummyProvider + } + + // MARK: - Mock Configuration API + + @discardableResult public func then(_ action: @escaping ([Any?]) -> Void) -> Self { + self.actions.append({ args in action(args) }) + return self + } + + public func thenReturn(_ value: R) async throws { + self.returnValue = value + try await finalize() + } + + public func thenReturn(_ closure: @escaping ([Any?]) -> R) async throws { + self.dynamicReturnValueRetriever = closure + try await finalize() + } + + public func thenThrow(_ error: Error) async throws { + self.returnError = error + try await finalize() + } + + // MARK: - Internal Functions + + private func finalize() async throws { + let function = try await self.build() + handler.register(stub: function) + } + + private func captureDetails() async { + /// Only run capture once + guard capturedFunctionName == nil else { return } + + var dummy: T = dummyProvider(self) + _ = try? await callBlock(&dummy) + } +} + +extension MockFunctionBuilder: Buildable { + func build() async throws -> MockFunction { + await captureDetails() + + guard let name: String = capturedFunctionName else { + throw MockError.invalidWhenBlock(message: "The when{...} block did not call a mockable function.") + } + + return MockFunction( + name: name, + generics: capturedGenerics, + arguments: capturedArguments, + returnValue: returnValue, + dynamicReturnValueRetriever: dynamicReturnValueRetriever, + returnError: returnError, + actions: actions + ) + } +} + +// MARK: - MockFunctionHandler + +extension MockFunctionBuilder: MockFunctionHandler { + public func mock(funcName: String, generics: [Any.Type], args: [Any?]) -> Output { + self.capturedFunctionName = funcName + self.capturedGenerics = generics + self.capturedArguments = args + + return MockHelper.unsafePlaceholder() + } +} + +private protocol _OptionalProtocol {} +extension Optional: _OptionalProtocol {} diff --git a/TestUtilities/MockFunctionHandler.swift b/TestUtilities/MockFunctionHandler.swift new file mode 100644 index 0000000000..daf1cc0a6c --- /dev/null +++ b/TestUtilities/MockFunctionHandler.swift @@ -0,0 +1,8 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public protocol MockFunctionHandler { + @discardableResult + func mock(funcName: String, generics: [Any.Type], args: [Any?]) -> Output +} diff --git a/TestUtilities/MockHandler.swift b/TestUtilities/MockHandler.swift new file mode 100644 index 0000000000..f3d48d268c --- /dev/null +++ b/TestUtilities/MockHandler.swift @@ -0,0 +1,323 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public final class MockHandler { + public let erasedDependencies: Any? + public let erasedDependenciesKey: Any? + + private let lock = NSLock() + private let dummyProvider: (any MockFunctionHandler) -> T + private let failureReporter: TestFailureReporter + private let forwardingHandler: (any MockFunctionHandler)? + private var stubs: [RecordedCall.Key: [MockFunction]] = [:] + private var calls: [RecordedCall.Key: [RecordedCall]] = [:] + + // MARK: - Initialization + + public init( + dummyProvider: @escaping (any MockFunctionHandler) -> T, + failureReporter: TestFailureReporter = NimbleFailureReporter(), + erasedDependenciesKey: Any?, + using erasedDependencies: Any? + ) { + self.erasedDependencies = erasedDependencies + self.erasedDependenciesKey = erasedDependenciesKey + self.dummyProvider = dummyProvider + self.failureReporter = failureReporter + self.forwardingHandler = nil + } + + public init(forwardingHandler: any MockFunctionHandler) { + self.erasedDependencies = nil + self.erasedDependenciesKey = nil + self.dummyProvider = { _ in fatalError("A dummy instance cannot create other dummies.") } + self.failureReporter = NimbleFailureReporter() + self.forwardingHandler = forwardingHandler + } + + + public static func invalid() -> MockHandler { + return MockHandler( + dummyProvider: { _ in fatalError("Should not call mock on a mock") }, + erasedDependenciesKey: nil, + using: nil + ) + } + + // MARK: - Setup + + func createBuilder(for callBlock: @escaping (inout T) async throws -> R) -> MockFunctionBuilder { + return MockFunctionBuilder( + handler: self, + callBlock: callBlock, + dummyProvider: dummyProvider + ) + } + + internal func register(stub: MockFunction) { + let key: RecordedCall.Key = RecordedCall.Key( + name: stub.name, + generics: stub.generics, + paramCount: stub.arguments.count + ) + + locked { + stubs[key, default: []].append(stub) + } + } + + internal func removeStubs(for functionBlock: @escaping (inout T) async throws -> R) async { + guard let expectedCall: RecordedCall = await expectedCall(for: functionBlock) else { return } + + locked { + stubs.removeValue(forKey: expectedCall.key) + } + } + + // MARK: - Verification + + func expectedCall(for functionBlock: @escaping (inout T) async throws -> R) async -> RecordedCall? { + let builder: MockFunctionBuilder = createBuilder(for: functionBlock) + + guard let builtFunction = try? await builder.build() else { + return nil + } + + return RecordedCall( + name: builtFunction.name, + generics: builtFunction.generics, + arguments: builtFunction.arguments + ) + } + + func recordedCallInfo(for functionBlock: @escaping (inout T) async throws -> R) async -> RecordedCallInfo? { + let builder: MockFunctionBuilder = createBuilder(for: functionBlock) + + guard let builtFunction = try? await builder.build() else { + return nil + } + + let expectedCall: RecordedCall = RecordedCall( + name: builtFunction.name, + generics: builtFunction.generics, + arguments: builtFunction.arguments + ) + let allCalls: [RecordedCall] = (locked { calls[expectedCall.key] } ?? []) + + return RecordedCallInfo( + expectedCall: expectedCall, + matchingCalls: allCalls.filter { expectedCall.matches(args: $0.arguments) }, + allCalls: allCalls + ) + } + + // MARK: - Test Lifecycle + + public func clearCalls() { + locked { calls.removeAll() } + } + + public func reset() { + locked { + stubs.removeAll() + calls.removeAll() + } + } + + // MARK: - Internal Logic + + @discardableResult private func locked(_ operation: () -> R) -> R { + lock.lock() + defer { lock.unlock() } + return operation() + } + + private func findAndExecute( + funcName: String, + generics: [Any.Type], + args: [Any?] + ) -> Result { + typealias CallMatches = ( + matchingCall: MockFunction?, + allCalls: [MockFunction] + ) + let recordedCall: RecordedCall = RecordedCall(name: funcName, generics: generics, arguments: args) + + /// Get the `last` value as it was the one called most recently + let maybeCallMatches: CallMatches? = locked { + calls[recordedCall.key, default: []].append(recordedCall) + + return stubs[recordedCall.key].map { allStubs in + ( + allStubs.last(where: { $0.asCall.matches(args: args) }), + allStubs + ) + } + } + + guard let callMatches: CallMatches = maybeCallMatches else { + return .failure(MockError.noStubFound(function: recordedCall.key.nameWithGenerics, args: args)) + } + guard let matchingCall: MockFunction = callMatches.matchingCall else { + return .failure(MockError.noMatchingStubFound( + function: recordedCall.key.nameWithGenerics, + expectedArgs: args, + mockedArgs: callMatches.allCalls.map { $0.arguments } + )) + } + + /// Perform any actions + for action in matchingCall.actions { + action(args) + } + + return execute(stub: matchingCall, args: args) + } + + private func execute(stub: MockFunction, args: [Any?]) -> Result { + if let error: Error = stub.returnError { + return .failure(error) + } + + /// Handle `Void` returns first + guard Output.self != Void.self else { + return .success(() as! Output) + } + + /// Try the `dynamicReturnValueRetriever` if there is one + if let returnValue: Any = stub.dynamicReturnValueRetriever?(args), let typedValue: Output = returnValue as? Output { + return .success(typedValue) + } + + /// Then handle the proper typed return value + if let returnValue: Any = stub.returnValue, let typedValue: Output = returnValue as? Output { + return .success(typedValue) + } + + return .failure(MockError.stubbedValueIsWrongType( + function: stub.asCall.key.nameWithGenerics, + expected: Output.self, + actual: type(of: stub.returnValue) + )) + } +} + +// MARK: - MockHandler.Key + +private extension MockHandler { + struct Key: Equatable, Hashable { + let name: String + let generics: [String] + let paramCount: Int + + init(name: String, generics: [Any.Type], paramCount: Int) { + self.name = name + self.generics = generics.map { String(describing: $0) } + self.paramCount = paramCount + } + } +} + +// MARK: - Mock Functions + +public extension MockHandler { + func mock( + funcName: String = #function, + generics: [Any.Type] = [], + args: [Any?] = [], + fileID: String = #fileID, + file: String = #file, + line: UInt = #line + ) -> Output { + if let forwardedHandler: (any MockFunctionHandler) = forwardingHandler { + return forwardedHandler.mock(funcName: funcName, generics: generics, args: args) + } + + return handleNonThrowingResult( + result: findAndExecute( + funcName: funcName, + generics: generics, + args: args + ), + funcName: funcName, + fileID: fileID, + file: file, + line: line + ) + } + + func mockNoReturn( + funcName: String = #function, + generics: [Any.Type] = [], + args: [Any?] = [], + fileID: String = #fileID, + file: String = #file, + line: UInt = #line + ) { + let _: Void = mock(funcName: funcName, generics: generics, args: args, fileID: fileID, file: file, line: line) + } + + func mockThrowing( + funcName: String = #function, + generics: [Any.Type] = [], + args: [Any?] = [], + fileID: String = #fileID, + file: String = #file, + line: UInt = #line + ) throws -> Output { + if let forwardedHandler: (any MockFunctionHandler) = forwardingHandler { + return forwardedHandler.mock(funcName: funcName, generics: generics, args: args) + } + + return try findAndExecute( + funcName: funcName, + generics: generics, + args: args + ).get() + } + + func mockThrowingNoReturn( + funcName: String = #function, + generics: [Any.Type] = [], + args: [Any?] = [], + fileID: String = #fileID, + file: String = #file, + line: UInt = #line + ) throws { + let _: Void = try mockThrowing(funcName: funcName, generics: generics, args: args, fileID: fileID, file: file, line: line) + } + + private func handleNonThrowingResult( + result: Result, + funcName: String, + fileID: String, + file: String, + line: UInt + ) -> Output { + switch result { + case .success(let value): return value + case .failure(let error): + /// Log if the failure was due to a missing mock + if (error as? MockError)?.shouldLogFailure == true { + failureReporter.reportFailure( + "\(error)", + fileID: fileID, + file: file, + line: line + ) + } + + /// Custom handle a `Void` return type before checking for Mocked conformance + guard Output.self != Void.self else { + return () as! Output + } + + if let fallbackValue: Output = MockFallbackRegistry.shared.makeFallback(for: Output.self) { + return fallbackValue + } + + fatalError("FATAL: The return type '\(Output.self)' of the non-throwing function '\(funcName)' does not conform to 'Mocked' and has no custom fallback registered. The framework cannot produce a default value.") + } + } +} diff --git a/TestUtilities/Mockable.swift b/TestUtilities/Mockable.swift new file mode 100644 index 0000000000..af57aec6cf --- /dev/null +++ b/TestUtilities/Mockable.swift @@ -0,0 +1,34 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public protocol Mockable { + associatedtype MockedType + + var handler: MockHandler { get } + + init(handler: MockHandler) + init(handlerForBuilder: any MockFunctionHandler) +} + +public extension Mockable { + static func create(erasedDependenciesKey: Any? = nil, using erasedDependencies: Any?) -> M { + let handler: MockHandler = MockHandler( + dummyProvider: { builderHandler in + return M(handlerForBuilder: builderHandler) as! M.MockedType + }, + erasedDependenciesKey: erasedDependenciesKey, + using: erasedDependencies + ) + + return M(handler: handler) + } + + func when(_ callBlock: @escaping (inout MockedType) async throws -> R) -> MockFunctionBuilder { + return handler.createBuilder(for: callBlock) + } + + func removeMocksFor(_ callBlock: @escaping (inout MockedType) async throws -> R) async { + await handler.removeStubs(for: callBlock) + } +} diff --git a/TestUtilities/Mocked.swift b/TestUtilities/Mocked.swift new file mode 100644 index 0000000000..4e2c7f0544 --- /dev/null +++ b/TestUtilities/Mocked.swift @@ -0,0 +1,238 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import Combine +import UIKit.UIApplication + +// MARK: - Mocked + +public protocol Mocked { + static var any: Self { get } + static var mock: Self { get } + + static var skipTypeMatchForAnyComparison: Bool { get } +} + +public extension Mocked { + static var skipTypeMatchForAnyComparison: Bool { false } +} + +public enum MockHelper { + /// This function is a hack to force Swift to think it has received a value, if we try to use the value it will crash but becasue this is only + /// ever used for constructing stub calls (as opposed to calling mocked functions) the result will never be used so won't crash + internal static func unsafePlaceholder() -> V { + let pointer = UnsafeMutablePointer.allocate(capacity: 1) + defer { pointer.deallocate() } + return pointer.move() + } + + /// This function is similar to the above but can be used for `Bool` to generate an "invalid" value for the `any` case, using the value + /// returned by this function outside of constructing stub calls will result in undefined behaviour or crashes + /// + /// This doesn't cause a crash because Swift essentially doesn't check that the underlying data matches (whereas for primitive-backed + /// enum values it does, and structs must have the right memory size and alignment so we can't use this approach for them) + public static func unsafeBoolValue() -> Bool { + var rawValueToLoad: UInt8 = .any + + return withUnsafeBytes(of: &rawValueToLoad) { + $0.load(as: Bool.self) + } + } +} + +// MARK: - DSL + +public func anyAny() -> Any { AnyValue.any } + +/// This type is here to allow us to have some ability to match something passed to a function expecting an `Any` against a `.any` value, +/// since we can't extend `Any` this type will be returned by `anyAny` and will match regardless of what the parameter type is +public final class AnyValue: Mocked { + public static var any: AnyValue { AnyValue(id: .any) } + public static var mock: AnyValue { AnyValue(id: .mock) } + public static var skipTypeMatchForAnyComparison: Bool { true } + + private let id: UUID + private init(id: UUID) { + self.id = id + } +} + +extension Optional: Mocked where Wrapped: Mocked { + public static var any: Self { Wrapped.any } + public static var mock: Self { Wrapped.mock } +} + +extension Int: Mocked { + public static let any: Int = (Int.max - 123) + public static let mock: Int = 0 +} +extension UInt: Mocked { + public static let any: UInt = (UInt.max - 123) + public static let mock: UInt = 0 +} +extension Int8: Mocked { + public static let any: Int8 = (Int8.max - 123) + public static let mock: Int8 = 0 +} +extension UInt8: Mocked { + public static let any: UInt8 = (UInt8.max - 123) + public static let mock: UInt8 = 0 +} +extension Int16: Mocked { + public static let any: Int16 = (Int16.max - 123) + public static let mock: Int16 = 0 +} +extension UInt16: Mocked { + public static let any: UInt16 = (UInt16.max - 123) + public static let mock: UInt16 = 0 +} +extension Int32: Mocked { + public static let any: Int32 = (Int32.max - 123) + public static let mock: Int32 = 0 +} +extension UInt32: Mocked { + public static let any: UInt32 = (UInt32.max - 123) + public static let mock: UInt32 = 0 +} +extension Int64: Mocked { + public static let any: Int64 = (Int64.max - 123) + public static let mock: Int64 = 0 +} +extension UInt64: Mocked { + public static let any: UInt64 = (UInt64.max - 123) + public static let mock: UInt64 = 0 +} +extension Float: Mocked { + public static let any: Float = (Float.greatestFiniteMagnitude - 123) + public static let mock: Float = 0 +} +extension Double: Mocked { + public static let any: Double = (Double.greatestFiniteMagnitude - 123) + public static let mock: Double = 0 +} +extension String: Mocked { + public static let any: String = "__MOCKED_ANY_VALUE__" + public static let mock: String = "" +} +extension Data: Mocked { + public static let any: Data = Data([1, 1, 1, 200, 200, 200, 1, 1, 1]) + public static let mock: Data = Data() +} +extension Bool: Mocked { + public static let any: Bool = MockHelper.unsafeBoolValue() + public static let mock: Bool = false +} + +extension Dictionary: Mocked { + public static var any: Self { + if let mockedKeyType = Key.self as? any (Mocked & Hashable).Type, let mockedValueType = Value.self as? any Mocked.Type { + let anyKey = mockedKeyType.any + let anyValue = mockedValueType.any + + guard let hashableKey = anyKey as? AnyHashable else { + return [:] + } + + return [hashableKey: anyValue] as! Self + } + + /// Try to handle generic dictionaries + if Key.self == AnyHashable.self && (Value.self == AnyHashable.self || Value.self == Any.self) { + return [String.any: String.any] as! Self + } + + return [:] + } + public static var mock: Self { [:] } +} +extension Array: Mocked { + public static var any: Self { + if let mockedType = Element.self as? any Mocked.Type { + return [mockedType.any] as! Self + } + + return [] + } + public static var mock: Self { [] } +} +extension Set: Mocked { + public static var any: Self { + if let mockedType = Element.self as? any Mocked.Type { + return [mockedType.any] as! Self + } + + return [] + } + public static var mock: Self { [] } +} + +extension UIApplication.State: Mocked { + public static let any: UIApplication.State = .inactive + public static let mock: UIApplication.State = .active +} + +private final class MockPointerHolder { + static let anyObjCBoolPointer: UnsafeMutablePointer = { + let pointer = UnsafeMutablePointer.allocate(capacity: 1) + pointer.initialize(to: ObjCBool(false)) + return pointer + }() + static let mockObjCBoolPointer: UnsafeMutablePointer = { + let pointer = UnsafeMutablePointer.allocate(capacity: 1) + pointer.initialize(to: ObjCBool(false)) + return pointer + }() +} + +extension UnsafeMutablePointer: Mocked where Pointee == ObjCBool { + public static var any: UnsafeMutablePointer { + return MockPointerHolder.anyObjCBoolPointer + } + + public static var mock: UnsafeMutablePointer { + return MockPointerHolder.mockObjCBoolPointer + } +} + + +extension UUID: Mocked { + public static let any: UUID = UUID(uuidString: "12300099-0099-0000-0000-990099000321")! + public static let mock: UUID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! +} + +extension URL: Mocked { + public static let any: URL = URL(fileURLWithPath: "__MOCKED_ANY_VALUE__") + public static let mock: URL = URL(fileURLWithPath: "mock") +} + +extension URLRequest: Mocked { + public static let any: URLRequest = URLRequest(url: URL(fileURLWithPath: "__MOCKED_ANY_VALUE__")) + public static let mock: URLRequest = URLRequest(url: URL(fileURLWithPath: "mock")) +} + +extension AnyPublisher: Mocked where Failure == Error { + public static var any: AnyPublisher { Fail(error: MockError.any).eraseToAnyPublisher() } + public static var mock: AnyPublisher { Fail(error: MockError.mock).eraseToAnyPublisher() } +} + +extension AsyncStream: Mocked { + public static var any: AsyncStream { AsyncStream { $0.finish() } } + public static var mock: AsyncStream { AsyncStream { $0.finish() } } +} + +extension FileManager.ItemReplacementOptions: Mocked { + public static let any: FileManager.ItemReplacementOptions = FileManager.ItemReplacementOptions(rawValue: .any) + public static let mock: FileManager.ItemReplacementOptions = FileManager.ItemReplacementOptions() +} + +extension FileProtectionType: Mocked { + public static let any: FileProtectionType = FileProtectionType(rawValue: .any) + public static let mock: FileProtectionType = .complete +} + +extension Data.WritingOptions: Mocked { + public static let any: Data.WritingOptions = Data.WritingOptions(rawValue: .any) + public static let mock: Data.WritingOptions = .atomic +} diff --git a/TestUtilities/Nimble/NimbleFailureReporter.swift b/TestUtilities/Nimble/NimbleFailureReporter.swift new file mode 100644 index 0000000000..72a15ca150 --- /dev/null +++ b/TestUtilities/Nimble/NimbleFailureReporter.swift @@ -0,0 +1,12 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +internal import Nimble + +public struct NimbleFailureReporter: TestFailureReporter { + public init() {} + + public func reportFailure(_ message: String, fileID: String, file: String, line: UInt) { + fail(message, fileID: fileID, file: file, line: line) + } +} diff --git a/TestUtilities/Nimble/NimbleVerification.swift b/TestUtilities/Nimble/NimbleVerification.swift new file mode 100644 index 0000000000..bbfd853199 --- /dev/null +++ b/TestUtilities/Nimble/NimbleVerification.swift @@ -0,0 +1,186 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +internal import Nimble + +public struct NimbleVerification { + fileprivate struct VerificationData { + class Result { + var callInfo: RecordedCallInfo? + } + + fileprivate let mock: M + fileprivate let callBlock: (inout M.MockedType) async throws -> R + fileprivate let capture: Result = Result() + } + + fileprivate let data: VerificationData + + @discardableResult public func wasCalled( + exactly times: Int, + timeout: DispatchTimeInterval = .seconds(0), + fileID: String = #fileID, + file: String = #filePath, + line: UInt = #line + ) async -> RecordedCallInfo? { + if timeout == .seconds(0) { + await expect(fileID: fileID, file: file, line: line, self.data).to(beCalled(exactly: times)) + } + else { + await expect(fileID: fileID, file: file, line: line, self.data) + .toEventually( + beCalled(exactly: times), + timeout: timeout.nimbleInterval, + description: "Timed out waiting for call count to be \(times)." + ) + } + + return data.capture.callInfo + } + + @discardableResult public func wasCalled( + atLeast times: Int = 1, + timeout: DispatchTimeInterval = .seconds(0), + fileID: String = #fileID, + file: String = #filePath, + line: UInt = #line + ) async -> RecordedCallInfo? { + if timeout == .seconds(0) { + await expect(fileID: fileID, file: file, line: line, self.data).to(beCalled(atLeast: times)) + } + else { + await expect(fileID: fileID, file: file, line: line, self.data) + .toEventually( + beCalled(atLeast: times), + timeout: timeout.nimbleInterval, + description: "Timed out waiting for call count to be at least \(times)." + ) + } + + return data.capture.callInfo + } + + @discardableResult public func wasNotCalled( + timeout: DispatchTimeInterval = .seconds(0), + fileID: String = #fileID, + file: String = #filePath, + line: UInt = #line + ) async -> RecordedCallInfo? { + if timeout == .seconds(0) { + await expect(fileID: fileID, file: file, line: line, self.data).to(beCalled(exactly: 0)) + } + else { + await expect(fileID: fileID, file: file, line: line, self.data) + .toEventually( + beCalled(exactly: 0), + timeout: timeout.nimbleInterval, + description: "Timed out waiting for call count to be 0." + ) + } + + return data.capture.callInfo + } +} + +public extension Mockable { + func verify(_ callBlock: @escaping (inout MockedType) async throws -> R) async -> NimbleVerification { + return NimbleVerification( + data: NimbleVerification.VerificationData(mock: self, callBlock: callBlock) + ) + } +} + +private func beCalled( + exactly exactTimes: Int? = nil, + atLeast atLeastTimes: Int? = nil +) -> AsyncMatcher.VerificationData> { + return AsyncMatcher { actualExpression in + let message: ExpectationMessage + + switch (exactTimes, atLeastTimes) { + case (_, 1), (.none, .none): message = ExpectationMessage.expectedTo("be called at least 1 time") + case (_, .some(let atLeastTimes)): + message = ExpectationMessage.expectedTo("be called at least \(atLeastTimes) time(s)") + + case (0, _): message = ExpectationMessage.expectedTo("not be called") + case (1, _): message = ExpectationMessage.expectedTo("be called exactly 1 time") + case (.some(let exactTimes), _): + message = ExpectationMessage.expectedTo("be called exactly \(exactTimes) time(s)") + } + + guard let info = try await actualExpression.evaluate() else { + return MatcherResult(status: .fail, message: message.appendedBeNilHint()) + } + + info.capture.callInfo = await info.mock.handler.recordedCallInfo(for: info.callBlock) + + switch (exactTimes, atLeastTimes) { + case (.some(let times), _): + if info.capture.callInfo?.matchingCalls.count == times { + return MatcherResult(status: .matches, message: message) + } + + case (_, .some(let times)): + if (info.capture.callInfo?.matchingCalls.count ?? 0) >= times { + return MatcherResult(status: .matches, message: message) + } + + case (.none, .none): + if (info.capture.callInfo?.matchingCalls.count ?? 0) >= 1 { + return MatcherResult(status: .matches, message: message) + } + } + + var details: String = "" + + if (exactTimes ?? 0) > 0 || (atLeastTimes ?? 0) > 0 { + details += "\nExpected to call \((info.capture.callInfo?.expectedCall.name).map { "'\($0)'" } ?? "function") with parameters:" + + if let expectedCall: RecordedCall = info.capture.callInfo?.expectedCall { + details += "\n- \(expectedCall.parameterSummary)" + } + else { + details += "\n- Unable to determine the expected parameters" + } + + details += "\n" + } + + if let allCalls: [RecordedCall] = info.capture.callInfo?.allCalls, !allCalls.isEmpty { + let callDescriptions: String = allCalls + .map { "- \($0.parameterSummary)" } + .joined(separator: "\n") + + details += "\nAll calls to this function with different arguments:\n\(callDescriptions)" + } else { + details += "\nNo other calls were made to this function." + } + + let gotMessage: String = ((exactTimes ?? 0) > 0 || (atLeastTimes ?? 0) > 0 ? + ", got \(info.capture.callInfo?.matchingCalls.count ?? 0) matching call(s)." : + ", got called \(info.capture.callInfo?.matchingCalls.count ?? 0) time(s)." + ) + + return MatcherResult( + status: .fail, + message: message + .appended(message: gotMessage) + .appended(details: details) + ) + } +} + +private extension DispatchTimeInterval { + var nimbleInterval: NimbleTimeInterval { + switch self { + case .seconds(let value): return .seconds(value) + case .milliseconds(let value): return .milliseconds(value) + case .microseconds(let value): return .microseconds(value) + case .nanoseconds(let value): return .nanoseconds(value) + case .never: return .seconds(0) + @unknown default: return .seconds(0) + } + } +} diff --git a/TestUtilities/RecordedCall.swift b/TestUtilities/RecordedCall.swift new file mode 100644 index 0000000000..b83b3ee2d1 --- /dev/null +++ b/TestUtilities/RecordedCall.swift @@ -0,0 +1,164 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation + +public struct RecordedCall { + public let name: String + internal let generics: [Any.Type] + internal let arguments: [Any?] + + internal var key: Key { Key(name: name, generics: generics, paramCount: arguments.count) } + public var parameterSummary: String { "[\(arguments.map { summary(for: $0) }.joined(separator: ", "))]" } +} + +public struct RecordedCallInfo { + public let expectedCall: RecordedCall + public let matchingCalls: [RecordedCall] + public let allCalls: [RecordedCall] +} + +internal extension RecordedCall { + struct Key: Equatable, Hashable { + let name: String + let generics: [String] + let paramCount: Int + + var nameWithGenerics: String { + guard !generics.isEmpty else { return name } + + let splitName: [String] = name.split(separator: "(").map { String($0) } + let genericsString: String = "<\(generics.map { "\($0)" }.joined(separator: ", "))>" + + switch (generics.count, splitName.count) { + case (1..., 1): return "\(splitName[0])\(genericsString)" + case (1..., 2): return "\(splitName[0])\(genericsString)(\(splitName[1])" + default: return name + } + } + + init(name: String, generics: [Any.Type], paramCount: Int) { + self.name = name + self.generics = generics.map { String(describing: $0) } + self.paramCount = paramCount + } + } + + func matches(args: [Any?]) -> Bool { + guard args.count == arguments.count else { return false } + + for (stubArg, callArg) in zip(arguments, args) { + if !argumentMatches(stubArg: stubArg, callArg: callArg) { + return false + } + } + + return true + } + + private func isEquatableMatch(lhs: E, rhs: Any) -> Bool { + if let rhs = rhs as? E { + return lhs == rhs + } + + return false + } + + + private func argumentMatches(stubArg: Any?, callArg: Any?) -> Bool { + func isWildcardMatch(_ value: T) -> Bool { + if isAnyValue(value) { + /// The value is an `any` so check if the mocked type is a "super" wildcard (like `MockEndpoint.any` that + /// matches any other value + if T.skipTypeMatchForAnyComparison { + return true + } + + /// Otherwise the types need to match + return callArg is T + } + + /// Not a wildcard + return false + } + + switch (stubArg, callArg) { + case (.none, .none): return true /// Too hard to compare `nil == nil` after type erasure + case (.none, .some): return false /// Expected `nil`, given a value + case (.some(let lhs), .none): return isAnyValue(lhs) /// Allow `any == nil` + case (.some(let stub), .some(let call)): + /// If the `stubArg` is `Mocked.any` then we want to match anything + if let mockedStub = stub as? any Mocked, isWildcardMatch(mockedStub) { + return true + } + + /// Check if there is an equatable match first (for performance reasons) + if let equatableValue = stub as? any Equatable, isEquatableMatch(lhs: equatableValue, rhs: call) { + return true + } + + /// Otherwise we need to use reflection to to a nested equality check (just in case a child element is a wildcard) + let mirrorLhs: Mirror = Mirror(reflecting: stub) + let mirrorRhs: Mirror = Mirror(reflecting: call) + + /// Since the `stub` isn't a wildcard the types need to match + guard String(describing: mirrorLhs.subjectType) == String(describing: mirrorRhs.subjectType) else { + return false + } + + switch mirrorLhs.displayStyle { + case .struct, .class, .tuple, .collection, .dictionary, .enum: + let childrenLhs: [Mirror.Child] = Array(mirrorLhs.children) + let childrenRhs: [Mirror.Child] = Array(mirrorRhs.children) + + /// If they are simple enums with no associated types then just compare the `summary` value + if childrenLhs.isEmpty && childrenRhs.isEmpty && mirrorLhs.displayStyle == .enum { + return summary(for: stub) == summary(for: call) + } + + /// Check enum case names are the same if applicable + if mirrorLhs.displayStyle == .enum { + let caseNameLhs: String = String(describing: stub).before(first: "(") + let caseNameRhs: String = String(describing: call).before(first: "(") + + if caseNameLhs != caseNameRhs { + return false + } + } + + /// If the number of args differ then there isn't a match + guard childrenLhs.count == childrenRhs.count else { return false } + + /// If any of the arguments don't match (recursively) then there is no match + for i in 0.. String { + if let index: String.Index = firstIndex(of: delimiter) { + return String(prefix(upTo: index)) + } + + return self + } +} diff --git a/TestUtilities/TestFailureReporter.swift b/TestUtilities/TestFailureReporter.swift new file mode 100644 index 0000000000..52ee43b3a9 --- /dev/null +++ b/TestUtilities/TestFailureReporter.swift @@ -0,0 +1,14 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public protocol TestFailureReporter { + /// Reports a fatal error for the current test case, stopping its execution + func reportFailure(_ message: String, fileID: String, file: String, line: UInt) +} + +public extension TestFailureReporter { + func reportFailure(_ message: String, fileID: String = #fileID, file: String = #filePath, line: UInt = #line) { + reportFailure(message, fileID: fileID, file: file, line: line) + } +} diff --git a/TestUtilities/Utilities/Async+Utilities.swift b/TestUtilities/Utilities/Async+Utilities.swift new file mode 100644 index 0000000000..d686ec3898 --- /dev/null +++ b/TestUtilities/Utilities/Async+Utilities.swift @@ -0,0 +1,16 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension AsyncStream { + static func singleValue(value: Element) -> AsyncStream { + var hasEmittedValue: Bool = false + + return AsyncStream(unfolding: { + guard !hasEmittedValue else { return nil } + + hasEmittedValue = true + return value + }) + } +} diff --git a/_SharedTestUtilities/TestExtensions.swift b/TestUtilities/Utilities/Collection+Utilities.swift similarity index 51% rename from _SharedTestUtilities/TestExtensions.swift rename to TestUtilities/Utilities/Collection+Utilities.swift index f187000ea8..3086f68331 100644 --- a/_SharedTestUtilities/TestExtensions.swift +++ b/TestUtilities/Utilities/Collection+Utilities.swift @@ -7,11 +7,3 @@ public extension Collection { return (indices.contains(index) ? self[index] : nil) } } - -public extension Array { - func allCombinations() -> [[Element]] { - guard !isEmpty else { return [[]] } - - return Array(self[1...]).allCombinations().flatMap { [$0, ([self[0]] + $0)] } - } -} diff --git a/_SharedTestUtilities/CombineExtensions.swift b/TestUtilities/Utilities/Combine+Utilities.swift similarity index 57% rename from _SharedTestUtilities/CombineExtensions.swift rename to TestUtilities/Utilities/Combine+Utilities.swift index 057a2ddb3d..ea8c425d47 100644 --- a/_SharedTestUtilities/CombineExtensions.swift +++ b/TestUtilities/Utilities/Combine+Utilities.swift @@ -2,7 +2,6 @@ import Foundation import Combine -import SessionUtilitiesKit public extension Publisher { func sinkAndStore(in storage: inout C) where C: RangeReplaceableCollection, C.Element == AnyCancellable { @@ -16,18 +15,3 @@ public extension Publisher { .store(in: &storage) } } - -public extension AnyPublisher { - func firstValue() -> Output? { - var value: Output? - - _ = self - .receive(on: ImmediateScheduler.shared) - .sink( - receiveCompletion: { _ in }, - receiveValue: { result in value = result } - ) - - return value - } -} diff --git a/_SharedTestUtilities/FixtureBase.swift b/_SharedTestUtilities/FixtureBase.swift new file mode 100644 index 0000000000..eef41f1c55 --- /dev/null +++ b/_SharedTestUtilities/FixtureBase.swift @@ -0,0 +1,93 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit +import TestUtilities + +open class FixtureBase { + let dependencies: TestDependencies = TestDependencies() + + public init() { + dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) + dependencies.forceSynchronous = true + } + + public func mock( + for singleton: SingletonConfig, + _ creation: (TestDependencies) -> R + ) -> R { + if let existingMock: R = dependencies.get(singleton: singleton) as? R { + return existingMock + } + + let value: R = creation(dependencies) + + guard let conformingValue: SingletonType = value as? SingletonType else { + fatalError("Type Mismatch: The mock of type '\(type(of: value))' does not conform to the required protocol '\(SingletonType.self)' for the provided singleton key.") + } + + dependencies.set(singleton: singleton, to: conformingValue) + return value + } + + public func mock( + cache: CacheConfig, + _ creation: (TestDependencies) -> R + ) -> R { + if let existingMock: R = dependencies.get(cache: cache) as? R { + return existingMock + } + + let value: R = creation(dependencies) + + guard let conformingValue: MutableCache = value as? MutableCache else { + fatalError("Type Mismatch: The mock of type '\(type(of: value))' does not conform to the required protocol '\(MutableCache.self)' for the provided singleton key.") + } + + dependencies.set(cache: cache, to: conformingValue) + return value + } + + public func mock( + for defaults: UserDefaultsConfig, + _ creation: (TestDependencies) -> T + ) -> T { + if let existingMock: T = dependencies.get(defaults: defaults) { + return existingMock + } + + let value: T = creation(dependencies) + dependencies.set(defaults: defaults, to: value) + + return value + } + + private func mock(_ creation: (TestDependencies) -> T) -> T { + if let existingMock: T = dependencies.get(other: ObjectIdentifier(T.self)) { + return existingMock + } + + let value: T = creation(dependencies) + dependencies.set(other: ObjectIdentifier(T.self), to: value) + + return value + } + + // MARK: - Mockable Convenience + + public func mock() -> R { + return mock { dependencies in R.create(using: dependencies) } + } + + public func mock(for singleton: SingletonConfig) -> R { + return mock(for: singleton) { dependencies in R.create(using: dependencies) } + } + + public func mock(cache: CacheConfig) -> R { + return mock(cache: cache) { dependencies in R.create(using: dependencies) } + } + + public func mock(defaults: UserDefaultsConfig) -> T { + return mock(for: defaults) { dependencies in T.create(using: dependencies) } + } +} diff --git a/_SharedTestUtilities/GRDBExtensions.swift b/_SharedTestUtilities/GRDBExtensions.swift deleted file mode 100644 index 29e2281825..0000000000 --- a/_SharedTestUtilities/GRDBExtensions.swift +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB - -@testable import SessionUtilitiesKit - -public extension MutablePersistableRecord where Self: MutableIdentifiable { - /// This is a test method which allows for inserting with a pre-defined id - mutating func insert(_ db: ObservingDatabase, withRowId rowId: ID) throws { - self.setId(rowId) - try insert(db) - } -} diff --git a/_SharedTestUtilities/Mock.swift b/_SharedTestUtilities/Mock.swift deleted file mode 100644 index 7b493a3d7b..0000000000 --- a/_SharedTestUtilities/Mock.swift +++ /dev/null @@ -1,1043 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Combine -import SessionUtilitiesKit - -// MARK: - MockError - -public enum MockError: Error { - case mockedData -} - -// MARK: - Mock - -public class Mock: DependenciesSettable, InitialSetupable { - private var _dependencies: Dependencies! - private let functionHandler: MockFunctionHandler - internal let functionConsumer: FunctionConsumer - - public var dependencies: Dependencies { _dependencies } - private var initialSetup: ((Mock) -> ())? - - // MARK: - Initialization - - internal required init( - functionHandler: MockFunctionHandler? = nil, - initialSetup: ((Mock) -> ())? = nil - ) { - self.functionConsumer = FunctionConsumer() - self.functionHandler = (functionHandler ?? self.functionConsumer) - self.initialSetup = initialSetup - } - - // MARK: - DependenciesSettable - - public func setDependencies(_ dependencies: Dependencies?) { - self._dependencies = dependencies - } - - // MARK: - InitialSetupable - - func performInitialSetup() { - self.initialSetup?(self) - self.initialSetup = nil - } - - // MARK: - MockFunctionHandler - - @discardableResult internal func mock(funcName: String = #function, generics: [Any.Type] = [], args: [Any?] = [], untrackedArgs: [Any?] = []) -> Output { - return functionHandler.mock( - funcName, - parameterCount: args.count, - parameterSummary: summary(for: args), - allParameterSummaryCombinations: summaries(for: args), - generics: generics, - args: args, - untrackedArgs: untrackedArgs - ) - } - - internal func mockNoReturn(funcName: String = #function, generics: [Any.Type] = [], args: [Any?] = [], untrackedArgs: [Any?] = []) { - functionHandler.mockNoReturn( - funcName, - parameterCount: args.count, - parameterSummary: summary(for: args), - allParameterSummaryCombinations: summaries(for: args), - generics: generics, - args: args, - untrackedArgs: untrackedArgs - ) - } - - @discardableResult internal func mockThrowing(funcName: String = #function, generics: [Any.Type] = [], args: [Any?] = [], untrackedArgs: [Any?] = []) throws -> Output { - return try functionHandler.mockThrowing( - funcName, - parameterCount: args.count, - parameterSummary: summary(for: args), - allParameterSummaryCombinations: summaries(for: args), - generics: generics, - args: args, - untrackedArgs: untrackedArgs - ) - } - - internal func mockThrowingNoReturn(funcName: String = #function, generics: [Any.Type] = [], args: [Any?] = [], untrackedArgs: [Any?] = []) throws { - try functionHandler.mockThrowingNoReturn( - funcName, - parameterCount: args.count, - parameterSummary: summary(for: args), - allParameterSummaryCombinations: summaries(for: args), - generics: generics, - args: args, - untrackedArgs: untrackedArgs - ) - } - - internal func getExpectation(funcName: String = #function, generics: [Any.Type] = [], args: [Any?] = [], untrackedArgs: [Any?] = []) -> MockFunction { - return functionConsumer.getExpectation( - funcName, - parameterCount: args.count, - parameterSummary: summary(for: args), - allParameterSummaryCombinations: summaries(for: args), - generics: generics, - args: args, - untrackedArgs: untrackedArgs - ) - } - - // MARK: - Functions - - internal func reset() { - functionConsumer.reset() - } - - internal func removeMocksFor(_ callBlock: @escaping (inout T) throws -> R) { - let builder: MockFunctionBuilder = MockFunctionBuilder(callBlock, mockInit: type(of: self).init) - functionConsumer.removeBuilder(builder.build) - } - - internal func when(_ callBlock: @escaping (inout T) throws -> R) -> MockFunctionBuilder { - let builder: MockFunctionBuilder = MockFunctionBuilder(callBlock, mockInit: type(of: self).init) - functionConsumer.addBuilder(builder.build) - - return builder - } - - internal func when(_ callBlock: @escaping (inout T) async throws -> R) -> MockFunctionBuilder { - let builder: MockFunctionBuilder = MockFunctionBuilder(callBlock, mockInit: type(of: self).init) - functionConsumer.addBuilder(builder.build) - - return builder - } - - internal func allCalls(_ functionBlock: @escaping (inout T) async throws -> R) -> [CallDetails]? { - let maybeTargetFunction: MockFunction? = try? MockFunctionBuilder.mockFunctionWith(self, functionBlock) - let key: FunctionConsumer.Key = FunctionConsumer.Key( - name: (maybeTargetFunction?.name ?? ""), - generics: (maybeTargetFunction?.generics ?? []), - paramCount: (maybeTargetFunction?.parameterCount ?? 0) - ) - - return functionConsumer.calls[key] - } - - // MARK: - Convenience - - private func summaries(for argument: Any) -> [ParameterCombination] { - switch argument { - case let array as [Any]: - return array.allCombinations() - .map { ParameterCombination(count: $0.count, summary: summary(for: $0)) } - - default: return [ParameterCombination(count: 1, summary: summary(for: argument))] - } - } - - private func summary(for argument: Any) -> String { - if - let customDescribable: CustomArgSummaryDescribable = argument as? CustomArgSummaryDescribable, - let customArgSummaryDescribable: String = customDescribable.customArgSummaryDescribable - { return customArgSummaryDescribable } - - switch argument { - case let string as String: return string - case let array as [Any]: return "[\(array.map { summary(for: $0) }.joined(separator: ", "))]" - - case let dict as [String: Any]: - if dict.isEmpty { return "[:]" } - - let sortedValues: [String] = dict - .map { key, value in "\(summary(for: key)):\(summary(for: value))" } - .sorted() - return "[\(sortedValues.joined(separator: ", "))]" - - case let data as Data: return "Data(base64Encoded: \(data.base64EncodedString()))" - - default: - // Default to the `debugDescription` if available but sort any dictionary content by keys - return sortDictionariesInReflectedString(String(reflecting: argument)) - } - } - - private func sortDictionariesInReflectedString(_ input: String) -> String { - // Regular expression to match the headers dictionary - let pattern = "\\[(.+?)\\]" - let regex = try! NSRegularExpression(pattern: pattern, options: []) - - var result = "" - var lastRange = input.startIndex.. String { - var pairs: [(String, String)] = [] - var currentKey = "" - var currentValue = "" - var inQuotes = false - var parsingKey = true - var nestedLevel = 0 - - for char in dictionaryString { - switch char { - case "\"": - inQuotes.toggle() - if nestedLevel > 0 { - currentKey.append(char) - continue - } - - case ":": - if !inQuotes && nestedLevel == 0 { - parsingKey = false - continue - } - - case ",": - if !inQuotes && nestedLevel == 0 { - pairs.append((currentKey.trimmingCharacters(in: .whitespaces), currentValue.trimmingCharacters(in: .whitespaces))) - currentKey = "" - currentValue = "" - parsingKey = true - continue - } - - case "[", "{": nestedLevel += (parsingKey ? 0 : 1) - case "]", "}": nestedLevel -= (parsingKey ? 0 : 1) - default: break - } - - switch parsingKey { - case true: currentKey.append(char) - case false: currentValue.append(char) - } - } - - // Add the last pair if exists - if !currentKey.isEmpty || !currentValue.isEmpty { - pairs.append((currentKey.trimmingCharacters(in: .whitespaces), currentValue.trimmingCharacters(in: .whitespaces))) - } - - // Sort pairs by key - let sortedPairs = pairs.sorted { $0.0 < $1.0 } - - // Join sorted pairs back into a string - return sortedPairs.map { "\($0): \($1)" }.joined(separator: ", ") - } -} - -// MARK: - MockFunctionHandler - -protocol MockFunctionHandler { - func mock( - _ functionName: String, - parameterCount: Int, - parameterSummary: String, - allParameterSummaryCombinations: [ParameterCombination], - generics: [Any.Type], - args: [Any?], - untrackedArgs: [Any?] - ) -> Output - - func mockNoReturn( - _ functionName: String, - parameterCount: Int, - parameterSummary: String, - allParameterSummaryCombinations: [ParameterCombination], - generics: [Any.Type], - args: [Any?], - untrackedArgs: [Any?] - ) - - func mockThrowing( - _ functionName: String, - parameterCount: Int, - parameterSummary: String, - allParameterSummaryCombinations: [ParameterCombination], - generics: [Any.Type], - args: [Any?], - untrackedArgs: [Any?] - ) throws -> Output - - func mockThrowingNoReturn( - _ functionName: String, - parameterCount: Int, - parameterSummary: String, - allParameterSummaryCombinations: [ParameterCombination], - generics: [Any.Type], - args: [Any?], - untrackedArgs: [Any?] - ) throws -} - -// MARK: - CallDetails - -internal struct CallDetails: Equatable, Hashable { - let parameterSummary: String - let allParameterSummaryCombinations: [ParameterCombination] - - internal init(parameterSummary: String, allParameterSummaryCombinations: [ParameterCombination]) { - self.parameterSummary = parameterSummary - self.allParameterSummaryCombinations = allParameterSummaryCombinations - } -} - -// MARK: - ParameterCombination - -internal struct ParameterCombination: Equatable, Hashable { - let count: Int - let summary: String -} - -// MARK: - MockFunction - -internal class MockFunction { - var name: String - var parameterCount: Int - var parameterSummary: String - var allParameterSummaryCombinations: [ParameterCombination] - var generics: [Any.Type] - var args: [Any?] - var untrackedArgs: [Any?] - var actions: [([Any?], [Any?]) -> Void] - var asyncActions: [([Any?], [Any?]) async -> Void] - var returnError: (any Error)? - var closureCallArgs: [Any?] - var returnValue: Any? - var dynamicReturnValueRetriever: (([Any?], [Any?]) -> Any?)? - - init( - name: String, - parameterCount: Int, - parameterSummary: String, - allParameterSummaryCombinations: [ParameterCombination], - generics: [Any.Type], - args: [Any?], - untrackedArgs: [Any?], - actions: [([Any?], [Any?]) -> Void], - asyncActions: [([Any?], [Any?]) async -> Void], - returnError: (any Error)?, - closureCallArgs: [Any?], - returnValue: Any?, - dynamicReturnValueRetriever: (([Any?], [Any?]) -> Any?)? - ) { - self.name = name - self.parameterCount = parameterCount - self.parameterSummary = parameterSummary - self.allParameterSummaryCombinations = allParameterSummaryCombinations - self.generics = generics - self.args = args - self.untrackedArgs = untrackedArgs - self.asyncActions = asyncActions - self.actions = actions - self.returnError = returnError - self.closureCallArgs = closureCallArgs - self.returnValue = returnValue - self.dynamicReturnValueRetriever = dynamicReturnValueRetriever - } -} - -// MARK: - MockFunctionBuilder - -internal class MockFunctionBuilder: MockFunctionHandler { - private let callBlock: (inout T) async throws -> R - private let mockInit: (MockFunctionHandler?, ((Mock) -> ())?) -> Mock - private var functionName: String? - private var parameterCount: Int? - private var parameterSummary: String? - private var allParameterSummaryCombinations: [ParameterCombination]? - private var generics: [Any.Type]? - private var args: [Any?]? - private var untrackedArgs: [Any?]? - private var actions: [([Any?], [Any?]) -> Void] = [] - private var asyncActions: [([Any?], [Any?]) async -> Void] = [] - private var closureCallArgs: [Any?] = [] - private var returnValue: R? - private var dynamicReturnValueRetriever: (([Any?], [Any?]) -> R?)? - private var returnError: Error? - - /// This value should only ever be set via the `NimbleExtensions` `generateCallInfo` function, in order to use a closure to - /// generate the return value the `dynamicReturnValueRetriever` value should be used instead - internal var returnValueGenerator: ((String, [Any.Type], Int, String, [ParameterCombination]) -> R?)? - - // MARK: - Initialization - - init(_ callBlock: @escaping (inout T) async throws -> R, mockInit: @escaping (MockFunctionHandler?, ((Mock) -> ())?) -> Mock) { - self.callBlock = callBlock - self.mockInit = mockInit - } - - static func mockFunctionWith( - _ validInstance: M, - _ functionBlock: @escaping (inout T) async throws -> R - ) throws -> MockFunction? where M: Mock { - let builder: MockFunctionBuilder = MockFunctionBuilder(functionBlock, mockInit: type(of: validInstance).init) - builder.returnValueGenerator = { name, generics, parameterCount, parameterSummary, allParameterSummaryCombinations in - validInstance.functionConsumer - .firstFunction( - for: FunctionConsumer.Key(name: name, generics: generics, paramCount: parameterCount), - matchingParameterSummaryIfPossible: parameterSummary, - allParameterSummaryCombinations: allParameterSummaryCombinations - )? - .returnValue as? R - } - - return try builder.build() - } - - // MARK: - Behaviours - - /// Closure parameter is an array of arguments called by the function - @discardableResult func then(_ block: @escaping ([Any?]) -> Void) -> MockFunctionBuilder { - actions.append({ args, _ in block(args) }) - return self - } - - /// Closure parameter is an array of arguments called by the function - @discardableResult func then(_ block: @escaping ([Any?]) async -> Void) -> MockFunctionBuilder { - asyncActions.append({ args, _ in await block(args) }) - return self - } - - /// Closure parameters are an array of arguments, followed by an array of "untracked" arguments called by the function - @discardableResult func then(_ block: @escaping ([Any?], [Any?]) -> Void) -> MockFunctionBuilder { - actions.append(block) - return self - } - - /// Closure parameters are an array of arguments, followed by an array of "untracked" arguments called by the function - @discardableResult func then(_ block: @escaping ([Any?], [Any?]) async -> Void) -> MockFunctionBuilder { - asyncActions.append(block) - return self - } - - func withClosureCallArgs(_ values: [Any?]) { - closureCallArgs = values - } - - func thenReturn(_ value: R?) { - (value as? (any InitialSetupable))?.performInitialSetup() - returnValue = value - } - - func thenReturn(_ closure: @escaping (([Any?], [Any?]) -> R?)) { - dynamicReturnValueRetriever = { args, untrackedArgs in - let result = closure(args, untrackedArgs) - (result as? (any InitialSetupable))?.performInitialSetup() - return result - } - } - - func thenThrow(_ error: Error) { - returnError = error - } - - // MARK: - MockFunctionHandler - - func mock( - _ functionName: String, - parameterCount: Int, - parameterSummary: String, - allParameterSummaryCombinations: [ParameterCombination], - generics: [Any.Type], - args: [Any?], - untrackedArgs: [Any?] - ) -> Output { - self.functionName = functionName - self.parameterCount = parameterCount - self.parameterSummary = parameterSummary - self.allParameterSummaryCombinations = allParameterSummaryCombinations - self.generics = generics - self.args = args - self.untrackedArgs = untrackedArgs - - let result: Any? = ( - returnValue ?? - dynamicReturnValueRetriever?(args, untrackedArgs) ?? - returnValueGenerator?(functionName, generics, parameterCount, parameterSummary, allParameterSummaryCombinations) - ) - - switch result { - case .some(let value as Output): return value - case .some(let value as (any Numeric)): - guard - let numericType: (any Numeric.Type) = Output.self as? any Numeric.Type, - let convertedValue: Output = convertNumeric(value, to: numericType) as? Output - else { return (result as! Output) } - - return convertedValue - - default: return (result as! Output) - } - } - - func mockNoReturn( - _ functionName: String, - parameterCount: Int, - parameterSummary: String, - allParameterSummaryCombinations: [ParameterCombination], - generics: [Any.Type], - args: [Any?], - untrackedArgs: [Any?] - ) { - self.functionName = functionName - self.parameterCount = parameterCount - self.parameterSummary = parameterSummary - self.allParameterSummaryCombinations = allParameterSummaryCombinations - self.generics = generics - self.args = args - self.untrackedArgs = untrackedArgs - } - - func mockThrowing( - _ functionName: String, - parameterCount: Int, - parameterSummary: String, - allParameterSummaryCombinations: [ParameterCombination], - generics: [Any.Type], - args: [Any?], - untrackedArgs: [Any?] - ) throws -> Output { - self.functionName = functionName - self.parameterCount = parameterCount - self.parameterSummary = parameterSummary - self.allParameterSummaryCombinations = allParameterSummaryCombinations - self.generics = generics - self.args = args - self.untrackedArgs = untrackedArgs - - if let returnError: Error = returnError { throw returnError } - - let result: Any? = ( - returnValue ?? - dynamicReturnValueRetriever?(args, untrackedArgs) ?? - returnValueGenerator?(functionName, generics, parameterCount, parameterSummary, allParameterSummaryCombinations) - ) - - switch result { - case .some(let value as Output): return value - case .some(let value as (any Numeric)): - guard - let numericType: (any Numeric.Type) = Output.self as? any Numeric.Type, - let convertedValue: Output = convertNumeric(value, to: numericType) as? Output - else { throw MockError.mockedData } - - return convertedValue - - default: return try Optional.none as? Output ?? { throw MockError.mockedData }() - } - } - - func mockThrowingNoReturn( - _ functionName: String, - parameterCount: Int, - parameterSummary: String, - allParameterSummaryCombinations: [ParameterCombination], - generics: [Any.Type], - args: [Any?], - untrackedArgs: [Any?] - ) throws { - self.functionName = functionName - self.parameterCount = parameterCount - self.parameterSummary = parameterSummary - self.allParameterSummaryCombinations = allParameterSummaryCombinations - self.generics = generics - self.args = args - self.untrackedArgs = untrackedArgs - - if let returnError: Error = returnError { throw returnError } - } - - // MARK: - Build - - func build() throws -> MockFunction { - var completionMock = mockInit(self, nil) as! T - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - Task { - await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { _ = try? await self.callBlock(&completionMock) } - group.addTask { - let numIterations: UInt64 = 50 - - for _ in (0..(_ value: [Element]) where R == AnyPublisher<[Element], Never> { - returnValue = Just(value) - .setFailureType(to: Never.self) - .eraseToAnyPublisher() - } - - func thenReturn(_ value: [Element]) where R == AnyPublisher<[Element], Error> { - returnValue = Just(value) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - func thenReturn(_ value: Set) where R == AnyPublisher, Never> { - returnValue = Just(value) - .setFailureType(to: Never.self) - .eraseToAnyPublisher() - } - - func thenReturn(_ value: Set) where R == AnyPublisher, Error> { - returnValue = Just(value) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } -} - -// MARK: - DependenciesSettable - -protocol DependenciesSettable { - var dependencies: Dependencies { get } - - func setDependencies(_ dependencies: Dependencies?) -} - -// MARK: - InitialSetupable - -protocol InitialSetupable { - func performInitialSetup() -} - -// MARK: - FunctionConsumer - -internal class FunctionConsumer: MockFunctionHandler { - internal struct Key: Equatable, Hashable { - let name: String - let paramCount: Int - - internal init(name: String, generics: [Any.Type], paramCount: Int) { - let splitName: [String] = name.split(separator: "(").map { String($0) } - let genericsString: String = "<\(generics.map { "\($0)" }.joined(separator: ", "))>" - - switch (generics.count, splitName.count) { - case (1..., 1): self.name = "\(splitName[0])\(genericsString)" - case (1..., 2): self.name = "\(splitName[0])\(genericsString)(\(splitName[1])" - default: self.name = name - } - self.paramCount = paramCount - } - } - - var trackCalls: Bool = true - @ThreadSafeObject var functionBuilders: [() throws -> MockFunction?] = [] - @ThreadSafeObject var functionHandlers: [Key: [String: MockFunction]] = [:] - @ThreadSafeObject var calls: [Key: [CallDetails]] = [:] - - fileprivate func getExpectation( - _ functionName: String, - parameterCount: Int, - parameterSummary: String, - allParameterSummaryCombinations: [ParameterCombination], - generics: [Any.Type], - args: [Any?], - untrackedArgs: [Any?] - ) -> MockFunction { - let key: Key = Key(name: functionName, generics: generics, paramCount: parameterCount) - - if !functionBuilders.isEmpty { - functionBuilders - .compactMap { builder in try? builder() } - .forEach { function in - let key: Key = Key( - name: function.name, - generics: function.generics, - paramCount: function.parameterCount - ) - var updatedHandlers: [String: MockFunction] = (functionHandlers[key] ?? [:]) - - // Add the actual 'parameterSummary' value for the handlers (override any - // existing entries - updatedHandlers[function.parameterSummary] = function - - // Upsert entries for all remaining combinations (assume we want to - // overwrite any existing entries) - function.allParameterSummaryCombinations.forEach { combination in - updatedHandlers[combination.summary] = function - } - - _functionHandlers.performUpdate { $0.setting(key, updatedHandlers) } - } - - _functionBuilders.performUpdate { _ in [] } - } - - let maybeResult: MockFunction? = firstFunction( - for: key, - matchingParameterSummaryIfPossible: parameterSummary, - allParameterSummaryCombinations: allParameterSummaryCombinations - ) - - guard let result: MockFunction = maybeResult else { - preconditionFailure("No expectations found for \(key.name)") - } - - return result - } - - private func getAndTrackExpectation( - _ functionName: String, - parameterCount: Int, - parameterSummary: String, - allParameterSummaryCombinations: [ParameterCombination], - generics: [Any.Type], - args: [Any?], - untrackedArgs: [Any?] - ) -> MockFunction { - let key: Key = Key(name: functionName, generics: generics, paramCount: parameterCount) - let expectation: MockFunction = getExpectation( - functionName, - parameterCount: parameterCount, - parameterSummary: parameterSummary, - allParameterSummaryCombinations: allParameterSummaryCombinations, - generics: generics, - args: args, - untrackedArgs: untrackedArgs - ) - - // Record the call so it can be validated later (assuming we are tracking calls) - if trackCalls { - _calls.performUpdate { - $0.setting(key, ($0[key] ?? []).appending( - CallDetails( - parameterSummary: parameterSummary, - allParameterSummaryCombinations: allParameterSummaryCombinations - ) - )) - } - } - - for action in expectation.actions { - action(args, untrackedArgs) - } - - if !expectation.asyncActions.isEmpty { - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - Task { - for action in expectation.asyncActions { - await action(args, untrackedArgs) - } - - semaphore.signal() - } - semaphore.wait() - } - - return expectation - } - - func mock( - _ functionName: String, - parameterCount: Int, - parameterSummary: String, - allParameterSummaryCombinations: [ParameterCombination], - generics: [Any.Type], - args: [Any?], - untrackedArgs: [Any?] - ) -> Output { - let expectation: MockFunction = getAndTrackExpectation( - functionName, - parameterCount: parameterCount, - parameterSummary: parameterSummary, - allParameterSummaryCombinations: allParameterSummaryCombinations, - generics: generics, - args: args, - untrackedArgs: untrackedArgs - ) - - switch (expectation.returnValue, expectation.dynamicReturnValueRetriever) { - case (.some(let value as Output), _): return value - case (.some(let value as (any Numeric)), _): - guard - let numericType: (any Numeric.Type) = Output.self as? any Numeric.Type, - let convertedValue: Output = convertNumeric(value, to: numericType) as? Output - else { return (value as! Output) } - - return convertedValue - - case (_, .some(let closure)): return closure(args, untrackedArgs) as! Output - default: return (expectation.returnValue as! Output) - } - } - - func mockNoReturn( - _ functionName: String, - parameterCount: Int, - parameterSummary: String, - allParameterSummaryCombinations: [ParameterCombination], - generics: [Any.Type], - args: [Any?], - untrackedArgs: [Any?] - ) { - _ = getAndTrackExpectation( - functionName, - parameterCount: parameterCount, - parameterSummary: parameterSummary, - allParameterSummaryCombinations: allParameterSummaryCombinations, - generics: generics, - args: args, - untrackedArgs: untrackedArgs - ) - } - - func mockThrowing( - _ functionName: String, - parameterCount: Int, - parameterSummary: String, - allParameterSummaryCombinations: [ParameterCombination], - generics: [Any.Type], - args: [Any?], - untrackedArgs: [Any?] - ) throws -> Output { - let expectation: MockFunction = getAndTrackExpectation( - functionName, - parameterCount: parameterCount, - parameterSummary: parameterSummary, - allParameterSummaryCombinations: allParameterSummaryCombinations, - generics: generics, - args: args, - untrackedArgs: untrackedArgs - ) - - switch (expectation.returnError, expectation.returnValue, expectation.dynamicReturnValueRetriever) { - case (.some(let error), _, _): throw error - case (_, .some(let value as Output), _): return value - case (_, .some(let value as (any Numeric)), _): - guard - let numericType: (any Numeric.Type) = Output.self as? any Numeric.Type, - let convertedValue: Output = convertNumeric(value, to: numericType) as? Output - else { throw MockError.mockedData } - - return convertedValue - - case (_, _, .some(let closure)): return closure(args, untrackedArgs) as! Output - default: return try Optional.none as? Output ?? { throw MockError.mockedData }() - } - } - - func mockThrowingNoReturn( - _ functionName: String, - parameterCount: Int, - parameterSummary: String, - allParameterSummaryCombinations: [ParameterCombination], - generics: [Any.Type], - args: [Any?], - untrackedArgs: [Any?] - ) throws { - let expectation: MockFunction = getAndTrackExpectation( - functionName, - parameterCount: parameterCount, - parameterSummary: parameterSummary, - allParameterSummaryCombinations: allParameterSummaryCombinations, - generics: generics, - args: args, - untrackedArgs: untrackedArgs - ) - - switch expectation.returnError { - case .some(let error): throw error - default: return - } - } - - func firstFunction( - for key: Key, - matchingParameterSummaryIfPossible parameterSummary: String, - allParameterSummaryCombinations: [ParameterCombination] - ) -> MockFunction? { - guard let possibleExpectations: [String: MockFunction] = functionHandlers[key] else { return nil } - - guard let expectation: MockFunction = possibleExpectations[parameterSummary] else { - // We didn't find an exact match so try to find the match with the most matching parameters, - // do this by sorting based on the largest param count and checking if there is a match - let maybeExpectation: MockFunction? = allParameterSummaryCombinations - .sorted(by: { lhs, rhs in lhs.count > rhs.count }) - .compactMap { combination in possibleExpectations[combination.summary] } - .first - - if let expectation: MockFunction = maybeExpectation { - return expectation - } - - // A `nil` response might be value but in a lot of places we will need to force-cast - // so try to find a non-nil response first - return ( - possibleExpectations.values.first(where: { $0.returnValue != nil }) ?? - possibleExpectations.values.first(where: { $0.dynamicReturnValueRetriever != nil }) ?? - possibleExpectations.values.first - ) - } - - return expectation - } - - fileprivate func addBuilder(_ build: @escaping () throws -> MockFunction) { - _functionBuilders.performUpdate { $0.appending(build) } - } - - fileprivate func removeBuilder(_ build: @escaping () throws -> MockFunction) { - let oldTrackCalls: Bool = trackCalls - trackCalls = false - - guard let builtFunction: MockFunction = try? build() else { - trackCalls = oldTrackCalls - return - } - - _functionBuilders.performUpdate { - $0.filter { existingBuild in - guard let existingFunction: MockFunction = try? existingBuild() else { return true } - - /// If the function name and number of parameters match then assume it's the same function and remove it - return ( - builtFunction.name != existingFunction.name || - builtFunction.parameterCount != existingFunction.parameterCount - ) - } - } - trackCalls = oldTrackCalls - } - - fileprivate func reset() { - trackCalls = true - clearCalls() - - _functionBuilders.performUpdate { _ in [] } - _functionHandlers.performUpdate { _ in [:] } - } - - fileprivate func clearCalls() { - _calls.set(to: [:]) - } -} - -// MARK: - Conversion Convenience - -private extension MockFunctionHandler { - func convertNumeric(_ value: Any, to type: any Numeric.Type) -> (any Numeric)? { - switch (value, type) { - case (let x as any BinaryInteger, is Int64.Type): return Int64(x) - case (let x as any BinaryInteger, is Int32.Type): return Int32(x) - case (let x as any BinaryInteger, is Int16.Type): return Int16(x) - case (let x as any BinaryInteger, is Int8.Type): return Int8(x) - case (let x as any BinaryInteger, is Int.Type): return Int(x) - case (let x as any BinaryInteger, is UInt64.Type): return UInt64(x) - case (let x as any BinaryInteger, is UInt32.Type): return UInt32(x) - case (let x as any BinaryInteger, is UInt16.Type): return UInt16(x) - case (let x as any BinaryInteger, is UInt8.Type): return UInt8(x) - case (let x as any BinaryInteger, is UInt.Type): return UInt(x) - case (let x as any BinaryInteger, is Float.Type): return Float(x) - case (let x as any BinaryInteger, is Double.Type): return Double(x) - case (let x as any BinaryInteger, is TimeInterval.Type): return TimeInterval(x) - - case (let x as any BinaryFloatingPoint, is Int64.Type): return Int64(x) - case (let x as any BinaryFloatingPoint, is Int32.Type): return Int32(x) - case (let x as any BinaryFloatingPoint, is Int16.Type): return Int16(x) - case (let x as any BinaryFloatingPoint, is Int8.Type): return Int8(x) - case (let x as any BinaryFloatingPoint, is Int.Type): return Int(x) - case (let x as any BinaryFloatingPoint, is UInt64.Type): return UInt64(x) - case (let x as any BinaryFloatingPoint, is UInt32.Type): return UInt32(x) - case (let x as any BinaryFloatingPoint, is UInt16.Type): return UInt16(x) - case (let x as any BinaryFloatingPoint, is UInt8.Type): return UInt8(x) - case (let x as any BinaryFloatingPoint, is UInt.Type): return UInt(x) - case (let x as any BinaryFloatingPoint, is Float.Type): return Float(x) - case (let x as any BinaryFloatingPoint, is Double.Type): return Double(x) - case (let x as any BinaryFloatingPoint, is TimeInterval.Type): return TimeInterval(x) - - default: return nil - } - } -} - -// MARK: - CustomArgSummaryDescribable - -protocol CustomArgSummaryDescribable { - var customArgSummaryDescribable: String? { get } -} diff --git a/_SharedTestUtilities/MockAppContext.swift b/_SharedTestUtilities/MockAppContext.swift deleted file mode 100644 index 325498e134..0000000000 --- a/_SharedTestUtilities/MockAppContext.swift +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. - -import UIKit -import SessionUtilitiesKit - -class MockAppContext: Mock, AppContext { - var mainWindow: UIWindow? { mock() } - var frontMostViewController: UIViewController? { mock() } - - var isValid: Bool { mock() } - var appLaunchTime: Date { mock() } - var isMainApp: Bool { mock() } - var isMainAppAndActive: Bool { mock() } - var isShareExtension: Bool { mock() } - var reportedApplicationState: UIApplication.State { mock() } - var backgroundTimeRemaining: TimeInterval { mock() } - - // Override the extension functions - var isInBackground: Bool { mock() } - var isAppForegroundAndActive: Bool { mock() } - - func setMainWindow(_ mainWindow: UIWindow) { - mockNoReturn(args: [mainWindow]) - } - - func ensureSleepBlocking(_ shouldBeBlocking: Bool, blockingObjects: [Any]) { - mockNoReturn(args: [shouldBeBlocking, blockingObjects]) - } - - func beginBackgroundTask(expirationHandler: @escaping () -> ()) -> UIBackgroundTaskIdentifier { - return mock(args: [expirationHandler]) - } - - func endBackgroundTask(_ backgroundTaskIdentifier: UIBackgroundTaskIdentifier) { - mockNoReturn(args: [backgroundTaskIdentifier]) - } -} diff --git a/_SharedTestUtilities/MockCrypto.swift b/_SharedTestUtilities/MockCrypto.swift deleted file mode 100644 index 458b5c164f..0000000000 --- a/_SharedTestUtilities/MockCrypto.swift +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtilitiesKit - -class MockCrypto: Mock, CryptoType { - func tryGenerate(_ generator: Crypto.Generator) throws -> R { - return try mockThrowing(funcName: "generate<\(R.self)>(\(generator.id))", args: generator.args) - } - - func verify(_ verification: Crypto.Verification) -> Bool { - return mock(funcName: "verify(\(verification.id))", args: verification.args) - } -} diff --git a/_SharedTestUtilities/MockFileManager.swift b/_SharedTestUtilities/MockFileManager.swift deleted file mode 100644 index 473fad7b93..0000000000 --- a/_SharedTestUtilities/MockFileManager.swift +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtilitiesKit - -class MockFileManager: Mock, FileManagerType { - var temporaryDirectory: String { mock() } - var documentsDirectoryPath: String { mock() } - var appSharedDataDirectoryPath: String { mock() } - var temporaryDirectoryAccessibleAfterFirstAuth: String { mock() } - - func clearOldTemporaryDirectories() { mockNoReturn() } - - func ensureDirectoryExists(at path: String, fileProtectionType: FileProtectionType) throws { - try mockThrowingNoReturn(args: [path, fileProtectionType]) - } - - - func protectFileOrFolder(at path: String, fileProtectionType: FileProtectionType) throws { - try mockThrowingNoReturn(args: [path, fileProtectionType]) - } - - func fileSize(of path: String) -> UInt64? { - return mock(args: [path]) - } - - func temporaryFilePath(fileExtension: String?) -> String { - return mock(args: [fileExtension]) - } - - func write(data: Data, toTemporaryFileWithExtension fileExtension: String?) throws -> String? { - return try mockThrowing(args: [data, fileExtension]) - } - - // MARK: - Forwarded NSFileManager - - var currentDirectoryPath: String { mock() } - - func urls(for directory: FileManager.SearchPathDirectory, in domains: FileManager.SearchPathDomainMask) -> [URL] { - return mock(args: [directory, domains]) - } - - func enumerator( - at url: URL, - includingPropertiesForKeys: [URLResourceKey]?, - options: FileManager.DirectoryEnumerationOptions, - errorHandler: ((URL, Error) -> Bool)? - ) -> FileManager.DirectoryEnumerator? { - return mock(args: [url, includingPropertiesForKeys, options, errorHandler]) - } - - func fileExists(atPath: String) -> Bool { return mock(args: [atPath]) } - func fileExists(atPath: String, isDirectory: UnsafeMutablePointer?) -> Bool { - return mock(args: [atPath, isDirectory]) - } - - func contents(atPath: String) -> Data? { return mock(args: [atPath]) } - func contentsOfDirectory(at url: URL) throws -> [URL] { return try mockThrowing(args: [url]) } - func contentsOfDirectory(atPath path: String) throws -> [String] { return try mockThrowing(args: [path]) } - func isDirectoryEmpty(at url: URL) -> Bool { return mock(args: [url]) } - func isDirectoryEmpty(atPath path: String) -> Bool { return mock(args: [path]) } - - func createFile(atPath: String, contents: Data?, attributes: [FileAttributeKey : Any]?) -> Bool { - return mock(args: [atPath, contents, attributes]) - } - - func createDirectory(atPath: String, withIntermediateDirectories: Bool, attributes: [FileAttributeKey: Any]?) throws { - try mockThrowingNoReturn(args: [atPath, withIntermediateDirectories, attributes]) - } - - func createDirectory(at url: URL, withIntermediateDirectories: Bool, attributes: [FileAttributeKey: Any]?) throws { - try mockThrowingNoReturn(args: [url, withIntermediateDirectories, attributes]) - } - - func copyItem(atPath: String, toPath: String) throws { return try mockThrowing(args: [atPath, toPath]) } - func copyItem(at fromUrl: URL, to toUrl: URL) throws { return try mockThrowing(args: [fromUrl, toUrl]) } - func moveItem(atPath: String, toPath: String) throws { return try mockThrowing(args: [atPath, toPath]) } - func moveItem(at fromUrl: URL, to toUrl: URL) throws { return try mockThrowing(args: [fromUrl, toUrl]) } - func replaceItem( - atPath originalItemPath: String, - withItemAtPath newItemPath: String, - backupItemName: String?, - options: FileManager.ItemReplacementOptions - ) throws -> String? { - return try mockThrowing(args: [originalItemPath, newItemPath, backupItemName, options]) - } - func replaceItemAt( - _ originalItemURL: URL, - withItemAt newItemURL: URL, - backupItemName: String?, - options: FileManager.ItemReplacementOptions - ) throws -> URL? { - return try mockThrowing(args: [originalItemURL, newItemURL, backupItemName, options]) - } - func removeItem(atPath: String) throws { return try mockThrowing(args: [atPath]) } - - func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] { - return try mockThrowing(args: [path]) - } - - func setAttributes(_ attributes: [FileAttributeKey: Any], ofItemAtPath path: String) throws { - try mockThrowingNoReturn(args: [attributes, path]) - } -} - -// MARK: - Convenience - -extension Mock where T == FileManagerType { - func defaultInitialSetup() { - self.when { $0.appSharedDataDirectoryPath }.thenReturn("/test") - self.when { try $0.ensureDirectoryExists(at: .any, fileProtectionType: .any) }.thenReturn(()) - self.when { try $0.protectFileOrFolder(at: .any, fileProtectionType: .any) }.thenReturn(()) - self.when { $0.fileExists(atPath: .any) }.thenReturn(false) - self.when { $0.fileExists(atPath: .any, isDirectory: .any) }.thenReturn(false) - self.when { $0.temporaryFilePath(fileExtension: .any) }.thenReturn("tmpFile") - self.when { $0.createFile(atPath: .any, contents: .any, attributes: .any) }.thenReturn(true) - self.when { try $0.setAttributes(.any, ofItemAtPath: .any) }.thenReturn(()) - self.when { try $0.moveItem(atPath: .any, toPath: .any) }.thenReturn(()) - self.when { - _ = try $0.replaceItem( - atPath: .any, - withItemAtPath: .any, - backupItemName: .any, - options: .any - ) - }.thenReturn(nil) - self.when { - _ = try $0.replaceItemAt( - .any, - withItemAt: .any, - backupItemName: .any, - options: .any - ) - }.thenReturn(nil) - self.when { try $0.removeItem(atPath: .any) }.thenReturn(()) - self.when { $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3])) - self.when { try $0.contentsOfDirectory(at: .any) }.thenReturn([]) - self.when { try $0.contentsOfDirectory(atPath: .any) }.thenReturn([]) - self.when { - try $0.createDirectory( - atPath: .any, - withIntermediateDirectories: .any, - attributes: .any - ) - }.thenReturn(()) - self.when { $0.isDirectoryEmpty(atPath: .any) }.thenReturn(true) - } -} diff --git a/_SharedTestUtilities/MockGeneralCache.swift b/_SharedTestUtilities/MockGeneralCache.swift deleted file mode 100644 index 18d30ffe9f..0000000000 --- a/_SharedTestUtilities/MockGeneralCache.swift +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import UIKit -import SessionUtilitiesKit - -class MockGeneralCache: Mock, GeneralCacheType { - var userExists: Bool { - get { return mock() } - set { mockNoReturn(args: [newValue]) } - } - - var sessionId: SessionId { - get { return mock() } - set { mockNoReturn(args: [newValue]) } - } - - var ed25519Seed: [UInt8] { - get { return mock() } - set { mockNoReturn(args: [newValue]) } - } - - var ed25519SecretKey: [UInt8] { - get { return mock() } - set { mockNoReturn(args: [newValue]) } - } - - var recentReactionTimestamps: [Int64] { - get { return (mock() ?? []) } - set { mockNoReturn(args: [newValue]) } - } - - var contextualActionLookupMap: [Int: [String: [Int: Any]]] { - get { return (mock() ?? [:]) } - set { mockNoReturn(args: [newValue]) } - } - - func setSecretKey(ed25519SecretKey: [UInt8]) { - mockNoReturn(args: [ed25519SecretKey]) - } -} diff --git a/_SharedTestUtilities/MockKeychain.swift b/_SharedTestUtilities/MockKeychain.swift deleted file mode 100644 index 8b92b49522..0000000000 --- a/_SharedTestUtilities/MockKeychain.swift +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtilitiesKit - -class MockKeychain: Mock, KeychainStorageType { - func string(forKey key: KeychainStorage.StringKey) throws -> String { - return try mockThrowing(args: [key]) - } - - func set(string: String, forKey key: KeychainStorage.StringKey) throws { - return try mockThrowing(args: [key]) - } - - func remove(key: KeychainStorage.StringKey) throws { - return try mockThrowing(args: [key]) - } - - func data(forKey key: KeychainStorage.DataKey) throws -> Data { - return try mockThrowing(args: [key]) - } - - func set(data: Data, forKey key: KeychainStorage.DataKey) throws { - return try mockThrowing(args: [key]) - } - - func remove(key: KeychainStorage.DataKey) throws { - return try mockThrowing(args: [key]) - } - - func removeAll() throws { try mockThrowingNoReturn() } - - func migrateLegacyKeyIfNeeded(legacyKey: String, legacyService: String?, toKey key: KeychainStorage.DataKey) throws { - try mockThrowingNoReturn(args: [legacyKey, legacyService, key]) - } - - func getOrGenerateEncryptionKey( - forKey key: KeychainStorage.DataKey, - length: Int, - cat: Log.Category, - legacyKey: String?, - legacyService: String? - ) throws -> Data { - return try mockThrowing(args: [key, length, cat, legacyKey, legacyService]) - } -} diff --git a/_SharedTestUtilities/MockUserDefaults.swift b/_SharedTestUtilities/MockUserDefaults.swift deleted file mode 100644 index 28ead9e1eb..0000000000 --- a/_SharedTestUtilities/MockUserDefaults.swift +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtilitiesKit - -class MockUserDefaults: Mock, UserDefaultsType { - var allKeys: [String] { mock() } - - func object(forKey defaultName: String) -> Any? { return mock(args: [defaultName]) } - func string(forKey defaultName: String) -> String? { return mock(args: [defaultName]) } - func array(forKey defaultName: String) -> [Any]? { return mock(args: [defaultName]) } - func dictionary(forKey defaultName: String) -> [String: Any]? { return mock(args: [defaultName]) } - func data(forKey defaultName: String) -> Data? { return mock(args: [defaultName]) } - func stringArray(forKey defaultName: String) -> [String]? { return mock(args: [defaultName]) } - func integer(forKey defaultName: String) -> Int { return (mock(args: [defaultName]) ?? 0) } - func float(forKey defaultName: String) -> Float { return (mock(args: [defaultName]) ?? 0) } - func double(forKey defaultName: String) -> Double { return (mock(args: [defaultName]) ?? 0) } - func bool(forKey defaultName: String) -> Bool { return (mock(args: [defaultName]) ?? false) } - func url(forKey defaultName: String) -> URL? { return mock(args: [defaultName]) } - - func set(_ value: Any?, forKey defaultName: String) { mockNoReturn(args: [value, defaultName]) } - func set(_ value: Int, forKey defaultName: String) { mockNoReturn(args: [value, defaultName]) } - func set(_ value: Float, forKey defaultName: String) { mockNoReturn(args: [value, defaultName]) } - func set(_ value: Double, forKey defaultName: String) { mockNoReturn(args: [value, defaultName]) } - func set(_ value: Bool, forKey defaultName: String) { mockNoReturn(args: [value, defaultName]) } - func set(_ url: URL?, forKey defaultName: String) { mockNoReturn(args: [url, defaultName]) } - - func removeObject(forKey defaultName: String) { - mockNoReturn(args: [defaultName]) - } - - func removeAll() { mockNoReturn() } -} diff --git a/_SharedTestUtilities/Mocked.swift b/_SharedTestUtilities/Mocked.swift deleted file mode 100644 index 4d4aa57415..0000000000 --- a/_SharedTestUtilities/Mocked.swift +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import UIKit.UIApplication -import Combine -import GRDB -import SessionUtilitiesKit - -// MARK: - Mocked - -protocol Mocked { static var mock: Self { get } } -protocol MockedGeneric { - associatedtype Generic - - static func mock(type: Generic.Type) -> Self -} -protocol MockedDoubleGeneric { - associatedtype GenericA - associatedtype GenericB - - static func mock(typeA: GenericA.Type, typeB: GenericB.Type) -> Self -} - -// MARK: - DSL - -/// Needs to be a function as you can't extend 'Any' -func anyAny() -> Any { 0 } - -extension Mocked { static var any: Self { mock } } - -extension Int: Mocked { static var mock: Int { 0 } } -extension Int64: Mocked { static var mock: Int64 { 0 } } -extension Dictionary: Mocked { static var mock: Self { [:] } } -extension Array: Mocked { static var mock: Self { [] } } -extension Set: Mocked { static var mock: Self { [] } } -extension Float: Mocked { static var mock: Float { 0 } } -extension Double: Mocked { static var mock: Double { 0 } } -extension String: Mocked { static var mock: String { "" } } -extension Data: Mocked { static var mock: Data { Data() } } -extension Bool: Mocked { static var mock: Bool { false } } -extension UnsafeMutablePointer?: Mocked { static var mock: UnsafeMutablePointer? { nil } } - -// The below types either can't be mocked or use the 'MockedGeneric' or 'MockedDoubleGeneric' types -// so need their own direct 'any' values - -extension Error { static var any: Error { TestError.mock } } - -extension UIApplication.State { static var any: UIApplication.State { .active } } -extension TimeInterval { static var any: TimeInterval { 0 } } -extension SessionId { static var any: SessionId { SessionId.invalid } } -extension Dependencies { - static var any: Dependencies { - TestDependencies { dependencies in - dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) - dependencies.forceSynchronous = true - } - } -} - -// MARK: - Conformance - -extension ObservingDatabase: Mocked { - static var mock: Self { - var result: Database! - try! DatabaseQueue().read { result = $0 } - return ObservingDatabase.create(result!, using: .any) as! Self - } -} - -extension ObservedEvent: Mocked { - static var mock: ObservedEvent = ObservedEvent(key: "mock", value: nil) -} - -extension UUID: Mocked { - static var mock: UUID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! -} - -extension URL: Mocked { - static var mock: URL = URL(fileURLWithPath: "mock") -} - -extension URLRequest: Mocked { - static var mock: URLRequest = URLRequest(url: URL(fileURLWithPath: "mock")) -} - -extension AnyPublisher: MockedGeneric where Failure == Error { - typealias Generic = Output - - static func any(type: Output.Type) -> AnyPublisher { mock(type: type) } - - static func mock(type: Output.Type) -> AnyPublisher { - return Fail(error: MockError.mockedData).eraseToAnyPublisher() - } -} - -extension KeyPair: Mocked { - static var mock: KeyPair = KeyPair( - publicKey: Data(hex: TestConstants.publicKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ) -} - -extension Job: Mocked { - static var mock: Job = Job(variant: .mock) -} - -extension Job.Variant: Mocked { - static var mock: Job.Variant = .messageSend -} - -extension JobRunner.JobResult: Mocked { - static var mock: JobRunner.JobResult = .succeeded -} - -extension FileProtectionType: Mocked { - static var mock: FileProtectionType = .complete -} - -extension Log.Category: Mocked { - static var mock: Log.Category = .create("mock", defaultLevel: .debug) -} - -extension Setting.BoolKey: Mocked { - static var mock: Setting.BoolKey = "mockBool" -} - -extension Setting.EnumKey: Mocked { - static var mock: Setting.EnumKey = "mockEnum" -} - -extension FileManager.ItemReplacementOptions: Mocked { - static var mock: FileManager.ItemReplacementOptions = FileManager.ItemReplacementOptions() -} - -// MARK: - Encodable Convenience - -extension Mocked where Self: Encodable { - func encoded(using dependencies: Dependencies) -> Data { - try! JSONEncoder(using: dependencies).with(outputFormatting: .sortedKeys).encode(self) - } -} - -extension MockedGeneric where Self: Encodable { - func encoded(using dependencies: Dependencies) -> Data { - try! JSONEncoder(using: dependencies).with(outputFormatting: .sortedKeys).encode(self) - } -} - -extension Array where Element: Encodable { - func encoded(using dependencies: Dependencies) -> Data { - try! JSONEncoder(using: dependencies).with(outputFormatting: .sortedKeys).encode(self) - } -} diff --git a/_SharedTestUtilities/NimbleExtensions.swift b/_SharedTestUtilities/NimbleExtensions.swift index 12e0786b16..cc11c341c0 100644 --- a/_SharedTestUtilities/NimbleExtensions.swift +++ b/_SharedTestUtilities/NimbleExtensions.swift @@ -2,332 +2,6 @@ import Foundation import Nimble -import SessionUtilitiesKit - -public enum CallAmount { - case atLeast(times: Int) - case exactly(times: Int) - case noMoreThan(times: Int) -} - -public enum ParameterMatchType { - case none - case all - case atLeast(Int) -} - -fileprivate extension String.StringInterpolation { - mutating func appendInterpolation(times: Int) { - appendInterpolation("\(times) time\(times == 1 ? "" : "s")") - } - - mutating func appendInterpolation(parameters: Int) { - appendInterpolation("\(parameters) parameter\(parameters == 1 ? "" : "s")") - } -} - -/// Validates whether the function called in `functionBlock` has been called according to the parameter constraints -/// -/// - Parameters: -/// - amount: An enum constraining the number of times the function can be called (Default is `.atLeast(times: 1)` -/// -/// - matchingParameters: A boolean indicating whether the parameters for the function call need to match exactly -/// -/// - exclusive: A boolean indicating whether no other functions should be called -/// -/// - functionBlock: A closure in which the function to be validated should be called -public func call( - _ amount: CallAmount = .atLeast(times: 1), - matchingParameters: ParameterMatchType = .none, - exclusive: Bool = false, - functionBlock: @escaping (inout T) async throws -> R -) -> Matcher where M: Mock { - return Matcher.define { actualExpression in - /// First generate the call info - let callInfo: CallInfo = generateCallInfo(actualExpression, functionBlock) - let expectedDescription: String = { - let timesDescription: String? = { - switch amount { - case .atLeast(let times): return (times <= 1 ? nil : "at least \(times: times)") - case .exactly(let times): return "exactly \(times: times)" - case .noMoreThan(let times): return (times <= 0 ? nil : "no more than \(times: times))") - } - }() - let matchingParametersDescription: String? = { - let paramInfo: String = (callInfo.targetFunctionParameters.map { ": \($0)" } ?? "") - - switch matchingParameters { - case .none: return nil - case .all: return "matching the parameters\(paramInfo)" - case .atLeast(let count): return "matching at least \(parameters: count)" - } - }() - - return [ - "call '\(callInfo.functionName)'\(exclusive ? " exclusively" : "")", - timesDescription, - matchingParametersDescription - ] - .compactMap { $0 } - .joined(separator: " ") - }() - - /// If an error was thrown when generating call info then fail (mock value likely invalid) - guard callInfo.caughtError == nil else { - return MatcherResult( - bool: false, - message: .expectedCustomValueTo( - expectedDescription, - actual: "an error (invalid mock param, not called or no mocked return value)" - ) - ) - } - - /// If there is no function within the 'callInfo' then we can't provide more useful info - guard let targetFunction: MockFunction = callInfo.targetFunction else { - return MatcherResult( - bool: false, - message: .expectedCustomValueTo( - expectedDescription, - actual: "no call details" - ) - ) - } - - /// If the mock wasn't called at all then no other data will be useful - guard !callInfo.allFunctionsCalled.isEmpty else { - return MatcherResult( - bool: false, - message: .expectedCustomValueTo( - expectedDescription, - actual: "no calls" - ) - ) - } - - /// If we require the call to be exclusive (ie. the only function called on the mock) then make sure there were no - /// other functions called - guard - !exclusive || - callInfo.allFunctionsCalled.count == 0 || ( - callInfo.allFunctionsCalled.count == 1 && - callInfo.allFunctionsCalled[0].name == targetFunction.name - ) - else { - let otherFunctionsCalled: [String] = callInfo.allFunctionsCalled - .map { "\($0.name) (params: \($0.paramCount))" } - .filter { $0 != "\(callInfo.functionName) (params: \(callInfo.parameterCount))" } - - return MatcherResult( - bool: false, - message: .expectedCustomValueTo( - expectedDescription, - actual: "calls to other functions: [\(otherFunctionsCalled.joined(separator: ", "))]" - ) - ) - } - - /// Check how accurate the calls made actually were - let validTargetParameterCombinations: Set = targetFunction.allParameterSummaryCombinations - .filter { combination -> Bool in - switch matchingParameters { - case .none: return true - case .all: return (combination.count == targetFunction.parameterCount) - case .atLeast(let count): return (combination.count >= count) - } - } - .map { $0.summary } - .asSet() - let allValidCallDetails: [CallDetails] = callInfo.allCallDetails - .compactMap { details -> CallDetails? in - let validCombinations: [ParameterCombination] = details.allParameterSummaryCombinations - .filter { combination in - switch matchingParameters { - case .none: return true - case .all: - return ( - combination.count == targetFunction.parameterCount && - combination.summary == targetFunction.parameterSummary - ) - - case .atLeast(let count): - return ( - combination.count >= count && - validTargetParameterCombinations.contains(combination.summary) - ) - } - } - - guard !validCombinations.isEmpty else { return nil } - - return CallDetails( - parameterSummary: details.parameterSummary, - allParameterSummaryCombinations: validCombinations - ) - } - let metCallCountRequirement: Bool = { - switch amount { - case .atLeast(let times): return (allValidCallDetails.count >= times) - case .exactly(let times): return (allValidCallDetails.count == times) - case .noMoreThan(let times): return (allValidCallDetails.count <= times) - } - }() - let allCallsMetParamRequirements: Bool = (allValidCallDetails.count == callInfo.allCallDetails.count) - let totalUniqueParamCount: Int = callInfo.allCallDetails - .map { $0.parameterSummary } - .asSet() - .count - - switch (exclusive, metCallCountRequirement, allCallsMetParamRequirements, totalUniqueParamCount) { - /// No calls with the matching parameter requirements but only one parameter combination so include the param info - case (_, false, false, 1): - return MatcherResult( - bool: false, - message: .expectedCustomValueTo( - expectedDescription, - actual: "called \(times: callInfo.allCallDetails.count) with different parameters: \(callInfo.allCallDetails[0].parameterSummary)" - ) - ) - - /// The calls were made with the correct parameters, but didn't call enough times - case (_, false, true, _): - return MatcherResult( - bool: false, - message: .expectedCustomValueTo( - expectedDescription, - actual: "called \(times: callInfo.allCallDetails.count)" - ) - ) - - /// There were multiple parameter combinations - /// - /// **Note:** A getter/setter combo will have function calls split between no params and the set value, if the - /// setter didn't match then we still want to show the incorrect parameters - case (true, true, false, _), (_, false, false, _): - let distinctSetterCombinations: Set = callInfo.allCallDetails - .filter { $0.parameterSummary != "[]" } - .asSet() - let maxParamMatch: Int = allValidCallDetails - .flatMap { $0.allParameterSummaryCombinations.map { $0.count } } - .max() - .defaulting(to: 0) - - return MatcherResult( - bool: false, - message: .expectedCustomValueTo( - expectedDescription, - actual: { - guard distinctSetterCombinations.count != 1 else { - return "called with: \(Array(distinctSetterCombinations)[0].parameterSummary)" - } - - return ( - "called \(times: allValidCallDetails.count) with matching parameters " + - "(\(times: callInfo.allCallDetails.count) total" + ( - !metCallCountRequirement ? ")" : - ", matching at most \(parameters: maxParamMatch))" - ) - ) - }() - ) - ) - - default: - return MatcherResult( - bool: true, - message: .expectedCustomValueTo( - expectedDescription, - actual: "call to '\(callInfo.functionName)'" - ) - ) - } - } -} - -// MARK: - Shared Code - -fileprivate struct CallInfo { - let didError: Bool - let caughtError: Error? - let targetFunction: MockFunction? - let allFunctionsCalled: [FunctionConsumer.Key] - let allCallDetails: [CallDetails] - - var functionName: String { "\((targetFunction?.name).map { "\($0)" } ?? "a function")" } - var parameterCount: Int { (targetFunction?.parameterCount ?? 0) } - var targetFunctionParameters: String? { targetFunction?.parameterSummary } - - static var error: CallInfo { - CallInfo( - didError: true, - caughtError: nil, - targetFunction: nil, - allFunctionsCalled: [], - allCallDetails: [] - ) - } - - init( - didError: Bool = false, - caughtError: Error?, - targetFunction: MockFunction?, - allFunctionsCalled: [FunctionConsumer.Key], - allCallDetails: [CallDetails] - ) { - self.didError = didError - self.caughtError = caughtError - self.targetFunction = targetFunction - self.allFunctionsCalled = allFunctionsCalled - self.allCallDetails = allCallDetails - } -} - -fileprivate func generateCallInfo( - _ actualExpression: Nimble.Expression, - _ functionBlock: @escaping (inout T) async throws -> R -) -> CallInfo where M: Mock { - var maybeTargetFunction: MockFunction? - var allFunctionsCalled: [FunctionConsumer.Key] = [] - var allCallDetails: [CallDetails] = [] - var caughtError: Error? = nil - - // Just hope for the best and if there is a force-cast there's not much we can do - do { - guard let validInstance: M = try actualExpression.evaluate() else { - throw TestError.unableToEvaluateExpression - } - - allFunctionsCalled = Array(validInstance.functionConsumer.calls.keys) - - // Only check for the specific function calls if there was at least a single - // call (if there weren't any this will likely throw errors when attempting - // to build) - if !allFunctionsCalled.isEmpty { - validInstance.functionConsumer.trackCalls = false - maybeTargetFunction = try MockFunctionBuilder.mockFunctionWith(validInstance, functionBlock) - - let key: FunctionConsumer.Key = FunctionConsumer.Key( - name: (maybeTargetFunction?.name ?? ""), - generics: (maybeTargetFunction?.generics ?? []), - paramCount: (maybeTargetFunction?.parameterCount ?? 0) - ) - allCallDetails = validInstance.functionConsumer.calls[key] - .defaulting(to: []) - validInstance.functionConsumer.trackCalls = true - } - else { - allCallDetails = [] - } - } - catch { caughtError = error } - - return CallInfo( - caughtError: caughtError, - targetFunction: maybeTargetFunction, - allFunctionsCalled: allFunctionsCalled, - allCallDetails: allCallDetails - ) -} public extension SyncExpectation { func retrieveValue() async -> Value? { diff --git a/_SharedTestUtilities/SynchronousStorage.swift b/_SharedTestUtilities/SynchronousStorage.swift index 59510cf436..b8c8a55248 100644 --- a/_SharedTestUtilities/SynchronousStorage.swift +++ b/_SharedTestUtilities/SynchronousStorage.swift @@ -2,48 +2,17 @@ import Combine import GRDB +import TestUtilities @testable import SessionUtilitiesKit -class SynchronousStorage: Storage, DependenciesSettable, InitialSetupable { - public var dependencies: Dependencies - private let initialData: ((ObservingDatabase) throws -> ())? +class SynchronousStorage: Storage { + public let dependencies: Dependencies - public init( - customWriter: DatabaseWriter? = nil, - migrations: [Migration.Type]? = nil, - using dependencies: Dependencies, - initialData: ((ObservingDatabase) throws -> ())? = nil - ) { + public override init(customWriter: DatabaseWriter? = nil, using dependencies: Dependencies) { self.dependencies = dependencies - self.initialData = initialData super.init(customWriter: customWriter, using: dependencies) - - if let migrations: [Migration.Type] = migrations { - perform( - migrations: migrations, - async: false, - onProgressUpdate: nil, - onComplete: { _ in } - ) - } - } - - // MARK: - DependenciesSettable - - func setDependencies(_ dependencies: Dependencies?) { - guard let dependencies: Dependencies = dependencies else { return } - - self.dependencies = dependencies - } - - // MARK: - InitialSetupable - - func performInitialSetup() { - guard let closure: ((ObservingDatabase) throws -> ()) = initialData else { return } - - write { db in try closure(db) } } // MARK: - Overwritten Functions diff --git a/_SharedTestUtilities/TestDependencies.swift b/_SharedTestUtilities/TestDependencies.swift index 0815ea21df..c9849c8d14 100644 --- a/_SharedTestUtilities/TestDependencies.swift +++ b/_SharedTestUtilities/TestDependencies.swift @@ -1,7 +1,9 @@ // Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import Foundation +import _Concurrency import Quick +import TestUtilities @testable import SessionUtilitiesKit @@ -10,12 +12,15 @@ public class TestDependencies: Dependencies { @ThreadSafeObject private var cacheInstances: [String: MutableCacheType] = [:] @ThreadSafeObject private var defaultsInstances: [String: (any UserDefaultsType)] = [:] @ThreadSafeObject private var featureInstances: [String: (any FeatureType)] = [:] + @ThreadSafeObject private var featureValues: [String: Any] = [:] + @ThreadSafeObject private var otherInstances: [ObjectIdentifier: Any] = [:] // MARK: - Subscript Access override public subscript(singleton singleton: SingletonConfig) -> S { guard let value: S = (singletonInstances[singleton.identifier] as? S) else { - let value: S = singleton.createInstance(self) + let key: Dependencies.Key = Dependencies.Key.Variant.singleton.key(singleton.identifier) + let value: S = singleton.createInstance(self, key) _singletonInstances.performUpdate { $0.setting(singleton.identifier, value) } return value } @@ -30,7 +35,8 @@ public class TestDependencies: Dependencies { override public subscript(cache cache: CacheConfig) -> I { guard let value: M = (cacheInstances[cache.identifier] as? M) else { - let value: M = cache.createInstance(self) + let key: Dependencies.Key = Dependencies.Key.Variant.cache.key(cache.identifier) + let value: M = cache.createInstance(self, key) let mutableInstance: MutableCacheType = cache.mutableInstance(value) _cacheInstances.performUpdate { $0.setting(cache.identifier, mutableInstance) } return cache.immutableInstance(value) @@ -46,7 +52,8 @@ public class TestDependencies: Dependencies { override public subscript(defaults defaults: UserDefaultsConfig) -> UserDefaultsType { guard let value: UserDefaultsType = defaultsInstances[defaults.identifier] else { - let value: UserDefaultsType = defaults.createInstance(self) + let key: Dependencies.Key = Dependencies.Key.Variant.userDefaults.key(defaults.identifier) + let value: UserDefaultsType = defaults.createInstance(self, key) _defaultsInstances.performUpdate { $0.setting(defaults.identifier, value) } return value } @@ -56,19 +63,23 @@ public class TestDependencies: Dependencies { override public subscript(feature feature: FeatureConfig) -> T { guard let value: Feature = (featureInstances[feature.identifier] as? Feature) else { - let value: Feature = feature.createInstance(self) + let key: Dependencies.Key = Dependencies.Key.Variant.feature.key(feature.identifier) + let value: Feature = feature.createInstance(self, key) _featureInstances.performUpdate { $0.setting(feature.identifier, value) } - return value.currentValue(using: self) + return value.currentValue(in: self) } - return value.currentValue(using: self) + return value.currentValue(in: self) } public subscript(feature feature: FeatureConfig) -> T? { get { return (featureInstances[feature.identifier] as? T) } set { if featureInstances[feature.identifier] == nil { - _featureInstances.performUpdate { $0.setting(feature.identifier, feature.createInstance(self)) } + let key: Dependencies.Key = Dependencies.Key.Variant.feature.key(feature.identifier) + _featureInstances.performUpdate { + $0.setting(feature.identifier, feature.createInstance(self, key)) + } } set(feature: feature, to: newValue) @@ -120,7 +131,8 @@ public class TestDependencies: Dependencies { cache: CacheConfig, _ mutation: (M) -> R ) -> R { - let value: M = ((cacheInstances[cache.identifier] as? M) ?? cache.createInstance(self)) + let key: Dependencies.Key = Dependencies.Key.Variant.cache.key(cache.identifier) + let value: M = ((cacheInstances[cache.identifier] as? M) ?? cache.createInstance(self, key)) let mutableInstance: MutableCacheType = cache.mutableInstance(value) _cacheInstances.performUpdate { $0.setting(cache.identifier, mutableInstance) } return mutation(value) @@ -130,7 +142,8 @@ public class TestDependencies: Dependencies { cache: CacheConfig, _ mutation: (M) throws -> R ) throws -> R { - let value: M = ((cacheInstances[cache.identifier] as? M) ?? cache.createInstance(self)) + let key: Dependencies.Key = Dependencies.Key.Variant.cache.key(cache.identifier) + let value: M = ((cacheInstances[cache.identifier] as? M) ?? cache.createInstance(self, key)) let mutableInstance: MutableCacheType = cache.mutableInstance(value) _cacheInstances.performUpdate { $0.setting(cache.identifier, mutableInstance) } return try mutation(value) @@ -204,81 +217,73 @@ public class TestDependencies: Dependencies { // MARK: - Instance replacing + public func get(singleton: SingletonConfig) -> S? { + return _singletonInstances.performMap { $0[singleton.identifier] as? S } + } + + public func get(cache: CacheConfig) -> M? { + return _cacheInstances.performMap { $0[cache.identifier] as? M } + } + + public func get(defaults: UserDefaultsConfig) -> T? { + return _defaultsInstances.performMap { $0[defaults.identifier] as? T } + } + + public func get(feature: FeatureConfig) -> T? { + return _featureInstances.performMap { $0[feature.identifier] as? T } + } + + public func get(other: ObjectIdentifier) -> T? { + return _otherInstances.performMap { $0[other] as? T } + } + public override func set(singleton: SingletonConfig, to instance: S) { + (instance as? DependenciesSettable)?.setDependencies(self) _singletonInstances.performUpdate { $0.setting(singleton.identifier, instance) } } public override func set(cache: CacheConfig, to instance: M) { + (instance as? DependenciesSettable)?.setDependencies(self) _cacheInstances.performUpdate { $0.setting(cache.identifier, cache.mutableInstance(instance)) } } + public func set(defaults: UserDefaultsConfig, to instance: T) { + (instance as? DependenciesSettable)?.setDependencies(self) + _defaultsInstances.performUpdate { $0.setting(defaults.identifier, instance) } + } + + public func set(feature: FeatureConfig, to instance: Feature) { + (instance as? DependenciesSettable)?.setDependencies(self) + _featureInstances.performUpdate { $0.setting(feature.identifier, instance) } + } + + public func set(other: ObjectIdentifier, to instance: T) { + (instance as? DependenciesSettable)?.setDependencies(self) + _otherInstances.performUpdate { $0.setting(other, instance) } + } + public override func remove(cache: CacheConfig) { _cacheInstances.performUpdate { $0.setting(cache.identifier, nil) } } + + // MARK: - FeatureStorageType + + public override var hardfork: Int { 2 } + public override var softfork: Int { 11 } + + public override func rawFeatureValue(forKey defaultName: String) -> Any? { + return _featureValues.performMap { $0[defaultName] } + } + + public override func storeFeatureValue(_ value: Any?, forKey defaultName: String) { + _featureValues.performUpdate { $0.setting(defaultName, value) } + } } -// MARK: - TestState Convenience +// MARK: - DependenciesSettable -internal extension TestState { - init( - wrappedValue: @escaping @autoclosure () -> T?, - cache: CacheConfig, - in dependenciesRetriever: @escaping @autoclosure () -> TestDependencies? - ) where T: MutableCacheType { - self.init(wrappedValue: { - let dependencies: TestDependencies? = dependenciesRetriever() - let value: T? = wrappedValue() - (value as? DependenciesSettable)?.setDependencies(dependencies) - dependencies?[cache: cache] = (value as! M) - (value as? (any InitialSetupable))?.performInitialSetup() - - return value - }()) - } - - init( - wrappedValue: @escaping @autoclosure () -> T?, - singleton: SingletonConfig, - in dependenciesRetriever: @escaping @autoclosure () -> TestDependencies? - ) { - self.init(wrappedValue: { - let dependencies: TestDependencies? = dependenciesRetriever() - let value: T? = wrappedValue() - (value as? DependenciesSettable)?.setDependencies(dependencies) - dependencies?[singleton: singleton] = (value as! S) - (value as? (any InitialSetupable))?.performInitialSetup() - - return value - }()) - } - - init( - wrappedValue: @escaping @autoclosure () -> T?, - defaults: UserDefaultsConfig, - in dependenciesRetriever: @escaping @autoclosure () -> TestDependencies? - ) where T: UserDefaultsType { - self.init(wrappedValue: { - let dependencies: TestDependencies? = dependenciesRetriever() - let value: T? = wrappedValue() - (value as? DependenciesSettable)?.setDependencies(dependencies) - dependencies?[defaults: defaults] = value - (value as? (any InitialSetupable))?.performInitialSetup() - - return value - }()) - } +protocol DependenciesSettable { + var dependencies: Dependencies { get } - static func create( - closure: @escaping () async -> T? - ) -> T? { - var value: T? - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - Task { - value = await closure() - semaphore.signal() - } - semaphore.wait() - - return value - } + func setDependencies(_ dependencies: Dependencies?) }