diff --git a/Scripts/build_libSession_util.sh b/Scripts/build_libSession_util.sh index a3e2f8f015..2f9cbbba6e 100755 --- a/Scripts/build_libSession_util.sh +++ b/Scripts/build_libSession_util.sh @@ -3,11 +3,24 @@ # Need to set the path or we won't find cmake PATH=${PATH}:/usr/local/bin:/opt/local/bin:/opt/homebrew/bin:/opt/homebrew/opt/m4/bin:/sbin/md5 required_packages=("cmake" "m4" "pkg-config") + +# Calculate paths DERIVED_DATA_PATH=$(echo "${BUILD_DIR}" | sed -E 's#^(.*[dD]erived[Dd]ata)(/[sS]ession-[^/]+)?.*#\1\2#' | tr -d '\n') PRE_BUILT_FRAMEWORK_DIR="${DERIVED_DATA_PATH}/SourcePackages/artifacts/libsession-util-spm/SessionUtil" FRAMEWORK_DIR="libsession-util.xcframework" - -exec 3>&1 # Save original stdout +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 +function finish { + # Restore stdout + exec 1>&3 3>&- +} +trap finish EXIT ERR SIGINT SIGTERM # Determine whether we want to build from source TARGET_ARCH_DIR="" @@ -22,42 +35,16 @@ else fi if [ "${COMPILE_LIB_SESSION}" != "YES" ]; then - STATIC_LIB_PATH="${PRE_BUILT_FRAMEWORK_DIR}/${FRAMEWORK_DIR}/${TARGET_ARCH_DIR}/libsession-util.a" - - if [ ! -f "${STATIC_LIB_PATH}" ]; then - echo "error: Pre-packaged library doesn't exist in the expected location: ${STATIC_LIB_PATH}." - exit 1 - fi - - # If we'd replaced the framework with our compiled version then change it back - if [ -d "${PRE_BUILT_FRAMEWORK_DIR}/${FRAMEWORK_DIR}_Old" ]; then - rm -rf "${PRE_BUILT_FRAMEWORK_DIR}/${FRAMEWORK_DIR}" - mv "${PRE_BUILT_FRAMEWORK_DIR}/${FRAMEWORK_DIR}_Old" "${PRE_BUILT_FRAMEWORK_DIR}/${FRAMEWORK_DIR}" - fi + echo "Restoring original headers to Xcode Indexer cache from backup..." + rm -rf "${INDEX_DIR}/include" + rsync -rt --exclude='.DS_Store' "${PRE_BUILT_FRAMEWORK_DIR}/${TARGET_ARCH_DIR}/Headers/" "${INDEX_DIR}/include" - # If we previously built from source then we should copy the pre-built package across - # just to make sure we don't unintentionally use the wrong build - if [ -d "${TARGET_BUILD_DIR}/LibSessionUtil" ]; then - echo "Removing old compiled build data" - - if [ -d "${TARGET_BUILD_DIR}/include" ]; then - rm -r "${TARGET_BUILD_DIR}/include" - fi - - if [ -f "${TARGET_BUILD_DIR}/libsession-util.a" ]; then - rm -r "${TARGET_BUILD_DIR}/libsession-util.a" - fi - - cp "${PRE_BUILT_FRAMEWORK_DIR}/${FRAMEWORK_DIR}/${TARGET_ARCH_DIR}/libsession-util.a" "${TARGET_BUILD_DIR}/libsession-util.a" - cp -r "${PRE_BUILT_FRAMEWORK_DIR}/${FRAMEWORK_DIR}/${TARGET_ARCH_DIR}/Headers" "${TARGET_BUILD_DIR}" - fi - echo "Using pre-packaged SessionUtil" exit 0 fi # Ensure the machine has the build dependencies installed -echo "Validating build requirements" +echo "Validating build requirements: ${required_packages[*]}" missing_packages=() for package in "${required_packages[@]}"; do @@ -68,120 +55,73 @@ done if [ ${#missing_packages[@]} -ne 0 ]; then packages=$(echo "${missing_packages[@]}") - echo "error: Some build dependencies are not installed, please install them ('brew install ${packages}'):" + echo "error: Missing build dependencies: ${missing_packages[*]}. Please install them (eg. 'brew install ${missing_packages[*]}'):" exit 1 fi -# Ensure the build directory exists (in case we need it before XCode creates it) -COMPILE_DIR="${TARGET_BUILD_DIR}/LibSessionUtil" -mkdir -p "${COMPILE_DIR}" - -if [ ! -d "${LIB_SESSION_SOURCE_DIR}" ] || [ ! -d "${LIB_SESSION_SOURCE_DIR}/src" ]; then - echo "error: Could not find LibSession source in 'LIB_SESSION_SOURCE_DIR' directory: ${LIB_SESSION_SOURCE_DIR}." - exit 1 -fi - -# Validate the submodules in 'LIB_SESSION_SOURCE_DIR' -are_submodules_valid() { - local PARENT_PATH=$1 - - # Change into the path to check for it's submodules - cd "${PARENT_PATH}" - local SUB_MODULE_PATHS=($(git config --file .gitmodules --get-regexp path | awk '{ print $2 }')) - - # If there are no submodules then return success based on whether the folder has any content - if [ ${#SUB_MODULE_PATHS[@]} -eq 0 ]; then - if [[ ! -z "$(ls -A "${PARENT_PATH}")" ]]; then - return 0 - else - return 1 - fi - fi - - # Loop through the child submodules and check if they are valid - for i in "${!SUB_MODULE_PATHS[@]}"; do - local CHILD_PATH="${SUB_MODULE_PATHS[$i]}" - - # If the child path doesn't exist then it's invalid - if [ ! -d "${PARENT_PATH}/${CHILD_PATH}" ]; then - echo "Submodule '${CHILD_PATH}' doesn't exist." - return 1 - fi - - are_submodules_valid "${PARENT_PATH}/${CHILD_PATH}" - local RESULT=$? - - if [ "${RESULT}" -eq 1 ]; then - echo "Submodule '${CHILD_PATH}' is in an invalid state." - return 1 - fi - done - - return 0 -} - -# Validate the state of the submodules -are_submodules_valid "${LIB_SESSION_SOURCE_DIR}" "LibSession-Util" - -HAS_INVALID_SUBMODULE=$? +# Ensure the source directory is there +echo "LibSession source: ${LIB_SESSION_SOURCE_DIR}" +echo "Build dir: ${COMPILE_DIR}" -if [ "${HAS_INVALID_SUBMODULE}" -eq 1 ]; then - echo "error: Submodules are in an invalid state, please run 'git submodule update --init --recursive' in ${LIB_SESSION_SOURCE_DIR}." +echo "- Validating source exists" +if [ -z "${LIB_SESSION_SOURCE_DIR}" ] || [ ! -d "${LIB_SESSION_SOURCE_DIR}" ]; then + echo "error: LIB_SESSION_SOURCE_DIR is not set or not a directory: '${LIB_SESSION_SOURCE_DIR}'" exit 1 fi -# Generate a hash of the libSession-util source files and check if they differ from the last hash -echo "Checking for changes to source" - -NEW_SOURCE_HASH=$(find "${LIB_SESSION_SOURCE_DIR}/src" -type f -exec md5 {} + | awk '{print $NF}' | sort | md5 | awk '{print $NF}') -NEW_HEADER_HASH=$(find "${LIB_SESSION_SOURCE_DIR}/include" -type f -exec md5 {} + | awk '{print $NF}' | sort | md5 | awk '{print $NF}') -NEW_EXTERNAL_HASH=$(find "${LIB_SESSION_SOURCE_DIR}/external" -type f -exec md5 {} + | awk '{print $NF}' | sort | md5 | awk '{print $NF}') - -if [ -f "${COMPILE_DIR}/libsession_util_source_dir.log" ]; then - read -r OLD_SOURCE_DIR < "${COMPILE_DIR}/libsession_util_source_dir.log" -fi - -if [ -f "${COMPILE_DIR}/libsession_util_source_hash.log" ]; then - read -r OLD_SOURCE_HASH < "${COMPILE_DIR}/libsession_util_source_hash.log" +# Validate submodules +echo "- Validating submodules" +if ! (cd "${LIB_SESSION_SOURCE_DIR}" && git submodule status --recursive | grep -q '^-'); then + echo "- Submodules appear to be initialized and updated." +else + (cd "${LIB_SESSION_SOURCE_DIR}" && git submodule status --recursive) # Show problematic submodules + echo "error: Submodules in ${LIB_SESSION_SOURCE_DIR} are not initialized or updated. Please run 'git submodule update --init --recursive' there." + exit 1 fi -if [ -f "${COMPILE_DIR}/libsession_util_header_hash.log" ]; then - read -r OLD_HEADER_HASH < "${COMPILE_DIR}/libsession_util_header_hash.log" -fi +# Check the current state of the build (comparing hashes to determine if there was a source change) +echo "- Checking if libSession changed..." +REQUIRES_BUILD=0 -if [ -f "${COMPILE_DIR}/libsession_util_external_hash.log" ]; then - read -r OLD_EXTERNAL_HASH < "${COMPILE_DIR}/libsession_util_external_hash.log" +# 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) + +PREVIOUS_BUILT_FRAMEWORK_SLICE_DIR="" +if [ -f "$LAST_BUILT_FRAMEWORK_SLICE_DIR_FILE" ]; then + read -r PREVIOUS_BUILT_FRAMEWORK_SLICE_DIR < "$LAST_BUILT_FRAMEWORK_SLICE_DIR_FILE" fi -if [ -f "${COMPILE_DIR}/libsession_util_archs.log" ]; then - read -r OLD_ARCHS < "${COMPILE_DIR}/libsession_util_archs.log" +PREVIOUS_BUILT_HASH="" +if [ -f "$LAST_SUCCESSFUL_HASH_FILE" ]; then + read -r PREVIOUS_BUILT_HASH < "$LAST_SUCCESSFUL_HASH_FILE" fi -# Check the current state of the build (comparing hashes to determine if there was a source change) -REQUIRES_BUILD=0 +# Ensure the build directory exists (in case we need it before XCode creates it) +mkdir -p "${COMPILE_DIR}" -if [ "${LIB_SESSION_SOURCE_DIR}" != "${OLD_SOURCE_DIR}" ]; then - echo "Build is not up-to-date (source dir change) - removing old build and rebuilding" - rm -rf "${COMPILE_DIR}" - mkdir -p "${COMPILE_DIR}" - REQUIRES_BUILD=1 -elif [ "${NEW_SOURCE_HASH}" != "${OLD_SOURCE_HASH}" ]; then - echo "Build is not up-to-date (source change) - creating new build" - REQUIRES_BUILD=1 -elif [ "${NEW_HEADER_HASH}" != "${OLD_HEADER_HASH}" ]; then - echo "Build is not up-to-date (header change) - creating new build" +if [ "${CURRENT_SOURCE_TREE_HASH}" != "${PREVIOUS_BUILT_HASH}" ]; then + echo "- Build is not up-to-date (source change) - creating new build" REQUIRES_BUILD=1 -elif [ "${NEW_EXTERNAL_HASH}" != "${OLD_EXTERNAL_HASH}" ]; then - echo "Build is not up-to-date (external lib change) - creating new build" - REQUIRES_BUILD=1 -elif [ "${ARCHS[*]}" != "${OLD_ARCHS}" ]; then - echo "Build is not up-to-date (build architectures changed) - creating new build" +elif [ "${TARGET_ARCH_DIR}" != "${PREVIOUS_BUILT_FRAMEWORK_SLICE_DIR}" ]; then + echo "- Build is not up-to-date (build architectures changed) - creating new build" REQUIRES_BUILD=1 elif [ ! -f "${COMPILE_DIR}/libsession-util.a" ]; then - echo "Build is not up-to-date (no static lib) - creating new build" + echo "- Build is not up-to-date (no static lib) - creating new build" REQUIRES_BUILD=1 else - echo "Build is up-to-date" + echo "- Build is up-to-date" fi if [ "${REQUIRES_BUILD}" == 1 ]; then @@ -230,7 +170,6 @@ if [ "${REQUIRES_BUILD}" == 1 ]; then # Remove any old build logs (since we are doing a new build) rm -rf "${COMPILE_DIR}/libsession_util_output.log" touch "${COMPILE_DIR}/libsession_util_output.log" - echo "CMake build logs: ${COMPILE_DIR}/libsession_util_output.log" submodule_check=ON build_type="Release" @@ -239,13 +178,31 @@ if [ "${REQUIRES_BUILD}" == 1 ]; then submodule_check=OFF build_type="Debug" fi + + # Remove old header files + rm -rf "${COMPILE_DIR}/Headers" + + # Copy the headers across first (if the build fails we still want these so we get less issues + # with Xcode's autocomplete) + mkdir -p "${COMPILE_DIR}/Headers" + cp -r "${LIB_SESSION_SOURCE_DIR}/include/session" "${COMPILE_DIR}/Headers" + + echo "- Generating modulemap for SPM artifact slice" + modmap_path="${COMPILE_DIR}/Headers/module.modulemap" + echo "module SessionUtil {" >"$modmap_path" + echo " module capi {" >>"$modmap_path" + for x in $(cd "${COMPILE_DIR}/Headers" && find session -name '*.h'); do + echo " header \"$x\"" >>"$modmap_path" + done + echo -e " export *\n }" >>"$modmap_path" + echo "}" >>"$modmap_path" # Build the individual architectures for i in "${!TARGET_ARCHS[@]}"; do build="${COMPILE_DIR}/${TARGET_ARCHS[$i]}" platform="${TARGET_PLATFORMS[$i]}" log_file="${COMPILE_DIR}/libsession_util_output.log" - echo "Building ${TARGET_ARCHS[$i]} for $platform in $build" + echo "- Building ${TARGET_ARCHS[$i]} for $platform in $build" # Redirect the build output to a log file and only include the progress lines in the XCode output exec > >(tee "$log_file" | grep --line-buffered '^\[.*%\]') 2>&1 @@ -309,13 +266,25 @@ if [ "${REQUIRES_BUILD}" == 1 ]; then echo "error: $error" fi done + + # If the build failed we still want to copy files across because it'll help errors appear correctly + 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) + 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" exit 1 fi done # Remove the old static library file rm -rf "${COMPILE_DIR}/libsession-util.a" - rm -rf "${COMPILE_DIR}/Headers" # If needed combine simulator builds into a multi-arch lib if [ "${#TARGET_SIM_ARCHS[@]}" -eq "1" ]; then @@ -335,42 +304,29 @@ if [ "${REQUIRES_BUILD}" == 1 ]; then echo "Built multiple architectures, merging into single static library" lipo -create "${COMPILE_DIR}"/ios-*/libsession-util.a -output "${COMPILE_DIR}/libsession-util.a" fi - - # Save the updated build info to disk to prevent rebuilds when there were no changes - echo "${LIB_SESSION_SOURCE_DIR}" > "${COMPILE_DIR}/libsession_util_source_dir.log" - echo "${NEW_SOURCE_HASH}" > "${COMPILE_DIR}/libsession_util_source_hash.log" - echo "${NEW_HEADER_HASH}" > "${COMPILE_DIR}/libsession_util_header_hash.log" - echo "${NEW_EXTERNAL_HASH}" > "${COMPILE_DIR}/libsession_util_external_hash.log" - echo "${ARCHS[*]}" > "${COMPILE_DIR}/libsession_util_archs.log" - # Copy the headers across - echo "Copy headers" - mkdir -p "${COMPILE_DIR}/Headers" - cp -r "${LIB_SESSION_SOURCE_DIR}/include/session" "${COMPILE_DIR}/Headers" + 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" + echo "- Build complete" fi -# Remove any previous versions (in case there is a discrepancy anywhere -rm -rf "${BUILT_PRODUCTS_DIR}/include" -rm -rf "${BUILT_PRODUCTS_DIR}/libsession-util.a" - -cp -r "${COMPILE_DIR}/Headers" "${BUILT_PRODUCTS_DIR}/include" -cp "${COMPILE_DIR}/libsession-util.a" "${BUILT_PRODUCTS_DIR}" - -# Generate the 'module.modulemap' (needed for XCode to be able to find the headers) -# -# Note: We do this last and don't include the `COMPILE_DIR` because, if we do, Xcode -# sees both files and considers the module redefined -echo "Generate modulemap" -modmap="${BUILT_PRODUCTS_DIR}/include/module.modulemap" -echo "module SessionUtil {" >"$modmap" -echo " module capi {" >>"$modmap" -for x in $(cd "${COMPILE_DIR}/Headers" && find session -name '*.h'); do -echo " header \"$x\"" >>"$modmap" -done -echo -e " export *\n }" >>"$modmap" -echo "}" >>"$modmap" +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) +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" # 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 2e917f1798..d290881f63 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -26,8 +26,6 @@ 34CF0788203E6B78005C4D61 /* ringback_tone_ansi.caf in Resources */ = {isa = PBXBuildFile; fileRef = 34CF0784203E6B77005C4D61 /* ringback_tone_ansi.caf */; }; 34CF078A203E6B78005C4D61 /* end_call_tone_cept.caf in Resources */ = {isa = PBXBuildFile; fileRef = 34CF0786203E6B78005C4D61 /* end_call_tone_cept.caf */; }; 34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F04F1F7D45A60066283D /* GifPickerCell.swift */; }; - 4503F1BE20470A5B00CEE724 /* classic-quiet.aifc in Resources */ = {isa = PBXBuildFile; fileRef = 4503F1BB20470A5B00CEE724 /* classic-quiet.aifc */; }; - 4503F1BF20470A5B00CEE724 /* classic.aifc in Resources */ = {isa = PBXBuildFile; fileRef = 4503F1BC20470A5B00CEE724 /* classic.aifc */; }; 4520D8D51D417D8E00123472 /* Photos.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4520D8D41D417D8E00123472 /* Photos.framework */; }; 4521C3C01F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4521C3BF1F59F3BA00B4C582 /* TextFieldHelper.swift */; }; 4535186E1FC635DD00210559 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4535186C1FC635DD00210559 /* MainInterface.storyboard */; }; @@ -217,7 +215,6 @@ B835249B25C3AB650089A44F /* VisibleMessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B835249A25C3AB650089A44F /* VisibleMessageCell.swift */; }; B83524A525C3BA4B0089A44F /* InfoMessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83524A425C3BA4B0089A44F /* InfoMessageCell.swift */; }; B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */; }; - B84664F5235022F30083A1CD /* MentionUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84664F4235022F30083A1CD /* MentionUtilities.swift */; }; B849789625D4A2F500D0D0B3 /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B849789525D4A2F500D0D0B3 /* LinkPreviewView.swift */; }; B84A89BC25DE328A0040017D /* ProfilePictureVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84A89BB25DE328A0040017D /* ProfilePictureVC.swift */; }; B85357BF23A1AE0800AAF6CD /* SeedReminderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85357BE23A1AE0800AAF6CD /* SeedReminderView.swift */; }; @@ -317,7 +314,6 @@ C37F5414255BAFA7002AEA92 /* SignalUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C33FD9AB255A548A00E217F9 /* SignalUtilitiesKit.framework */; }; C37F54DC255BB84A002AEA92 /* SessionSnodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */; }; C38EF00C255B61CC007E1867 /* SignalUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C33FD9AB255A548A00E217F9 /* SignalUtilitiesKit.framework */; }; - C38EF22B255B6D5D007E1867 /* ShareViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF226255B6D5D007E1867 /* ShareViewDelegate.swift */; }; C38EF24D255B6D67007E1867 /* UIView+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF240255B6D67007E1867 /* UIView+OWS.swift */; }; C38EF24E255B6D67007E1867 /* Collection+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF241255B6D67007E1867 /* Collection+OWS.swift */; }; C38EF2B3255B6D9C007E1867 /* UIViewController+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2B1255B6D9C007E1867 /* UIViewController+Utilities.swift */; }; @@ -417,6 +413,9 @@ FD02CC122C367762009AB976 /* Request+PushNotificationAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD02CC112C367761009AB976 /* Request+PushNotificationAPI.swift */; }; FD02CC142C3677E6009AB976 /* Request+OpenGroupAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD02CC132C3677E6009AB976 /* Request+OpenGroupAPI.swift */; }; FD02CC162C3681EF009AB976 /* RevokeSubaccountResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD02CC152C3681EF009AB976 /* RevokeSubaccountResponse.swift */; }; + FD05593D2DFA3A2800DC48CE /* VoipPayloadKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD05593C2DFA3A2200DC48CE /* VoipPayloadKey.swift */; }; + FD05594E2E012D2700DC48CE /* _028_RenameAttachments.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD05594D2E012D1A00DC48CE /* _028_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 */; }; @@ -468,14 +467,15 @@ FD17D7AE27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7AD27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift */; }; FD17D7B827F51ECA00122BE0 /* Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7B727F51ECA00122BE0 /* Migration.swift */; }; FD17D7BA27F51F2100122BE0 /* TargetMigrations.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7B927F51F2100122BE0 /* TargetMigrations.swift */; }; - FD17D7C327F5204C00122BE0 /* Database+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7C227F5204C00122BE0 /* Database+Utilities.swift */; }; FD17D7C727F5207C00122BE0 /* DatabaseMigrator+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7C627F5207C00122BE0 /* DatabaseMigrator+Utilities.swift */; }; FD17D7CA27F546D900122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7C927F546D900122BE0 /* _001_InitialSetupMigration.swift */; }; - FD17D7CD27F546FF00122BE0 /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7CC27F546FF00122BE0 /* Setting.swift */; }; FD17D7E527F6A09900122BE0 /* Identity.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E427F6A09900122BE0 /* Identity.swift */; }; FD17D7E727F6A16700122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.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 */; }; FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */; }; FD1D732E2A86114600E3F410 /* _015_BlockCommunityMessageRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1D732D2A86114600E3F410 /* _015_BlockCommunityMessageRequests.swift */; }; @@ -568,7 +568,6 @@ FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5FB2554B0A000555489 /* MessageReceiver.swift */; }; FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF224255B6D5D007E1867 /* SignalAttachment.swift */; }; FD245C53285065DB00B966DD /* ProximityMonitoringManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */; }; - FD245C54285065E000B966DD /* ThumbnailService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAF1255A580500E217F9 /* ThumbnailService.swift */; }; FD245C55285065E500B966DD /* OpenGroupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */; }; FD245C56285065EA00B966DD /* SNProto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7822553AAF200C340D1 /* SNProto.swift */; }; FD245C59285065FC00B966DD /* ControlMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7702553A41E00C340D1 /* ControlMessage.swift */; }; @@ -647,6 +646,7 @@ FD3AABE928306BBD00E5099A /* ThreadPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */; }; FD3C906727E416AF00CD579F /* BlindedIdLookupSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906627E416AF00CD579F /* BlindedIdLookupSpec.swift */; }; FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; }; + FD3F2EE72DE6CC4100FD6849 /* NotificationsManagerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3F2EE62DE6CC3B00FD6849 /* NotificationsManagerSpec.swift */; }; FD3F2EF22DF273D900FD6849 /* ThemedAttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3F2EF12DF273D100FD6849 /* ThemedAttributedString.swift */; }; FD3FAB592ADF906300DC5421 /* Profile+CurrentUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3FAB582ADF906300DC5421 /* Profile+CurrentUser.swift */; }; FD3FAB5F2AE9BC2200DC5421 /* EditGroupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3FAB5E2AE9BC2200DC5421 /* EditGroupViewModel.swift */; }; @@ -686,6 +686,11 @@ FD4C53AF2CC1D62E003B10F4 /* _021_ReworkRecipientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4C53AE2CC1D61E003B10F4 /* _021_ReworkRecipientState.swift */; }; 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 */; }; + 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 */; }; FD559DF52A7368CB00C7C62A /* DispatchQueue+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD559DF42A7368CB00C7C62A /* DispatchQueue+Utilities.swift */; }; FD5931A72A8DA5DA0040147D /* SQLInterpolation+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5931A62A8DA5DA0040147D /* SQLInterpolation+Utilities.swift */; }; FD5931AB2A8DCB0A0040147D /* ScopeAdapter+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5931AA2A8DCB0A0040147D /* ScopeAdapter+Utilities.swift */; }; @@ -734,6 +739,7 @@ FD6DF00B2ACFE40D0084BA4C /* _005_AddSnodeReveivedMessageInfoPrimaryKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6DF00A2ACFE40D0084BA4C /* _005_AddSnodeReveivedMessageInfoPrimaryKey.swift */; }; FD6E4C8A2A1AEE4700C7C243 /* LegacyUnsubscribeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6E4C892A1AEE4700C7C243 /* LegacyUnsubscribeRequest.swift */; }; FD705A92278D051200F16121 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A91278D051200F16121 /* ReusableView.swift */; }; + FD70F25C2DC1F184003729B7 /* _026_MessageDeduplicationTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD70F25B2DC1F176003729B7 /* _026_MessageDeduplicationTable.swift */; }; FD7115EB28C5D78E00B47552 /* ThreadSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115EA28C5D78E00B47552 /* ThreadSettingsViewModel.swift */; }; FD7115F228C6CB3900B47552 /* _010_AddThreadIdToFTS.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115F128C6CB3900B47552 /* _010_AddThreadIdToFTS.swift */; }; FD7115F428C71EB200B47552 /* ThreadDisappearingMessagesSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115F328C71EB200B47552 /* ThreadDisappearingMessagesSettingsViewModel.swift */; }; @@ -793,6 +799,17 @@ FD78E9EE2DD6D32500D55B50 /* ImageDataManager+Singleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A622DD5BDDD00BEF49F /* ImageDataManager+Singleton.swift */; }; FD78E9F02DD6D61200D55B50 /* Data+Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754CB2C9BAF37002A2623 /* Data+Image.swift */; }; FD78E9F22DDA9EA200D55B50 /* MockImageDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78E9F12DDA9E9B00D55B50 /* MockImageDataManager.swift */; }; + FD78E9F42DDABA4F00D55B50 /* AttachmentUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78E9F32DDABA4200D55B50 /* AttachmentUploader.swift */; }; + FD78E9F62DDD43AD00D55B50 /* Mutation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78E9F52DDD43AB00D55B50 /* Mutation.swift */; }; + FD78E9FA2DDD74D200D55B50 /* _027_MoveSettingsToLibSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78E9F72DDD742100D55B50 /* _027_MoveSettingsToLibSession.swift */; }; + FD78E9FD2DDD97F200D55B50 /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78E9FC2DDD97F000D55B50 /* Setting.swift */; }; + FD78EA002DDDA21100D55B50 /* ValueFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78E9FE2DDDA1AF00D55B50 /* ValueFetcher.swift */; }; + FD78EA022DDEBC3200D55B50 /* DebounceTaskManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78EA012DDEBC2C00D55B50 /* DebounceTaskManager.swift */; }; + FD78EA042DDEC3C500D55B50 /* MultiTaskManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78EA032DDEC3C000D55B50 /* MultiTaskManager.swift */; }; + FD78EA062DDEC8F600D55B50 /* AsyncSequence+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78EA052DDEC8F100D55B50 /* AsyncSequence+Utilities.swift */; }; + FD78EA0A2DDFE45E00D55B50 /* Interaction+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78EA092DDFE45900D55B50 /* Interaction+UI.swift */; }; + FD78EA0B2DDFE45E00D55B50 /* Interaction+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78EA092DDFE45900D55B50 /* Interaction+UI.swift */; }; + FD78EA0D2DDFEDE200D55B50 /* LibSession+Local.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78EA0C2DDFEDDF00D55B50 /* LibSession+Local.swift */; }; FD7F74572BAA9D31006DDFD8 /* _006_DropSnodeCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7F74562BAA9D31006DDFD8 /* _006_DropSnodeCache.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 */; }; @@ -836,19 +853,28 @@ FD8A5B222DC0489C004C689B /* AdaptiveHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B212DC0489B004C689B /* AdaptiveHStack.swift */; }; FD8A5B252DC05B16004C689B /* Number+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B242DC05B16004C689B /* Number+Utilities.swift */; }; FD8A5B292DC060E2004C689B /* Double+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B282DC060DD004C689B /* Double+Utilities.swift */; }; + FD8A5B302DC18D61004C689B /* GeneralCacheSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B2F2DC18D5E004C689B /* GeneralCacheSpec.swift */; }; FD8A5B322DC191B4004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B312DC191AB004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift */; }; FD8A5B342DC1A732004C689B /* _008_ResetUserConfigLastHashes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B332DC1A726004C689B /* _008_ResetUserConfigLastHashes.swift */; }; FD8ECF7B29340FFD00C0D1BB /* LibSession+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8ECF7A29340FFD00C0D1BB /* LibSession+SessionMessagingKit.swift */; }; FD8ECF7D2934293A00C0D1BB /* _013_SessionUtilChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8ECF7C2934293A00C0D1BB /* _013_SessionUtilChanges.swift */; }; FD8ECF7F2934298100C0D1BB /* ConfigDump.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8ECF7E2934298100C0D1BB /* ConfigDump.swift */; }; FD8FD7622C37B7BD001E38C7 /* Position.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71162B28E1451400B47552 /* Position.swift */; }; - FD8FD7642C37C24A001E38C7 /* EquatableIgnoring.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8FD7632C37C24A001E38C7 /* EquatableIgnoring.swift */; }; FD9004142818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */; }; FD9004152818B46300ABAAF6 /* JobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7432804EF1B004C14C5 /* JobRunner.swift */; }; FD9004162818B46700ABAAF6 /* JobRunnerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */; }; FD96F3A529DBC3DC00401309 /* MessageSendJobSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD96F3A429DBC3DC00401309 /* MessageSendJobSpec.swift */; }; FD97B2402A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD97B23F2A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift */; }; FD97B2422A3FEBF30027DD57 /* UnreadMarkerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD97B2412A3FEBF30027DD57 /* UnreadMarkerCell.swift */; }; + FD981BC42DC304E600564172 /* MessageDeduplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BC32DC304E100564172 /* MessageDeduplication.swift */; }; + FD981BC62DC3310B00564172 /* ExtensionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BC52DC3310800564172 /* ExtensionHelper.swift */; }; + FD981BC92DC4641100564172 /* ExtensionHelperSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BC82DC4640D00564172 /* ExtensionHelperSpec.swift */; }; + FD981BCB2DC4A21C00564172 /* MessageDeduplicationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BCA2DC4A21800564172 /* MessageDeduplicationSpec.swift */; }; + FD981BCD2DC81ABF00564172 /* MockExtensionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BCC2DC81ABB00564172 /* MockExtensionHelper.swift */; }; + FD981BD32DC9770E00564172 /* MentionUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84664F4235022F30083A1CD /* MentionUtilities.swift */; }; + FD981BD52DC978B400564172 /* MentionUtilities+DisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BD42DC978AC00564172 /* MentionUtilities+DisplayName.swift */; }; + FD981BD72DC9A61A00564172 /* NotificationCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BD62DC9A61600564172 /* NotificationCategory.swift */; }; + FD981BD92DC9A69600564172 /* NotificationUserInfoKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BD82DC9A69000564172 /* NotificationUserInfoKey.swift */; }; FD99D0872D0FA731005D2E15 /* ThreadSafe.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99D0862D0FA72E005D2E15 /* ThreadSafe.swift */; }; FD99D0922D10F5EE005D2E15 /* ThreadSafeSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99D0912D10F5EB005D2E15 /* ThreadSafeSpec.swift */; }; FD9AECA52AAA9609009B3406 /* NotificationResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9AECA42AAA9609009B3406 /* NotificationResolution.swift */; }; @@ -861,11 +887,28 @@ 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 */; }; + FDB11A502DCC6ADE00BEF49F /* ThreadUpdateInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A4F2DCC6ADD00BEF49F /* ThreadUpdateInfo.swift */; }; + FDB11A522DCC6B0000BEF49F /* OpenGroupUrlInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A512DCC6AFF00BEF49F /* OpenGroupUrlInfo.swift */; }; + FDB11A542DCD7A7F00BEF49F /* Task+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A532DCD7A7B00BEF49F /* Task+Utilities.swift */; }; + FDB11A562DD17C3300BEF49F /* MockLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A552DD17C3000BEF49F /* MockLogger.swift */; }; + FDB11A572DD17D0600BEF49F /* MockLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A552DD17C3000BEF49F /* MockLogger.swift */; }; + FDB11A582DD17D0600BEF49F /* MockLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A552DD17C3000BEF49F /* MockLogger.swift */; }; + FDB11A592DD17D0600BEF49F /* MockLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A552DD17C3000BEF49F /* MockLogger.swift */; }; + FDB11A5B2DD1901000BEF49F /* CurrentValueAsyncStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A5A2DD1900B00BEF49F /* CurrentValueAsyncStream.swift */; }; + FDB11A5D2DD300D300BEF49F /* SNProtoContent+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A5C2DD300CF00BEF49F /* SNProtoContent+Utilities.swift */; }; + FDB11A5F2DD5B77800BEF49F /* Message+Origin.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A5E2DD5B77800BEF49F /* Message+Origin.swift */; }; FDB11A612DD5BDCC00BEF49F /* ImageDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A602DD5BDC900BEF49F /* ImageDataManager.swift */; }; FDB348632BE3774000B716C2 /* BezierPathView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB348622BE3774000B716C2 /* BezierPathView.swift */; }; FDB3486E2BE8457F00B716C2 /* BackgroundTaskManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3486D2BE8457F00B716C2 /* BackgroundTaskManager.swift */; }; FDB3487E2BE856C800B716C2 /* UIBezierPath+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3487D2BE856C800B716C2 /* UIBezierPath+Utilities.swift */; }; FDB348892BE8705D00B716C2 /* SessionUtilitiesKit.h in Headers */ = {isa = PBXBuildFile; fileRef = FDB3486C2BE8448500B716C2 /* SessionUtilitiesKit.h */; settings = {ATTRIBUTES = (Public, ); }; }; + FDB3DA772E1B6ACA00148F8D /* (null) in Frameworks */ = {isa = PBXBuildFile; }; + FDB3DA792E1B6AD000148F8D /* (null) in Frameworks */ = {isa = PBXBuildFile; }; + FDB3DA7B2E1B6AD800148F8D /* (null) in Frameworks */ = {isa = PBXBuildFile; }; + FDB3DA7D2E1B6AFC00148F8D /* (null) in Frameworks */ = {isa = PBXBuildFile; }; + FDB3DA842E1CA22400148F8D /* UIActivityViewController+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3DA832E1CA21C00148F8D /* UIActivityViewController+Utilities.swift */; }; + FDB3DA862E1E1F0E00148F8D /* TaskCancellation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3DA852E1E1F0B00148F8D /* TaskCancellation.swift */; }; FDB4BBC72838B91E00B7C95D /* LinkPreviewError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB4BBC62838B91E00B7C95D /* LinkPreviewError.swift */; }; FDB5DAC12A9443A5002C8721 /* MessageSender+Groups.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB5DAC02A9443A5002C8721 /* MessageSender+Groups.swift */; }; FDB5DAC72A9447E7002C8721 /* _022_GroupsRebuildChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB5DAC62A9447E7002C8721 /* _022_GroupsRebuildChanges.swift */; }; @@ -919,7 +962,6 @@ FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909727D7129B005DAE71 /* PersonalizationSpec.swift */; }; FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; - FDC383392A93411100FFD6A2 /* Setting+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1D73292A85AA2000E3F410 /* Setting+Utilities.swift */; }; FDC4380927B31D4E00C60D73 /* OpenGroupAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380827B31D4E00C60D73 /* OpenGroupAPIError.swift */; }; FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381627B32EC700C60D73 /* Personalization.swift */; }; FDC4382027B36ADC00C60D73 /* SOGSEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */; }; @@ -940,9 +982,7 @@ FDC438C927BB706500C60D73 /* SendDirectMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C827BB706500C60D73 /* SendDirectMessageRequest.swift */; }; FDC438CB27BB7DB100C60D73 /* UpdateMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438CA27BB7DB100C60D73 /* UpdateMessageRequest.swift */; }; FDC438CD27BC641200C60D73 /* Set+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438CC27BC641200C60D73 /* Set+Utilities.swift */; }; - FDC498B72AC15F7D00EDD897 /* AppNotificationCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC498B62AC15F7D00EDD897 /* AppNotificationCategory.swift */; }; FDC498B92AC15FE300EDD897 /* AppNotificationAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC498B82AC15FE300EDD897 /* AppNotificationAction.swift */; }; - FDC498BB2AC1606C00EDD897 /* AppNotificationUserInfoKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC498BA2AC1606C00EDD897 /* AppNotificationUserInfoKey.swift */; }; FDC6D7602862B3F600B04575 /* Dependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC6D75F2862B3F600B04575 /* Dependencies.swift */; }; FDCD2E032A41294E00964D6A /* LegacyGroupOnlyRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCD2E022A41294E00964D6A /* LegacyGroupOnlyRequest.swift */; }; FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; }; @@ -959,7 +999,16 @@ FDE33BBC2D5C124900E56F42 /* DispatchTimeInterval+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE33BBB2D5C124300E56F42 /* DispatchTimeInterval+Utilities.swift */; }; FDE33BBE2D5C3AF100E56F42 /* _023_GroupsExpiredFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE33BBD2D5C3AE800E56F42 /* _023_GroupsExpiredFlag.swift */; }; FDE519F72AB7CDC700450C53 /* Result+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE519F62AB7CDC700450C53 /* Result+Utilities.swift */; }; - FDE519F92AB802BB00450C53 /* Message+Origin.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE519F82AB802BB00450C53 /* Message+Origin.swift */; }; + FDE5218E2E03A06B00061B8E /* AttachmentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE5218D2E03A06700061B8E /* AttachmentManager.swift */; }; + FDE521902E04CCEB00061B8E /* AVURLAsset+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE5218F2E04CCE600061B8E /* AVURLAsset+Utilities.swift */; }; + FDE521942E050B1100061B8E /* DismissCallbackAVPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE521932E050B0800061B8E /* DismissCallbackAVPlayerViewController.swift */; }; + FDE5219A2E08DBB800061B8E /* ImageLoading+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */; }; + FDE5219C2E08E76C00061B8E /* SessionImageView_SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE5219B2E08E76600061B8E /* SessionImageView_SwiftUI.swift */; }; + FDE5219E2E0D0B9B00061B8E /* AsyncAccessible.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE5219D2E0D0B9800061B8E /* AsyncAccessible.swift */; }; + FDE521A02E0D230000061B8E /* ObservationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE5219F2E0D22FD00061B8E /* ObservationManager.swift */; }; + FDE521A22E0D23AB00061B8E /* ObservableKey+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE521A12E0D23A200061B8E /* ObservableKey+SessionMessagingKit.swift */; }; + FDE521A52E0D288A00061B8E /* Dependencies+Observation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE521A32E0D283E00061B8E /* Dependencies+Observation.swift */; }; + FDE521A62E0E6C8C00061B8E /* MockNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5C2CAA28CF00C0B51B /* MockNotificationsManager.swift */; }; FDE6E99829F8E63A00F93C5D /* Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE6E99729F8E63A00F93C5D /* Accessibility.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 */; }; @@ -1012,7 +1061,6 @@ FDE755202C9BC1A6002A2623 /* CacheConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE7551F2C9BC1A6002A2623 /* CacheConfig.swift */; }; FDE755222C9BC1BA002A2623 /* LibSessionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE755212C9BC1BA002A2623 /* LibSessionError.swift */; }; FDE755242C9BC1D1002A2623 /* Publisher+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE755232C9BC1D1002A2623 /* Publisher+Utilities.swift */; }; - FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */; }; FDEF57212C3CF03A00131302 /* (null) in Sources */ = {isa = PBXBuildFile; }; FDEF57222C3CF03D00131302 /* (null) in Sources */ = {isa = PBXBuildFile; }; FDEF57232C3CF04300131302 /* (null) in Sources */ = {isa = PBXBuildFile; }; @@ -1026,7 +1074,7 @@ FDF0B7422804EA4F004C14C5 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7412804EA4F004C14C5 /* _002_SetupStandardJobs.swift */; }; FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74828060D13004C14C5 /* QuotedReplyModel.swift */; }; FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74A28061F7A004C14C5 /* InteractionAttachment.swift */; }; - FDF0B7512807BA56004C14C5 /* NotificationsProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */; }; + FDF0B7512807BA56004C14C5 /* NotificationsManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7502807BA56004C14C5 /* NotificationsManagerType.swift */; }; FDF0B7582807F368004C14C5 /* MessageReceiverError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7572807F368004C14C5 /* MessageReceiverError.swift */; }; FDF0B75A2807F3A3004C14C5 /* MessageSenderError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7592807F3A3004C14C5 /* MessageSenderError.swift */; }; FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B75B2807F41D004C14C5 /* MessageSender+Convenience.swift */; }; @@ -1321,8 +1369,6 @@ 34CF0784203E6B77005C4D61 /* ringback_tone_ansi.caf */ = {isa = PBXFileReference; lastKnownFileType = file; name = ringback_tone_ansi.caf; path = Session/Meta/AudioFiles/ringback_tone_ansi.caf; sourceTree = SOURCE_ROOT; }; 34CF0786203E6B78005C4D61 /* end_call_tone_cept.caf */ = {isa = PBXFileReference; lastKnownFileType = file; name = end_call_tone_cept.caf; path = Session/Meta/AudioFiles/end_call_tone_cept.caf; sourceTree = SOURCE_ROOT; }; 34D1F04F1F7D45A60066283D /* GifPickerCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifPickerCell.swift; sourceTree = ""; }; - 4503F1BB20470A5B00CEE724 /* classic-quiet.aifc */ = {isa = PBXFileReference; lastKnownFileType = file; path = "classic-quiet.aifc"; sourceTree = ""; }; - 4503F1BC20470A5B00CEE724 /* classic.aifc */ = {isa = PBXFileReference; lastKnownFileType = file; path = classic.aifc; sourceTree = ""; }; 4520D8D41D417D8E00123472 /* Photos.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Photos.framework; path = System/Library/Frameworks/Photos.framework; sourceTree = SDKROOT; }; 4521C3BF1F59F3BA00B4C582 /* TextFieldHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextFieldHelper.swift; sourceTree = ""; }; 453518681FC635DD00210559 /* SessionShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SessionShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1596,7 +1642,6 @@ C33FDA7A255A57FB00E217F9 /* NSRegularExpression+SSK.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSRegularExpression+SSK.swift"; sourceTree = ""; }; C33FDA87255A57FC00E217F9 /* TypingIndicators.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicators.swift; sourceTree = ""; }; C33FDA9E255A57FF00E217F9 /* ReverseDispatchQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReverseDispatchQueue.swift; sourceTree = ""; }; - C33FDAF1255A580500E217F9 /* ThumbnailService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailService.swift; sourceTree = ""; }; C33FDAFD255A580600E217F9 /* LRUCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LRUCache.swift; sourceTree = ""; }; C33FDB3A255A580B00E217F9 /* PollerType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PollerType.swift; sourceTree = ""; }; C33FDB3F255A580C00E217F9 /* String+SSK.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+SSK.swift"; sourceTree = ""; }; @@ -1614,7 +1659,6 @@ C379DCF3256735770002D4EB /* VisibleMessage+Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+Attachment.swift"; sourceTree = ""; }; C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SNProtoEnvelope+Conversion.swift"; sourceTree = ""; }; C38EF224255B6D5D007E1867 /* SignalAttachment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SignalAttachment.swift; path = "SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift"; sourceTree = SOURCE_ROOT; }; - C38EF226255B6D5D007E1867 /* ShareViewDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ShareViewDelegate.swift; path = SignalUtilitiesKit/Utilities/ShareViewDelegate.swift; sourceTree = SOURCE_ROOT; }; C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIDevice+featureSupport.swift"; path = "SessionUtilitiesKit/General/UIDevice+featureSupport.swift"; sourceTree = SOURCE_ROOT; }; C38EF240255B6D67007E1867 /* UIView+OWS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIView+OWS.swift"; path = "SignalUtilitiesKit/Utilities/UIView+OWS.swift"; sourceTree = SOURCE_ROOT; }; C38EF241255B6D67007E1867 /* Collection+OWS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "Collection+OWS.swift"; path = "SignalUtilitiesKit/Utilities/Collection+OWS.swift"; sourceTree = SOURCE_ROOT; }; @@ -1729,6 +1773,9 @@ FD02CC112C367761009AB976 /* Request+PushNotificationAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Request+PushNotificationAPI.swift"; sourceTree = ""; }; FD02CC132C3677E6009AB976 /* Request+OpenGroupAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Request+OpenGroupAPI.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 /* _028_RenameAttachments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _028_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 = ""; }; @@ -1775,17 +1822,17 @@ FD17D7B727F51ECA00122BE0 /* Migration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Migration.swift; sourceTree = ""; }; FD17D7B927F51F2100122BE0 /* TargetMigrations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetMigrations.swift; sourceTree = ""; }; FD17D7BE27F51F8200122BE0 /* ColumnExpressible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColumnExpressible.swift; sourceTree = ""; }; - FD17D7C227F5204C00122BE0 /* Database+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database+Utilities.swift"; sourceTree = ""; }; FD17D7C627F5207C00122BE0 /* DatabaseMigrator+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DatabaseMigrator+Utilities.swift"; sourceTree = ""; }; FD17D7C927F546D900122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; - FD17D7CC27F546FF00122BE0 /* Setting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = ""; }; FD17D7E427F6A09900122BE0 /* Identity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Identity.swift; sourceTree = ""; }; FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_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 = ""; }; FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = ""; }; - FD1D73292A85AA2000E3F410 /* Setting+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Setting+Utilities.swift"; sourceTree = ""; }; FD1D732D2A86114600E3F410 /* _015_BlockCommunityMessageRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _015_BlockCommunityMessageRequests.swift; sourceTree = ""; }; FD2272532C32911A004D8A6C /* SendReadReceiptsJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SendReadReceiptsJob.swift; sourceTree = ""; }; FD2272542C32911A004D8A6C /* GroupLeavingJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupLeavingJob.swift; sourceTree = ""; }; @@ -1909,6 +1956,7 @@ FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = ""; }; FD3C906627E416AF00CD579F /* BlindedIdLookupSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdLookupSpec.swift; sourceTree = ""; }; FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModel.swift; sourceTree = ""; }; + FD3F2EE62DE6CC3B00FD6849 /* NotificationsManagerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsManagerSpec.swift; sourceTree = ""; }; FD3F2EF12DF273D100FD6849 /* ThemedAttributedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemedAttributedString.swift; sourceTree = ""; }; FD3FAB582ADF906300DC5421 /* Profile+CurrentUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Profile+CurrentUser.swift"; sourceTree = ""; }; FD3FAB5E2AE9BC2200DC5421 /* EditGroupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditGroupViewModel.swift; sourceTree = ""; }; @@ -1939,6 +1987,9 @@ FD52090428B4915F006098F6 /* PrivacySettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettingsViewModel.swift; sourceTree = ""; }; FD52090628B49738006098F6 /* ConfirmationModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmationModal.swift; sourceTree = ""; }; FD52090828B59411006098F6 /* ScreenLockWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenLockWindow.swift; sourceTree = ""; }; + FD52CB592E12166D00A4DA70 /* OnboardingSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingSpec.swift; sourceTree = ""; }; + FD52CB622E13B61700A4DA70 /* ObservableKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableKey.swift; sourceTree = ""; }; + FD52CB642E13B6E600A4DA70 /* ObservationBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationBuilder.swift; sourceTree = ""; }; FD559DF42A7368CB00C7C62A /* DispatchQueue+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchQueue+Utilities.swift"; sourceTree = ""; }; FD5931A62A8DA5DA0040147D /* SQLInterpolation+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SQLInterpolation+Utilities.swift"; sourceTree = ""; }; FD5931AA2A8DCB0A0040147D /* ScopeAdapter+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ScopeAdapter+Utilities.swift"; sourceTree = ""; }; @@ -1966,6 +2017,7 @@ FD6DF00A2ACFE40D0084BA4C /* _005_AddSnodeReveivedMessageInfoPrimaryKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _005_AddSnodeReveivedMessageInfoPrimaryKey.swift; sourceTree = ""; }; FD6E4C892A1AEE4700C7C243 /* LegacyUnsubscribeRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyUnsubscribeRequest.swift; sourceTree = ""; }; FD705A91278D051200F16121 /* ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = ""; }; + FD70F25B2DC1F176003729B7 /* _026_MessageDeduplicationTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _026_MessageDeduplicationTable.swift; sourceTree = ""; }; FD7115EA28C5D78E00B47552 /* ThreadSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSettingsViewModel.swift; sourceTree = ""; }; FD7115EF28C5D7DE00B47552 /* SessionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionHeaderView.swift; sourceTree = ""; }; FD7115F128C6CB3900B47552 /* _010_AddThreadIdToFTS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _010_AddThreadIdToFTS.swift; sourceTree = ""; }; @@ -2000,7 +2052,6 @@ FD716E6528502EE200C96BF4 /* CurrentCallProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentCallProtocol.swift; sourceTree = ""; }; FD716E6B28505E1C00C96BF4 /* MessageRequestsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewModel.swift; sourceTree = ""; }; FD716E7028505E5100C96BF4 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; - FD72BD992BDF5EEA00CF6CF6 /* Message+Origin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+Origin.swift"; sourceTree = ""; }; FD72BDA02BE368C800CF6CF6 /* UIWindowLevel+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindowLevel+Utilities.swift"; sourceTree = ""; }; FD72BDA32BE3690B00CF6CF6 /* CryptoSMKSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoSMKSpec.swift; sourceTree = ""; }; FD72BDA62BE369DC00CF6CF6 /* CryptoOpenGroupAPISpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoOpenGroupAPISpec.swift; sourceTree = ""; }; @@ -2016,6 +2067,16 @@ FD7728972849E8110018502F /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; FD778B6329B189FF001BAC6B /* _014_GenerateInitialUserConfigDumps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _014_GenerateInitialUserConfigDumps.swift; sourceTree = ""; }; FD78E9F12DDA9E9B00D55B50 /* MockImageDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockImageDataManager.swift; sourceTree = ""; }; + FD78E9F32DDABA4200D55B50 /* AttachmentUploader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploader.swift; sourceTree = ""; }; + FD78E9F52DDD43AB00D55B50 /* Mutation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mutation.swift; sourceTree = ""; }; + FD78E9F72DDD742100D55B50 /* _027_MoveSettingsToLibSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _027_MoveSettingsToLibSession.swift; sourceTree = ""; }; + FD78E9FC2DDD97F000D55B50 /* Setting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = ""; }; + FD78E9FE2DDDA1AF00D55B50 /* ValueFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValueFetcher.swift; sourceTree = ""; }; + FD78EA012DDEBC2C00D55B50 /* DebounceTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebounceTaskManager.swift; sourceTree = ""; }; + FD78EA032DDEC3C000D55B50 /* MultiTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiTaskManager.swift; sourceTree = ""; }; + FD78EA052DDEC8F100D55B50 /* AsyncSequence+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncSequence+Utilities.swift"; sourceTree = ""; }; + FD78EA092DDFE45900D55B50 /* Interaction+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Interaction+UI.swift"; sourceTree = ""; }; + FD78EA0C2DDFEDDF00D55B50 /* LibSession+Local.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LibSession+Local.swift"; sourceTree = ""; }; FD7F74562BAA9D31006DDFD8 /* _006_DropSnodeCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _006_DropSnodeCache.swift; sourceTree = ""; }; FD7F745A2BAAA35E006DDFD8 /* LibSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibSession.swift; sourceTree = ""; }; FD7F745E2BAAA3B4006DDFD8 /* TypeConversion+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TypeConversion+Utilities.swift"; sourceTree = ""; }; @@ -2053,17 +2114,25 @@ FD8A5B212DC0489B004C689B /* AdaptiveHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveHStack.swift; sourceTree = ""; }; FD8A5B242DC05B16004C689B /* Number+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Number+Utilities.swift"; sourceTree = ""; }; FD8A5B282DC060DD004C689B /* Double+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Utilities.swift"; sourceTree = ""; }; + FD8A5B2F2DC18D5E004C689B /* GeneralCacheSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralCacheSpec.swift; sourceTree = ""; }; FD8A5B312DC191AB004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _025_DropLegacyClosedGroupKeyPairTable.swift; sourceTree = ""; }; FD8A5B332DC1A726004C689B /* _008_ResetUserConfigLastHashes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _008_ResetUserConfigLastHashes.swift; sourceTree = ""; }; FD8ECF7A29340FFD00C0D1BB /* LibSession+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LibSession+SessionMessagingKit.swift"; sourceTree = ""; }; FD8ECF7C2934293A00C0D1BB /* _013_SessionUtilChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _013_SessionUtilChanges.swift; sourceTree = ""; }; FD8ECF7E2934298100C0D1BB /* ConfigDump.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigDump.swift; sourceTree = ""; }; - FD8FD7632C37C24A001E38C7 /* EquatableIgnoring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EquatableIgnoring.swift; sourceTree = ""; }; FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = ""; }; FD9401CE2ABD04AC003A4834 /* TRANSLATIONS.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = TRANSLATIONS.md; sourceTree = ""; }; FD96F3A429DBC3DC00401309 /* MessageSendJobSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSendJobSpec.swift; sourceTree = ""; }; FD97B23F2A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ARC4RandomNumberGenerator.swift; sourceTree = ""; }; FD97B2412A3FEBF30027DD57 /* UnreadMarkerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnreadMarkerCell.swift; sourceTree = ""; }; + FD981BC32DC304E100564172 /* MessageDeduplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeduplication.swift; sourceTree = ""; }; + FD981BC52DC3310800564172 /* ExtensionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionHelper.swift; sourceTree = ""; }; + FD981BC82DC4640D00564172 /* ExtensionHelperSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionHelperSpec.swift; sourceTree = ""; }; + FD981BCA2DC4A21800564172 /* MessageDeduplicationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeduplicationSpec.swift; sourceTree = ""; }; + FD981BCC2DC81ABB00564172 /* MockExtensionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockExtensionHelper.swift; sourceTree = ""; }; + FD981BD42DC978AC00564172 /* MentionUtilities+DisplayName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MentionUtilities+DisplayName.swift"; sourceTree = ""; }; + FD981BD62DC9A61600564172 /* NotificationCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCategory.swift; sourceTree = ""; }; + FD981BD82DC9A69000564172 /* NotificationUserInfoKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationUserInfoKey.swift; sourceTree = ""; }; 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 = ""; }; @@ -2073,12 +2142,22 @@ 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 = ""; }; + FDB11A4F2DCC6ADD00BEF49F /* ThreadUpdateInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadUpdateInfo.swift; sourceTree = ""; }; + FDB11A512DCC6AFF00BEF49F /* OpenGroupUrlInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupUrlInfo.swift; sourceTree = ""; }; + FDB11A532DCD7A7B00BEF49F /* Task+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Task+Utilities.swift"; sourceTree = ""; }; + FDB11A552DD17C3000BEF49F /* MockLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockLogger.swift; sourceTree = ""; }; + FDB11A5A2DD1900B00BEF49F /* CurrentValueAsyncStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentValueAsyncStream.swift; sourceTree = ""; }; + FDB11A5C2DD300CF00BEF49F /* SNProtoContent+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SNProtoContent+Utilities.swift"; sourceTree = ""; }; + FDB11A5E2DD5B77800BEF49F /* Message+Origin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+Origin.swift"; sourceTree = ""; }; FDB11A602DD5BDC900BEF49F /* ImageDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDataManager.swift; sourceTree = ""; }; FDB11A622DD5BDDD00BEF49F /* ImageDataManager+Singleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageDataManager+Singleton.swift"; sourceTree = ""; }; FDB348622BE3774000B716C2 /* BezierPathView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BezierPathView.swift; sourceTree = ""; }; FDB3486C2BE8448500B716C2 /* SessionUtilitiesKit.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SessionUtilitiesKit.h; sourceTree = ""; }; FDB3486D2BE8457F00B716C2 /* BackgroundTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskManager.swift; sourceTree = ""; }; FDB3487D2BE856C800B716C2 /* UIBezierPath+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBezierPath+Utilities.swift"; sourceTree = ""; }; + FDB3DA832E1CA21C00148F8D /* UIActivityViewController+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIActivityViewController+Utilities.swift"; sourceTree = ""; }; + FDB3DA852E1E1F0B00148F8D /* TaskCancellation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCancellation.swift; sourceTree = ""; }; FDB4BBC62838B91E00B7C95D /* LinkPreviewError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewError.swift; sourceTree = ""; }; FDB5DAC02A9443A5002C8721 /* MessageSender+Groups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageSender+Groups.swift"; sourceTree = ""; }; FDB5DAC62A9447E7002C8721 /* _022_GroupsRebuildChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _022_GroupsRebuildChanges.swift; sourceTree = ""; }; @@ -2146,9 +2225,7 @@ FDC438C827BB706500C60D73 /* SendDirectMessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendDirectMessageRequest.swift; sourceTree = ""; }; FDC438CA27BB7DB100C60D73 /* UpdateMessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateMessageRequest.swift; sourceTree = ""; }; FDC438CC27BC641200C60D73 /* Set+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Set+Utilities.swift"; sourceTree = ""; }; - FDC498B62AC15F7D00EDD897 /* AppNotificationCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNotificationCategory.swift; sourceTree = ""; }; FDC498B82AC15FE300EDD897 /* AppNotificationAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNotificationAction.swift; sourceTree = ""; }; - FDC498BA2AC1606C00EDD897 /* AppNotificationUserInfoKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNotificationUserInfoKey.swift; sourceTree = ""; }; FDC6D75F2862B3F600B04575 /* Dependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dependencies.swift; sourceTree = ""; }; FDCCC6E82ABA7402002BBEF5 /* EmojiGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiGenerator.swift; sourceTree = ""; }; FDCD2E022A41294E00964D6A /* LegacyGroupOnlyRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyGroupOnlyRequest.swift; sourceTree = ""; }; @@ -2168,7 +2245,15 @@ FDE33BBB2D5C124300E56F42 /* DispatchTimeInterval+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchTimeInterval+Utilities.swift"; sourceTree = ""; }; FDE33BBD2D5C3AE800E56F42 /* _023_GroupsExpiredFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _023_GroupsExpiredFlag.swift; sourceTree = ""; }; FDE519F62AB7CDC700450C53 /* Result+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Result+Utilities.swift"; sourceTree = ""; }; - FDE519F82AB802BB00450C53 /* Message+Origin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+Origin.swift"; sourceTree = ""; }; + FDE5218D2E03A06700061B8E /* AttachmentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentManager.swift; sourceTree = ""; }; + FDE5218F2E04CCE600061B8E /* AVURLAsset+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVURLAsset+Utilities.swift"; sourceTree = ""; }; + FDE521932E050B0800061B8E /* DismissCallbackAVPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissCallbackAVPlayerViewController.swift; sourceTree = ""; }; + FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageLoading+Convenience.swift"; sourceTree = ""; }; + FDE5219B2E08E76600061B8E /* SessionImageView_SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionImageView_SwiftUI.swift; sourceTree = ""; }; + FDE5219D2E0D0B9800061B8E /* AsyncAccessible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncAccessible.swift; sourceTree = ""; }; + FDE5219F2E0D22FD00061B8E /* ObservationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationManager.swift; sourceTree = ""; }; + FDE521A12E0D23A200061B8E /* ObservableKey+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ObservableKey+SessionMessagingKit.swift"; sourceTree = ""; }; + FDE521A32E0D283E00061B8E /* Dependencies+Observation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dependencies+Observation.swift"; sourceTree = ""; }; FDE6E99729F8E63A00F93C5D /* Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Accessibility.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 = ""; }; @@ -2226,7 +2311,6 @@ FDE755212C9BC1BA002A2623 /* LibSessionError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibSessionError.swift; sourceTree = ""; }; FDE755232C9BC1D1002A2623 /* Publisher+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Publisher+Utilities.swift"; sourceTree = ""; }; FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerError.swift; sourceTree = ""; }; - FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlMessageProcessRecord.swift; sourceTree = ""; }; FDEF573D2C40F2A100131302 /* GroupUpdateMemberLeftNotificationMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupUpdateMemberLeftNotificationMessage.swift; sourceTree = ""; }; FDEF57642C44B8C200131302 /* ProcessIP2CountryData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessIP2CountryData.swift; sourceTree = ""; }; FDEF57662C44C1DF00131302 /* GeoLite2-Country-Blocks-IPv4.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = "GeoLite2-Country-Blocks-IPv4.csv"; sourceTree = ""; }; @@ -2246,7 +2330,7 @@ FDF0B7432804EF1B004C14C5 /* JobRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunner.swift; sourceTree = ""; }; FDF0B74828060D13004C14C5 /* QuotedReplyModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotedReplyModel.swift; sourceTree = ""; }; FDF0B74A28061F7A004C14C5 /* InteractionAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionAttachment.swift; sourceTree = ""; }; - FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsProtocol.swift; sourceTree = ""; }; + FDF0B7502807BA56004C14C5 /* NotificationsManagerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsManagerType.swift; sourceTree = ""; }; FDF0B7572807F368004C14C5 /* MessageReceiverError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReceiverError.swift; sourceTree = ""; }; FDF0B7592807F3A3004C14C5 /* MessageSenderError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSenderError.swift; sourceTree = ""; }; FDF0B75B2807F41D004C14C5 /* MessageSender+Convenience.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "MessageSender+Convenience.swift"; sourceTree = ""; }; @@ -2364,6 +2448,7 @@ files = ( C3C2A6C62553896A00C340D1 /* SessionUtilitiesKit.framework in Frameworks */, 946F5A732D5DA3AC00A5ADCE /* Punycode in Frameworks */, + FDB3DA7D2E1B6AFC00148F8D /* (null) in Frameworks */, FD6673F82D7021F200041530 /* SessionUtil in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2374,6 +2459,7 @@ files = ( FD6673F62D7021E700041530 /* SessionUtil in Frameworks */, FD6A38EC2C2A63B500762359 /* KeychainSwift in Frameworks */, + FDB3DA792E1B6AD000148F8D /* (null) in Frameworks */, FD6A38EF2C2A641200762359 /* DifferenceKit in Frameworks */, FD756BEB2D0181D700BD7199 /* GRDB in Frameworks */, FD6A38E92C2A630E00762359 /* CocoaLumberjackSwift in Frameworks */, @@ -2388,6 +2474,7 @@ FD6673FA2D7021F800041530 /* SessionUtil in Frameworks */, FD2286732C38D43900BC06F7 /* DifferenceKit in Frameworks */, FDC4386C27B4E90300C60D73 /* SessionUtilitiesKit.framework in Frameworks */, + FDB3DA772E1B6ACA00148F8D /* (null) in Frameworks */, C3C2A70B25539E1E00C340D1 /* SessionSnodeKit.framework in Frameworks */, FD6A39132C2A946A00762359 /* SwiftProtobuf in Frameworks */, ); @@ -2399,6 +2486,7 @@ files = ( FD11E22E2CA4D12C001BAF58 /* WebRTC in Frameworks */, FD11E22D2CA4D12C001BAF58 /* DifferenceKit in Frameworks */, + FDB3DA7B2E1B6AD800148F8D /* (null) in Frameworks */, B8FF8DAE25C0D00F004D1F22 /* SessionMessagingKit.framework in Frameworks */, B8FF8DAF25C0D00F004D1F22 /* SessionUtilitiesKit.framework in Frameworks */, FDB6A87C2AD75B7F002D4F96 /* PhotosUI.framework in Frameworks */, @@ -2507,8 +2595,6 @@ 45B74A622044AAB400CD42F8 /* chord.aifc */, 45B74A702044AAB500CD42F8 /* circles-quiet.aifc */, 45B74A6A2044AAB500CD42F8 /* circles.aifc */, - 4503F1BB20470A5B00CEE724 /* classic-quiet.aifc */, - 4503F1BC20470A5B00CEE724 /* classic.aifc */, 45B74A6E2044AAB500CD42F8 /* complete-quiet.aifc */, 45B74A652044AAB400CD42F8 /* complete.aifc */, 45B74A632044AAB400CD42F8 /* hello-quiet.aifc */, @@ -2570,6 +2656,8 @@ 7B93D07527CF1A8900811CB6 /* MockDataGenerator.swift */, 4C090A1A210FD9C7001FD7F9 /* HapticFeedback.swift */, FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */, + FDE521932E050B0800061B8E /* DismissCallbackAVPlayerViewController.swift */, + FD981BD42DC978AC00564172 /* MentionUtilities+DisplayName.swift */, 45C0DC1A1E68FE9000E04C47 /* UIApplication+OWS.swift */, 45C0DC1D1E69011F00E04C47 /* UIStoryboard+OWS.swift */, 45B5360D206DD8BB00D61655 /* UIResponder+OWS.swift */, @@ -2578,8 +2666,9 @@ C3D0972A2510499C00F6E3E4 /* BackgroundPoller.swift */, C31A6C5B247F2CF3001123EF /* CGRect+Utilities.swift */, C35E8AAD2485E51D00ACB629 /* IP2Country.swift */, - B84664F4235022F30083A1CD /* MentionUtilities.swift */, B886B4A82398BA1500211ABE /* QRCode.swift */, + FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */, + FDB3DA832E1CA21C00148F8D /* UIActivityViewController+Utilities.swift */, B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */, B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */, FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */, @@ -2714,6 +2803,7 @@ 942256902C23F8DD00C0FDBF /* SessionSearchBar.swift */, 942256912C23F8DD00C0FDBF /* SessionTextField.swift */, 942256922C23F8DD00C0FDBF /* Toast.swift */, + FDE5219B2E08E76600061B8E /* SessionImageView_SwiftUI.swift */, ); path = SwiftUI; sourceTree = ""; @@ -2966,10 +3056,10 @@ C300A5C72554B03900555489 /* Control Messages */, C3C2A74325539EB700C340D1 /* Message.swift */, C352A30825574D8400338F3E /* Message+Destination.swift */, - FDE519F82AB802BB00450C53 /* Message+Origin.swift */, - FDF71EA22B072C2800A8D6B5 /* LibSessionMessage.swift */, 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */, - FD72BD992BDF5EEA00CF6CF6 /* Message+Origin.swift */, + FDB11A5E2DD5B77800BEF49F /* Message+Origin.swift */, + FDF71EA22B072C2800A8D6B5 /* LibSessionMessage.swift */, + FDB11A5C2DD300CF00BEF49F /* SNProtoContent+Utilities.swift */, ); path = Messages; sourceTree = ""; @@ -3015,6 +3105,7 @@ C32C5B1B256DC160003C73A2 /* Quotes */, C32C5995256DAF85003C73A2 /* Typing Indicators */, FD7728A1284F0DF50018502F /* Message Handling */, + FD78E9F32DDABA4200D55B50 /* AttachmentUploader.swift */, B8D0A25825E367AC00C1835E /* Notification+MessageReceiver.swift */, C300A5F12554B09800555489 /* MessageSender.swift */, FDF0B75B2807F41D004C14C5 /* MessageSender+Convenience.swift */, @@ -3155,6 +3246,7 @@ FD848B9728422F1A000E298B /* Date+Utilities.swift */, FD8A5B282DC060DD004C689B /* Double+Utilities.swift */, 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */, + B84664F4235022F30083A1CD /* MentionUtilities.swift */, FD8A5B242DC05B16004C689B /* Number+Utilities.swift */, FD8A5B1D2DBF4BB8004C689B /* ScreenLock+Errors.swift */, 7BA1E0E72A8087DB00123D0D /* SwiftUI+Utilities.swift */, @@ -3405,7 +3497,7 @@ children = ( FDC13D4E2A16EE41007267C7 /* Types */, FDC4382D27B383A600C60D73 /* Models */, - FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */, + FDF0B7502807BA56004C14C5 /* NotificationsManagerType.swift */, C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */, ); path = Notifications; @@ -3474,14 +3566,20 @@ isa = PBXGroup; children = ( FD428B1E2B4B758B006D0888 /* AppReadiness.swift */, + FDE5218D2E03A06700061B8E /* AttachmentManager.swift */, + FDE5219D2E0D0B9800061B8E /* AsyncAccessible.swift */, FD47E0B02AA6A05800A55E41 /* Authentication+SessionMessagingKit.swift */, FD859EF127BF6BA200510D0C /* Data+Utilities.swift */, + FDE521A32E0D283E00061B8E /* Dependencies+Observation.swift */, C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */, FD4C4E9B2B02E2A300C72199 /* DisplayPictureError.swift */, FD2273072C353109004D8A6C /* DisplayPictureManager.swift */, + FD981BC52DC3310800564172 /* ExtensionHelper.swift */, C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */, FDB11A622DD5BDDD00BEF49F /* ImageDataManager+Singleton.swift */, C3A71D0A2558989C0043A11F /* MessageWrapper.swift */, + FDE521A12E0D23A200061B8E /* ObservableKey+SessionMessagingKit.swift */, + FD1A55422E179AE6003761E4 /* ObservableKeyEvent+Utilities.swift */, C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */, C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */, C38EF281255B6D84007E1867 /* OWSAudioSession.swift */, @@ -3550,8 +3648,9 @@ FDF01FAE2A9ED0C800CAF969 /* Dependency Injection */, B8A582B0258C66C900AFD84C /* General */, FD9004102818ABB000ABAAF6 /* JobRunner */, - B8A582AF258C665E00AFD84C /* Media */, FD7F74582BAAA349006DDFD8 /* LibSession */, + B8A582AF258C665E00AFD84C /* Media */, + FD52CB5D2E13B5D600A4DA70 /* Observations */, FD2272D22C34ECBB004D8A6C /* Types */, FD09796527F6B0A800936362 /* Utilities */, FD37E9FE28A5F2CD003AE748 /* Configuration.swift */, @@ -3642,7 +3741,6 @@ C33FDA9E255A57FF00E217F9 /* ReverseDispatchQueue.swift */, C38EF241255B6D67007E1867 /* Collection+OWS.swift */, C38EF3AE255B6DE5007E1867 /* OrderedDictionary.swift */, - C38EF226255B6D5D007E1867 /* ShareViewDelegate.swift */, FDB3487D2BE856C800B716C2 /* UIBezierPath+Utilities.swift */, ); path = Utilities; @@ -3651,7 +3749,6 @@ C3D9E3B52567685D0040E4F3 /* Attachments */ = { isa = PBXGroup; children = ( - C33FDAF1255A580500E217F9 /* ThumbnailService.swift */, C38EF224255B6D5D007E1867 /* SignalAttachment.swift */, ); path = Attachments; @@ -3786,13 +3883,16 @@ FD09796527F6B0A800936362 /* Utilities */ = { isa = PBXGroup; children = ( + FDE5218F2E04CCE600061B8E /* AVURLAsset+Utilities.swift */, 94C58AC82D2E036E00609195 /* Permissions.swift */, FD97B23F2A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift */, + FD78EA052DDEC8F100D55B50 /* AsyncSequence+Utilities.swift */, FD7443452D07CA9F00862443 /* CGFloat+Utilities.swift */, FD7443462D07CA9F00862443 /* CGPoint+Utilities.swift */, FD7443472D07CA9F00862443 /* CGRect+Utilities.swift */, FD7443482D07CA9F00862443 /* CGSize+Utilities.swift */, FD7443492D07CA9F00862443 /* Codable+Utilities.swift */, + FD1A55402E161AF3003761E4 /* Combine+Utilities.swift */, FDC1BD692CFE7B67002CDC71 /* DirectoryArchiver.swift */, FDE33BBB2D5C124300E56F42 /* DispatchTimeInterval+Utilities.swift */, FD559DF42A7368CB00C7C62A /* DispatchQueue+Utilities.swift */, @@ -3805,6 +3905,7 @@ FD09797127FAA2F500936362 /* Optional+Utilities.swift */, FDE519F62AB7CDC700450C53 /* Result+Utilities.swift */, FD00CDCA2D5317A3006B96D3 /* Scheduler+Utilities.swift */, + FDB11A532DCD7A7B00BEF49F /* Task+Utilities.swift */, FDC0F0032BFECE12002CBFB9 /* TimeUnit.swift */, FDE755132C9BC169002A2623 /* UIAlertAction+Utilities.swift */, FDE755152C9BC169002A2623 /* UIApplicationState+Utilities.swift */, @@ -3834,7 +3935,7 @@ FD09799A27FFC82D00936362 /* Quote.swift */, FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */, FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */, - FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */, + FD981BC32DC304E100564172 /* MessageDeduplication.swift */, FD5C7308285007920029977D /* BlindedIdLookup.swift */, FD09B7E6288670FD00ED0B66 /* Reaction.swift */, FD8ECF7E2934298100C0D1BB /* ConfigDump.swift */, @@ -3871,6 +3972,9 @@ FDE33BBD2D5C3AE800E56F42 /* _023_GroupsExpiredFlag.swift */, FD860CBB2D6E7A9400BBE29C /* _024_FixBustedInteractionVariant.swift */, FD8A5B312DC191AB004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift */, + FD70F25B2DC1F176003729B7 /* _026_MessageDeduplicationTable.swift */, + FD78E9F72DDD742100D55B50 /* _027_MoveSettingsToLibSession.swift */, + FD05594D2E012D1A00DC48CE /* _028_RenameAttachments.swift */, ); path = Migrations; sourceTree = ""; @@ -3916,6 +4020,7 @@ FD17D7B927F51F2100122BE0 /* TargetMigrations.swift */, FD7162DA281B6C440060647B /* TypedTableAlias.swift */, FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */, + FD1A553D2E14BE0E003761E4 /* PagedData.swift */, ); path = Types; sourceTree = ""; @@ -3923,7 +4028,6 @@ FD17D7BB27F51F5C00122BE0 /* Utilities */ = { isa = PBXGroup; children = ( - FD17D7C227F5204C00122BE0 /* Database+Utilities.swift */, FD17D7C627F5207C00122BE0 /* DatabaseMigrator+Utilities.swift */, FDF22210281B5E0B000A4995 /* TableRecord+Utilities.swift */, FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */, @@ -3955,7 +4059,6 @@ FD17D7E427F6A09900122BE0 /* Identity.swift */, FDF0B73F280402C4004C14C5 /* Job.swift */, FDE754D12C9BAF53002A2623 /* JobDependencies.swift */, - FD17D7CC27F546FF00122BE0 /* Setting.swift */, ); path = Models; sourceTree = ""; @@ -4004,9 +4107,10 @@ FDE755042C9BB4ED002A2623 /* Bencode.swift */, FDE755032C9BB4ED002A2623 /* BencodeDecoder.swift */, FD2272D32C34ECE1004D8A6C /* BencodeEncoder.swift */, + FDB11A5A2DD1900B00BEF49F /* CurrentValueAsyncStream.swift */, FD3FAB682AF1ADCA00DC5421 /* FileManager.swift */, FD6A38F02C2A66B100762359 /* KeychainStorage.swift */, - FD8FD7632C37C24A001E38C7 /* EquatableIgnoring.swift */, + FD78EA032DDEC3C000D55B50 /* MultiTaskManager.swift */, FD2272E92C351CA7004D8A6C /* Threading.swift */, FDAA16752AC28A3B00DDBF77 /* UserDefaultsType.swift */, ); @@ -4025,7 +4129,6 @@ isa = PBXGroup; children = ( FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */, - FD1D73292A85AA2000E3F410 /* Setting+Utilities.swift */, ); path = Database; sourceTree = ""; @@ -4081,6 +4184,7 @@ FD37E9F428A5F0FB003AE748 /* Database */ = { isa = PBXGroup; children = ( + FD78EA082DDFE45000D55B50 /* Convenience */, FD37E9F728A5F143003AE748 /* Migrations */, ); path = Database; @@ -4131,6 +4235,34 @@ path = "Shared Models"; sourceTree = ""; }; + FD3F2EE52DE6CC3500FD6849 /* Notifications */ = { + isa = PBXGroup; + children = ( + FD3F2EE62DE6CC3B00FD6849 /* NotificationsManagerSpec.swift */, + ); + path = Notifications; + sourceTree = ""; + }; + FD52CB582E12166500A4DA70 /* Onboarding */ = { + isa = PBXGroup; + children = ( + FD52CB592E12166D00A4DA70 /* OnboardingSpec.swift */, + ); + path = Onboarding; + sourceTree = ""; + }; + FD52CB5D2E13B5D600A4DA70 /* Observations */ = { + isa = PBXGroup; + children = ( + FD78EA012DDEBC2C00D55B50 /* DebounceTaskManager.swift */, + FD52CB642E13B6E600A4DA70 /* ObservationBuilder.swift */, + FD52CB622E13B61700A4DA70 /* ObservableKey.swift */, + FDE5219F2E0D22FD00061B8E /* ObservationManager.swift */, + FDB3DA852E1E1F0B00148F8D /* TaskCancellation.swift */, + ); + path = Observations; + sourceTree = ""; + }; FD61FCF62D308CAE005752DE /* Database */ = { isa = PBXGroup; children = ( @@ -4143,6 +4275,7 @@ isa = PBXGroup; children = ( FD61FCF82D308CC5005752DE /* GroupMemberSpec.swift */, + FD981BCA2DC4A21800564172 /* MessageDeduplicationSpec.swift */, ); path = Models; sourceTree = ""; @@ -4174,6 +4307,7 @@ FDC0F00B2C04100E002CBFB9 /* Session.xctestplan */, FD71161228D00D5300B47552 /* Conversations */, FD19363D2ACA66CF004BCF0F /* Database */, + FD52CB582E12166500A4DA70 /* Onboarding */, FD71161828D00E0100B47552 /* Settings */, ); path = SessionTests; @@ -4302,9 +4436,27 @@ path = "Message Handling"; sourceTree = ""; }; + FD78E9FB2DDD97EC00D55B50 /* Types */ = { + isa = PBXGroup; + children = ( + FD0559542E026CC900DC48CE /* ObservingDatabase.swift */, + FD78E9FC2DDD97F000D55B50 /* Setting.swift */, + ); + path = Types; + sourceTree = ""; + }; + FD78EA082DDFE45000D55B50 /* Convenience */ = { + isa = PBXGroup; + children = ( + FD78EA092DDFE45900D55B50 /* Interaction+UI.swift */, + ); + path = Convenience; + sourceTree = ""; + }; FD7F74582BAAA349006DDFD8 /* LibSession */ = { isa = PBXGroup; children = ( + FD78E9FB2DDD97EC00D55B50 /* Types */, FD7F74592BAAA352006DDFD8 /* Utilities */, FD7F745A2BAAA35E006DDFD8 /* LibSession.swift */, FDE755212C9BC1BA002A2623 /* LibSessionError.swift */, @@ -4346,6 +4498,7 @@ children = ( FD0B77B129B82B7A009169BA /* ArrayUtilitiesSpec.swift */, FD23CE252A676B5B0000B97C /* DependenciesSpec.swift */, + FD8A5B2F2DC18D5E004C689B /* GeneralCacheSpec.swift */, FD83B9BA27CF20AF005E1583 /* SessionIdSpec.swift */, ); path = General; @@ -4363,6 +4516,7 @@ FD83B9D127D59495005E1583 /* MockUserDefaults.swift */, FD49E2452B05C1D500FFBBB5 /* MockKeychain.swift */, FD0150372CA24328005B08A1 /* MockJobRunner.swift */, + FDB11A552DD17C3000BEF49F /* MockLogger.swift */, FD83B9BD27CF2243005E1583 /* TestConstants.swift */, FD6531892AA025C500DFEEAA /* TestDependencies.swift */, FD9DD2702A72516D00ECB68E /* TestExtensions.swift */, @@ -4451,6 +4605,7 @@ FD2272F92C352D8E004D8A6C /* LibSession+GroupInfo.swift */, FD2272F72C352D8D004D8A6C /* LibSession+GroupKeys.swift */, FD2272F52C352D8D004D8A6C /* LibSession+GroupMembers.swift */, + FD78EA0C2DDFEDDF00D55B50 /* LibSession+Local.swift */, FD2272F82C352D8E004D8A6C /* LibSession+Shared.swift */, FD2272F12C352D8D004D8A6C /* LibSession+SharedGroup.swift */, FD2272F22C352D8D004D8A6C /* LibSession+UserGroups.swift */, @@ -4488,6 +4643,14 @@ path = Jobs; sourceTree = ""; }; + FD981BC72DC4640100564172 /* Utilities */ = { + isa = PBXGroup; + children = ( + FD981BC82DC4640D00564172 /* ExtensionHelperSpec.swift */, + ); + path = Utilities; + sourceTree = ""; + }; FDAA16792AC28E2200DDBF77 /* Models */ = { isa = PBXGroup; children = ( @@ -4526,6 +4689,10 @@ FDC13D4E2A16EE41007267C7 /* Types */ = { isa = PBXGroup; children = ( + FD05593C2DFA3A2200DC48CE /* VoipPayloadKey.swift */, + FD981BD62DC9A61600564172 /* NotificationCategory.swift */, + FDB11A4B2DCC527900BEF49F /* NotificationContent.swift */, + FD981BD82DC9A69000564172 /* NotificationUserInfoKey.swift */, FDC13D482A16EC20007267C7 /* Service.swift */, FD3765F52ADE5BA500DC1489 /* ServiceInfo.swift */, FDD82C3E2A205D0A00425F05 /* ProcessResult.swift */, @@ -4539,6 +4706,10 @@ isa = PBXGroup; children = ( FDC1BD652CFD6C4E002CDC71 /* Config.swift */, + FD78E9F52DDD43AB00D55B50 /* Mutation.swift */, + FDB11A512DCC6AFF00BEF49F /* OpenGroupUrlInfo.swift */, + FDB11A4F2DCC6ADD00BEF49F /* ThreadUpdateInfo.swift */, + FD78E9FE2DDDA1AF00D55B50 /* ValueFetcher.swift */, ); path = Types; sourceTree = ""; @@ -4629,6 +4800,7 @@ FDE754A72C9B964D002A2623 /* Sending & Receiving */, FD7692F52A53A2C7000E4B70 /* Shared Models */, FD8ECF802934385900C0D1BB /* LibSession */, + FD981BC72DC4640100564172 /* Utilities */, ); path = SessionMessagingKitTests; sourceTree = ""; @@ -4653,6 +4825,7 @@ FD336F6E2CAA37CB00C0B51B /* MockCommunityPoller.swift */, FD336F582CAA28CF00C0B51B /* MockCommunityPollerCache.swift */, FD336F592CAA28CF00C0B51B /* MockDisplayPictureCache.swift */, + FD981BCC2DC81ABB00564172 /* MockExtensionHelper.swift */, FD336F5A2CAA28CF00C0B51B /* MockGroupPollerCache.swift */, FD78E9F12DDA9E9B00D55B50 /* MockImageDataManager.swift */, FD336F5B2CAA28CF00C0B51B /* MockLibSessionCache.swift */, @@ -4667,9 +4840,7 @@ FDC498B52AC15F6D00EDD897 /* Types */ = { isa = PBXGroup; children = ( - FDC498B62AC15F7D00EDD897 /* AppNotificationCategory.swift */, FDC498B82AC15FE300EDD897 /* AppNotificationAction.swift */, - FDC498BA2AC1606C00EDD897 /* AppNotificationUserInfoKey.swift */, ); path = Types; sourceTree = ""; @@ -4707,6 +4878,7 @@ FDE754A72C9B964D002A2623 /* Sending & Receiving */ = { isa = PBXGroup; children = ( + FD3F2EE52DE6CC3500FD6849 /* Notifications */, FD336F6A2CAA29BC00C0B51B /* Pollers */, FDE754A42C9B964D002A2623 /* MessageReceiverGroupsSpec.swift */, FDE754A52C9B964D002A2623 /* MessageSenderGroupsSpec.swift */, @@ -5025,7 +5197,6 @@ isa = PBXNativeTarget; buildConfigurationList = C3C2A5AA255385C100C340D1 /* Build configuration list for PBXNativeTarget "SessionSnodeKit" */; buildPhases = ( - FDC605702C71683E009B3D45 /* Build LibSession if Needed */, C3C2A59A255385C100C340D1 /* Headers */, C3C2A59B255385C100C340D1 /* Sources */, C3C2A59C255385C100C340D1 /* Frameworks */, @@ -5077,7 +5248,6 @@ isa = PBXNativeTarget; buildConfigurationList = C3C2A6F925539DE700C340D1 /* Build configuration list for PBXNativeTarget "SessionMessagingKit" */; buildPhases = ( - FDC605712C716852009B3D45 /* Build LibSession if Needed */, C3C2A6EB25539DE700C340D1 /* Headers */, C3C2A6EC25539DE700C340D1 /* Sources */, C3C2A6ED25539DE700C340D1 /* Frameworks */, @@ -5463,10 +5633,8 @@ 45B74A832044AAB600CD42F8 /* circles.aifc in Resources */, 45B74A892044AAB600CD42F8 /* circles-quiet.aifc in Resources */, C34C8F7423A7830B00D82669 /* SpaceMono-Bold.ttf in Resources */, - 4503F1BF20470A5B00CEE724 /* classic.aifc in Resources */, FDEF57712C44D2D300131302 /* GeoLite2-Country-Blocks-IPv4 in Resources */, B8D07405265C683300F77E07 /* ElegantIcons.ttf in Resources */, - 4503F1BE20470A5B00CEE724 /* classic-quiet.aifc in Resources */, 45B74A7E2044AAB600CD42F8 /* complete.aifc in Resources */, 9473386E2BDF5F3E00B9E169 /* InfoPlist.xcstrings in Resources */, B8CCF6352396005F0091D419 /* SpaceMono-Regular.ttf in Resources */, @@ -5722,52 +5890,13 @@ outputFileListPaths = ( ); outputPaths = ( + "$(TARGET_BUILD_DIR)/LibSessionUtil_BuildCache/libsession_util_built.timestamp", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${SRCROOT}/Scripts/build_libSession_util.sh\" ${COMPILE_LIB_SESSION}\n"; showEnvVarsInLog = 0; }; - FDC605702C71683E009B3D45 /* Build LibSession if Needed */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Build LibSession if Needed"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${SRCROOT}/Scripts/build_libSession_util.sh\"\n"; - showEnvVarsInLog = 0; - }; - FDC605712C716852009B3D45 /* Build LibSession if Needed */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Build LibSession if Needed"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${SRCROOT}/Scripts/build_libSession_util.sh\"\n"; - showEnvVarsInLog = 0; - }; FDD82C422A2085B900425F05 /* Add Commit Hash To Build Info Plist */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -5822,6 +5951,7 @@ C3ADC66126426688005F1414 /* ShareNavController.swift in Sources */, 7BAF54D427ACCF01003D12F8 /* SAEScreenLockViewController.swift in Sources */, B817AD9C26436F73009DF825 /* ThreadPickerVC.swift in Sources */, + FD78EA0A2DDFE45E00D55B50 /* Interaction+UI.swift in Sources */, FD2272DE2C34F11F004D8A6C /* _001_ThemePreferences.swift in Sources */, 7BAF54D327ACCF01003D12F8 /* ShareAppExtensionContext.swift in Sources */, ); @@ -5852,8 +5982,10 @@ FD8A5B102DBF2F17004C689B /* NavBarSessionIcon.swift in Sources */, 942256972C23F8DD00C0FDBF /* SessionSearchBar.swift in Sources */, FD71165928E436E800B47552 /* ConfirmationModal.swift in Sources */, + FD981BD32DC9770E00564172 /* MentionUtilities.swift in Sources */, 7BBBDC44286EAD2D00747E59 /* TappableLabel.swift in Sources */, FD8A5B252DC05B16004C689B /* Number+Utilities.swift in Sources */, + FDE5219C2E08E76C00061B8E /* SessionImageView_SwiftUI.swift in Sources */, FD09B7E328865FDA00ED0B66 /* HighlightMentionBackgroundView.swift in Sources */, FD3FAB632AEB9A1500DC5421 /* ToastController.swift in Sources */, FD2272E42C35134B004D8A6C /* Data+Utilities.swift in Sources */, @@ -5949,7 +6081,6 @@ C38EF3BC255B6DE7007E1867 /* ImageEditorPanGestureRecognizer.swift in Sources */, C38EF372255B6DCC007E1867 /* MediaMessageView.swift in Sources */, C38EF387255B6DD2007E1867 /* AttachmentItemCollection.swift in Sources */, - C38EF22B255B6D5D007E1867 /* ShareViewDelegate.swift in Sources */, C38EF3BF255B6DE7007E1867 /* ImageEditorView.swift in Sources */, C38EF3FA255B6DF7007E1867 /* DirectionalPanGestureRecognizer.swift in Sources */, C38EF3BB255B6DE7007E1867 /* ImageEditorStrokeItem.swift in Sources */, @@ -6085,6 +6216,7 @@ FD2272F02C352200004D8A6C /* General.swift in Sources */, FD2272EC2C352155004D8A6C /* Feature.swift in Sources */, FD2272EE2C3521D6004D8A6C /* FeatureConfig.swift in Sources */, + FDE521A02E0D230000061B8E /* ObservationManager.swift in Sources */, FDE754DF2C9BAF8A002A2623 /* KeyPair.swift in Sources */, FD2272D12C34EBD6004D8A6C /* JSONDecoder+Utilities.swift in Sources */, FD6A38F12C2A66B100762359 /* KeychainStorage.swift in Sources */, @@ -6104,22 +6236,28 @@ FD3765EA2ADE37B400DC1489 /* Authentication.swift in Sources */, FD17D7CA27F546D900122BE0 /* _001_InitialSetupMigration.swift in Sources */, FDE755202C9BC1A6002A2623 /* CacheConfig.swift in Sources */, + FD1A553E2E14BE11003761E4 /* PagedData.swift in Sources */, FD4BB22C2D63FA8600D0DC3D /* (null) in Sources */, FDE755192C9BC169002A2623 /* UIImage+Utilities.swift in Sources */, C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */, FD97B2402A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift in Sources */, FD5931AB2A8DCB0A0040147D /* ScopeAdapter+Utilities.swift in Sources */, + FD78E9FD2DDD97F200D55B50 /* Setting.swift in Sources */, FD4BB22B2D63F20700D0DC3D /* MigrationHelper.swift in Sources */, + FD1A55412E161AF6003761E4 /* Combine+Utilities.swift in Sources */, FD99D0872D0FA731005D2E15 /* ThreadSafe.swift in Sources */, + FD78EA022DDEBC3200D55B50 /* DebounceTaskManager.swift in Sources */, FD37E9FF28A5F2CD003AE748 /* Configuration.swift in Sources */, FDB7400B28EB99A70094D718 /* TimeInterval+Utilities.swift in Sources */, FD09797D27FBDB2000936362 /* Notification+Utilities.swift in Sources */, FDC6D7602862B3F600B04575 /* Dependencies.swift in Sources */, + FDE521902E04CCEB00061B8E /* AVURLAsset+Utilities.swift in Sources */, FD6673FF2D77F9C100041530 /* ScreenLock.swift in Sources */, FD78E9F02DD6D61200D55B50 /* Data+Image.swift in Sources */, FD17D7C727F5207C00122BE0 /* DatabaseMigrator+Utilities.swift in Sources */, FD848B9328420164000E298B /* UnicodeScalar+Utilities.swift in Sources */, FDE754CE2C9BAF37002A2623 /* ImageFormat.swift in Sources */, + FDB11A542DCD7A7F00BEF49F /* Task+Utilities.swift in Sources */, FDE7551A2C9BC169002A2623 /* UIApplicationState+Utilities.swift in Sources */, 94C58AC92D2E037200609195 /* Permissions.swift in Sources */, FD09796B27F6C67500936362 /* Failable.swift in Sources */, @@ -6127,13 +6265,17 @@ FD7115FA28C8153400B47552 /* UIBarButtonItem+Combine.swift in Sources */, FD705A92278D051200F16121 /* ReusableView.swift in Sources */, FD17D7BA27F51F2100122BE0 /* TargetMigrations.swift in Sources */, - FD17D7C327F5204C00122BE0 /* Database+Utilities.swift in Sources */, FDE754DD2C9BAF8A002A2623 /* Mnemonic.swift in Sources */, + FD52CB652E13B6E900A4DA70 /* ObservationBuilder.swift in Sources */, FDBEE52E2B6A18B900C143A0 /* UserDefaultsConfig.swift in Sources */, 947D7FE32D5181F400E8E413 /* _006_RenameTableSettingToKeyValueStore.swift in Sources */, + FD78EA042DDEC3C500D55B50 /* MultiTaskManager.swift in Sources */, + FD78EA062DDEC8F600D55B50 /* AsyncSequence+Utilities.swift in Sources */, FDC438CD27BC641200C60D73 /* Set+Utilities.swift in Sources */, B8856DE6256F15F2001CE70E /* String+SSK.swift in Sources */, FDE754E02C9BAF8A002A2623 /* Hex.swift in Sources */, + FDB11A5B2DD1901000BEF49F /* CurrentValueAsyncStream.swift in Sources */, + FDB3DA862E1E1F0E00148F8D /* TaskCancellation.swift in Sources */, FDE754CC2C9BAF37002A2623 /* MediaUtils.swift in Sources */, FDE754DE2C9BAF8A002A2623 /* Crypto+SessionUtilitiesKit.swift in Sources */, FDF2220F281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift in Sources */, @@ -6153,7 +6295,6 @@ FD5D201E27B0D87C00FEA984 /* SessionId.swift in Sources */, FDC1BD6A2CFE7B6B002CDC71 /* DirectoryArchiver.swift in Sources */, FDF22211281B5E0B000A4995 /* TableRecord+Utilities.swift in Sources */, - FD8FD7642C37C24A001E38C7 /* EquatableIgnoring.swift in Sources */, FD7728962849E7E90018502F /* String+Utilities.swift in Sources */, C35D0DB525AE5F1200B6BF49 /* UIEdgeInsets.swift in Sources */, FDF222092818D2B0000A4995 /* NSAttributedString+Utilities.swift in Sources */, @@ -6161,7 +6302,6 @@ FD29598D2A43BC0B00888A17 /* Version.swift in Sources */, FD71160028C8253500B47552 /* UIView+Combine.swift in Sources */, B8856D23256F116B001CE70E /* Weak.swift in Sources */, - FD17D7CD27F546FF00122BE0 /* Setting.swift in Sources */, FDE754D42C9BAF6B002A2623 /* UICollectionView+ReusableView.swift in Sources */, FDE754C02C9BAEF6002A2623 /* Array+Utilities.swift in Sources */, FD17D7E527F6A09900122BE0 /* Identity.swift in Sources */, @@ -6170,7 +6310,9 @@ FDDF074429C3E3D000E5E8B5 /* FetchRequest+Utilities.swift in Sources */, FD7F745B2BAAA35E006DDFD8 /* LibSession.swift in Sources */, FD74434A2D07CA9F00862443 /* Codable+Utilities.swift in Sources */, + FD0559562E026E1B00DC48CE /* ObservingDatabase.swift in Sources */, FD74434B2D07CA9F00862443 /* CGFloat+Utilities.swift in Sources */, + FD52CB632E13B61700A4DA70 /* ObservableKey.swift in Sources */, FD74434C2D07CA9F00862443 /* CGSize+Utilities.swift in Sources */, FD74434D2D07CA9F00862443 /* CGPoint+Utilities.swift in Sources */, FD74434E2D07CA9F00862443 /* CGRect+Utilities.swift in Sources */, @@ -6197,9 +6339,11 @@ 7B81682828B310D50069F315 /* _007_HomeQueryOptimisationIndexes.swift in Sources */, FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */, FD2273002C352D8E004D8A6C /* LibSession+GroupKeys.swift in Sources */, + FD70F25C2DC1F184003729B7 /* _026_MessageDeduplicationTable.swift in Sources */, FD2272FB2C352D8E004D8A6C /* LibSession+UserGroups.swift in Sources */, B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */, FD22726F2C32911C004D8A6C /* ProcessPendingGroupMemberRemovalsJob.swift in Sources */, + FD981BD72DC9A61A00564172 /* NotificationCategory.swift in Sources */, C300A5D32554B05A00555489 /* TypingIndicator.swift in Sources */, FDF71EA52B07363500A8D6B5 /* MessageReceiver+LibSession.swift in Sources */, FDC13D582A17207D007267C7 /* UnsubscribeResponse.swift in Sources */, @@ -6207,12 +6351,15 @@ FD245C5F2850662200B966DD /* OWSWindowManager.m in Sources */, FDF40CDE2897A1BC006A0CC4 /* _004_RemoveLegacyYDB.swift in Sources */, FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */, + FD78E9F42DDABA4F00D55B50 /* AttachmentUploader.swift in Sources */, FDE754A32C9A8FD1002A2623 /* SwarmPoller.swift in Sources */, 7B81682C28B72F480069F315 /* PendingChange.swift in Sources */, FD5C7309285007920029977D /* BlindedIdLookup.swift in Sources */, FD71161C28D194FB00B47552 /* MentionInfo.swift in Sources */, 7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */, C300A5F22554B09800555489 /* MessageSender.swift in Sources */, + FDB11A4C2DCC527D00BEF49F /* NotificationContent.swift in Sources */, + FDE5218E2E03A06B00061B8E /* AttachmentManager.swift in Sources */, FD47E0B12AA6A05800A55E41 /* Authentication+SessionMessagingKit.swift in Sources */, FD2272832C337830004D8A6C /* GroupPoller.swift in Sources */, FD22726C2C32911C004D8A6C /* GroupLeavingJob.swift in Sources */, @@ -6224,15 +6371,18 @@ FD09B7E7288670FD00ED0B66 /* Reaction.swift in Sources */, FD245C5A2850660100B966DD /* LinkPreviewDraft.swift in Sources */, FDD82C3F2A205D0A00425F05 /* ProcessResult.swift in Sources */, + FDE5219E2E0D0B9B00061B8E /* AsyncAccessible.swift in Sources */, FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */, FD22726D2C32911C004D8A6C /* CheckForAppUpdatesJob.swift in Sources */, 7B81682A28B6F1420069F315 /* ReactionResponse.swift in Sources */, FD2273082C353109004D8A6C /* DisplayPictureManager.swift in Sources */, + FDE521A22E0D23AB00061B8E /* ObservableKey+SessionMessagingKit.swift in Sources */, FD2273022C352D8E004D8A6C /* LibSession+GroupInfo.swift in Sources */, FDDD554E2C1FCB77006CBF03 /* _019_ScheduleAppUpdateCheckJob.swift in Sources */, FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */, FD2272FC2C352D8E004D8A6C /* LibSession+Contacts.swift in Sources */, FD848B9628422A2A000E298B /* MessageViewModel.swift in Sources */, + FD05593D2DFA3A2800DC48CE /* VoipPayloadKey.swift in Sources */, FD2272782C32911C004D8A6C /* AttachmentDownloadJob.swift in Sources */, FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */, FD428B232B4B9969006D0888 /* _017_RebuildFTSIfNeeded_2_4_5.swift in Sources */, @@ -6266,13 +6416,14 @@ FDC13D542A16FF29007267C7 /* LegacyGroupRequest.swift in Sources */, B8B320B7258C30D70020074B /* HTMLMetadata.swift in Sources */, FD09798727FD1B7800936362 /* GroupMember.swift in Sources */, + FD78EA0D2DDFEDE200D55B50 /* LibSession+Local.swift in Sources */, FDCD2E032A41294E00964D6A /* LegacyGroupOnlyRequest.swift in Sources */, FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */, FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */, FDE754F02C9BB08B002A2623 /* Crypto+Attachments.swift in Sources */, FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, FDE754A12C9A60A6002A2623 /* Crypto+OpenGroupAPI.swift in Sources */, - FDF0B7512807BA56004C14C5 /* NotificationsProtocol.swift in Sources */, + FDF0B7512807BA56004C14C5 /* NotificationsManagerType.swift in Sources */, FD2272722C32911C004D8A6C /* FailedAttachmentDownloadsJob.swift in Sources */, FDC13D5A2A1721C5007267C7 /* LegacyNotifyRequest.swift in Sources */, FD245C59285065FC00B966DD /* ControlMessage.swift in Sources */, @@ -6284,8 +6435,8 @@ FD5C7307284F103B0029977D /* MessageReceiver+MessageRequests.swift in Sources */, C3A71D0B2558989C0043A11F /* MessageWrapper.swift in Sources */, FD3FAB592ADF906300DC5421 /* Profile+CurrentUser.swift in Sources */, - FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */, FDEF573E2C40F2A100131302 /* GroupUpdateMemberLeftNotificationMessage.swift in Sources */, + FDB11A5D2DD300D300BEF49F /* SNProtoContent+Utilities.swift in Sources */, FDE755002C9BB0FA002A2623 /* SessionEnvironment.swift in Sources */, FDB5DADC2A95D840002C8721 /* GroupUpdateMemberChangeMessage.swift in Sources */, FD2272812C32911C004D8A6C /* UpdateProfilePictureJob.swift in Sources */, @@ -6296,12 +6447,14 @@ FD245C662850665900B966DD /* OpenGroupAPI.swift in Sources */, FD245C5B2850660500B966DD /* ReadReceipt.swift in Sources */, FD428B1F2B4B758B006D0888 /* AppReadiness.swift in Sources */, + FD78EA002DDDA21100D55B50 /* ValueFetcher.swift in Sources */, FD22726B2C32911C004D8A6C /* SendReadReceiptsJob.swift in Sources */, B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */, C3A71D1E25589AC30043A11F /* WebSocketProto.swift in Sources */, C3C2A7852553AAF300C340D1 /* SessionProtos.pb.swift in Sources */, FDF0B7422804EA4F004C14C5 /* _002_SetupStandardJobs.swift in Sources */, B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */, + FDE521A52E0D288A00061B8E /* Dependencies+Observation.swift in Sources */, FDF8487F29405994007DCAE5 /* HTTPHeader+OpenGroup.swift in Sources */, FD8ECF7D2934293A00C0D1BB /* _013_SessionUtilChanges.swift in Sources */, FD17D7A227F40F0500122BE0 /* _001_InitialSetupMigration.swift in Sources */, @@ -6328,12 +6481,14 @@ FDC13D502A16EE50007267C7 /* PushNotificationAPIEndpoint.swift in Sources */, FD432434299C6985008A0213 /* PendingReadReceipt.swift in Sources */, FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */, + FD78E9F62DDD43AD00D55B50 /* Mutation.swift in Sources */, + FDB11A5F2DD5B77800BEF49F /* Message+Origin.swift in Sources */, FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */, FDC4387827B5C35400C60D73 /* SendMessageRequest.swift in Sources */, - FDC383392A93411100FFD6A2 /* Setting+Utilities.swift in Sources */, FDB5DAE62A95D8B0002C8721 /* GroupUpdateDeleteMemberContentMessage.swift in Sources */, 7B5233C6290636D700F8F375 /* _018_DisappearingMessagesConfiguration.swift in Sources */, FD5C72FD284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift in Sources */, + FDB11A502DCC6ADE00BEF49F /* ThreadUpdateInfo.swift in Sources */, B8D0A25925E367AC00C1835E /* Notification+MessageReceiver.swift in Sources */, FDC1BD662CFD6C4F002CDC71 /* Config.swift in Sources */, FD245C53285065DB00B966DD /* ProximityMonitoringManager.swift in Sources */, @@ -6351,6 +6506,7 @@ FDC4382027B36ADC00C60D73 /* SOGSEndpoint.swift in Sources */, FDC438C927BB706500C60D73 /* SendDirectMessageRequest.swift in Sources */, C3A71D1F25589AC30043A11F /* WebSocketResources.pb.swift in Sources */, + FD981BC42DC304E600564172 /* MessageDeduplication.swift in Sources */, FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */, FD09796E27FA6D0000936362 /* Contact.swift in Sources */, FD02CC142C3677E6009AB976 /* Request+OpenGroupAPI.swift in Sources */, @@ -6359,12 +6515,13 @@ FDBA8A842D597975007C19C0 /* FailedGroupInvitesAndPromotionsJob.swift in Sources */, FD778B6429B189FF001BAC6B /* _014_GenerateInitialUserConfigDumps.swift in Sources */, FDC13D562A171FE4007267C7 /* UnsubscribeRequest.swift in Sources */, + FD1A55432E179AED003761E4 /* ObservableKeyEvent+Utilities.swift in Sources */, C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, FDC438CB27BB7DB100C60D73 /* UpdateMessageRequest.swift in Sources */, FD8ECF7F2934298100C0D1BB /* ConfigDump.swift in Sources */, - FDE519F92AB802BB00450C53 /* Message+Origin.swift in Sources */, FD22727F2C32911C004D8A6C /* GetExpirationJob.swift in Sources */, FD2272792C32911C004D8A6C /* DisplayPictureDownloadJob.swift in Sources */, + FD981BD92DC9A69600564172 /* NotificationUserInfoKey.swift in Sources */, C352A2FF25574B6300338F3E /* (null) in Sources */, FD16AB612A1DD9B60083D849 /* ProfilePictureView+Convenience.swift in Sources */, B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */, @@ -6382,6 +6539,8 @@ FDF8488029405994007DCAE5 /* HTTPQueryParam+OpenGroup.swift in Sources */, FD8A5B322DC191B4004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift in Sources */, FD245C632850664600B966DD /* Configuration.swift in Sources */, + FD981BC62DC3310B00564172 /* ExtensionHelper.swift in Sources */, + FD78E9FA2DDD74D200D55B50 /* _027_MoveSettingsToLibSession.swift in Sources */, FD2272FF2C352D8E004D8A6C /* LibSession+UserProfile.swift in Sources */, FD5C7305284F0FF30029977D /* MessageReceiver+VisibleMessages.swift in Sources */, FDB5DAE82A95D96C002C8721 /* MessageReceiver+Groups.swift in Sources */, @@ -6404,15 +6563,16 @@ FDF0B75E280AAF35004C14C5 /* Preferences.swift in Sources */, FDF2F0222DAE1AF500491E8A /* MessageReceiver+LegacyClosedGroups.swift in Sources */, FD02CC122C367762009AB976 /* Request+PushNotificationAPI.swift in Sources */, + FD05594E2E012D2700DC48CE /* _028_RenameAttachments.swift in Sources */, FD22726E2C32911C004D8A6C /* FailedMessageSendsJob.swift in Sources */, FDB5DAD42A9483F3002C8721 /* GroupUpdateInviteMessage.swift in Sources */, FDB5DAE22A95D8A0002C8721 /* GroupUpdateInviteResponseMessage.swift in Sources */, + FDB11A522DCC6B0000BEF49F /* OpenGroupUrlInfo.swift in Sources */, FD3559462CC1FF200088F2A9 /* _020_AddMissingWhisperFlag.swift in Sources */, FD5C72F9284F0E880029977D /* MessageReceiver+TypingIndicators.swift in Sources */, FD5C7303284F0FA50029977D /* MessageReceiver+Calls.swift in Sources */, FD83B9C927D0487A005E1583 /* SendDirectMessageResponse.swift in Sources */, FDC438AA27BB12BB00C60D73 /* UserModeratorRequest.swift in Sources */, - FD245C54285065E000B966DD /* ThumbnailService.swift in Sources */, FDC4385D27B4C18900C60D73 /* Room.swift in Sources */, FDE755022C9BB122002A2623 /* _011_AddPendingReadReceipts.swift in Sources */, ); @@ -6458,6 +6618,7 @@ FD71164E28E3F8CC00B47552 /* SessionCell+Info.swift in Sources */, B84A89BC25DE328A0040017D /* ProfilePictureVC.swift in Sources */, FD12A8452AD63C2200EEBA0D /* TableDataState.swift in Sources */, + FDB3DA842E1CA22400148F8D /* UIActivityViewController+Utilities.swift in Sources */, FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */, FDE754F82C9BB0B0002A2623 /* UserNotificationConfig.swift in Sources */, 7B0EFDF62755CC5400FFAAE7 /* CallMissedTipsModal.swift in Sources */, @@ -6481,6 +6642,7 @@ B849789625D4A2F500D0D0B3 /* LinkPreviewView.swift in Sources */, FD71164428E2CB8A00B47552 /* SessionCell+Accessory.swift in Sources */, 7B1B52DF28580D51006069F2 /* EmojiPickerCollectionView.swift in Sources */, + FDE5219A2E08DBB800061B8E /* ImageLoading+Convenience.swift in Sources */, FD71165228E410BE00B47552 /* SessionTableSection.swift in Sources */, C3D0972B2510499C00F6E3E4 /* BackgroundPoller.swift in Sources */, 9422568B2C23F8C800C0FDBF /* LoadAccountScreen.swift in Sources */, @@ -6497,7 +6659,6 @@ FD87DCFE28B7582C00AF0F98 /* BlockedContactsViewModel.swift in Sources */, FD37E9DD28A384EB003AE748 /* PrimaryColorSelectionView.swift in Sources */, 942256812C23F8BB00C0FDBF /* NewMessageScreen.swift in Sources */, - FDC498B72AC15F7D00EDD897 /* AppNotificationCategory.swift in Sources */, FDE754B82C9B96BB002A2623 /* WebRTCSession+DataChannel.swift in Sources */, FDFDE126282D05380098B17F /* MediaInteractiveDismiss.swift in Sources */, 34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */, @@ -6532,6 +6693,7 @@ FD71160428C95B5600B47552 /* PhotoCollectionPickerViewModel.swift in Sources */, FDE754B72C9B96BB002A2623 /* WebRTCSession+MessageHandling.swift in Sources */, FD37EA1928AC5CCA003AE748 /* NotificationSoundViewModel.swift in Sources */, + FDE521942E050B1100061B8E /* DismissCallbackAVPlayerViewController.swift in Sources */, 7BAFA75A2AAEA281001DA43E /* LinkPreviewView_SwiftUI.swift in Sources */, FD6674002D77F9FD00041530 /* ScreenLockWindow.swift in Sources */, FD71163E28E2C82900B47552 /* SessionCell.swift in Sources */, @@ -6556,6 +6718,7 @@ B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */, C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */, 7B9F71D02852EEE2006DFE7B /* Emoji+Category.swift in Sources */, + FD78EA0B2DDFE45E00D55B50 /* Interaction+UI.swift in Sources */, 7BAADFCC27B0EF23007BCF92 /* CallVideoView.swift in Sources */, 942256892C23F8C800C0FDBF /* LandingScreen.swift in Sources */, B8CCF63F23975CFB0091D419 /* JoinOpenGroupVC.swift in Sources */, @@ -6593,15 +6756,14 @@ 9479981C2DD44ADC008F5CD5 /* ThreadNotificationSettingsViewModel.swift in Sources */, 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */, 9422568C2C23F8C800C0FDBF /* DisplayNameScreen.swift in Sources */, - B84664F5235022F30083A1CD /* MentionUtilities.swift in Sources */, 7B9F71D72853100A006DFE7B /* Emoji+Available.swift in Sources */, FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */, FDEF57212C3CF03A00131302 /* (null) in Sources */, 7B9F71D32852EEE2006DFE7B /* Emoji.swift in Sources */, - FDC498BB2AC1606C00EDD897 /* AppNotificationUserInfoKey.swift in Sources */, C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */, 3488F9362191CC4000E524CC /* MediaView.swift in Sources */, B8569AC325CB5D2900DBA3DB /* ConversationVC+Interaction.swift in Sources */, + FD981BD52DC978B400564172 /* MentionUtilities+DisplayName.swift in Sources */, 3496955C219B605E00DCFE74 /* ImagePickerController.swift in Sources */, 34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */, C31A6C5C247F2CF3001123EF /* CGRect+Utilities.swift in Sources */, @@ -6667,7 +6829,9 @@ 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 */, FD481A972CAE0AE000ECC4CF /* MockAppContext.swift in Sources */, FD2AAAF028ED57B500A49611 /* SynchronousStorage.swift in Sources */, @@ -6681,6 +6845,8 @@ FD23EA5D28ED00FA0058676E /* TestConstants.swift in Sources */, FD71161A28D00E1100B47552 /* NotificationContentViewModelSpec.swift in Sources */, FDFE75B52ABD46B700655640 /* MockUserDefaults.swift in Sources */, + FD52CB5B2E123FBC00A4DA70 /* CommonSSKMockExtensions.swift in Sources */, + FD52CB5C2E12536400A4DA70 /* MockExtensionHelper.swift in Sources */, 9499E68B2DF92F4E00091434 /* ThreadNotificationSettingsViewModelSpec.swift in Sources */, FD01504B2CA243CB005B08A1 /* Mock.swift in Sources */, FD0969FA2A6A00B000C5C365 /* Mocked.swift in Sources */, @@ -6691,6 +6857,7 @@ 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 */, ); @@ -6709,6 +6876,8 @@ 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 */, FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, FD0150402CA2433D005B08A1 /* BencodeDecoderSpec.swift in Sources */, FD0150412CA2433D005B08A1 /* BencodeEncoderSpec.swift in Sources */, @@ -6742,6 +6911,7 @@ 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 */, FDAA167B2AC28E2F00DDBF77 /* SnodeRequestSpec.swift in Sources */, @@ -6775,13 +6945,17 @@ FDC2908727D7047F005DAE71 /* RoomSpec.swift in Sources */, FDE754A82C9B964D002A2623 /* MessageReceiverGroupsSpec.swift in Sources */, FD01504C2CA243CB005B08A1 /* Mock.swift in Sources */, + FD981BC92DC4641100564172 /* ExtensionHelperSpec.swift in Sources */, + FD981BCB2DC4A21C00564172 /* MessageDeduplicationSpec.swift in Sources */, FD83B9C727CF3F10005E1583 /* CapabilitiesSpec.swift in Sources */, FD2AAAF128ED57B500A49611 /* SynchronousStorage.swift in Sources */, FD23CE2A2A6775660000B97C /* MockCrypto.swift in Sources */, FD336F6F2CAA37CB00C0B51B /* MockCommunityPoller.swift in Sources */, FDC2909827D7129B005DAE71 /* PersonalizationSpec.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+SMK.swift in Sources */, @@ -6822,6 +6996,7 @@ FD01502A2CA23DB7005B08A1 /* GRDBExtensions.swift in Sources */, FDC2908D27D70905005DAE71 /* UpdateMessageRequestSpec.swift in Sources */, FD01503B2CA24328005B08A1 /* MockJobRunner.swift in Sources */, + FD3F2EE72DE6CC4100FD6849 /* NotificationsManagerSpec.swift in Sources */, FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */, FD3C906727E416AF00CD579F /* BlindedIdLookupSpec.swift in Sources */, FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */, @@ -7517,11 +7692,6 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "\"${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"", - /usr/lib/swift, - ); MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; @@ -7531,7 +7701,6 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_INCLUDE_PATHS = "$(inherited) \"$(TARGET_BUILD_DIR)/libSessionUtil\""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -7597,11 +7766,6 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "\"${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"", - /usr/lib/swift, - ); MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = NO; @@ -7612,7 +7776,6 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_INCLUDE_PATHS = "$(inherited) \"$(TARGET_BUILD_DIR)/libSessionUtil\""; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -7657,11 +7820,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "\"${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"", - /usr/lib/swift, - ); + LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -7670,7 +7829,6 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_INCLUDE_PATHS = "$(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -7736,11 +7894,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "\"${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"", - /usr/lib/swift, - ); + LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = NO; @@ -7751,7 +7905,6 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_INCLUDE_PATHS = "$(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -7796,11 +7949,6 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "\"${DT_TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"", - /usr/lib/swift, - ); MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; @@ -7810,7 +7958,6 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_INCLUDE_PATHS = "$(inherited) \"$(TARGET_BUILD_DIR)/libSessionUtil\""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -7876,11 +8023,6 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "\"${DT_TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"", - /usr/lib/swift, - ); MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = NO; @@ -7891,7 +8033,6 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_INCLUDE_PATHS = "$(inherited) \"$(TARGET_BUILD_DIR)/libSessionUtil\""; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -7931,7 +8072,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = ""; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 606; + CURRENT_PROJECT_VERSION = 611; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -7968,11 +8109,10 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_VALUE = YES; GCC_WARN_UNUSED_VARIABLE = YES; - HEADER_SEARCH_PATHS = "$(BUILT_PRODUCTS_DIR)/include/**"; IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.13.0; + MARKETING_VERSION = 2.13.1; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "-Werror=protocol"; OTHER_SWIFT_FLAGS = "-D DEBUG -Xfrontend -warn-long-expression-type-checking=100"; @@ -8013,7 +8153,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = ""; - CURRENT_PROJECT_VERSION = 606; + CURRENT_PROJECT_VERSION = 611; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -8045,11 +8185,10 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_VALUE = YES; GCC_WARN_UNUSED_VARIABLE = YES; - HEADER_SEARCH_PATHS = "$(BUILT_PRODUCTS_DIR)/include/**"; IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.13.0; + MARKETING_VERSION = 2.13.1; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-DNS_BLOCK_ASSERTIONS=1", @@ -8080,10 +8219,6 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; DEVELOPMENT_TEAM = SUQ8J2PCT7; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(SRCROOT)", - ); GCC_PRECOMPILE_PREFIX_HEADER = YES; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", @@ -8095,24 +8230,11 @@ GCC_WARN_MULTIPLE_DEFINITION_TYPES_FOR_SELECTOR = NO; GCC_WARN_STRICT_SELECTOR_MATCH = YES; GCC_WARN_UNDECLARED_SELECTOR = YES; - HEADER_SEARCH_PATHS = ( - "$(inherited)", - "\"${SRCROOT}/RedPhone/lib/ogg/include\"", - "\"${SRCROOT}/RedPhone/lib/debug/include\"", - "\"$(SRCROOT)/libtommath\"", - "\"$(SRCROOT)/libtomcrypt/headers\"", - "\"$(SRCROOT)/MMDrawerController\"", - "\"$(SRCROOT)/Libraries\"/**", - ); INFOPLIST_FILE = "Session/Meta/Session-Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(SRCROOT)", - ); LLVM_LTO = NO; OTHER_LDFLAGS = "$(inherited)"; OTHER_SWIFT_FLAGS = "$(inherited)"; @@ -8145,10 +8267,6 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; DEVELOPMENT_TEAM = SUQ8J2PCT7; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(SRCROOT)", - ); GCC_PRECOMPILE_PREFIX_HEADER = YES; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", @@ -8160,24 +8278,11 @@ GCC_WARN_MULTIPLE_DEFINITION_TYPES_FOR_SELECTOR = NO; GCC_WARN_STRICT_SELECTOR_MATCH = YES; GCC_WARN_UNDECLARED_SELECTOR = YES; - HEADER_SEARCH_PATHS = ( - "$(inherited)", - "\"${SRCROOT}/RedPhone/lib/ogg/include\"", - "\"${SRCROOT}/RedPhone/lib/debug/include\"", - "\"$(SRCROOT)/libtommath\"", - "\"$(SRCROOT)/libtomcrypt/headers\"", - "\"$(SRCROOT)/MMDrawerController\"", - "\"$(SRCROOT)/Libraries\"/**", - ); INFOPLIST_FILE = "Session/Meta/Session-Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(SRCROOT)", - ); LLVM_LTO = NO; OTHER_LDFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; @@ -8541,7 +8646,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 606; + CURRENT_PROJECT_VERSION = 611; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8577,14 +8682,10 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_VALUE = YES; GCC_WARN_UNUSED_VARIABLE = YES; - HEADER_SEARCH_PATHS = ( - "$(BUILT_PRODUCTS_DIR)/include/**", - "$(LIB_SESSION_SOURCE_DIR)/include/**", - ); IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.13.0; + MARKETING_VERSION = 2.13.1; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "-fobjc-arc-exceptions", @@ -8615,10 +8716,6 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; DEVELOPMENT_TEAM = SUQ8J2PCT7; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(SRCROOT)", - ); GCC_PRECOMPILE_PREFIX_HEADER = YES; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", @@ -8630,24 +8727,11 @@ GCC_WARN_MULTIPLE_DEFINITION_TYPES_FOR_SELECTOR = NO; GCC_WARN_STRICT_SELECTOR_MATCH = YES; GCC_WARN_UNDECLARED_SELECTOR = YES; - HEADER_SEARCH_PATHS = ( - "$(inherited)", - "\"${SRCROOT}/RedPhone/lib/ogg/include\"", - "\"${SRCROOT}/RedPhone/lib/debug/include\"", - "\"$(SRCROOT)/libtommath\"", - "\"$(SRCROOT)/libtomcrypt/headers\"", - "\"$(SRCROOT)/MMDrawerController\"", - "\"$(SRCROOT)/Libraries\"/**", - ); INFOPLIST_FILE = "Session/Meta/Session-Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(SRCROOT)", - ); LLVM_LTO = NO; OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; @@ -8911,11 +8995,6 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "\"${DT_TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"", - /usr/lib/swift, - ); MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; @@ -8925,7 +9004,6 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_INCLUDE_PATHS = "$(inherited) \"$(TARGET_BUILD_DIR)/libSessionUtil\""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -8970,11 +9048,6 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "\"${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"", - /usr/lib/swift, - ); MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; @@ -8984,7 +9057,6 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_INCLUDE_PATHS = "$(inherited) \"$(TARGET_BUILD_DIR)/libSessionUtil\""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -9029,11 +9101,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "\"${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"", - /usr/lib/swift, - ); + LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; @@ -9043,7 +9111,6 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_INCLUDE_PATHS = "$(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -9178,7 +9245,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = YES; - CURRENT_PROJECT_VERSION = 606; + CURRENT_PROJECT_VERSION = 611; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; @@ -9208,14 +9275,10 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_VALUE = YES; GCC_WARN_UNUSED_VARIABLE = YES; - HEADER_SEARCH_PATHS = ( - "$(BUILT_PRODUCTS_DIR)/include/**", - "$(LIB_SESSION_SOURCE_DIR)/include/**", - ); IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.13.0; + MARKETING_VERSION = 2.13.1; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-DNS_BLOCK_ASSERTIONS=1", @@ -9245,10 +9308,6 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; DEVELOPMENT_TEAM = SUQ8J2PCT7; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(SRCROOT)", - ); GCC_PRECOMPILE_PREFIX_HEADER = YES; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", @@ -9260,24 +9319,11 @@ GCC_WARN_MULTIPLE_DEFINITION_TYPES_FOR_SELECTOR = NO; GCC_WARN_STRICT_SELECTOR_MATCH = YES; GCC_WARN_UNDECLARED_SELECTOR = YES; - HEADER_SEARCH_PATHS = ( - "$(inherited)", - "\"${SRCROOT}/RedPhone/lib/ogg/include\"", - "\"${SRCROOT}/RedPhone/lib/debug/include\"", - "\"$(SRCROOT)/libtommath\"", - "\"$(SRCROOT)/libtomcrypt/headers\"", - "\"$(SRCROOT)/MMDrawerController\"", - "\"$(SRCROOT)/Libraries\"/**", - ); INFOPLIST_FILE = "Session/Meta/Session-Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(SRCROOT)", - ); LLVM_LTO = NO; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; PRODUCT_NAME = Session; @@ -9656,11 +9702,6 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "\"${DT_TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"", - /usr/lib/swift, - ); MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = NO; @@ -9671,7 +9712,6 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_INCLUDE_PATHS = "$(inherited) \"$(TARGET_BUILD_DIR)/libSessionUtil\""; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -9739,11 +9779,6 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "\"${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"", - /usr/lib/swift, - ); MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = NO; @@ -9754,7 +9789,6 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_INCLUDE_PATHS = "$(inherited) \"$(TARGET_BUILD_DIR)/libSessionUtil\""; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -9822,11 +9856,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "\"${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"", - /usr/lib/swift, - ); + LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; MTL_ENABLE_DEBUG_INFO = NO; @@ -9837,7 +9867,6 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_INCLUDE_PATHS = "$(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -10181,7 +10210,7 @@ repositoryURL = "https://github.com/session-foundation/libsession-util-spm"; requirement = { kind = exactVersion; - version = 1.4.1; + version = 1.5.0; }; }; FD6A38E72C2A630E00762359 /* XCRemoteSwiftPackageReference "CocoaLumberjack" */ = { diff --git a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b02e3e118d..e623a17464 100644 --- a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/session-foundation/libsession-util-spm", "state" : { - "revision" : "de7e51153b43783442c680482db676a8b19be61d", - "version" : "1.4.1" + "revision" : "57ce5d86a01ff1906c401c3d98bbe572c1c9c9c9", + "version" : "1.5.0" } }, { diff --git a/Session/Calls/Call Management/SessionCall.swift b/Session/Calls/Call Management/SessionCall.swift index 4de7b664ec..d747fbcdfd 100644 --- a/Session/Calls/Call Management/SessionCall.swift +++ b/Session/Calls/Call Management/SessionCall.swift @@ -206,7 +206,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { // MARK: - Actions - public func startSessionCall(_ db: Database) { + public func startSessionCall(_ db: ObservingDatabase) { let sessionId: String = self.sessionId let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(state: .outgoing) @@ -245,10 +245,10 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { try? webRTCSession .sendPreOffer( - db, message: message, + threadId: thread.id, interactionId: interaction?.id, - in: thread + authMethod: try Authentication.with(db, swarmPublicKey: thread.id, using: dependencies) ) .retry(5) // Start the timeout timer for the call diff --git a/Session/Calls/Call Management/SessionCallManager.swift b/Session/Calls/Call Management/SessionCallManager.swift index b0a020246e..1ff8d81c8d 100644 --- a/Session/Calls/Call Management/SessionCallManager.swift +++ b/Session/Calls/Call Management/SessionCallManager.swift @@ -209,8 +209,7 @@ public final class SessionCallManager: NSObject, CallManagerProtocol { contactName: Profile.displayName( db, id: caller, - threadVariant: .contact, - using: dependencies + threadVariant: .contact ), uuid: uuid, mode: mode, diff --git a/Session/Calls/CallVC.swift b/Session/Calls/CallVC.swift index ba579ab6e2..042342e981 100644 --- a/Session/Calls/CallVC.swift +++ b/Session/Calls/CallVC.swift @@ -570,10 +570,10 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel try Profile.fetchOne(db, id: call.sessionId) } - switch profile?.profilePictureFileName { - case .some(let fileName): profilePictureView.loadImage(from: fileName) + switch profile?.displayPictureUrl.map({ try? dependencies[singleton: .displayPictureManager].path(for: $0) }) { + case .some(let filePath): profilePictureView.loadImage(from: filePath) case .none: - profilePictureView.image = PlaceholderIcon.generate( + profilePictureView.loadPlaceholder( seed: call.sessionId, text: call.contactName, size: 300 diff --git a/Session/Calls/Views & Modals/IncomingCallBanner.swift b/Session/Calls/Views & Modals/IncomingCallBanner.swift index 41277a293f..e40a97724d 100644 --- a/Session/Calls/Views & Modals/IncomingCallBanner.swift +++ b/Session/Calls/Views & Modals/IncomingCallBanner.swift @@ -131,7 +131,7 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate { profilePictureView.update( publicKey: call.sessionId, threadVariant: .contact, - displayPictureFilename: nil, + displayPictureUrl: nil, profile: dependencies[singleton: .storage].read { [sessionId = call.sessionId] db in Profile.fetchOrCreate(db, id: sessionId) }, diff --git a/Session/Calls/WebRTC/WebRTCSession+MessageHandling.swift b/Session/Calls/WebRTC/WebRTCSession+MessageHandling.swift index 853e2ba458..2acb7d03be 100644 --- a/Session/Calls/WebRTC/WebRTCSession+MessageHandling.swift +++ b/Session/Calls/WebRTC/WebRTCSession+MessageHandling.swift @@ -15,7 +15,7 @@ extension WebRTCSession { } public func handleRemoteSDP(_ sdp: RTCSessionDescription, from sessionId: String) { - Log.info(.calls, "Received remote SDP: \(sdp.sdp).") + Log.debug(.calls, "Received remote SDP: \(sdp.sdp).") peerConnection?.setRemoteDescription(sdp, completionHandler: { [weak self] error in if let error = error { diff --git a/Session/Calls/WebRTC/WebRTCSession.swift b/Session/Calls/WebRTC/WebRTCSession.swift index c2dfbe8467..99e48d1e25 100644 --- a/Session/Calls/WebRTC/WebRTCSession.swift +++ b/Session/Calls/WebRTC/WebRTCSession.swift @@ -131,23 +131,22 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { // MARK: - Signaling public func sendPreOffer( - _ db: Database, message: CallMessage, + threadId: String, interactionId: Int64?, - in thread: SessionThread + authMethod: AuthenticationMethod ) throws -> AnyPublisher { Log.info(.calls, "Sending pre-offer message.") return try MessageSender .preparedSend( - db, message: message, - to: try Message.Destination.from(db, threadId: thread.id, threadVariant: thread.variant), - namespace: try Message.Destination - .from(db, threadId: thread.id, threadVariant: thread.variant) - .defaultNamespace, + to: .contact(publicKey: threadId), + namespace: .default, interactionId: interactionId, - fileIds: [], + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) .send(using: dependencies) @@ -182,32 +181,31 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { } dependencies[singleton: .storage] - .writePublisher { db -> Network.PreparedRequest in - try MessageSender - .preparedSend( - db, - message: CallMessage( - uuid: uuid, - kind: .offer, - sdps: [ sdp.sdp ], - sentTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - ) - .with(try? thread.disappearingMessagesConfiguration - .fetchOne(db)? - .forcedWithDisappearAfterReadIfNeeded() - ), - to: try Message.Destination - .from(db, threadId: thread.id, threadVariant: thread.variant), - namespace: try Message.Destination - .from(db, threadId: thread.id, threadVariant: thread.variant) - .defaultNamespace, - interactionId: nil, - fileIds: [], - using: dependencies - ) + .writePublisher { db -> (AuthenticationMethod, DisappearingMessagesConfiguration?) in + ( + try Authentication.with(db, swarmPublicKey: thread.id, using: dependencies), + try DisappearingMessagesConfiguration.fetchOne(db, id: thread.id) + ) } - .flatMap { $0.send(using: dependencies) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .tryFlatMap { authMethod, disappearingMessagesConfiguration in + try MessageSender.preparedSend( + message: CallMessage( + uuid: uuid, + kind: .offer, + sdps: [ sdp.sdp ], + sentTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + ) + .with(disappearingMessagesConfiguration?.forcedWithDisappearAfterReadIfNeeded()), + to: .contact(publicKey: thread.id), + namespace: .default, + interactionId: nil, + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), + using: dependencies + ).send(using: dependencies) + } .sinkUntilComplete( receiveCompletion: { result in switch result { @@ -228,14 +226,21 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { let mediaConstraints: RTCMediaConstraints = mediaConstraints(false) return dependencies[singleton: .storage] - .readPublisher { db -> SessionThread in - guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId) else { - throw WebRTCSessionError.noThread - } + .readPublisher { [dependencies] db -> (AuthenticationMethod, DisappearingMessagesConfiguration?) in + /// Ensure a thread exists for the `sessionId` and that it's a `contact` thread + guard + SessionThread + .filter(id: sessionId) + .filter(SessionThread.Columns.variant == SessionThread.Variant.contact) + .isNotEmpty(db) + else { throw WebRTCSessionError.noThread } - return thread + return ( + try Authentication.with(db, swarmPublicKey: sessionId, using: dependencies), + try DisappearingMessagesConfiguration.fetchOne(db, id: sessionId) + ) } - .flatMap { [weak self, dependencies] thread in + .flatMap { [weak self, dependencies] authMethod, disappearingMessagesConfiguration in Future { resolver in self?.peerConnection?.answer(for: mediaConstraints) { [weak self] sdp, error in if let error = error { @@ -254,40 +259,34 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { } } - dependencies[singleton: .storage] - .writePublisher { db -> Network.PreparedRequest in - try MessageSender - .preparedSend( - db, - message: CallMessage( - uuid: uuid, - kind: .answer, - sdps: [ sdp.sdp ] - ) - .with(try? thread.disappearingMessagesConfiguration - .fetchOne(db)? - .forcedWithDisappearAfterReadIfNeeded() - ), - to: try Message.Destination - .from(db, threadId: thread.id, threadVariant: thread.variant), - namespace: try Message.Destination - .from(db, threadId: thread.id, threadVariant: thread.variant) - .defaultNamespace, - interactionId: nil, - fileIds: [], - using: dependencies - ) - } - .flatMap { $0.send(using: dependencies) } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .sinkUntilComplete( - receiveCompletion: { result in - switch result { - case .finished: resolver(Result.success(())) - case .failure(let error): resolver(Result.failure(error)) - } - } + Result { + try MessageSender.preparedSend( + message: CallMessage( + uuid: uuid, + kind: .answer, + sdps: [ sdp.sdp ] + ) + .with(disappearingMessagesConfiguration?.forcedWithDisappearAfterReadIfNeeded()), + to: .contact(publicKey: sessionId), + namespace: .default, + interactionId: nil, + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), + using: dependencies ) + } + .publisher + .flatMap { $0.send(using: dependencies) } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: resolver(Result.success(())) + case .failure(let error): resolver(Result.failure(error)) + } + } + ) } } } @@ -313,17 +312,27 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { // Empty the queue self.queuedICECandidates.removeAll() - dependencies[singleton: .storage] - .writePublisher { [dependencies] db -> Network.PreparedRequest in - guard let thread: SessionThread = try SessionThread.fetchOne(db, id: contactSessionId) else { - throw WebRTCSessionError.noThread - } + return dependencies[singleton: .storage] + .readPublisher { [dependencies] db -> (AuthenticationMethod, DisappearingMessagesConfiguration?) in + /// Ensure a thread exists for the `sessionId` and that it's a `contact` thread + guard + SessionThread + .filter(id: contactSessionId) + .filter(SessionThread.Columns.variant == SessionThread.Variant.contact) + .isNotEmpty(db) + else { throw WebRTCSessionError.noThread } + return ( + try Authentication.with(db, swarmPublicKey: contactSessionId, using: dependencies), + try DisappearingMessagesConfiguration.fetchOne(db, id: contactSessionId) + ) + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .tryFlatMap { [dependencies] authMethod, disappearingMessagesConfiguration in Log.info(.calls, "Batch sending \(candidates.count) ICE candidates.") return try MessageSender .preparedSend( - db, message: CallMessage( uuid: uuid, kind: .iceCandidates( @@ -332,23 +341,15 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { ), sdps: candidates.map { $0.sdp } ) - .with(try? thread.disappearingMessagesConfiguration - .fetchOne(db)? - .forcedWithDisappearAfterReadIfNeeded() - ), - to: try Message.Destination - .from(db, threadId: thread.id, threadVariant: thread.variant), - namespace: try Message.Destination - .from(db, threadId: thread.id, threadVariant: thread.variant) - .defaultNamespace, + .with(disappearingMessagesConfiguration?.forcedWithDisappearAfterReadIfNeeded()), + to: .contact(publicKey: contactSessionId), + namespace: .default, interactionId: nil, - fileIds: [], + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) - } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .flatMap { [dependencies] preparedRequest in - preparedRequest .send(using: dependencies) .retry(5) } @@ -367,37 +368,42 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { public func endCall(with sessionId: String) { return dependencies[singleton: .storage] - .writePublisher { [dependencies, uuid] db -> Network.PreparedRequest in - guard let thread: SessionThread = try SessionThread.fetchOne(db, id: sessionId) else { - throw WebRTCSessionError.noThread - } + .readPublisher { [dependencies] db -> (AuthenticationMethod, DisappearingMessagesConfiguration?) in + /// Ensure a thread exists for the `sessionId` and that it's a `contact` thread + guard + SessionThread + .filter(id: sessionId) + .filter(SessionThread.Columns.variant == SessionThread.Variant.contact) + .isNotEmpty(db) + else { throw WebRTCSessionError.noThread } + return ( + try Authentication.with(db, swarmPublicKey: sessionId, using: dependencies), + try DisappearingMessagesConfiguration.fetchOne(db, id: sessionId) + ) + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .tryFlatMap { [dependencies, uuid] authMethod, disappearingMessagesConfiguration in Log.info(.calls, "Sending end call message.") return try MessageSender .preparedSend( - db, message: CallMessage( uuid: uuid, kind: .endCall, sdps: [] ) - .with(try? thread.disappearingMessagesConfiguration - .fetchOne(db)? - .forcedWithDisappearAfterReadIfNeeded() - ), - to: try Message.Destination.from(db, threadId: thread.id, threadVariant: thread.variant), - namespace: try Message.Destination - .from(db, threadId: thread.id, threadVariant: thread.variant) - .defaultNamespace, + .with(disappearingMessagesConfiguration?.forcedWithDisappearAfterReadIfNeeded()), + to: .contact(publicKey: sessionId), + namespace: .default, interactionId: nil, - fileIds: [], + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) - } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .flatMap { [dependencies] preparedRequest in - preparedRequest.send(using: dependencies).retry(5) + .send(using: dependencies) + .retry(5) } .sinkUntilComplete( receiveCompletion: { result in diff --git a/Session/Closed Groups/EditGroupViewModel.swift b/Session/Closed Groups/EditGroupViewModel.swift index b20335827f..10bc1ee81a 100644 --- a/Session/Closed Groups/EditGroupViewModel.swift +++ b/Session/Closed Groups/EditGroupViewModel.swift @@ -100,7 +100,7 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl return Just(EditGroupViewModel.minVersionBannerInfo).eraseToAnyPublisher() } - lazy var observation: TargetObservation = ObservationBuilder + lazy var observation: TargetObservation = ObservationBuilderOld .databaseObservation(self) { [dependencies, threadId, userSessionId] db -> State in guard let group: ClosedGroup = try ClosedGroup.fetchOne(db, id: threadId) else { return State.invalidState @@ -108,8 +108,18 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl var profileFront: Profile? var profileBack: Profile? + let hasDownloadedDisplayPicture: Bool = { + guard + let displayPictureUrl: String = group.displayPictureUrl, + let path: String = try? dependencies[singleton: .displayPictureManager] + .path(for: displayPictureUrl), + dependencies[singleton: .fileManager].fileExists(atPath: path) + else { return false } + + return true + }() - if group.displayPictureFilename == nil { + if !hasDownloadedDisplayPicture { let frontProfileId: String? = try GroupMember .filter(GroupMember.Columns.groupId == threadId) .filter(GroupMember.Columns.role == GroupMember.Role.standard) @@ -192,7 +202,7 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl id: threadId, size: .hero, threadVariant: (isUpdatedGroup ? .group : .legacyGroup), - displayPictureFilename: state.group.displayPictureFilename, + displayPictureUrl: state.group.displayPictureUrl, profile: state.profile, profileIcon: .none, additionalProfile: state.additionalProfile, diff --git a/Session/Closed Groups/NewClosedGroupVC.swift b/Session/Closed Groups/NewClosedGroupVC.swift index fc020705ec..6e891f4160 100644 --- a/Session/Closed Groups/NewClosedGroupVC.swift +++ b/Session/Closed Groups/NewClosedGroupVC.swift @@ -174,11 +174,11 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate private lazy var fadeView: GradientView = { let result: GradientView = GradientView() result.themeBackgroundGradient = [ - .value(.newConversation_background, alpha: 0), // Want this to take up 20% (~25pt) - .newConversation_background, - .newConversation_background, - .newConversation_background, - .newConversation_background + .value(.backgroundSecondary, alpha: 0), // Want this to take up 20% (~25pt) + .backgroundSecondary, + .backgroundSecondary, + .backgroundSecondary, + .backgroundSecondary ] result.set(.height, to: Values.footerGradientHeight(window: UIApplication.shared.keyWindow)) @@ -202,7 +202,7 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate override func viewDidLoad() { super.viewDidLoad() - view.themeBackgroundColor = .newConversation_background + view.themeBackgroundColor = .backgroundSecondary let customTitleFontSize = Values.largeFontSize setNavBarTitle("groupCreate".localized(), customFontSize: customTitleFontSize) diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index 3537130071..e2277f727e 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -246,7 +246,8 @@ extension ContextMenuVC { dependencies[singleton: .openGroupManager].isUserModeratorOrAdmin( publicKey: threadViewModel.currentUserSessionId, for: threadViewModel.openGroupRoomToken, - on: threadViewModel.openGroupServer + on: threadViewModel.openGroupServer, + currentUserSessionIds: (threadViewModel.currentUserSessionIds ?? []) ) ) let shouldShowEmojiActions: Bool = { diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index c0b4879212..fdbf9eac85 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -114,7 +114,7 @@ extension ConversationVC: self.showBlockedModalIfNeeded() return } - guard viewModel.dependencies[singleton: .storage, key: .areCallsEnabled] else { + guard viewModel.dependencies.mutate(cache: .libSession, { $0.get(.areCallsEnabled) }) else { let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "callsPermissionsRequired".localized(), @@ -305,7 +305,7 @@ extension ConversationVC: // MARK: - ExpandingAttachmentsButtonDelegate func handleGIFButtonTapped() { - guard viewModel.dependencies[singleton: .storage, key: .isGiphyEnabled] else { + guard viewModel.dependencies.mutate(cache: .libSession, { $0.get(.isGiphyEnabled) }) else { let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "giphyWarning".localized(), @@ -316,16 +316,9 @@ extension ConversationVC: ), confirmTitle: "theContinue".localized() ) { [weak self, dependencies = viewModel.dependencies] _ in - dependencies[singleton: .storage].writeAsync( - updates: { db in - db[.isGiphyEnabled] = true - }, - completion: { _ in - DispatchQueue.main.async { - self?.handleGIFButtonTapped() - } - } - ) + dependencies.setAsync(.isGiphyEnabled, true) { + Task { @MainActor in self?.handleGIFButtonTapped() } + } } ) @@ -627,116 +620,111 @@ extension ConversationVC: let threadId: String = self.viewModel.threadData.threadId let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant - DispatchQueue.global(qos:.userInitiated).asyncAfter(deadline: .now() + 0.01, using: viewModel.dependencies) { [dependencies = viewModel.dependencies] in - // Generate the quote thumbnail if needed (want this to happen outside of the DBWrite thread as - // this can take up to 0.5s - let quoteThumbnailAttachment: Attachment? = optimisticData.quoteModel?.attachment? - .cloneAsQuoteThumbnail(using: dependencies) - - // Actually send the message - dependencies[singleton: .storage] - .writePublisher { [weak self] db in - // Update the thread to be visible (if it isn't already) - if self?.viewModel.threadData.threadShouldBeVisible == false { - _ = try SessionThread - .filter(id: threadId) - .updateAllAndConfig( - db, - SessionThread.Columns.shouldBeVisible.set(to: true), - SessionThread.Columns.pinnedPriority.set(to: LibSession.visiblePriority), - SessionThread.Columns.isDraft.set(to: false), - using: dependencies - ) - } - - // Insert the interaction and associated it with the optimistically inserted message so - // we can remove it once the database triggers a UI update - let insertedInteraction: Interaction = try optimisticData.interaction.inserted(db) - self?.viewModel.associate(optimisticMessageId: optimisticData.id, to: insertedInteraction.id) - - // If there is a LinkPreview draft then check the state of any existing link previews and - // insert a new one if needed - if let linkPreviewDraft: LinkPreviewDraft = optimisticData.linkPreviewDraft { - let invalidLinkPreviewAttachmentStates: [Attachment.State] = [ - .failedDownload, .pendingDownload, .downloading, .failedUpload, .invalid - ] - let linkPreviewAttachmentId: String? = try? insertedInteraction.linkPreview - .select(.attachmentId) - .asRequest(of: String.self) - .fetchOne(db) - let linkPreviewAttachmentState: Attachment.State = linkPreviewAttachmentId - .map { - try? Attachment - .filter(id: $0) - .select(.state) - .asRequest(of: Attachment.State.self) - .fetchOne(db) - } - .defaulting(to: .invalid) - - // If we don't have a "valid" existing link preview then upsert a new one - if invalidLinkPreviewAttachmentStates.contains(linkPreviewAttachmentState) { - try LinkPreview( - url: linkPreviewDraft.urlString, - title: linkPreviewDraft.title, - attachmentId: try optimisticData.linkPreviewAttachment?.inserted(db).id, - using: dependencies - ).upsert(db) - } - } - - // If there is a Quote the insert it now - if let interactionId: Int64 = insertedInteraction.id, let quoteModel: QuotedReplyModel = optimisticData.quoteModel { - try Quote( - interactionId: interactionId, - authorId: quoteModel.authorId, - timestampMs: quoteModel.timestampMs, - body: nil, - attachmentId: try quoteThumbnailAttachment?.inserted(db).id - ).insert(db) - } - - // Process any attachments - try Attachment.process( - db, - attachments: optimisticData.attachmentData, - for: insertedInteraction.id - ) - - try MessageSender.send( + // Actually send the message + viewModel.dependencies[singleton: .storage] + .writePublisher { [weak self, dependencies = viewModel.dependencies] db in + // Update the thread to be visible (if it isn't already) + if self?.viewModel.threadData.threadShouldBeVisible == false { + try SessionThread.updateVisibility( db, - interaction: insertedInteraction, threadId: threadId, - threadVariant: threadVariant, + isVisible: true, + additionalChanges: [SessionThread.Columns.isDraft.set(to: false)], using: dependencies ) } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .sinkUntilComplete( - receiveCompletion: { [weak self] result in - switch result { - case .finished: break - case .failure(let error): - self?.viewModel.failedToStoreOptimisticOutgoingMessage(id: optimisticData.id, error: error) + + // Insert the interaction and associated it with the optimistically inserted message so + // we can remove it once the database triggers a UI update + let insertedInteraction: Interaction = try optimisticData.interaction.inserted(db) + self?.viewModel.associate(optimisticMessageId: optimisticData.id, to: insertedInteraction.id) + + // If there is a LinkPreview draft then check the state of any existing link previews and + // insert a new one if needed + if let linkPreviewDraft: LinkPreviewDraft = optimisticData.linkPreviewDraft { + let invalidLinkPreviewAttachmentStates: [Attachment.State] = [ + .failedDownload, .pendingDownload, .downloading, .failedUpload, .invalid + ] + let linkPreviewAttachmentId: String? = try? insertedInteraction.linkPreview + .select(.attachmentId) + .asRequest(of: String.self) + .fetchOne(db) + let linkPreviewAttachmentState: Attachment.State = linkPreviewAttachmentId + .map { + try? Attachment + .filter(id: $0) + .select(.state) + .asRequest(of: Attachment.State.self) + .fetchOne(db) } - - self?.handleMessageSent() + .defaulting(to: .invalid) + + // If we don't have a "valid" existing link preview then upsert a new one + if invalidLinkPreviewAttachmentStates.contains(linkPreviewAttachmentState) { + try LinkPreview( + url: linkPreviewDraft.urlString, + title: linkPreviewDraft.title, + attachmentId: try optimisticData.linkPreviewAttachment?.inserted(db).id, + using: dependencies + ).upsert(db) } + } + + // If there is a Quote the insert it now + if let interactionId: Int64 = insertedInteraction.id, let quoteModel: QuotedReplyModel = optimisticData.quoteModel { + try Quote( + interactionId: interactionId, + authorId: quoteModel.authorId, + timestampMs: quoteModel.timestampMs, + body: nil + ).insert(db) + } + + // Process any attachments + try AttachmentUploader.process( + db, + attachments: optimisticData.attachmentData, + for: insertedInteraction.id ) - } + + try MessageSender.send( + db, + interaction: insertedInteraction, + threadId: threadId, + threadVariant: threadVariant, + using: dependencies + ) + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .sinkUntilComplete( + receiveCompletion: { [weak self] result in + switch result { + case .finished: break + case .failure(let error): + self?.viewModel.failedToStoreOptimisticOutgoingMessage(id: optimisticData.id, error: error) + } + + self?.handleMessageSent() + } + ) } func handleMessageSent() { - if viewModel.dependencies[singleton: .storage, key: .playNotificationSoundInForeground] { + if viewModel.dependencies.mutate(cache: .libSession, { $0.get(.playNotificationSoundInForeground) }) { let soundID = Preferences.Sound.systemSoundId(for: .messageSent, quiet: true) AudioServicesPlaySystemSound(soundID) } let threadId: String = self.viewModel.threadData.threadId - viewModel.dependencies[singleton: .storage].writeAsync { [dependencies = viewModel.dependencies] db in - dependencies[singleton: .typingIndicators].didStopTyping(db, threadId: threadId, direction: .outgoing) - + Task { + await viewModel.dependencies[singleton: .typingIndicators].didStopTyping( + threadId: threadId, + direction: .outgoing + ) + } + + viewModel.dependencies[singleton: .storage].writeAsync { db in _ = try SessionThread .filter(id: threadId) .updateAll(db, SessionThread.Columns.messageDraft.set(to: "")) @@ -756,11 +744,9 @@ extension ConversationVC: confirmStyle: .danger, cancelStyle: .alert_text ) { [weak self, dependencies = viewModel.dependencies] _ in - dependencies[singleton: .storage].writeAsync { db in - db[.areLinkPreviewsEnabled] = true + dependencies.setAsync(.areLinkPreviewsEnabled, true) { + Task { @MainActor in self?.snInputView.autoGenerateLinkPreview() } } - - self?.snInputView.autoGenerateLinkPreview() } ) @@ -775,14 +761,14 @@ extension ConversationVC: let newText: String = (inputTextView.text ?? "") if !newText.isEmpty { - viewModel.dependencies[singleton: .typingIndicators].startIfNeeded( - threadId: viewModel.threadData.threadId, - threadVariant: viewModel.threadData.threadVariant, - threadIsBlocked: (viewModel.threadData.threadIsBlocked == true), - threadIsMessageRequest: (viewModel.threadData.threadIsMessageRequest == true), - direction: .outgoing, - timestampMs: viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - ) + Task { [threadData = viewModel.threadData, dependencies = viewModel.dependencies] in + await viewModel.dependencies[singleton: .typingIndicators].startIfNeeded( + threadId: threadData.threadId, + threadVariant: threadData.threadVariant, + direction: .outgoing, + timestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + ) + } } updateMentions(for: newText) @@ -857,9 +843,7 @@ extension ConversationVC: currentMentionStartIndex = lastCharacterIndex snInputView.showMentionsUI( for: self.viewModel.mentions(), - currentUserSessionId: self.viewModel.threadData.currentUserSessionId, - currentUserBlinded15SessionId: self.viewModel.threadData.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: self.viewModel.threadData.currentUserBlinded25SessionId + currentUserSessionIds: (self.viewModel.threadData.currentUserSessionIds ?? []) ) } else if lastCharacter.isWhitespace || lastCharacter == "@" { // the lastCharacter == "@" is to check for @@ @@ -871,9 +855,7 @@ extension ConversationVC: let query = String(newText[newText.index(after: currentMentionStartIndex)...]) // + 1 to get rid of the @ snInputView.showMentionsUI( for: self.viewModel.mentions(for: query), - currentUserSessionId: self.viewModel.threadData.currentUserSessionId, - currentUserBlinded15SessionId: self.viewModel.threadData.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: self.viewModel.threadData.currentUserBlinded25SessionId + currentUserSessionIds: (self.viewModel.threadData.currentUserSessionIds ?? []) ) } } @@ -1060,7 +1042,8 @@ extension ConversationVC: serverHash: nil, serverExpirationTimestamp: nil, using: dependencies - ) + )? + .interactionId let expirationTimerUpdateMessage: ExpirationTimerUpdate = ExpirationTimerUpdate() .with(sentTimestampMs: UInt64(currentTimestampMs)) @@ -1173,16 +1156,28 @@ extension ConversationVC: guard albumView.numItems > 1 || !mediaView.attachment.isVideo else { guard - let originalFilePath: String = mediaView.attachment.originalFilePath(using: viewModel.dependencies), - viewModel.dependencies[singleton: .fileManager].fileExists(atPath: originalFilePath) + let path: String = try? viewModel.dependencies[singleton: .attachmentManager] + .createTemporaryFileForOpening( + downloadUrl: mediaView.attachment.downloadUrl, + mimeType: mediaView.attachment.contentType, + sourceFilename: mediaView.attachment.sourceFilename + ), + viewModel.dependencies[singleton: .fileManager].fileExists(atPath: path) else { return Log.warn(.conversation, "Missing video file") } /// When playing media we need to change the AVAudioSession to 'playback' mode so the device "silent mode" /// doesn't prevent video audio from playing try? AVAudioSession.sharedInstance().setCategory(.playback) - let viewController: AVPlayerViewController = AVPlayerViewController() - viewController.player = AVPlayer(url: URL(fileURLWithPath: originalFilePath)) - self.navigationController?.present(viewController, animated: true) + let viewController: DismissCallbackAVPlayerViewController = DismissCallbackAVPlayerViewController { [dependencies = viewModel.dependencies] in + /// Sanity check to make sure we don't unintentionally remove a proper attachment file + guard path.hasPrefix(dependencies[singleton: .fileManager].temporaryDirectory) else { + return + } + + try? dependencies[singleton: .fileManager].removeItem(atPath: path) + } + viewController.player = AVPlayer(url: URL(fileURLWithPath: path)) + self.present(viewController, animated: true) return } @@ -1221,24 +1216,43 @@ extension ConversationVC: guard !handleLinkTapIfNeeded(cell: cell, targetView: (cell as? VisibleMessageCell)?.documentView), let attachment: Attachment = cellViewModel.attachments?.first, - let originalFilePath: String = attachment.originalFilePath(using: viewModel.dependencies) + let path: String = try? viewModel.dependencies[singleton: .attachmentManager] + .createTemporaryFileForOpening( + downloadUrl: attachment.downloadUrl, + mimeType: attachment.contentType, + sourceFilename: attachment.sourceFilename + ), + viewModel.dependencies[singleton: .fileManager].fileExists(atPath: path) else { return } /// When playing media we need to change the AVAudioSession to 'playback' mode so the device "silent mode" /// doesn't prevent video audio from playing try? AVAudioSession.sharedInstance().setCategory(.playback) - let viewController: AVPlayerViewController = AVPlayerViewController() - viewController.player = AVPlayer(url: URL(fileURLWithPath: originalFilePath)) + let viewController: DismissCallbackAVPlayerViewController = DismissCallbackAVPlayerViewController { [dependencies = viewModel.dependencies] in + /// Sanity check to make sure we don't unintentionally remove a proper attachment file + guard path.hasPrefix(dependencies[singleton: .fileManager].temporaryDirectory) else { + return + } + + try? dependencies[singleton: .fileManager].removeItem(atPath: path) + } + viewController.player = AVPlayer(url: URL(fileURLWithPath: path)) self.navigationController?.present(viewController, animated: true) case .genericAttachment: guard !handleLinkTapIfNeeded(cell: cell, targetView: (cell as? VisibleMessageCell)?.documentView), let attachment: Attachment = cellViewModel.attachments?.first, - let originalFilePath: String = attachment.originalFilePath(using: viewModel.dependencies) + let path: String = try? viewModel.dependencies[singleton: .attachmentManager] + .createTemporaryFileForOpening( + downloadUrl: attachment.downloadUrl, + mimeType: attachment.contentType, + sourceFilename: attachment.sourceFilename + ), + viewModel.dependencies[singleton: .fileManager].fileExists(atPath: path) else { return } - let fileUrl: URL = URL(fileURLWithPath: originalFilePath) + let fileUrl: URL = URL(fileURLWithPath: path) // Open a preview of the document for text, pdf or microsoft files if @@ -1257,6 +1271,9 @@ extension ConversationVC: // Otherwise share the file let shareVC = UIActivityViewController(activityItems: [ fileUrl ], applicationActivities: nil) + shareVC.completionWithItemsHandler = { [dependencies = viewModel.dependencies] _, success, _, _ in + UIActivityViewController.notifyIfNeeded(success, using: dependencies) + } if UIDevice.current.isIPad { shareVC.excludedActivityTypes = [] @@ -1481,7 +1498,8 @@ extension ConversationVC: shouldShowClearAllButton: viewModel.dependencies[singleton: .openGroupManager].isUserModeratorOrAdmin( publicKey: self.viewModel.threadData.currentUserSessionId, for: self.viewModel.threadData.openGroupRoomToken, - on: self.viewModel.threadData.openGroupServer + on: self.viewModel.threadData.openGroupServer, + currentUserSessionIds: (self.viewModel.threadData.currentUserSessionIds ?? []) ) ) reactionListSheet.modalPresentationStyle = .overFullScreen @@ -1530,63 +1548,59 @@ extension ConversationVC: } func removeAllReactions(_ cellViewModel: MessageViewModel, for emoji: String) { - guard cellViewModel.threadVariant == .community else { return } + guard + cellViewModel.threadVariant == .community, + let roomToken: String = viewModel.threadData.openGroupRoomToken, + let server: String = viewModel.threadData.openGroupServer, + let publicKey: String = viewModel.threadData.openGroupPublicKey, + let capabilities: Set = viewModel.threadData.openGroupCapabilities, + let openGroupServerMessageId: Int64 = cellViewModel.openGroupServerMessageId + else { return } - viewModel.dependencies[singleton: .storage] - .readPublisher { [dependencies = viewModel.dependencies] db -> (Network.PreparedRequest, OpenGroupAPI.PendingChange) in - guard - let openGroup: OpenGroup = try? OpenGroup - .fetchOne(db, id: cellViewModel.threadId), - let openGroupServerMessageId: Int64 = try? Interaction - .select(.openGroupServerMessageId) - .filter(id: cellViewModel.id) - .asRequest(of: Int64.self) - .fetchOne(db) - else { throw StorageError.objectNotFound } - - let preparedRequest: Network.PreparedRequest = try OpenGroupAPI - .preparedReactionDeleteAll( - db, - emoji: emoji, - id: openGroupServerMessageId, - in: openGroup.roomToken, - on: openGroup.server, - using: dependencies - ) - let pendingChange: OpenGroupAPI.PendingChange = dependencies[singleton: .openGroupManager] - .addPendingReaction( - emoji: emoji, - id: openGroupServerMessageId, - in: openGroup.roomToken, - on: openGroup.server, - type: .removeAll - ) - - return (preparedRequest, pendingChange) - } - .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: viewModel.dependencies) - .flatMap { [dependencies = viewModel.dependencies] preparedRequest, pendingChange in - preparedRequest.send(using: dependencies) - .handleEvents( - receiveOutput: { _, response in - dependencies[singleton: .openGroupManager].updatePendingChange( - pendingChange, - seqNo: response.seqNo - ) - } + let pendingChange: OpenGroupAPI.PendingChange = viewModel.dependencies[singleton: .openGroupManager] + .addPendingReaction( + emoji: emoji, + id: openGroupServerMessageId, + in: roomToken, + on: server, + type: .removeAll + ) + + Result { + try OpenGroupAPI.preparedReactionDeleteAll( + emoji: emoji, + id: openGroupServerMessageId, + roomToken: roomToken, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: roomToken, + server: server, + publicKey: publicKey, + capabilities: capabilities ) - .eraseToAnyPublisher() - } - .sinkUntilComplete( - receiveCompletion: { [dependencies = viewModel.dependencies] _ in - dependencies[singleton: .storage].writeAsync { db in - _ = try Reaction - .filter(Reaction.Columns.interactionId == cellViewModel.id) - .filter(Reaction.Columns.emoji == emoji) - .deleteAll(db) - } - } + ), + using: viewModel.dependencies ) + } + .publisher + .flatMap { [dependencies = viewModel.dependencies] in $0.send(using: dependencies) } + .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: viewModel.dependencies) + .sinkUntilComplete( + receiveCompletion: { [dependencies = viewModel.dependencies] _ in + dependencies[singleton: .storage].writeAsync { db in + _ = try Reaction + .filter(Reaction.Columns.interactionId == cellViewModel.id) + .filter(Reaction.Columns.emoji == emoji) + .deleteAll(db) + } + }, + receiveValue: { [dependencies = viewModel.dependencies] _, response in + dependencies[singleton: .openGroupManager].updatePendingChange( + pendingChange, + seqNo: response.seqNo + ) + } + ) } func react(_ cellViewModel: MessageViewModel, with emoji: String, remove: Bool) { @@ -1598,6 +1612,7 @@ extension ConversationVC: else { return } // Perform local rate limiting (don't allow more than 20 reactions within 60 seconds) + 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() @@ -1655,148 +1670,162 @@ extension ConversationVC: } } .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: viewModel.dependencies) - .flatMap { [dependencies = viewModel.dependencies] pendingChange -> AnyPublisher, Error> in - dependencies[singleton: .storage].writePublisher { [weak self] db -> Network.PreparedRequest in - // Update the thread to be visible (if it isn't already) - if self?.viewModel.threadData.threadShouldBeVisible == false { - _ = try SessionThread - .filter(id: cellViewModel.threadId) - .updateAllAndConfig( - db, - SessionThread.Columns.shouldBeVisible.set(to: true), - using: dependencies - ) - } - - let pendingReaction: Reaction? = { - guard !remove else { - return try? Reaction - .filter(Reaction.Columns.interactionId == cellViewModel.id) - .filter(Reaction.Columns.authorId == cellViewModel.currentUserSessionId) - .filter(Reaction.Columns.emoji == emoji) - .fetchOne(db) - } - - let sortId: Int64 = Reaction.getSortId( - db, - interactionId: cellViewModel.id, - emoji: emoji - ) - - return Reaction( - interactionId: cellViewModel.id, - serverHash: nil, - timestampMs: sentTimestampMs, - authorId: cellViewModel.currentUserSessionId, - emoji: emoji, - count: 1, - sortId: sortId - ) - }() - - // Update the database - if remove { - try Reaction + .flatMapStorageWritePublisher(using: viewModel.dependencies) { [weak self, dependencies = viewModel.dependencies] db, pendingChange -> (OpenGroupAPI.PendingChange?, Reaction?, Message.Destination, AuthenticationMethod) in + // Update the thread to be visible (if it isn't already) + if self?.viewModel.threadData.threadShouldBeVisible == false { + try SessionThread.updateVisibility( + db, + threadId: cellViewModel.threadId, + isVisible: true, + using: dependencies + ) + } + + let pendingReaction: Reaction? = { + guard !remove else { + return try? Reaction .filter(Reaction.Columns.interactionId == cellViewModel.id) - .filter(Reaction.Columns.authorId == cellViewModel.currentUserSessionId) + // TODO: [Database Relocation] Stop `currentUserSessionIds` from being nullable + .filter((cellViewModel.currentUserSessionIds ?? []).contains(Reaction.Columns.authorId)) .filter(Reaction.Columns.emoji == emoji) - .deleteAll(db) - } - else { - try pendingReaction?.insert(db) - - // Add it to the recent list - Emoji.addRecent(db, emoji: emoji) + .fetchOne(db) } - switch threadVariant { - case .community: - guard - let serverMessageId: Int64 = cellViewModel.openGroupServerMessageId, - let openGroupServer: String = cellViewModel.threadOpenGroupServer, - let openGroupRoom: String = openGroupRoom, - let pendingChange: OpenGroupAPI.PendingChange = pendingChange, - dependencies[singleton: .openGroupManager].doesOpenGroupSupport(db, capability: .reactions, on: openGroupServer) - else { throw MessageSenderError.invalidMessage } - - let preparedRequest: Network.PreparedRequest = try { - guard !remove else { - return try OpenGroupAPI - .preparedReactionDelete( - db, - emoji: emoji, - id: serverMessageId, - in: openGroupRoom, - on: openGroupServer, - using: dependencies - ) - .map { _, response in response.seqNo } - } - + let sortId: Int64 = Reaction.getSortId( + db, + interactionId: cellViewModel.id, + emoji: emoji + ) + + return Reaction( + interactionId: cellViewModel.id, + serverHash: nil, + timestampMs: sentTimestampMs, + authorId: cellViewModel.currentUserSessionId, + emoji: emoji, + count: 1, + sortId: sortId + ) + }() + + // Update the database + if remove { + try Reaction + .filter(Reaction.Columns.interactionId == cellViewModel.id) + // TODO: [Database Relocation] Stop `currentUserSessionIds` from being nullable + .filter((cellViewModel.currentUserSessionIds ?? []).contains(Reaction.Columns.authorId)) + .filter(Reaction.Columns.emoji == emoji) + .deleteAll(db) + } + else { + try pendingReaction?.insert(db) + + // Add it to the recent list + Emoji.addRecent(db, emoji: emoji) + } + + switch threadVariant { + case .community: + guard + let openGroupServer: String = cellViewModel.threadOpenGroupServer, + dependencies[singleton: .openGroupManager].doesOpenGroupSupport(db, capability: .reactions, on: openGroupServer) + else { throw MessageSenderError.invalidMessage } + + default: break + } + + return ( + pendingChange, + pendingReaction, + try Message.Destination.from(db, threadId: threadId, threadVariant: threadVariant), + try Authentication.with(db, threadId: threadId, threadVariant: threadVariant, using: dependencies) + ) + } + .tryFlatMap { [dependencies = viewModel.dependencies] pendingChange, pendingReaction, destination, authMethod in + switch threadVariant { + case .community: + guard + let serverMessageId: Int64 = cellViewModel.openGroupServerMessageId, + let openGroupServer: String = cellViewModel.threadOpenGroupServer, + let openGroupRoom: String = openGroupRoom, + let pendingChange: OpenGroupAPI.PendingChange = pendingChange + else { throw MessageSenderError.invalidMessage } + + let preparedRequest: Network.PreparedRequest = try { + guard !remove else { return try OpenGroupAPI - .preparedReactionAdd( - db, + .preparedReactionDelete( emoji: emoji, id: serverMessageId, - in: openGroupRoom, - on: openGroupServer, + roomToken: openGroupRoom, + authMethod: authMethod, using: dependencies ) .map { _, response in response.seqNo } - }() + } - return preparedRequest - .handleEvents( - receiveOutput: { _, seqNo in - dependencies[singleton: .openGroupManager].updatePendingChange( - pendingChange, - seqNo: seqNo - ) - }, - receiveCompletion: { [weak self] result in - switch result { - case .finished: break - case .failure: - dependencies[singleton: .openGroupManager].removePendingChange(pendingChange) - - self?.handleReactionSentFailure(pendingReaction, remove: remove) - } - } + return try OpenGroupAPI + .preparedReactionAdd( + emoji: emoji, + id: serverMessageId, + roomToken: openGroupRoom, + authMethod: authMethod, + using: dependencies ) - .map { _, _ in () } - - default: - return try MessageSender.preparedSend( - db, - message: VisibleMessage( - sentTimestampMs: UInt64(sentTimestampMs), - text: nil, - reaction: VisibleMessage.VMReaction( - timestamp: UInt64(cellViewModel.timestampMs), - publicKey: { - guard cellViewModel.variant == .standardIncoming else { - return cellViewModel.currentUserSessionId - } - - return cellViewModel.authorId - }(), - emoji: emoji, - kind: (remove ? .remove : .react) + .map { _, response in response.seqNo } + }() + + return preparedRequest + .handleEvents( + receiveOutput: { _, seqNo in + dependencies[singleton: .openGroupManager].updatePendingChange( + pendingChange, + seqNo: seqNo ) - ), - to: try Message.Destination - .from(db, threadId: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant), - namespace: try Message.Destination - .from(db, threadId: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant) - .defaultNamespace, - interactionId: cellViewModel.id, - fileIds: [], - using: dependencies + }, + receiveCompletion: { [weak self] result in + switch result { + case .finished: break + case .failure: + dependencies[singleton: .openGroupManager].removePendingChange(pendingChange) + + self?.handleReactionSentFailure(pendingReaction, remove: remove) + } + } ) - } + .map { _, _ in () } + .send(using: dependencies) + + default: + return try MessageSender.preparedSend( + message: VisibleMessage( + sentTimestampMs: UInt64(sentTimestampMs), + text: nil, + reaction: VisibleMessage.VMReaction( + timestamp: UInt64(cellViewModel.timestampMs), + publicKey: { + guard cellViewModel.variant == .standardIncoming else { + return cellViewModel.currentUserSessionId + } + + return cellViewModel.authorId + }(), + emoji: emoji, + kind: (remove ? .remove : .react) + ) + ), + to: destination, + namespace: .default, + interactionId: cellViewModel.id, + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), + using: dependencies + ) + .map { _, _ in () } + .send(using: dependencies) } } - .flatMap { [dependencies = viewModel.dependencies] request in request.send(using: dependencies) } .sinkUntilComplete() } @@ -1984,31 +2013,6 @@ extension ConversationVC: let interaction: Interaction = try? Interaction.fetchOne(db, id: cellViewModel.id) else { return } - if - let quote = try? interaction.quote.fetchOne(db), - let quotedAttachment = try? quote.attachment.fetchOne(db), - quotedAttachment.isVisualMedia, - quotedAttachment.downloadUrl == Attachment.nonMediaQuoteFileId, - let quotedInteraction = try? quote.originalInteraction.fetchOne(db) - { - let attachment: Attachment? = { - if let attachment = try? quotedInteraction.attachments.fetchOne(db) { - return attachment - } - if - let linkPreview = try? quotedInteraction.linkPreview.fetchOne(db), - let linkPreviewAttachment = try? linkPreview.attachment.fetchOne(db) - { - return linkPreviewAttachment - } - - return nil - }() - try quote.with( - attachmentId: attachment?.cloneAsQuoteThumbnail(using: dependencies)?.inserted(db).id - ).update(db) - } - // Remove message sending jobs for the same interaction in database // Prevent the same message being sent twice try Job.filter(Job.Columns.interactionId == interaction.id).deleteAll(db) @@ -2035,9 +2039,7 @@ extension ConversationVC: timestampMs: cellViewModel.timestampMs, attachments: cellViewModel.attachments, linkPreviewAttachment: cellViewModel.linkPreviewAttachment, - currentUserSessionId: cellViewModel.currentUserSessionId, - currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId + currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []) ) guard let quoteDraft: QuotedReplyModel = maybeQuoteDraft else { return } @@ -2072,8 +2074,9 @@ extension ConversationVC: attachment.state == .uploaded ), let type: UTType = UTType(sessionMimeType: attachment.contentType), - let originalFilePath: String = attachment.originalFilePath(using: viewModel.dependencies), - let data: Data = try? Data(contentsOf: URL(fileURLWithPath: originalFilePath)) + let path: String = try? viewModel.dependencies[singleton: .attachmentManager] + .path(for: attachment.downloadUrl), + let data: Data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return } UIPasteboard.general.setData(data, forPasteboardType: type.identifier) @@ -2220,11 +2223,17 @@ extension ConversationVC: ) } .compactMap { attachment in - guard let originalFilePath: String = attachment.originalFilePath(using: viewModel.dependencies) else { - return nil - } + guard + let path: String = try? viewModel.dependencies[singleton: .attachmentManager] + .createTemporaryFileForOpening( + downloadUrl: attachment.downloadUrl, + mimeType: attachment.contentType, + sourceFilename: attachment.sourceFilename + ), + viewModel.dependencies[singleton: .fileManager].fileExists(atPath: path) + else { return nil } - return (attachment, originalFilePath) + return (attachment, path) } guard !mediaAttachments.isEmpty else { return } @@ -2233,32 +2242,41 @@ extension ConversationVC: isSavingMedia: true, presentingViewController: self, using: viewModel.dependencies - ) { [weak self] in - mediaAttachments.forEach { attachment, originalFilePath in - PHPhotoLibrary.shared().performChanges( - { + ) { [weak self, dependencies = viewModel.dependencies] in + PHPhotoLibrary.shared().performChanges( + { + mediaAttachments.forEach { attachment, path in if attachment.isImage || attachment.isAnimated { PHAssetChangeRequest.creationRequestForAssetFromImage( - atFileURL: URL(fileURLWithPath: originalFilePath) + atFileURL: URL(fileURLWithPath: path) ) } else if attachment.isVideo { PHAssetChangeRequest.creationRequestForAssetFromVideo( - atFileURL: URL(fileURLWithPath: originalFilePath) + atFileURL: URL(fileURLWithPath: path) ) } - }, - completionHandler: { _, _ in - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(ContextMenuVC.dismissDurationPartOne * 1000))) { [weak self] in - self?.viewModel.showToast( - text: "saved".localized(), - backgroundColor: .toast_background, - inset: Values.largeSpacing + (self?.inputAccessoryView?.frame.height ?? 0) - ) + } + }, + completionHandler: { [dependencies] _, _ in + mediaAttachments.forEach { attachment, path in + /// Sanity check to make sure we don't unintentionally remove a proper attachment file + guard path.hasPrefix(dependencies[singleton: .fileManager].temporaryDirectory) else { + return } + + try? dependencies[singleton: .fileManager].removeItem(atPath: path) } - ) - } + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(ContextMenuVC.dismissDurationPartOne * 1000))) { [weak self] in + self?.viewModel.showToast( + text: "saved".localized(), + backgroundColor: .toast_background, + inset: Values.largeSpacing + (self?.inputAccessoryView?.frame.height ?? 0) + ) + } + } + ) // Send a 'media saved' notification if needed guard self?.viewModel.threadData.threadVariant == .contact, cellViewModel.variant == .standardIncoming else { @@ -2283,46 +2301,61 @@ extension ConversationVC: confirmTitle: "theContinue".localized(), confirmStyle: .danger, cancelStyle: .alert_text, - onConfirm: { [weak self, dependencies = viewModel.dependencies] _ in - dependencies[singleton: .storage] - .readPublisher { db -> Network.PreparedRequest in - guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { - throw StorageError.objectNotFound - } - - return try OpenGroupAPI - .preparedUserBan( - db, - sessionId: cellViewModel.authorId, - from: [openGroup.roomToken], - on: openGroup.server, - using: dependencies + onConfirm: { [weak self, threadData = viewModel.threadData, dependencies = viewModel.dependencies] _ in + Result { + guard + cellViewModel.threadVariant == .community, + let roomToken: String = threadData.openGroupRoomToken, + let server: String = threadData.openGroupServer, + let publicKey: String = threadData.openGroupPublicKey, + let capabilities: Set = threadData.openGroupCapabilities, + cellViewModel.openGroupServerMessageId != nil + else { throw CryptoError.invalidAuthentication } + + return ( + roomToken, + Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: roomToken, + server: server, + publicKey: publicKey, + capabilities: capabilities ) - } - .flatMap { $0.send(using: dependencies) } - .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) - .receive(on: DispatchQueue.main, using: dependencies) - .sinkUntilComplete( - receiveCompletion: { result in - DispatchQueue.main.async { [weak self] in - switch result { - case .finished: - self?.viewModel.showToast( - text: "banUserBanned".localized(), - backgroundColor: .backgroundSecondary, - inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing - ) - case .failure: - self?.viewModel.showToast( - text: "banErrorFailed".localized(), - backgroundColor: .backgroundSecondary, - inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing - ) - } - completion?() + ) + ) + } + .publisher + .tryFlatMap { (roomToken: String, authMethod: AuthenticationMethod) in + try OpenGroupAPI.preparedUserBan( + sessionId: cellViewModel.authorId, + from: [roomToken], + authMethod: authMethod, + using: dependencies + ).send(using: dependencies) + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) + .receive(on: DispatchQueue.main, using: dependencies) + .sinkUntilComplete( + receiveCompletion: { result in + DispatchQueue.main.async { [weak self] in + switch result { + case .finished: + self?.viewModel.showToast( + text: "banUserBanned".localized(), + backgroundColor: .backgroundSecondary, + inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing + ) + case .failure: + self?.viewModel.showToast( + text: "banErrorFailed".localized(), + backgroundColor: .backgroundSecondary, + inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing + ) } + completion?() } - ) + } + ) self?.becomeFirstResponder() }, @@ -2347,46 +2380,61 @@ extension ConversationVC: confirmTitle: "theContinue".localized(), confirmStyle: .danger, cancelStyle: .alert_text, - onConfirm: { [weak self, dependencies = viewModel.dependencies] _ in - dependencies[singleton: .storage] - .readPublisher { db in - guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { - throw StorageError.objectNotFound - } + onConfirm: { [weak self, threadData = viewModel.threadData, dependencies = viewModel.dependencies] _ in + Result { + guard + cellViewModel.threadVariant == .community, + let roomToken: String = threadData.openGroupRoomToken, + let server: String = threadData.openGroupServer, + let publicKey: String = threadData.openGroupPublicKey, + let capabilities: Set = threadData.openGroupCapabilities, + let openGroupServerMessageId: Int64 = cellViewModel.openGroupServerMessageId + else { throw CryptoError.invalidAuthentication } - return try OpenGroupAPI - .preparedUserBanAndDeleteAllMessages( - db, - sessionId: cellViewModel.authorId, - in: openGroup.roomToken, - on: openGroup.server, - using: dependencies + return ( + roomToken, + Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: roomToken, + server: server, + publicKey: publicKey, + capabilities: capabilities ) - } - .flatMap { $0.send(using: dependencies) } - .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) - .receive(on: DispatchQueue.main, using: dependencies) - .sinkUntilComplete( - receiveCompletion: { result in - DispatchQueue.main.async { [weak self] in - switch result { - case .finished: - self?.viewModel.showToast( - text: "banUserBanned".localized(), - backgroundColor: .backgroundSecondary, - inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing - ) - case .failure: - self?.viewModel.showToast( - text: "banErrorFailed".localized(), - backgroundColor: .backgroundSecondary, - inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing - ) - } - completion?() + ) + ) + } + .publisher + .tryFlatMap { (roomToken: String, authMethod: AuthenticationMethod) in + try OpenGroupAPI.preparedUserBanAndDeleteAllMessages( + sessionId: cellViewModel.authorId, + roomToken: roomToken, + authMethod: authMethod, + using: dependencies + ).send(using: dependencies) + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) + .receive(on: DispatchQueue.main, using: dependencies) + .sinkUntilComplete( + receiveCompletion: { result in + DispatchQueue.main.async { [weak self] in + switch result { + case .finished: + self?.viewModel.showToast( + text: "banUserBanned".localized(), + backgroundColor: .backgroundSecondary, + inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing + ) + case .failure: + self?.viewModel.showToast( + text: "banErrorFailed".localized(), + backgroundColor: .backgroundSecondary, + inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing + ) } + completion?() } - ) + } + ) self?.becomeFirstResponder() }, @@ -2515,18 +2563,15 @@ extension ConversationVC: } // Get data - let dataSourceOrNil = DataSourcePath(fileUrl: audioRecorder.url, sourceFilename: nil, shouldDeleteOnDeinit: true, using: viewModel.dependencies) + let fileName = ("messageVoice".localized() as NSString) + .appendingPathExtension("m4a") // stringlint:ignore + let dataSourceOrNil = DataSourcePath(fileUrl: audioRecorder.url, sourceFilename: fileName, shouldDeleteOnDeinit: true, using: viewModel.dependencies) self.audioRecorder = nil guard let dataSource = dataSourceOrNil else { return Log.error(.conversation, "Couldn't load recorded data.") } - // Create attachment - let fileName = ("messageVoice".localized() as NSString) - .appendingPathExtension("m4a") // stringlint:ignore - dataSource.sourceFilename = fileName - let attachment = SignalAttachment.voiceMessageAttachment(dataSource: dataSource, type: .mpeg4Audio, using: viewModel.dependencies) guard !attachment.hasError else { @@ -2601,6 +2646,16 @@ extension ConversationVC: UIDocumentInteractionControllerDelegate { func documentInteractionControllerViewControllerForPreview(_ controller: UIDocumentInteractionController) -> UIViewController { return self } + + public func documentInteractionControllerDidEndPreview(_ controller: UIDocumentInteractionController) { + guard let temporaryFileUrl: URL = controller.url else { return } + + /// Now that we are finished with it we want to remove the temporary file (just to be safe ensure that it starts with the + /// `temporaryDirectory` so we don't accidentally delete a proper file if logic elsewhere changes) + if temporaryFileUrl.path.starts(with: viewModel.dependencies[singleton: .fileManager].temporaryDirectory) { + try? viewModel.dependencies[singleton: .fileManager].removeItem(atPath: temporaryFileUrl.path) + } + } } // MARK: - Message Request Actions @@ -2633,9 +2688,8 @@ extension ConversationVC { switch threadVariant { case .contact: - // If the contact doesn't exist then we should create it so we can store the 'isApproved' state - // (it'll be updated with correct profile info if they accept the message request so this - // shouldn't cause weird behaviours) + /// If the contact doesn't exist then we should create it so we can store the `isApproved` state (it'll be updated + /// with correct profile info if they accept the message request so this shouldn't cause weird behaviours) guard let contact: Contact = viewModel.dependencies[singleton: .storage].read({ [dependencies = viewModel.dependencies] db in Contact.fetchOrCreate(db, id: threadId, using: dependencies) @@ -2675,16 +2729,19 @@ extension ConversationVC { } // Default 'didApproveMe' to true for the person approving the message request + let updatedDidApproveMe: Bool = (contact.didApproveMe || !isDraft) try contact.upsert(db) try Contact .filter(id: contact.id) .updateAllAndConfig( db, Contact.Columns.isApproved.set(to: true), - Contact.Columns.didApproveMe - .set(to: contact.didApproveMe || !isDraft), + Contact.Columns.didApproveMe.set(to: updatedDidApproveMe), using: dependencies ) + db.addContactEvent(id: contact.id, change: .isApproved(true)) + db.addContactEvent(id: contact.id, change: .didApproveMe(updatedDidApproveMe)) + db.addEvent(contact.id, forKey: .messageRequestAccepted) } .map { _ in () } .catch { _ in Just(()).eraseToAnyPublisher() } @@ -2911,9 +2968,7 @@ extension ConversationVC { // MARK: - MediaPresentationContextProvider extension ConversationVC: MediaPresentationContextProvider { - func mediaPresentationContext(mediaItem: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? { - guard case let .gallery(galleryItem, _) = mediaItem else { return nil } - + func mediaPresentationContext(mediaId: String, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? { // Note: According to Apple's docs the 'indexPathsForVisibleRows' method returns an // unsorted array which means we can't use it to determine the desired 'visibleCell' // we are after, due to this we will need to iterate all of the visible cells to find @@ -2924,7 +2979,7 @@ extension ConversationVC: MediaPresentationContextProvider { .albumView? .itemViews .contains(where: { mediaView in - mediaView.attachment.id == galleryItem.attachment.id + mediaView.attachment.id == mediaId })) .defaulting(to: false) } @@ -2932,7 +2987,7 @@ extension ConversationVC: MediaPresentationContextProvider { let maybeTargetView: MediaView? = maybeMessageCell? .albumView? .itemViews - .first(where: { $0.attachment.id == galleryItem.attachment.id }) + .first(where: { $0.attachment.id == mediaId }) guard let messageCell: VisibleMessageCell = maybeMessageCell, @@ -2993,7 +3048,7 @@ extension ConversationVC: MediaPresentationContextProvider { } return MediaPresentationContext( - mediaView: targetView, + mediaView: targetView.imageView, presentationFrame: presentationFrame, cornerRadius: cornerRadius, cornerMask: cornerMask diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index be055c890f..4bf322bf00 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -792,7 +792,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval || viewModel.threadData.profile != updatedThreadData.profile || viewModel.threadData.additionalProfile != updatedThreadData.additionalProfile || - viewModel.threadData.displayPictureFilename != updatedThreadData.displayPictureFilename + viewModel.threadData.threadDisplayPictureUrl != updatedThreadData.threadDisplayPictureUrl { updateNavBarButtons( threadData: updatedThreadData, @@ -1383,7 +1383,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa profilePictureView.update( publicKey: threadData.threadId, // Contact thread uses the contactId threadVariant: threadData.threadVariant, - displayPictureFilename: threadData.displayPictureFilename, + displayPictureUrl: threadData.threadDisplayPictureUrl, profile: threadData.profile, additionalProfile: threadData.additionalProfile, using: viewModel.dependencies @@ -1980,10 +1980,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa self.searchController.resultsBar.startLoading() DispatchQueue.global(qos: .userInitiated).async { [weak self] in - self?.viewModel.pagedDataObserver?.load(.jumpTo( - id: interactionInfo.id, - paddingForInclusive: 5 - )) + self?.viewModel.pagedDataObserver?.load(.jumpTo(id: interactionInfo.id, padding: 5)) } return } diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 272da0d35c..4989d12775 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -108,7 +108,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold }() // MARK: - Initialization - + // TODO: [Database Relocation] Initialise this with the thread data from the home screen (might mean we can avoid some of the `initialData` query? init( threadId: String, threadVariant: SessionThread.Variant, @@ -125,8 +125,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold currentUserIsClosedGroupAdmin: Bool?, openGroupPermissions: OpenGroup.Permissions?, threadWasMarkedUnread: Bool, - blinded15SessionId: SessionId?, - blinded25SessionId: SessionId? + currentUserSessionIds: Set ) let initialData: InitialData? = dependencies[singleton: .storage].read { db -> InitialData in @@ -216,20 +215,28 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold .asRequest(of: Bool.self) .fetchOne(db)) .defaulting(to: false) - let blinded15SessionId: SessionId? = SessionThread.getCurrentUserBlindedSessionId( - db, - threadId: threadId, - threadVariant: threadVariant, - blindingPrefix: .blinded15, - using: dependencies - ) - let blinded25SessionId: SessionId? = SessionThread.getCurrentUserBlindedSessionId( - db, - threadId: threadId, - threadVariant: threadVariant, - blindingPrefix: .blinded25, - using: dependencies - ) + var currentUserSessionIds: Set = Set([userSessionId.hexString]) + + if + threadVariant == .community, + let openGroupCapabilityInfo: LibSession.OpenGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo + .fetchOne(db, id: threadId) + { + currentUserSessionIds = currentUserSessionIds.inserting(SessionThread.getCurrentUserBlindedSessionId( + threadId: threadId, + threadVariant: threadVariant, + blindingPrefix: .blinded15, + openGroupCapabilityInfo: openGroupCapabilityInfo, + using: dependencies + )?.hexString) + currentUserSessionIds = currentUserSessionIds.inserting(SessionThread.getCurrentUserBlindedSessionId( + threadId: threadId, + threadVariant: threadVariant, + blindingPrefix: .blinded25, + openGroupCapabilityInfo: openGroupCapabilityInfo, + using: dependencies + )?.hexString) + } return ( userSessionId, @@ -241,8 +248,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold currentUserIsClosedGroupAdmin, openGroupPermissions, threadWasMarkedUnread, - blinded15SessionId, - blinded25SessionId + currentUserSessionIds ) } @@ -264,18 +270,25 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold threadWasMarkedUnread: initialData?.threadWasMarkedUnread, using: dependencies ).populatingPostQueryData( - currentUserBlinded15SessionIdForThisThread: initialData?.blinded15SessionId?.hexString, - currentUserBlinded25SessionIdForThisThread: initialData?.blinded25SessionId?.hexString, + recentReactionEmoji: nil, + openGroupCapabilities: nil, + currentUserSessionIds: ( + initialData?.currentUserSessionIds ?? + [dependencies[cache: .general].sessionId.hexString] + ), wasKickedFromGroup: ( threadVariant == .group && - LibSession.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadId), using: dependencies) + dependencies.mutate(cache: .libSession) { cache in + cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadId)) + } ), groupIsDestroyed: ( threadVariant == .group && - LibSession.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadId), using: dependencies) + dependencies.mutate(cache: .libSession) { cache in + cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadId)) + } ), - threadCanWrite: true, // Assume true - using: dependencies + threadCanWrite: true // Assume true ) self.pagedDataObserver = nil self.dependencies = dependencies @@ -287,8 +300,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold self.pagedDataObserver = self.setupPagedObserver( for: threadId, userSessionId: (initialData?.userSessionId ?? dependencies[cache: .general].sessionId), - blinded15SessionId: initialData?.blinded15SessionId, - blinded25SessionId: initialData?.blinded25SessionId, + currentUserSessionIds: ( + initialData?.currentUserSessionIds ?? + [dependencies[cache: .general].sessionId.hexString] + ), using: dependencies ) @@ -329,44 +344,50 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold public lazy var observableThreadData: ThreadObservation = setupObservableThreadData(for: self.threadId) private func setupObservableThreadData(for threadId: String) -> ThreadObservation { - return ValueObservation - .trackingConstantRegion { [weak self, dependencies] db -> SessionThreadViewModel? in + return ObservationBuilderOld + .databaseObservation(dependencies) { [weak self, dependencies] db -> SessionThreadViewModel? in let userSessionId: SessionId = dependencies[cache: .general].sessionId let recentReactionEmoji: [String] = try Emoji.getRecent(db, withDefaultEmoji: true) let threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel .conversationQuery(threadId: threadId, userSessionId: userSessionId) .fetchOne(db) + let openGroupCapabilities: Set? = (threadViewModel?.threadVariant != .community ? + nil : + try Capability + .select(.variant) + .filter(Capability.Columns.openGroupServer == threadViewModel?.openGroupServer?.lowercased()) + .filter(Capability.Columns.isMissing == false) + .asRequest(of: Capability.Variant.self) + .fetchSet(db) + ) - return threadViewModel - .map { $0.with(recentReactionEmoji: recentReactionEmoji) } - .map { viewModel -> SessionThreadViewModel in - let wasKickedFromGroup: Bool = ( - viewModel.threadVariant == .group && - LibSession.wasKickedFromGroup( - groupSessionId: SessionId(.group, hex: viewModel.threadId), - using: dependencies - ) - ) - let groupIsDestroyed: Bool = ( - viewModel.threadVariant == .group && - LibSession.groupIsDestroyed( - groupSessionId: SessionId(.group, hex: viewModel.threadId), - using: dependencies - ) - ) - - return viewModel.populatingPostQueryData( - db, - currentUserBlinded15SessionIdForThisThread: self?.threadData.currentUserBlinded15SessionId, - currentUserBlinded25SessionIdForThisThread: self?.threadData.currentUserBlinded25SessionId, - wasKickedFromGroup: wasKickedFromGroup, - groupIsDestroyed: groupIsDestroyed, - threadCanWrite: viewModel.determineInitialCanWriteFlag(using: dependencies), - using: dependencies - ) - } + return threadViewModel.map { viewModel -> SessionThreadViewModel in + let wasKickedFromGroup: Bool = ( + viewModel.threadVariant == .group && + dependencies.mutate(cache: .libSession) { cache in + cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: viewModel.threadId)) + } + ) + let groupIsDestroyed: Bool = ( + viewModel.threadVariant == .group && + dependencies.mutate(cache: .libSession) { cache in + cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: viewModel.threadId)) + } + ) + + return viewModel.populatingPostQueryData( + recentReactionEmoji: recentReactionEmoji, + openGroupCapabilities: openGroupCapabilities, + currentUserSessionIds: ( + self?.threadData.currentUserSessionIds ?? + [userSessionId.hexString] + ), + wasKickedFromGroup: wasKickedFromGroup, + groupIsDestroyed: groupIsDestroyed, + threadCanWrite: viewModel.determineInitialCanWriteFlag(using: dependencies) + ) + } } - .removeDuplicates() .handleEvents(didFail: { Log.error(.conversation, "Observation failed with error: \($0)") }) } @@ -436,8 +457,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold private func setupPagedObserver( for threadId: String, userSessionId: SessionId, - blinded15SessionId: SessionId?, - blinded25SessionId: SessionId?, + currentUserSessionIds: Set, using dependencies: Dependencies ) -> PagedDatabaseObserver { return PagedDatabaseObserver( @@ -481,7 +501,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ), PagedData.ObservedChanges( table: Profile.self, - columns: [.profilePictureFileName], + columns: [.displayPictureUrl], joinToPagedType: { let interaction: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() @@ -505,8 +525,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold orderSQL: MessageViewModel.orderSQL, dataQuery: MessageViewModel.baseQuery( userSessionId: userSessionId, - blinded15SessionId: blinded15SessionId, - blinded25SessionId: blinded25SessionId, + currentUserSessionIds: currentUserSessionIds, orderSQL: MessageViewModel.orderSQL, groupSQL: MessageViewModel.groupSQL ), @@ -562,14 +581,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ], dataQuery: MessageViewModel.QuotedInfo.baseQuery( userSessionId: userSessionId, - blinded15SessionId: blinded15SessionId, - blinded25SessionId: blinded25SessionId - ), - joinToPagedType: MessageViewModel.QuotedInfo.joinToViewModelQuerySQL( - userSessionId: userSessionId, - blinded15SessionId: blinded15SessionId, - blinded25SessionId: blinded25SessionId + currentUserSessionIds: currentUserSessionIds ), + joinToPagedType: MessageViewModel.QuotedInfo.joinToViewModelQuerySQL(), retrieveRowIdsForReferencedRowIds: MessageViewModel.QuotedInfo.createReferencedRowIdsRetriever(), associateData: MessageViewModel.QuotedInfo.createAssociateDataClosure() ) @@ -635,16 +649,11 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ), isLastOutgoing: ( cellViewModel.id == sortedData - .filter { - $0.authorId == threadData.currentUserSessionId || - $0.authorId == threadData.currentUserBlinded15SessionId || - $0.authorId == threadData.currentUserBlinded25SessionId - } + .filter { (threadData.currentUserSessionIds ?? []).contains($0.authorId) } .last? .id ), - currentUserBlinded15SessionId: threadData.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: threadData.currentUserBlinded25SessionId, + currentUserSessionIds: (threadData.currentUserSessionIds ?? []), using: dependencies ) } @@ -710,20 +719,18 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold // Generate the optimistic data let optimisticMessageId: UUID = UUID() let threadData: SessionThreadViewModel = self.internalThreadData - let currentUserProfile: Profile = Profile.fetchOrCreateCurrentUser(using: dependencies) + let currentUserProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } let interaction: Interaction = Interaction( threadId: threadData.threadId, threadVariant: threadData.threadVariant, - authorId: (threadData.currentUserBlinded15SessionId ?? threadData.currentUserSessionId), + authorId: (threadData.currentUserSessionIds ?? []) + .first { $0.hasPrefix(SessionId.Prefix.blinded15.rawValue) } + .defaulting(to: threadData.currentUserSessionId), variant: .standardOutgoing, body: text, timestampMs: sentTimestampMs, hasMention: Interaction.isUserMentioned( - publicKeysToCheck: [ - threadData.currentUserSessionId, - threadData.currentUserBlinded15SessionId, - threadData.currentUserBlinded25SessionId - ].compactMap { $0 }, + publicKeysToCheck: (threadData.currentUserSessionIds ?? []), body: text ), expiresInSeconds: threadData.disappearingMessagesConfiguration?.expiresInSeconds(), @@ -734,7 +741,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold using: dependencies ) let optimisticAttachments: [Attachment]? = attachments - .map { Attachment.prepare(attachments: $0, using: dependencies) } + .map { AttachmentUploader.prepare(attachments: $0, using: dependencies) } let linkPreviewAttachment: Attachment? = linkPreviewDraft.map { draft in try? LinkPreview.generateAttachmentIfPossible( imageData: draft.jpegImageData, @@ -769,7 +776,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold return dependencies[singleton: .openGroupManager].isUserModeratorOrAdmin( publicKey: threadData.currentUserSessionId, for: threadData.openGroupRoomToken, - on: threadData.openGroupServer + on: threadData.openGroupServer, + currentUserSessionIds: (threadData.currentUserSessionIds ?? []) ) default: return false @@ -782,8 +790,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold interactionId: -1, // Can't save to db optimistically authorId: model.authorId, timestampMs: model.timestampMs, - body: model.body, - attachmentId: model.attachment?.id + body: model.body ) }, quoteAttachment: quoteModel?.attachment, @@ -915,12 +922,13 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold return (try MentionInfo .query( - userPublicKey: userSessionId.hexString, threadId: threadData.threadId, threadVariant: threadData.threadVariant, targetPrefixes: targetPrefixes, - currentUserBlinded15SessionId: self?.threadData.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: self?.threadData.currentUserBlinded25SessionId, + currentUserSessionIds: ( + self?.threadData.currentUserSessionIds ?? + [userSessionId.hexString] + ), pattern: pattern )? .fetchAll(db)) @@ -1017,8 +1025,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold self.pagedDataObserver = self.setupPagedObserver( for: updatedThreadId, userSessionId: dependencies[cache: .general].sessionId, - blinded15SessionId: nil, - blinded25SessionId: nil, + currentUserSessionIds: [dependencies[cache: .general].sessionId.hexString], using: dependencies ) @@ -1037,6 +1044,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold try Contact .filter(id: threadId) .updateAll(db, Contact.Columns.isTrusted.set(to: true)) + db.addContactEvent(id: threadId, change: .isTrusted(true)) // Start downloading any pending attachments for this contact (UI will automatically be // updated due to the database observation) @@ -1071,6 +1079,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold Contact.Columns.isBlocked.set(to: false), using: dependencies ) + db.addContactEvent(id: threadId, change: .isBlocked(false)) } } @@ -1137,8 +1146,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold let attachment: Attachment = viewModel.attachments?.first, attachment.isAudio, attachment.isValid, - let originalFilePath: String = attachment.originalFilePath(using: dependencies), - dependencies[singleton: .fileManager].fileExists(atPath: originalFilePath) + let path: String = try? dependencies[singleton: .attachmentManager].path(for: attachment.downloadUrl), + dependencies[singleton: .fileManager].fileExists(atPath: path) else { return nil } // Create the info with the update callback @@ -1165,8 +1174,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold guard let attachment: Attachment = viewModel.attachments?.first, - let originalFilePath: String = attachment.originalFilePath(using: dependencies), - dependencies[singleton: .fileManager].fileExists(atPath: originalFilePath) + let filePath: String = try? dependencies[singleton: .attachmentManager].path(for: attachment.downloadUrl), + dependencies[singleton: .fileManager].fileExists(atPath: filePath) else { return } // If the user interacted with the currently playing item @@ -1205,7 +1214,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold _audioPlayer.set(to: nil) let newAudioPlayer: OWSAudioPlayer = OWSAudioPlayer( - mediaUrl: URL(fileURLWithPath: originalFilePath), + mediaUrl: URL(fileURLWithPath: filePath), audioBehavior: .audioMessagePlayback, delegate: self ) @@ -1313,7 +1322,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold .firstIndex(where: { $0.id == interactionId }), currentIndex < (messageSection.elements.count - 1), messageSection.elements[currentIndex + 1].cellType == .voiceMessage, - dependencies[singleton: .storage, key: .shouldAutoPlayConsecutiveAudioMessages] + dependencies.mutate(cache: .libSession, { $0.get(.shouldAutoPlayConsecutiveAudioMessages) }) else { return } let nextItem: MessageViewModel = messageSection.elements[currentIndex + 1] diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 7d56bda0b4..6897337b15 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -275,9 +275,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M authorId: quoteDraftInfo.model.authorId, quotedText: quoteDraftInfo.model.body, threadVariant: threadVariant, - currentUserSessionId: quoteDraftInfo.model.currentUserSessionId, - currentUserBlinded15SessionId: quoteDraftInfo.model.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: quoteDraftInfo.model.currentUserBlinded25SessionId, + currentUserSessionIds: quoteDraftInfo.model.currentUserSessionIds, direction: (quoteDraftInfo.isOutgoing ? .outgoing : .incoming), attachment: quoteDraftInfo.model.attachment, using: dependencies @@ -300,7 +298,9 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M // told them about link previews yet let text = inputTextView.text! DispatchQueue.global(qos: .userInitiated).async { [weak self, dependencies] in - let areLinkPreviewsEnabled: Bool = dependencies[singleton: .storage, key: .areLinkPreviewsEnabled] + let areLinkPreviewsEnabled: Bool = dependencies.mutate(cache: .libSession) { cache in + cache.get(.areLinkPreviewsEnabled) + } if !LinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty && @@ -529,13 +529,9 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M func showMentionsUI( for candidates: [MentionInfo], - currentUserSessionId: String, - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String? + currentUserSessionIds: Set ) { - mentionsView.currentUserSessionId = currentUserSessionId - mentionsView.currentUserBlinded15SessionId = currentUserBlinded15SessionId - mentionsView.currentUserBlinded25SessionId = currentUserBlinded25SessionId + mentionsView.currentUserSessionIds = currentUserSessionIds mentionsView.candidates = candidates let mentionCellHeight = (ProfilePictureView.Size.message.viewSize + 2 * Values.smallSpacing) diff --git a/Session/Conversations/Input View/MentionSelectionView.swift b/Session/Conversations/Input View/MentionSelectionView.swift index c448ebd1b5..12d15f42ef 100644 --- a/Session/Conversations/Input View/MentionSelectionView.swift +++ b/Session/Conversations/Input View/MentionSelectionView.swift @@ -8,9 +8,7 @@ import SignalUtilitiesKit final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDelegate { private let dependencies: Dependencies - var currentUserSessionId: String? - var currentUserBlinded15SessionId: String? - var currentUserBlinded25SessionId: String? + var currentUserSessionIds: Set = [] var candidates: [MentionInfo] = [] { didSet { tableView.isScrollEnabled = (candidates.count > 4) @@ -93,11 +91,10 @@ final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDele isUserModeratorOrAdmin: dependencies[singleton: .openGroupManager].isUserModeratorOrAdmin( publicKey: candidates[indexPath.row].profile.id, for: candidates[indexPath.row].openGroupRoomToken, - on: candidates[indexPath.row].openGroupServer + on: candidates[indexPath.row].openGroupServer, + currentUserSessionIds: currentUserSessionIds ), - currentUserSessionId: currentUserSessionId, - currentUserBlinded15SessionId: currentUserBlinded15SessionId, - currentUserBlinded25SessionId: currentUserBlinded25SessionId, + currentUserSessionIds: currentUserSessionIds, isLast: (indexPath.row == (candidates.count - 1)), using: dependencies ) @@ -196,17 +193,10 @@ private extension MentionSelectionView { with profile: Profile, threadVariant: SessionThread.Variant, isUserModeratorOrAdmin: Bool, - currentUserSessionId: String?, - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String?, + currentUserSessionIds: Set, isLast: Bool, using dependencies: Dependencies ) { - let currentUserSessionIds: Set = [ - currentUserSessionId, - currentUserBlinded15SessionId, - currentUserBlinded25SessionId - ].compactMap { $0 }.asSet() displayNameLabel.text = (currentUserSessionIds.contains(profile.id) ? "you".localized() : profile.displayName(for: threadVariant) @@ -215,7 +205,7 @@ private extension MentionSelectionView { profilePictureView.update( publicKey: profile.id, threadVariant: .contact, // Always show the display picture in 'contact' mode - displayPictureFilename: nil, + displayPictureUrl: nil, profile: profile, profileIcon: (isUserModeratorOrAdmin ? .crown : .none), using: dependencies diff --git a/Session/Conversations/Message Cells/CallMessageCell.swift b/Session/Conversations/Message Cells/CallMessageCell.swift index 74eaf99721..6b57d809f1 100644 --- a/Session/Conversations/Message Cells/CallMessageCell.swift +++ b/Session/Conversations/Message Cells/CallMessageCell.swift @@ -168,7 +168,7 @@ final class CallMessageCell: MessageCell { let shouldShowInfoIcon: Bool = ( ( messageInfo.state == .permissionDenied && - !dependencies[singleton: .storage, key: .areCallsEnabled] + !dependencies.mutate(cache: .libSession, { $0.get(.areCallsEnabled) }) ) || ( messageInfo.state == .permissionDeniedMicrophone && Permissions.microphone != .granted @@ -231,7 +231,7 @@ final class CallMessageCell: MessageCell { guard ( messageInfo.state == .permissionDenied && - !dependencies[singleton: .storage, key: .areCallsEnabled] + !dependencies.mutate(cache: .libSession, { $0.get(.areCallsEnabled) }) ) || ( messageInfo.state == .permissionDeniedMicrophone && Permissions.microphone != .granted diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift index 9ca6fc32e9..0a57b99575 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift @@ -109,7 +109,7 @@ public extension LinkPreview { return nil } guard let image = UIImage(data: imageData) else { - Log.error("[LinkPreview] Could not load image: \(imageAttachment?.localRelativeFilePath ?? "unknown")") + Log.error("[LinkPreview] Could not load image: \(imageAttachment?.downloadUrl ?? "unknown")") return nil } diff --git a/Session/Conversations/Message Cells/Content Views/MediaView.swift b/Session/Conversations/Message Cells/Content Views/MediaView.swift index 83e19584d4..370c14ac1f 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaView.swift @@ -79,7 +79,7 @@ public class MediaView: UIView { // MARK: - UI - private lazy var imageView: SessionImageView = { + public lazy var imageView: SessionImageView = { let result: SessionImageView = SessionImageView( dataManager: dependencies[singleton: .imageDataManager] ) @@ -194,43 +194,11 @@ public class MediaView: UIView { case (_, false, _), (_, _, false): return configure(forError: .invalid) case (_, true, true): - if attachment.isAnimated { - /// We can't create an animated thumbnail so should just load the full file in this case - guard let filePath: String = attachment.originalFilePath(using: dependencies) else { - return configure(forError: .invalid) - } + imageView.loadThumbnail(size: .medium, attachment: attachment, using: dependencies) { [weak self] success in + guard !success else { return } - imageView.loadImage(from: filePath) - } - else { - /// Otherwise we want to load a thumbnail instead of the original image - let thumbnailPath: String = attachment.thumbnailPath(for: Attachment.ThumbnailSize.medium.dimension) - - imageView.loadImage(identifier: thumbnailPath) { [weak self] in - guard let self = self else { return nil } - - var data: Data? - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - - self.attachment.thumbnail( - size: .medium, - using: self.dependencies, - success: { _, _, imageData in - data = try? imageData() - semaphore.signal() - }, - failure: { semaphore.signal() } - ) - semaphore.wait() - - guard let data: Data = data else { - Log.error("[MediaView] Could not load thumbnail") - Task { @MainActor [weak self] in self?.configure(forError: .invalid) } - return nil - } - - return data - } + Log.error("[MediaView] Could not load thumbnail") + Task { @MainActor [weak self] in self?.configure(forError: .invalid) } } } diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index 419a146ef5..452fd78e2e 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -30,9 +30,7 @@ final class QuoteView: UIView { authorId: String, quotedText: String?, threadVariant: SessionThread.Variant, - currentUserSessionId: String?, - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String?, + currentUserSessionIds: Set, direction: Direction, attachment: Attachment?, using dependencies: Dependencies, @@ -48,9 +46,7 @@ final class QuoteView: UIView { authorId: authorId, quotedText: quotedText, threadVariant: threadVariant, - currentUserSessionId: currentUserSessionId, - currentUserBlinded15SessionId: currentUserBlinded15SessionId, - currentUserBlinded25SessionId: currentUserBlinded25SessionId, + currentUserSessionIds: currentUserSessionIds, direction: direction, attachment: attachment ) @@ -69,9 +65,7 @@ final class QuoteView: UIView { authorId: String, quotedText: String?, threadVariant: SessionThread.Variant, - currentUserSessionId: String?, - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String?, + currentUserSessionIds: Set, direction: Direction, attachment: Attachment? ) { @@ -137,32 +131,10 @@ final class QuoteView: UIView { } // Generate the thumbnail if needed - if attachment.isVisualMedia { - let thumbnailPath: String = attachment.thumbnailPath(for: Attachment.ThumbnailSize.medium.dimension) + imageView.loadThumbnail(size: .medium, attachment: attachment, using: dependencies) { [weak imageView] success in + guard success else { return } - imageView.loadImage( - identifier: thumbnailPath, - from: { [weak self] () -> Data? in - guard let self = self else { return nil } - - var data: Data? - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - - attachment.thumbnail( - size: .medium, - using: self.dependencies, - success: { _, _, imageData in - data = try? imageData() - semaphore.signal() - }, - failure: { semaphore.signal() } - ) - semaphore.wait() - - return data - }, - onComplete: { [weak imageView] in imageView?.contentMode = .scaleAspectFill } - ) + imageView?.contentMode = .scaleAspectFill } } else { @@ -202,9 +174,7 @@ final class QuoteView: UIView { MentionUtilities.highlightMentions( in: $0, threadVariant: threadVariant, - currentUserSessionId: currentUserSessionId, - currentUserBlinded15SessionId: currentUserBlinded15SessionId, - currentUserBlinded25SessionId: currentUserBlinded25SessionId, + currentUserSessionIds: currentUserSessionIds, location: { switch (mode, direction) { case (.draft, _): return .quoteDraft @@ -227,19 +197,10 @@ final class QuoteView: UIView { .defaulting(to: ThemedAttributedString(string: "messageErrorOriginal".localized(), attributes: [ .themeForegroundColor: targetThemeColor ])) // Label stack view - let isCurrentUser: Bool = [ - currentUserSessionId, - currentUserBlinded15SessionId, - currentUserBlinded25SessionId - ] - .compactMap { $0 } - .asSet() - .contains(authorId) - let authorLabel = UILabel() authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize) authorLabel.text = { - guard !isCurrentUser else { return "you".localized() } + guard !currentUserSessionIds.contains(authorId) else { return "you".localized() } guard body != nil else { // When we can't find the quoted message we want to hide the author label return Profile.displayNameNoFallback( diff --git a/Session/Conversations/Message Cells/Content Views/SwiftUI/QuoteView_SwiftUI.swift b/Session/Conversations/Message Cells/Content Views/SwiftUI/QuoteView_SwiftUI.swift index 06cba0aa29..2471b9fd3b 100644 --- a/Session/Conversations/Message Cells/Content Views/SwiftUI/QuoteView_SwiftUI.swift +++ b/Session/Conversations/Message Cells/Content Views/SwiftUI/QuoteView_SwiftUI.swift @@ -13,15 +13,11 @@ struct QuoteView_SwiftUI: View { var authorId: String var quotedText: String? var threadVariant: SessionThread.Variant - var currentUserSessionId: String? - var currentUserBlinded15SessionId: String? - var currentUserBlinded25SessionId: String? + var currentUserSessionIds: Set var direction: Direction var attachment: Attachment? } - @State private var thumbnail: UIImage? = nil - private static let thumbnailSize: CGFloat = 48 private static let iconSize: CGFloat = 24 private static let labelStackViewSpacing: CGFloat = 2 @@ -33,16 +29,7 @@ struct QuoteView_SwiftUI: View { private var info: Info private var onCancel: (() -> ())? - private var isCurrentUser: Bool { - return [ - info.currentUserSessionId, - info.currentUserBlinded15SessionId, - info.currentUserBlinded25SessionId - ] - .compactMap { $0 } - .asSet() - .contains(info.authorId) - } + private var isCurrentUser: Bool { info.currentUserSessionIds.contains(info.authorId) } private var quotedText: String? { if let quotedText = info.quotedText, !quotedText.isEmpty { return quotedText @@ -76,17 +63,6 @@ struct QuoteView_SwiftUI: View { self.dependencies = dependencies self.info = info self.onCancel = onCancel - - if let attachment = info.attachment, attachment.isVisualMedia { - attachment.thumbnail( - size: .small, - using: dependencies, - success: { [self] _, imageRetriever, _ in - self.thumbnail = imageRetriever() - }, - failure: {} - ) - } } var body: some View { @@ -95,42 +71,48 @@ struct QuoteView_SwiftUI: View { spacing: Values.smallSpacing ) { if let attachment: Attachment = info.attachment { - // Attachment thumbnail - if let image: UIImage = { - if let thumbnail = self.thumbnail { - return thumbnail - } + ZStack() { + RoundedRectangle( + cornerRadius: Self.cornerRadius + ) + .fill(themeColor: .messageBubble_overlay) + .frame( + width: Self.thumbnailSize, + height: Self.thumbnailSize + ) - let fallbackImageName: String = (attachment.isAudio ? "attachment_audio" : "actionsheet_document_black") - return UIImage(named: fallbackImageName)? - .withRenderingMode(.alwaysTemplate) - }() { - ZStack() { - RoundedRectangle( - cornerRadius: Self.cornerRadius - ) - .fill(themeColor: .messageBubble_overlay) - .frame( - width: Self.thumbnailSize, - height: Self.thumbnailSize - ) + SessionAsyncImage( + attachment: attachment, + thumbnailSize: .medium, + using: dependencies + ) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + let fallbackImageName: String = (attachment.isAudio ? "attachment_audio" : "actionsheet_document_black") - Image(uiImage: image) - .foregroundColor(themeColor: { - switch info.mode { - case .regular: return (info.direction == .outgoing ? - .messageBubble_outgoingText : - .messageBubble_incomingText - ) - case .draft: return .textPrimary - } - }()) - .frame( - width: Self.iconSize, - height: Self.iconSize, - alignment: .center - ) + if let image = UIImage(named: fallbackImageName)?.withRenderingMode(.alwaysTemplate) { + Image(uiImage: image) + .foregroundColor(themeColor: { + switch info.mode { + case .regular: return (info.direction == .outgoing ? + .messageBubble_outgoingText : + .messageBubble_incomingText + ) + case .draft: return .textPrimary + } + }()) + } + else { + Color.clear + } } + .frame( + width: Self.iconSize, + height: Self.iconSize, + alignment: .center + ) } } else { // Line view @@ -173,9 +155,7 @@ struct QuoteView_SwiftUI: View { MentionUtilities.highlightMentions( in: quotedText, threadVariant: info.threadVariant, - currentUserSessionId: info.currentUserSessionId, - currentUserBlinded15SessionId: info.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: info.currentUserBlinded25SessionId, + currentUserSessionIds: info.currentUserSessionIds, location: { switch (info.mode, info.direction) { case (.draft, _): return .quoteDraft @@ -234,6 +214,7 @@ struct QuoteView_SwiftUI_Previews: PreviewProvider { mode: .draft, authorId: "", threadVariant: .contact, + currentUserSessionIds: [], direction: .outgoing ), using: Dependencies.createEmpty() @@ -245,6 +226,7 @@ struct QuoteView_SwiftUI_Previews: PreviewProvider { mode: .regular, authorId: "", threadVariant: .contact, + currentUserSessionIds: [], direction: .incoming, attachment: Attachment( variant: .standard, diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index fa4faf8302..f075bc6091 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -310,7 +310,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { profilePictureView.update( publicKey: cellViewModel.authorId, threadVariant: .contact, // Always show the display picture in 'contact' mode - displayPictureFilename: nil, + displayPictureUrl: nil, profile: cellViewModel.profile, profileIcon: (cellViewModel.isSenderModeratorOrAdmin ? .crown : .none), using: dependencies @@ -550,9 +550,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { authorId: quote.authorId, quotedText: quote.body, threadVariant: cellViewModel.threadVariant, - currentUserSessionId: cellViewModel.currentUserSessionId, - currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId, + currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), attachment: cellViewModel.quoteAttachment, using: dependencies @@ -628,9 +626,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { authorId: quote.authorId, quotedText: quote.body, threadVariant: cellViewModel.threadVariant, - currentUserSessionId: cellViewModel.currentUserSessionId, - currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId, + currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), attachment: cellViewModel.quoteAttachment, using: dependencies @@ -680,9 +676,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { authorId: quote.authorId, quotedText: quote.body, threadVariant: cellViewModel.threadVariant, - currentUserSessionId: cellViewModel.currentUserSessionId, - currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId, + currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), attachment: cellViewModel.quoteAttachment, using: dependencies @@ -762,7 +756,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { return } - let isSelfSend: Bool = (reactionInfo.reaction.authorId == cellViewModel.currentUserSessionId) + let isSelfSend: Bool = (cellViewModel.currentUserSessionIds ?? []).contains(reactionInfo.reaction.authorId) if let value: ReactionViewModel = result.value(forKey: emoji) { result.replace( @@ -1178,13 +1172,10 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { else { return nil } let isOutgoing: Bool = (cellViewModel.variant == .standardOutgoing) - let attributedText: ThemedAttributedString = MentionUtilities.highlightMentions( in: body, threadVariant: cellViewModel.threadVariant, - currentUserSessionId: cellViewModel.currentUserSessionId, - currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId, + currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), location: (isOutgoing ? .outgoingMessage : .incomingMessage), textColor: textColor, attributes: [ diff --git a/Session/Conversations/Settings/ProfilePictureVC.swift b/Session/Conversations/Settings/ProfilePictureVC.swift index c09f7e84f2..8a21c190f9 100644 --- a/Session/Conversations/Settings/ProfilePictureVC.swift +++ b/Session/Conversations/Settings/ProfilePictureVC.swift @@ -7,7 +7,6 @@ import SessionUtilitiesKit /// Shown when the user taps a profile picture in the conversation settings. final class ProfilePictureVC: BaseVC { private let dependencies: Dependencies - private let imageIdentifier: String private let imageSource: ImageDataManager.DataSource private let snTitle: String @@ -34,7 +33,7 @@ final class ProfilePictureVC: BaseVC { result.clipsToBounds = true result.contentMode = .scaleAspectFill result.layer.cornerRadius = (imageSize / 2) - result.loadImage(identifier: imageIdentifier, from: imageSource) + result.loadImage(imageSource) result.set(.width, to: imageSize) result.set(.height, to: imageSize) @@ -43,9 +42,8 @@ final class ProfilePictureVC: BaseVC { // MARK: - Initialization - init(imageIdentifier: String, imageSource: ImageDataManager.DataSource, title: String, using dependencies: Dependencies) { + init(imageSource: ImageDataManager.DataSource, title: String, using dependencies: Dependencies) { self.dependencies = dependencies - self.imageIdentifier = imageIdentifier self.imageSource = imageSource self.snTitle = title diff --git a/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift index 706f636788..b7df60ec3c 100644 --- a/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift @@ -120,7 +120,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, Naviga } .eraseToAnyPublisher() - lazy var observation: TargetObservation = ObservationBuilder + lazy var observation: TargetObservation = ObservationBuilderOld .subject(configSubject) .compactMap { [weak self] currentConfig -> [SectionModel]? in self?.content(currentConfig) } @@ -368,7 +368,8 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, Naviga serverHash: nil, serverExpirationTimestamp: nil, using: dependencies - ) + )? + .interactionId // Update libSession switch threadVariant { diff --git a/Session/Conversations/Settings/ThreadNotificationSettingsViewModel.swift b/Session/Conversations/Settings/ThreadNotificationSettingsViewModel.swift index 255e84a26a..ed38fe76b6 100644 --- a/Session/Conversations/Settings/ThreadNotificationSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadNotificationSettingsViewModel.swift @@ -21,6 +21,7 @@ class ThreadNotificationSettingsViewModel: SessionTableViewModel, NavigatableSta public let observableState: ObservableTableSourceState = ObservableTableSourceState() private let threadId: String + private let threadVariant: SessionThread.Variant private let threadNotificationSettings: ThreadNotificationSettings private var threadNotificationSettingsSubject: CurrentValueSubject @@ -28,11 +29,13 @@ class ThreadNotificationSettingsViewModel: SessionTableViewModel, NavigatableSta init( threadId: String, + threadVariant: SessionThread.Variant, threadNotificationSettings: ThreadNotificationSettings, using dependencies: Dependencies ) { self.dependencies = dependencies self.threadId = threadId + self.threadVariant = threadVariant self.threadNotificationSettings = threadNotificationSettings self.threadNotificationSettingsSubject = CurrentValueSubject(threadNotificationSettings) } @@ -77,7 +80,7 @@ class ThreadNotificationSettingsViewModel: SessionTableViewModel, NavigatableSta } .eraseToAnyPublisher() - lazy var observation: TargetObservation = ObservationBuilder + lazy var observation: TargetObservation = ObservationBuilderOld .subject(threadNotificationSettingsSubject) .compactMap { [weak self] threadNotificationSettings -> [SectionModel]? in self?.content(threadNotificationSettings) } @@ -169,16 +172,11 @@ class ThreadNotificationSettingsViewModel: SessionTableViewModel, NavigatableSta guard self.threadNotificationSettings != updatedThreadNotificationSettings else { return } - dependencies[singleton: .storage].writeAsync { [threadId] db in - try SessionThread - .filter(id: threadId) - .updateAll( - db, - SessionThread.Columns.onlyNotifyForMentions - .set(to: updatedThreadNotificationSettings.threadOnlyNotifyForMentions), - SessionThread.Columns.mutedUntilTimestamp - .set(to: updatedThreadNotificationSettings.threadMutedUntilTimestamp) - ) - } + dependencies[singleton: .notificationsManager].updateSettings( + threadId: threadId, + threadVariant: threadVariant, + mentionsOnly: (updatedThreadNotificationSettings.threadOnlyNotifyForMentions == true), + mutedUntil: updatedThreadNotificationSettings.threadMutedUntilTimestamp + ) } } diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index ca7c44ce17..ccd9a1b54a 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -123,7 +123,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob } } - lazy var observation: TargetObservation = ObservationBuilder + lazy var observation: TargetObservation = ObservationBuilderOld .databaseObservation(self) { [dependencies, threadId = self.threadId] db -> State in let userSessionId: SessionId = dependencies[cache: .general].sessionId let threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel @@ -148,26 +148,21 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob return [] } + let isGroup: Bool = ( + threadViewModel.threadVariant == .legacyGroup || + threadViewModel.threadVariant == .group + ) let currentUserKickedFromGroup: Bool = ( - ( - threadViewModel.threadVariant == .legacyGroup || - threadViewModel.threadVariant == .group - ) && + isGroup && threadViewModel.currentUserIsClosedGroupMember != true ) let currentUserIsClosedGroupMember: Bool = ( - ( - threadViewModel.threadVariant == .legacyGroup || - threadViewModel.threadVariant == .group - ) && + isGroup && threadViewModel.currentUserIsClosedGroupMember == true ) let currentUserIsClosedGroupAdmin: Bool = ( - ( - threadViewModel.threadVariant == .legacyGroup || - threadViewModel.threadVariant == .group - ) && + isGroup && threadViewModel.currentUserIsClosedGroupAdmin == true ) let canEditDisplayName: Bool = ( @@ -190,7 +185,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob id: threadViewModel.id, size: .hero, threadVariant: threadViewModel.threadVariant, - displayPictureFilename: threadViewModel.displayPictureFilename, + displayPictureUrl: threadViewModel.threadDisplayPictureUrl, profile: threadViewModel.profile, profileIcon: { guard @@ -200,7 +195,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob else { return .none } // If we already have a display picture then the main profile gets the icon - return (threadViewModel.displayPictureFilename != nil ? .rightPlus : .none) + return (threadViewModel.threadDisplayPictureUrl != nil ? .rightPlus : .none) }(), additionalProfile: threadViewModel.additionalProfile, additionalProfileIcon: { @@ -221,10 +216,10 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob backgroundStyle: .noBackground ), onTap: { [weak self] in - switch (threadViewModel.threadVariant, threadViewModel.displayPictureFilename, currentUserIsClosedGroupAdmin) { + switch (threadViewModel.threadVariant, threadViewModel.threadDisplayPictureUrl, currentUserIsClosedGroupAdmin) { case (.contact, _, _): self?.viewDisplayPicture(threadViewModel: threadViewModel) case (.group, _, true): - self?.updateGroupDisplayPicture(currentFileName: threadViewModel.displayPictureFilename) + self?.updateGroupDisplayPicture(currentUrl: threadViewModel.threadDisplayPictureUrl) case (_, .some, _): self?.viewDisplayPicture(threadViewModel: threadViewModel) default: break @@ -242,25 +237,22 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob trailingAccessory: (!canEditDisplayName ? nil : .icon( .pencil, - size: .smallAspectFill, - customTint: .textSecondary, - shouldFill: true + size: .small, + customTint: .textSecondary ) ), styling: SessionCell.StyleInfo( alignment: .centerHugging, customPadding: SessionCell.Padding( top: Values.smallSpacing, - trailing: (!canEditDisplayName ? nil : - ((IconSize.small.size - (Values.smallSpacing * 2)) / 2) - ), + leading: (!canEditDisplayName ? nil : IconSize.small.size), bottom: { guard threadViewModel.threadVariant != .contact else { return Values.smallSpacing } guard threadViewModel.threadDescription == nil else { return Values.smallSpacing } return Values.largeSpacing }(), - interItem: Values.smallSpacing + interItem: 0 ), backgroundStyle: .noBackground ), @@ -504,14 +496,13 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob ), onTap: { [dependencies] in dependencies[singleton: .storage].writeAsync { db in - try SessionThread - .filter(id: threadViewModel.threadId) - .updateAllAndConfig( - db, - SessionThread.Columns.shouldBeVisible.set(to: true), - SessionThread.Columns.pinnedPriority.set(to: (threadViewModel.threadPinnedPriority <= 0 ? 1 : 0)), - using: dependencies - ) + try SessionThread.updateVisibility( + db, + threadId: threadViewModel.threadId, + isVisible: true, + customPriority: (threadViewModel.threadPinnedPriority <= LibSession.visiblePriority ? 1 : LibSession.visiblePriority), + using: dependencies + ) } } ) @@ -554,6 +545,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob SessionTableViewController( viewModel: ThreadNotificationSettingsViewModel( threadId: threadViewModel.threadId, + threadVariant: threadViewModel.threadVariant, threadNotificationSettings: .init( threadOnlyNotifyForMentions: threadViewModel.threadOnlyNotifyForMentions, threadMutedUntilTimestamp: threadViewModel.threadMutedUntilTimestamp @@ -778,14 +770,12 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob onTap: { [dependencies] in dependencies[singleton: .storage].writeAsync { db in if isThreadHidden { - try SessionThread - .filter(id: threadViewModel.threadId) - .updateAllAndConfig( - db, - SessionThread.Columns.shouldBeVisible.set(to: true), - SessionThread.Columns.pinnedPriority.set(to: LibSession.visiblePriority), - using: dependencies - ) + try SessionThread.updateVisibility( + db, + threadId: threadViewModel.threadId, + isVisible: true, + using: dependencies + ) } else { try SessionThread.deleteOrLeave( db, @@ -1139,13 +1129,12 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob private func viewDisplayPicture(threadViewModel: SessionThreadViewModel) { guard - let fileName: String = threadViewModel.displayPictureFilename, - let path: String = try? dependencies[singleton: .displayPictureManager].filepath(for: fileName) + let fileUrl: String = threadViewModel.threadDisplayPictureUrl, + let path: String = try? dependencies[singleton: .displayPictureManager].path(for: fileUrl) else { return } let navController: UINavigationController = StyledNavigationController( rootViewController: ProfilePictureVC( - imageIdentifier: fileName, imageSource: .url(URL(fileURLWithPath: path)), title: threadViewModel.displayName, using: dependencies @@ -1157,10 +1146,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob } private func inviteUsersToCommunity(threadViewModel: SessionThreadViewModel) { - let contact: TypedTableAlias = TypedTableAlias() - let groupMember: TypedTableAlias = TypedTableAlias() guard let name: String = threadViewModel.openGroupName, + let server: String = threadViewModel.openGroupServer, + let roomToken: String = threadViewModel.openGroupRoomToken, + let publicKey: String = threadViewModel.openGroupPublicKey, let communityUrl: String = LibSession.communityUrlFor( server: threadViewModel.openGroupServer, roomToken: threadViewModel.openGroupRoomToken, @@ -1168,6 +1158,32 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob ) else { return } + let openGroupCapabilityInfo: LibSession.OpenGroupCapabilityInfo = LibSession.OpenGroupCapabilityInfo( + roomToken: roomToken, + server: server, + publicKey: publicKey, + capabilities: (threadViewModel.openGroupCapabilities ?? []) + ) + let currentUserSessionIds: Set = Set([ + dependencies[cache: .general].sessionId.hexString, + SessionThread.getCurrentUserBlindedSessionId( + threadId: threadId, + threadVariant: threadVariant, + blindingPrefix: .blinded15, + openGroupCapabilityInfo: openGroupCapabilityInfo, + using: dependencies + )?.hexString, + SessionThread.getCurrentUserBlindedSessionId( + threadId: threadId, + threadVariant: threadVariant, + blindingPrefix: .blinded25, + openGroupCapabilityInfo: openGroupCapabilityInfo, + using: dependencies + )?.hexString + ].compactMap { $0 }) + let contact: TypedTableAlias = TypedTableAlias() + let groupMember: TypedTableAlias = TypedTableAlias() + self.transitionToScreen( SessionTableViewController( viewModel: UserListViewModel( @@ -1186,7 +1202,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob \(contact[.isApproved]) = TRUE AND \(contact[.didApproveMe]) = TRUE AND \(contact[.isBlocked]) = FALSE AND - \(contact[.id]) != \(threadViewModel.currentUserSessionId) + \(contact[.id]) NOT IN \(currentUserSessionIds) ) """), footerTitle: "membersInviteTitle".localized(), @@ -1239,7 +1255,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob linkPreviewUrl: communityUrl, using: dependencies ) - .inserted(db) + .inserted(db) try MessageSender.send( db, @@ -1290,23 +1306,26 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob .filter(GroupMember.Columns.groupId == threadId) .group(GroupMember.Columns.profileId), onTap: .callback { _, memberInfo in - dependencies[singleton: .storage].write { db in - try SessionThread.upsert( - db, - id: memberInfo.profileId, - variant: .contact, - values: SessionThread.TargetValues( - creationDateTimestamp: .useExistingOrSetTo( - dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000 + dependencies[singleton: .storage].writeAsync( + updates: { db in + try SessionThread.upsert( + db, + id: memberInfo.profileId, + variant: .contact, + values: SessionThread.TargetValues( + creationDateTimestamp: .useExistingOrSetTo( + dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000 + ), + shouldBeVisible: .useExisting, + isDraft: .useExistingOrSetTo(true) ), - shouldBeVisible: .useExisting, - isDraft: .useExistingOrSetTo(true) - ), - using: dependencies - ) - } - - transitionToConversation(memberInfo.profileId) + using: dependencies + ) + }, + completion: { _ in + transitionToConversation(memberInfo.profileId) + } + ) }, using: dependencies ) @@ -1506,29 +1525,45 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob } /// Update the nickname - dependencies[singleton: .storage].write { db in - try Profile - .filter(id: threadId) - .updateAllAndConfig( - db, - Profile.Columns.nickname.set(to: finalNickname), - using: dependencies - ) - } - modal.dismiss(animated: true) + dependencies[singleton: .storage].writeAsync( + updates: { db in + try Profile + .filter(id: threadId) + .updateAllAndConfig( + db, + Profile.Columns.nickname.set(to: finalNickname), + using: dependencies + ) + db.addProfileEvent(id: threadId, change: .nickname(finalNickname)) + db.addConversationEvent(id: threadId, type: .updated(.displayName(finalNickname))) + }, + completion: { _ in + DispatchQueue.main.async { + modal.dismiss(animated: true) + } + } + ) }, onCancel: { [dependencies, threadId] modal in /// Remove the nickname - dependencies[singleton: .storage].write { db in - try Profile - .filter(id: threadId) - .updateAllAndConfig( - db, - Profile.Columns.nickname.set(to: nil), - using: dependencies - ) - } - modal.dismiss(animated: true) + dependencies[singleton: .storage].writeAsync( + updates: { db in + try Profile + .filter(id: threadId) + .updateAllAndConfig( + db, + Profile.Columns.nickname.set(to: nil), + using: dependencies + ) + db.addProfileEvent(id: threadId, change: .nickname(nil)) + db.addConversationEvent(id: threadId, type: .updated(.displayName(displayName))) + }, + completion: { _ in + DispatchQueue.main.async { + modal.dismiss(animated: true) + } + } + ) } ) } @@ -1631,7 +1666,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob ) } - private func updateGroupDisplayPicture(currentFileName: String?) { + private func updateGroupDisplayPicture(currentUrl: String?) { guard dependencies[feature: .updatedGroupsAllowDisplayPicture] else { return } let iconName: String = "profile_placeholder" // stringlint:ignore @@ -1641,9 +1676,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob info: ConfirmationModal.Info( title: "groupSetDisplayPicture".localized(), body: .image( - identifier: (currentFileName ?? iconName), - source: currentFileName - .map { try? dependencies[singleton: .displayPictureManager].filepath(for: $0) } + source: currentUrl + .map { try? dependencies[singleton: .displayPictureManager].path(for: $0) } .map { ImageDataManager.DataSource.url(URL(fileURLWithPath: $0)) }, placeholder: UIImage(named: iconName).map { ImageDataManager.DataSource.image(iconName, $0) @@ -1663,22 +1697,24 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob confirmTitle: "save".localized(), confirmEnabled: .afterChange { info in switch info.body { - case .image(_, let source, _, _, _, _, _, _): return (source?.imageData != nil) + case .image(let source, _, _, _, _, _, _): return (source?.imageData != nil) default: return false } }, cancelTitle: "remove".localized(), - cancelEnabled: .bool(currentFileName != nil), + cancelEnabled: .bool(currentUrl != nil), hasCloseButton: true, dismissOnConfirm: false, onConfirm: { [weak self] modal in switch modal.info.body { - case .image(_, .some(let source), _, _, _, _, _, _): + case .image(.some(let source), _, _, _, _, _, _): guard let imageData: Data = source.imageData else { return } self?.updateGroupDisplayPicture( displayPictureUpdate: .groupUploadImageData(imageData), - onUploadComplete: { [weak modal] in modal?.close() } + onUploadComplete: { [weak modal] in + Task { @MainActor in modal?.close() } + } ) default: modal.close() @@ -1687,7 +1723,9 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob onCancel: { [weak self] modal in self?.updateGroupDisplayPicture( displayPictureUpdate: .groupRemove, - onUploadComplete: { [weak modal] in modal?.close() } + onUploadComplete: { [weak modal] in + Task { @MainActor in modal?.close() } + } ) } ) @@ -1736,8 +1774,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob return dependencies[singleton: .displayPictureManager] .prepareAndUploadDisplayPicture(imageData: data) .showingBlockingLoading(in: self?.navigatableState) - .map { url, fileName, key -> DisplayPictureManager.Update in - .groupUpdateTo(url: url, key: key, fileName: fileName) + .map { url, filePath, key -> DisplayPictureManager.Update in + .groupUpdateTo(url: url, key: key, filePath: filePath) } .mapError { $0 as Error } .handleEvents( @@ -1779,29 +1817,34 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob displayPictureUpdate, try? ClosedGroup .filter(id: threadId) - .select(.displayPictureFilename) + .select(.displayPictureUrl) .asRequest(of: String.self) .fetchOne(db) ) } - .flatMap { [threadId, dependencies] displayPictureUpdate, existingFileName -> AnyPublisher in + .flatMap { [threadId, dependencies] displayPictureUpdate, existingDownloadUrl -> AnyPublisher in MessageSender .updateGroup( groupSessionId: threadId, displayPictureUpdate: displayPictureUpdate, using: dependencies ) - .map { _ in existingFileName } + .map { _ in existingDownloadUrl } .eraseToAnyPublisher() } .handleEvents( - receiveOutput: { [dependencies] existingFileName in - // Remove any cached avatar image value - if let existingFileName: String = existingFileName { + receiveOutput: { [dependencies] existingDownloadUrl in + /// Remove any cached avatar image value + if + let existingDownloadUrl: String = existingDownloadUrl, + let existingFilePath: String = try? dependencies[singleton: .displayPictureManager] + .path(for: existingDownloadUrl) + { Task { await dependencies[singleton: .imageDataManager].removeImage( - identifier: existingFileName + identifier: existingFilePath ) + try? dependencies[singleton: .fileManager].removeItem(atPath: existingFilePath) } } } @@ -1827,6 +1870,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob Contact.Columns.isBlocked.set(to: isBlocked), using: dependencies ) + db.addContactEvent(id: threadId, change: .isBlocked(isBlocked)) } } @@ -1857,6 +1901,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob } // MARK: - Confirmation Modals + private func updateDisplayNameModal( threadViewModel: SessionThreadViewModel, currentUserIsClosedGroupAdmin: Bool diff --git a/Session/Conversations/Views & Modals/ConversationTitleView.swift b/Session/Conversations/Views & Modals/ConversationTitleView.swift index 6dba23d8bd..81c07987e0 100644 --- a/Session/Conversations/Views & Modals/ConversationTitleView.swift +++ b/Session/Conversations/Views & Modals/ConversationTitleView.swift @@ -109,7 +109,7 @@ final class ConversationTitleView: UIView { self.oldSize = bounds.size } - public func update( + @MainActor public func update( with name: String, isNoteToSelf: Bool, isMessageRequest: Bool, @@ -119,22 +119,6 @@ final class ConversationTitleView: UIView { userCount: Int?, disappearingMessagesConfig: DisappearingMessagesConfiguration? ) { - guard Thread.isMainThread else { - DispatchQueue.main.async { [weak self] in - self?.update( - with: name, - isNoteToSelf: isNoteToSelf, - isMessageRequest: isMessageRequest, - threadVariant: threadVariant, - mutedUntilTimestamp: mutedUntilTimestamp, - onlyNotifyForMentions: onlyNotifyForMentions, - userCount: userCount, - disappearingMessagesConfig: disappearingMessagesConfig - ) - } - return - } - let shouldHaveSubtitle: Bool = ( !isMessageRequest && ( Date().timeIntervalSince1970 <= (mutedUntilTimestamp ?? 0) || diff --git a/Session/Conversations/Views & Modals/ReactionListSheet.swift b/Session/Conversations/Views & Modals/ReactionListSheet.swift index 1ff94d667d..a054ccba49 100644 --- a/Session/Conversations/Views & Modals/ReactionListSheet.swift +++ b/Session/Conversations/Views & Modals/ReactionListSheet.swift @@ -229,7 +229,7 @@ final class ReactionListSheet: BaseVC { return } - if reactionInfo.reaction.authorId == cellViewModel.currentUserSessionId { + if (cellViewModel.currentUserSessionIds ?? []).contains(reactionInfo.reaction.authorId) { updatedValue.insert(reactionInfo, at: 0) } else { @@ -439,7 +439,7 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { let cellViewModel: MessageViewModel.ReactionInfo = self.selectedReactionUserList[indexPath.row] let authorId: String = cellViewModel.reaction.authorId let canRemoveEmoji: Bool = ( - authorId == self.messageViewModel.currentUserSessionId && + (self.messageViewModel.currentUserSessionIds ?? []).contains(authorId) && self.messageViewModel.threadVariant != .legacyGroup ) cell.update( @@ -458,11 +458,11 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { .icon( UIImage(named: "X")? .withRenderingMode(.alwaysTemplate), - size: .fit + size: .medium ) ), styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge), - isEnabled: (authorId == self.messageViewModel.currentUserSessionId) + isEnabled: (self.messageViewModel.currentUserSessionIds ?? []).contains(authorId) ), tableSize: tableView.bounds.size, using: dependencies @@ -483,7 +483,7 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { .first(where: { $0.isSelected })? .emoji, selectedReaction.rawValue == cellViewModel.reaction.emoji, - cellViewModel.reaction.authorId == self.messageViewModel.currentUserSessionId + (self.messageViewModel.currentUserSessionIds ?? []).contains(cellViewModel.reaction.authorId) else { return } delegate?.removeReact(self.messageViewModel, for: selectedReaction) diff --git a/Session/Database/Convenience/Interaction+UI.swift b/Session/Database/Convenience/Interaction+UI.swift new file mode 100644 index 0000000000..cb79dcfb24 --- /dev/null +++ b/Session/Database/Convenience/Interaction+UI.swift @@ -0,0 +1,72 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit +import SessionMessagingKit + +public extension Interaction.State { + func statusIconInfo( + variant: Interaction.Variant, + hasBeenReadByRecipient: Bool, + hasAttachments: Bool + ) -> (image: UIImage?, text: String?, themeTintColor: ThemeValue) { + guard variant == .standardOutgoing else { + return (nil, nil, .messageBubble_deliveryStatus) + } + + switch (self, hasBeenReadByRecipient, hasAttachments) { + case (.deleted, _, _), (.localOnly, _, _): + return (nil, nil, .messageBubble_deliveryStatus) + + case (.sending, _, true): + return ( + UIImage(systemName: "ellipsis.circle"), + "uploading".localized(), + .messageBubble_deliveryStatus + ) + + case (.sending, _, _): + return ( + UIImage(systemName: "ellipsis.circle"), + "sending".localized(), + .messageBubble_deliveryStatus + ) + + case (.sent, false, _): + return ( + UIImage(systemName: "checkmark.circle"), + "disappearingMessagesSent".localized(), + .messageBubble_deliveryStatus + ) + + case (.sent, true, _): + return ( + UIImage(systemName: "eye.fill"), + "read".localized(), + .messageBubble_deliveryStatus + ) + + case (.failed, _, _): + return ( + UIImage(systemName: "exclamationmark.triangle"), + "messageStatusFailedToSend".localized(), + .danger + ) + + case (.failedToSync, _, _): + return ( + UIImage(systemName: "exclamationmark.triangle"), + "messageStatusFailedToSync".localized(), + .warning + ) + + case (.syncing, _, _): + return ( + UIImage(systemName: "ellipsis.circle"), + "messageStatusSyncing".localized(), + .warning + ) + + } + } +} diff --git a/Session/Database/Migrations/_001_ThemePreferences.swift b/Session/Database/Migrations/_001_ThemePreferences.swift index 47bee51e1d..8bb9987f95 100644 --- a/Session/Database/Migrations/_001_ThemePreferences.swift +++ b/Session/Database/Migrations/_001_ThemePreferences.swift @@ -19,7 +19,7 @@ enum _001_ThemePreferences: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { // Determine if the user was matching the system setting (previously the absence of this value // indicated that the app should match the system setting) let isExistingUser: Bool = MigrationHelper.userExists(db) @@ -53,17 +53,39 @@ enum _001_ThemePreferences: Migration { """, arguments: [ matchSystemNightModeSettingAsData, - targetTheme.rawValue.data(using: .utf8), - targetPrimaryColor.rawValue.data(using: .utf8) + targetTheme.legacyStringKey.data(using: .utf8), + targetPrimaryColor.legacyStringKey.data(using: .utf8) ] ) - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } -extension Theme: @retroactive EnumStringSetting {} -extension Theme.PrimaryColor: @retroactive EnumStringSetting {} +private extension Theme { + var legacyStringKey: String { + switch self { + case .classicDark: return "classic_dark" + case .classicLight: return "classic_light" + case .oceanDark: return "ocean_dark" + case .oceanLight: return "ocean_light" + } + } +} + +private extension Theme.PrimaryColor { + var legacyStringKey: String { + switch self { + case .green: return "green" + case .blue: return "blue" + case .yellow: return "yellow" + case .pink: return "pink" + case .purple: return "purple" + case .orange: return "orange" + case .red: return "red" + } + } +} enum DeprecatedUIKitMigrationTarget: MigratableTarget { public static func migrations() -> TargetMigrations { @@ -77,7 +99,11 @@ enum DeprecatedUIKitMigrationTarget: MigratableTarget { [], // Legacy DB removal [ _001_ThemePreferences.self - ] + ], // Add job priorities + [], // Fix thread FTS + [], + [], // Renamed `Setting` to `KeyValueStore` + [] ] ) } diff --git a/Session/Emoji/EmojiWithSkinTones.swift b/Session/Emoji/EmojiWithSkinTones.swift index acce37b3f2..776de92dca 100644 --- a/Session/Emoji/EmojiWithSkinTones.swift +++ b/Session/Emoji/EmojiWithSkinTones.swift @@ -4,6 +4,7 @@ import Foundation import GRDB import DifferenceKit import SessionMessagingKit +import SessionUtilitiesKit public struct EmojiWithSkinTones: Hashable, Equatable, ContentEquatable, ContentIdentifiable { let baseEmoji: Emoji? @@ -55,7 +56,7 @@ public struct EmojiWithSkinTones: Hashable, Equatable, ContentEquatable, Content } extension Emoji { - static func getRecent(_ db: Database, withDefaultEmoji: Bool) throws -> [String] { + static func getRecent(_ db: ObservingDatabase, withDefaultEmoji: Bool) throws -> [String] { let recentReactionEmoji: [String] = (db[.recentReactionEmoji]? .components(separatedBy: ",")) .defaulting(to: []) @@ -72,7 +73,7 @@ extension Emoji { .prefix(6)) } - static func addRecent(_ db: Database, emoji: String) { + static func addRecent(_ db: ObservingDatabase, emoji: String) { // Add/move the emoji to the start of the most recent list db[.recentReactionEmoji] = (db[.recentReactionEmoji]? .components(separatedBy: ",")) @@ -83,7 +84,7 @@ extension Emoji { .joined(separator: ",") } - static func allSendableEmojiByCategoryWithPreferredSkinTones(_ db: Database) -> [Category: [EmojiWithSkinTones]] { + static func allSendableEmojiByCategoryWithPreferredSkinTones(_ db: ObservingDatabase) -> [Category: [EmojiWithSkinTones]] { return Category.allCases .reduce(into: [Category: [EmojiWithSkinTones]]()) { result, category in result[category] = category.normalizedEmoji @@ -92,7 +93,7 @@ extension Emoji { } } - private func withPreferredSkinTones(_ db: Database) -> EmojiWithSkinTones { + private func withPreferredSkinTones(_ db: ObservingDatabase) -> EmojiWithSkinTones { guard let rawSkinTones: String = db[.emojiPreferredSkinTones(emoji: rawValue)] else { return EmojiWithSkinTones(baseEmoji: self, skinTones: nil) } @@ -105,7 +106,7 @@ extension Emoji { ) } - func setPreferredSkinTones(_ db: Database, preferredSkinTonePermutation: [SkinTone]?) { + func setPreferredSkinTones(_ db: ObservingDatabase, preferredSkinTonePermutation: [SkinTone]?) { db[.emojiPreferredSkinTones(emoji: rawValue)] = preferredSkinTonePermutation .map { preferredSkinTonePermutation in preferredSkinTonePermutation diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 07eab88f38..dba59f936e 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import Combine import GRDB import DifferenceKit import SessionUIKit @@ -13,14 +14,13 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi public static let newConversationButtonSize: CGFloat = 60 private let viewModel: HomeViewModel - private var dataChangeObservable: DatabaseCancellable? { - didSet { oldValue?.cancel() } // Cancel the old observable if there was one - } - private var hasLoadedInitialStateData: Bool = false - private var hasLoadedInitialThreadData: Bool = false - private var isLoadingMore: Bool = false - private var isAutoLoadingNextPage: Bool = false - private var viewHasAppeared: Bool = false + private var disposables: Set = Set() + + /// Currently loaded version of the data for the `tableView`, will always match the value in the `viewModel` unless it's part way + /// through updating it's state + private var sections: [HomeViewModel.SectionModel] = [] + private var initialConversationLoadComplete: Bool = false + public var afterInitialConversationsLoad: (() -> Void)? // MARK: - LibSessionRespondingViewController @@ -31,11 +31,6 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi init(using dependencies: Dependencies) { self.viewModel = HomeViewModel(using: dependencies) - /// Dispatch adding the database observation to a background thread - DispatchQueue.global(qos: .userInitiated).async { [weak viewModel] in - dependencies[singleton: .storage].addObserver(viewModel?.pagedDataObserver) - } - super.init(nibName: nil, bundle: nil) } @@ -43,10 +38,6 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi preconditionFailure("Use init() instead.") } - deinit { - NotificationCenter.default.removeObserver(self) - } - // MARK: - UI private var tableViewTopConstraint: NSLayoutConstraint? @@ -339,22 +330,9 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi newConversationButton.center(.horizontal, in: view) newConversationButton.pin(.bottom, to: .bottom, of: view.safeAreaLayoutGuide, withInset: -Values.smallSpacing) - // Notifications - NotificationCenter.default.addObserver( - self, - selector: #selector(applicationDidBecomeActive(_:)), - name: UIApplication.didBecomeActiveNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(applicationDidResignActive(_:)), - name: UIApplication.didEnterBackgroundNotification, object: nil - ) - // Start polling if needed (i.e. if the user just created or restored their Session ID) if - Identity.userExists(using: viewModel.dependencies), + viewModel.dependencies[cache: .general].userExists, let appDelegate: AppDelegate = UIApplication.shared.delegate as? AppDelegate, viewModel.dependencies[singleton: .appContext].isMainAppAndActive { @@ -363,100 +341,50 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi // Onion request path countries cache viewModel.dependencies.warmCache(cache: .ip2Country) - } - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - startObservingChanges() + // Bind the UI to the view model + bindViewModel() } public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) viewModel.dependencies[singleton: .notificationsManager].scheduleSessionNetworkPageLocalNotifcation(force: false) - - self.viewHasAppeared = true - self.autoLoadNextPageIfNeeded() - } - - public override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - stopObservingChanges() - } - - @objc func applicationDidBecomeActive(_ notification: Notification) { - /// **Note:** When returning from the background we could have received notifications but the `PagedDatabaseObserver` - /// won't have them so we need to force a re-fetch of the current data to ensure everything is up to date - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - self?.viewModel.pagedDataObserver?.resume() - } - } - - @objc func applicationDidResignActive(_ notification: Notification) { - /// When going into the background we should stop listening to database changes (we will resume/reload after returning from - /// the background) - viewModel.pagedDataObserver?.suspend() } // MARK: - Updating - public func startObservingChanges(onReceivedInitialChange: (() -> Void)? = nil) { - guard dataChangeObservable == nil else { return } - - var runAndClearInitialChangeCallback: (() -> Void)? + @MainActor public func afterInitialConversationsLoaded(_ closure: @escaping () -> Void) { + guard viewModel.state.viewState == .loading else { return closure() } - runAndClearInitialChangeCallback = { [weak self] in - guard self?.hasLoadedInitialStateData == true && self?.hasLoadedInitialThreadData == true else { return } - - onReceivedInitialChange?() - runAndClearInitialChangeCallback = nil - } - - dataChangeObservable = viewModel.dependencies[singleton: .storage].start( - viewModel.observableState, - onError: { _ in }, - onChange: { [weak self] state in - // The default scheduler emits changes on the main thread - self?.handleUpdates(state) - runAndClearInitialChangeCallback?() - } - ) + afterInitialConversationsLoad = closure - self.viewModel.onThreadChange = { [weak self] updatedThreadData, changeset in - self?.handleThreadUpdates(updatedThreadData, changeset: changeset) - runAndClearInitialChangeCallback?() - } + /// Since we wouldn't have added the `HomeVC` to the view hierarchy yet it's possible it hasn't loaded it's view yet + /// so we should trigger it to do so now if needed + loadViewIfNeeded() } - private func stopObservingChanges() { - // Stop observing database changes - self.dataChangeObservable?.cancel() - self.dataChangeObservable = nil - self.viewModel.onThreadChange = nil + private func bindViewModel() { + viewModel.$state + .receive(on: DispatchQueue.main) + .removeDuplicates() + .sink { [weak self] state in self?.render(state: state) } + .store(in: &disposables) } - private func handleUpdates(_ updatedState: HomeViewModel.State, initialLoad: Bool = false) { - // Ensure the first load runs without animations (if we don't do this the cells will animate - // in from a frame of CGRect.zero) - guard hasLoadedInitialStateData else { - hasLoadedInitialStateData = true - UIView.performWithoutAnimation { handleUpdates(updatedState, initialLoad: true) } - return - } - - if updatedState.userProfile != self.viewModel.state.userProfile { - updateNavBarButtons(userProfile: updatedState.userProfile) - } + @MainActor private func render(state: HomeViewModel.State) { + // Update nav + updateNavBarButtons(userProfile: state.userProfile) // Update the 'view seed' UI - if updatedState.showViewedSeedBanner != self.viewModel.state.showViewedSeedBanner { + let shouldHideSeedReminderView: Bool = !state.showViewedSeedBanner + + if seedReminderView.isHidden != shouldHideSeedReminderView { tableViewTopConstraint?.isActive = false loadingConversationsLabelTopConstraint?.isActive = false - seedReminderView.isHidden = !updatedState.showViewedSeedBanner + seedReminderView.isHidden = !state.showViewedSeedBanner - if updatedState.showViewedSeedBanner { + if state.showViewedSeedBanner { loadingConversationsLabelTopConstraint = loadingConversationsLabel.pin(.top, to: .bottom, of: seedReminderView, withInset: Values.mediumSpacing) tableViewTopConstraint = tableView.pin(.top, to: .bottom, of: seedReminderView) } @@ -464,65 +392,49 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi loadingConversationsLabelTopConstraint = loadingConversationsLabel.pin(.top, to: .top, of: view, withInset: Values.veryLargeSpacing) tableViewTopConstraint = tableView.pin(.top, to: .top, of: view, withInset: Values.smallSpacing) } + + view.layoutIfNeeded() } - self.viewModel.updateState(updatedState) - } - - private func handleThreadUpdates( - _ updatedData: [HomeViewModel.SectionModel], - changeset: StagedChangeset<[HomeViewModel.SectionModel]>, - initialLoad: Bool = false - ) { - // Ensure the first load runs without animations (if we don't do this the cells will animate - // in from a frame of CGRect.zero) - guard hasLoadedInitialThreadData else { - UIView.performWithoutAnimation { [weak self, dependencies = viewModel.dependencies] in - // Hide the 'loading conversations' label (now that we have received conversation data) - self?.loadingConversationsLabel.isHidden = true + // Update the overall view state (loading, empty, or loaded) + switch state.viewState { + case .loading: + loadingConversationsLabel.isHidden = false + tableView.isHidden = true + emptyStateStackView.isHidden = true - // Show the empty state if there is no data - self?.accountCreatedView.isHidden = (dependencies[cache: .onboarding].initialFlow != .register) - self?.emptyStateLogoView.isHidden = (dependencies[cache: .onboarding].initialFlow == .register) - self?.emptyStateStackView.isHidden = ( - !updatedData.isEmpty && - updatedData.contains(where: { !$0.elements.isEmpty }) - ) + case .empty(let isNewUser): + loadingConversationsLabel.isHidden = true + tableView.isHidden = true + emptyStateStackView.isHidden = false + accountCreatedView.isHidden = !isNewUser + emptyStateLogoView.isHidden = isNewUser - self?.viewModel.updateThreadData(updatedData) - self?.tableView.reloadData() - self?.hasLoadedInitialThreadData = true - } - return - } - - // Hide the 'loading conversations' label (now that we have received conversation data) - loadingConversationsLabel.isHidden = true - - // Show the empty state if there is no data - if viewModel.dependencies[cache: .onboarding].initialFlow == .register { - accountCreatedView.isHidden = false - emptyStateLogoView.isHidden = true - } else { - accountCreatedView.isHidden = true - emptyStateLogoView.isHidden = false + case .loaded: + loadingConversationsLabel.isHidden = true + tableView.isHidden = false + emptyStateStackView.isHidden = true } - emptyStateStackView.isHidden = ( - !updatedData.isEmpty && - updatedData.contains(where: { !$0.elements.isEmpty }) - ) + // If we are still loading then don't try to load the table content (it'll be empty and we + // don't want to trigger the callbacks until a successful load) + guard state.viewState != .loading else { return } - CATransaction.begin() - CATransaction.setCompletionBlock { [weak self] in - // Complete page loading - self?.isLoadingMore = false - self?.autoLoadNextPageIfNeeded() + // Reload the table content (update without animations on the first render) + guard initialConversationLoadComplete else { + sections = state.sections + + UIView.performWithoutAnimation { + tableView.reloadData() + afterInitialConversationsLoad?() + afterInitialConversationsLoad = nil + initialConversationLoadComplete = true + } + return } - // Reload the table content (animate changes after the first load) tableView.reload( - using: changeset, + using: StagedChangeset(source: self.sections, target: state.sections), deleteSectionsAnimation: .none, insertSectionsAnimation: .none, reloadSectionsAnimation: .none, @@ -531,43 +443,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi reloadRowsAnimation: .none, interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues ) { [weak self] updatedData in - self?.viewModel.updateThreadData(updatedData) - } - - CATransaction.commit() - } - - private func autoLoadNextPageIfNeeded() { - guard - self.hasLoadedInitialThreadData && - !self.isAutoLoadingNextPage && - !self.isLoadingMore - else { return } - - self.isAutoLoadingNextPage = true - - DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in - self?.isAutoLoadingNextPage = false - - // Note: We sort the headers as we want to prioritise loading newer pages over older ones - let sections: [(HomeViewModel.Section, CGRect)] = (self?.viewModel.threadData - .enumerated() - .map { index, section in (section.model, (self?.tableView.rectForHeader(inSection: index) ?? .zero)) }) - .defaulting(to: []) - let shouldLoadMore: Bool = sections - .contains { section, headerRect in - section == .loadMore && - headerRect != .zero && - (self?.tableView.bounds.contains(headerRect) == true) - } - - guard shouldLoadMore else { return } - - self?.isLoadingMore = true - - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - self?.viewModel.pagedDataObserver?.load(.pageAfter) - } + self?.sections = updatedData } } @@ -583,7 +459,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi profilePictureView.update( publicKey: userProfile.id, threadVariant: .contact, - displayPictureFilename: nil, + displayPictureUrl: nil, profile: userProfile, profileIcon: { switch (viewModel.dependencies[feature: .serviceNetwork], viewModel.dependencies[feature: .forceOffline]) { @@ -619,7 +495,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi profilePictureView?.update( publicKey: userProfile.id, threadVariant: .contact, - displayPictureFilename: nil, + displayPictureUrl: nil, profile: userProfile, profileIcon: { switch (dependencies[feature: .serviceNetwork], dependencies[feature: .forceOffline]) { @@ -658,17 +534,15 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi // MARK: - UITableViewDataSource public func numberOfSections(in tableView: UITableView) -> Int { - return viewModel.threadData.count + return sections.count } public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - let section: HomeViewModel.SectionModel = viewModel.threadData[section] - - return section.elements.count + return sections[section].elements.count } public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let section: HomeViewModel.SectionModel = viewModel.threadData[indexPath.section] + let section: HomeViewModel.SectionModel = sections[indexPath.section] switch section.model { case .messageRequests: @@ -692,7 +566,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi } public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - let section: HomeViewModel.SectionModel = viewModel.threadData[section] + let section: HomeViewModel.SectionModel = sections[section] switch section.model { case .loadMore: @@ -714,7 +588,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi // MARK: - UITableViewDelegate public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - let section: HomeViewModel.SectionModel = viewModel.threadData[section] + let section: HomeViewModel.SectionModel = sections[section] switch section.model { case .loadMore: return HomeVC.loadingHeaderHeight @@ -723,18 +597,8 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi } public func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { - guard self.hasLoadedInitialThreadData && self.viewHasAppeared && !self.isLoadingMore else { return } - - let section: HomeViewModel.SectionModel = self.viewModel.threadData[section] - - switch section.model { - case .loadMore: - self.isLoadingMore = true - - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - self?.viewModel.pagedDataObserver?.load(.pageAfter) - } - + switch sections[section].model { + case .loadMore: self.viewModel.loadNextPage() default: break } } @@ -742,7 +606,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section] + let section: HomeViewModel.SectionModel = sections[indexPath.section] switch section.model { case .messageRequests: @@ -778,7 +642,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi } public func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section] + let section: HomeViewModel.SectionModel = sections[indexPath.section] let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] switch section.model { @@ -812,7 +676,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi } public func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section] + let section: HomeViewModel.SectionModel = sections[indexPath.section] let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] switch section.model { diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 58fa8b0651..86ce410a7e 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB import DifferenceKit import SignalUtilitiesKit @@ -9,12 +10,13 @@ import SessionUtilitiesKit // MARK: - Log.Category -private extension Log.Category { - static let cat: Log.Category = .create("HomeViewModel", defaultLevel: .warn) +public extension Log.Category { + static let homeViewModel: Log.Category = .create("HomeViewModel", defaultLevel: .warn) } // MARK: - HomeViewModel +@MainActor public class HomeViewModel: NavigatableStateHolder { public let navigatableState: NavigatableState = NavigatableState() @@ -30,318 +32,373 @@ public class HomeViewModel: NavigatableStateHolder { // MARK: - Variables - public static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 15) - - public struct State: Equatable { - let userSessionId: SessionId - let showViewedSeedBanner: Bool - let hasHiddenMessageRequests: Bool - let unreadMessageRequestThreadCount: Int - let userProfile: Profile - } + nonisolated fileprivate static let observationName: String = "HomeViewModel" // stringlint:ignore + @MainActor public static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 15) public let dependencies: Dependencies + private let userSessionId: SessionId + + /// This flag acts as a lock on the page loading logic, while it's weird to modify state within the `query` that isn't on the `State` + /// type, this is a primarily an optimisation to prevent the `loadPage` events from triggering multiple times since that can happen + /// due to how the UI is setup + private var currentlyHandlingPageLoad: Bool = false + + /// This is a cache of the observed data before any processing is done for the UI state to allow us to more easily do diffs + private var itemCache: [Int64: SessionThreadViewModel] = [:] + // MARK: - Initialization init(using dependencies: Dependencies) { - let initialState: State? = dependencies[singleton: .storage].read { db -> State in - try HomeViewModel.retrieveState(db, excludingMessageRequestThreadCount: true, using: dependencies) + self.dependencies = dependencies + self.userSessionId = dependencies[cache: .general].sessionId + self.state = State.initialState(using: dependencies) + + self.bindState() + } + + // MARK: - State + + public struct State: ObservableKeyProvider { + enum ViewState: Equatable { + case loading + case empty(isNewUser: Bool) + case loaded } - self.dependencies = dependencies - self.state = State( - userSessionId: (initialState?.userSessionId ?? dependencies[cache: .general].sessionId), - showViewedSeedBanner: (initialState?.showViewedSeedBanner ?? true), - hasHiddenMessageRequests: (initialState?.hasHiddenMessageRequests ?? false), - unreadMessageRequestThreadCount: 0, - userProfile: (initialState?.userProfile ?? Profile.fetchOrCreateCurrentUser(using: dependencies)) - ) - self.pagedDataObserver = nil + let viewState: ViewState + let userProfile: Profile + let showViewedSeedBanner: Bool + let hasHiddenMessageRequests: Bool + let unreadMessageRequestThreadCount: Int + let loadedPageInfo: PagedData.LoadedInfo + let sections: [SectionModel] - // Note: Since this references self we need to finish initializing before setting it, we - // also want to skip the initial query and trigger it async so that the push animation - // doesn't stutter (it should load basically immediately but without this there is a - // distinct stutter) - let userSessionId: SessionId = self.state.userSessionId - let thread: TypedTableAlias = TypedTableAlias() - self.pagedDataObserver = PagedDatabaseObserver( - pagedTable: SessionThread.self, - pageSize: HomeViewModel.pageSize, - idColumn: .id, - observedChanges: [ - PagedData.ObservedChanges( - table: SessionThread.self, - columns: [ - .id, - .shouldBeVisible, - .pinnedPriority, - .mutedUntilTimestamp, - .onlyNotifyForMentions, - .markedAsUnread - ] + public var observedKeys: Set { + var result: Set = [ + .unreadMessageRequestMessageReceived, + .messageRequestAccepted, + .loadPage(HomeViewModel.observationName), + .profile(userProfile.id), + .setting(Setting.BoolKey.hasViewedSeed), + .conversationCreated + ] + + sections.filter { $0.model == .threads }.first?.elements.forEach { threadViewModel in + result.insert(contentsOf: [ + .conversationUpdated(threadViewModel.threadId), + .conversationDeleted(threadViewModel.threadId), + .messageCreated(threadId: threadViewModel.threadId), + .messageUpdated( + id: threadViewModel.interactionId, + threadId: threadViewModel.threadId + ), + .messageDeleted( + id: threadViewModel.interactionId, + threadId: threadViewModel.threadId + ), + .typingIndicator(threadViewModel.threadId) + ]) + } + + return result + } + + @MainActor static func initialState(using dependencies: Dependencies) -> State { + return State( + viewState: .loading, + userProfile: Profile(id: dependencies[cache: .general].sessionId.hexString, name: ""), + showViewedSeedBanner: true, + hasHiddenMessageRequests: false, + unreadMessageRequestThreadCount: 0, + loadedPageInfo: PagedData.LoadedInfo( + record: SessionThreadViewModel.self, + pageSize: HomeViewModel.pageSize, + /// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed + /// for the query but differs from the JOINs that are actually used for performance reasons as the + /// basic logic can be simpler for where it's used + requiredJoinSQL: SessionThreadViewModel.optimisedJoinSQL, + filterSQL: SessionThreadViewModel.homeFilterSQL( + userSessionId: dependencies[cache: .general].sessionId + ), + groupSQL: SessionThreadViewModel.groupSQL, + orderSQL: SessionThreadViewModel.homeOrderSQL ), - PagedData.ObservedChanges( - table: Interaction.self, - columns: [ - .body, - .wasRead, - .state - ], - joinToPagedType: { - let interaction: TypedTableAlias = TypedTableAlias() + sections: [] + ) + } + } + + /// This value is the current state of the view + @Published private(set) var state: State + private var observationTask: Task? + private var previousSections: [SectionModel] = [] + + private func bindState() { + let startedAsNewUser: Bool = (dependencies[cache: .onboarding].initialFlow == .register) + let initialState: State = State.initialState(using: dependencies) + + observationTask = ObservationBuilder + .debounce(for: .milliseconds(250)) + .using(manager: dependencies[singleton: .observationManager]) + .query { [weak self, userSessionId, dependencies] previousState, events in + guard let self = self else { return initialState } + + /// Store mutable copies of the data to update + let currentState: State = (previousState ?? initialState) + var userProfile: Profile = currentState.userProfile + var showViewedSeedBanner: Bool = currentState.showViewedSeedBanner + var hasHiddenMessageRequests: Bool = currentState.hasHiddenMessageRequests + var unreadMessageRequestThreadCount: Int = currentState.unreadMessageRequestThreadCount + var loadResult: PagedData.LoadResult = currentState.loadedPageInfo.asResult + + /// Store a local copy of the events so we can manipulate it based on the state changes + var eventsToProcess: [ObservedEvent] = events + + /// If we have no previous state then we need to fetch the initial state + if previousState == nil { + /// Insert a fake event to force the initial page load + eventsToProcess.append(ObservedEvent( + key: .loadPage(HomeViewModel.observationName), + value: LoadPageEvent.initial + )) + + /// Load the values needed from `libSession` + dependencies.mutate(cache: .libSession) { libSession in + userProfile = libSession.profile + showViewedSeedBanner = !libSession.get(.hasViewedSeed) + hasHiddenMessageRequests = libSession.get(.hasHiddenMessageRequests) + } + + /// If we haven't hidden the message requests banner then we should include that in the initial fetch + if !hasHiddenMessageRequests { + eventsToProcess.append(ObservedEvent( + key: .unreadMessageRequestMessageReceived, + value: nil + )) + } + } + + /// If we have a `loadPage` event then we need to toggle the lock to prevent duplicate page loads from triggering + /// queries (if we are already loading a page elsewhere then just remove this event) + if eventsToProcess.contains(where: { $0.key.generic == .loadPage }) { + if self.currentlyHandlingPageLoad { + eventsToProcess = eventsToProcess.filter { $0.key.generic != .loadPage } + } + else { + self.currentlyHandlingPageLoad = true + } + } + defer { + if self.currentlyHandlingPageLoad { + self.currentlyHandlingPageLoad = false + } + } + + /// If there are no events we want to process then just return the current state + guard !eventsToProcess.isEmpty else { return currentState } + + /// Split the events between those that need database access and those that don't + let splitEvents: [Bool: [ObservedEvent]] = eventsToProcess + .grouped(by: \.requiresDatabaseQueryForHomeViewModel) + + /// Handle database events first + if let databaseEvents: Set = splitEvents[true].map({ Set($0) }) { + do { + var fetchedConversations: [SessionThreadViewModel] = [] + let rowIdsNeedingRequery: Set = self.extractRowIdsNeedingRequery( + events: databaseEvents, + cache: self.itemCache + ) + let loadPageEvent: LoadPageEvent? = databaseEvents + .first(where: { $0.key.generic == .loadPage })? + .value as? LoadPageEvent - return SQL("JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])") - }() - ), - PagedData.ObservedChanges( - table: Contact.self, - columns: [.isBlocked], - joinToPagedType: { - let contact: TypedTableAlias = TypedTableAlias() + /// Identify any inserted/deleted records + var insertedIds: Set = [] + var deletedIds: Set = [] - return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])") - }() - ), - PagedData.ObservedChanges( - table: Profile.self, - columns: [.name, .nickname, .profilePictureFileName], - joinToPagedType: { - let profile: TypedTableAlias = TypedTableAlias() - let groupMember: TypedTableAlias = TypedTableAlias() - let threadVariants: [SessionThread.Variant] = [.legacyGroup, .group] - let targetRole: GroupMember.Role = GroupMember.Role.standard + databaseEvents.forEach { event in + switch (event.key.generic, event.value) { + case (GenericObservableKey(.messageRequestAccepted), let threadId as String): + insertedIds.insert(threadId) + + case (GenericObservableKey(.conversationCreated), let event as ConversationEvent): + insertedIds.insert(event.id) + + case (.conversationDeleted, let event as ConversationEvent): + deletedIds.insert(event.id) + + default: break + } + } - return SQL(""" - JOIN \(Profile.self) ON ( - ( -- Contact profile change - \(profile[.id]) = \(thread[.id]) AND - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) - ) OR ( -- Closed group profile change - \(SQL("\(thread[.variant]) IN \(threadVariants)")) AND ( - profile.id = ( -- Front profile - SELECT MIN(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(thread[.id]) AND - \(SQL("\(groupMember[.role]) = \(targetRole)")) AND - \(groupMember[.profileId]) != \(userSessionId.hexString) - ) - ) OR - profile.id = ( -- Back profile - SELECT MAX(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(thread[.id]) AND - \(SQL("\(groupMember[.role]) = \(targetRole)")) AND - \(groupMember[.profileId]) != \(userSessionId.hexString) - ) - ) OR ( -- Fallback profile - profile.id = \(userSessionId.hexString) AND - ( - SELECT COUNT(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(thread[.id]) AND - \(SQL("\(groupMember[.role]) = \(targetRole)")) AND - \(groupMember[.profileId]) != \(userSessionId.hexString) - ) - ) = 1 - ) + try await dependencies[singleton: .storage].readAsync { db in + /// Update the `unreadMessageRequestThreadCount` if needed (since multiple events need this) + if databaseEvents.contains(where: { $0.key == .unreadMessageRequestMessageReceived || $0.key == .messageRequestAccepted }) { + unreadMessageRequestThreadCount = try SessionThread + .unreadMessageRequestsCountQuery(userSessionId: userSessionId) + .fetchOne(db) + .defaulting(to: 0) + } + + /// Update loaded page info as needed + if loadPageEvent != nil || !insertedIds.isEmpty || !deletedIds.isEmpty { + loadResult = try loadResult.load( + db, + target: ( + loadPageEvent?.target(with: loadResult) ?? + .reloadCurrent(insertedIds: insertedIds, deletedIds: deletedIds) ) ) + } + + /// Fetch any records needed + fetchedConversations.append( + contentsOf: try SessionThreadViewModel + .query( + userSessionId: userSessionId, + groupSQL: SessionThreadViewModel.groupSQL, + orderSQL: SessionThreadViewModel.homeOrderSQL, + rowIds: Array(rowIdsNeedingRequery) + loadResult.newRowIds + ) + .fetchAll(db) ) - """) - }() - ), - PagedData.ObservedChanges( - table: ClosedGroup.self, - columns: [.name, .invited, .displayPictureFilename], - joinToPagedType: { - let closedGroup: TypedTableAlias = TypedTableAlias() + } - return SQL("JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])") - }() - ), - PagedData.ObservedChanges( - table: OpenGroup.self, - columns: [.name, .displayPictureFilename], - joinToPagedType: { - let openGroup: TypedTableAlias = TypedTableAlias() + /// Update the `itemCache` with the newly fetched values + fetchedConversations.forEach { self.itemCache[$0.rowId] = $0 } - return SQL("JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])") - }() - ), - PagedData.ObservedChanges( - table: ThreadTypingIndicator.self, - columns: [.threadId], - joinToPagedType: { - let typingIndicator: TypedTableAlias = TypedTableAlias() - - return SQL("JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id])") - }() - ) - ], - /// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query but differs - /// from the JOINs that are actually used for performance reasons as the basic logic can be simpler for where it's used - joinSQL: SessionThreadViewModel.optimisedJoinSQL, - filterSQL: SessionThreadViewModel.homeFilterSQL(userSessionId: userSessionId), - groupSQL: SessionThreadViewModel.groupSQL, - orderSQL: SessionThreadViewModel.homeOrderSQL, - dataQuery: SessionThreadViewModel.baseQuery( - userSessionId: userSessionId, - groupSQL: SessionThreadViewModel.groupSQL, - orderSQL: SessionThreadViewModel.homeOrderSQL - ), - onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in - PagedData.processAndTriggerUpdates( - updatedData: self?.process(data: updatedData, for: updatedPageInfo), - currentDataRetriever: { self?.threadData }, - onDataChangeRetriever: { self?.onThreadChange }, - onUnobservedDataChange: { updatedData in - self?.unobservedThreadDataChanges = updatedData + /// Remove any deleted values + deletedIds.forEach { id in + guard + let key: Int64 = self.itemCache + .first(where: { _, value in value.threadId == id })? + .key + else { return } + + self.itemCache.removeValue(forKey: key) + } + } catch { + let eventList: String = databaseEvents.map { $0.key.rawValue }.joined(separator: ", ") + Log.critical(.homeViewModel, "Failed to fetch state for events [\(eventList)], due to error: \(error)") + } + } + + /// Then handle non-database events + splitEvents[false]?.forEach { event in + switch (event.key.generic, (event.value as? ProfileEvent)?.change) { + case (.profile, .name(let name)): + userProfile = userProfile.with(name: name) + + case (.profile, .nickname(let nickname)): + userProfile = userProfile.with(nickname: nickname) + + case (.profile, .displayPictureUrl(let url)): + userProfile = userProfile.with(displayPictureUrl: url) + + case (.setting, _) where Setting.BoolKey(rawValue: event.key.rawValue) == .hasViewedSeed: + showViewedSeedBanner = ( + (event.value as? Bool).map { hasViewedSeed in !hasViewedSeed } ?? + currentState.showViewedSeedBanner + ) + + case (.setting, _) where Setting.BoolKey(rawValue: event.key.rawValue) == .hasHiddenMessageRequests: + hasHiddenMessageRequests = ( + (event.value as? Bool) ?? + currentState.hasHiddenMessageRequests + ) + + default: break } + } + + /// Generate the new state + let updatedState: State = State( + viewState: (loadResult.info.totalCount == 0 ? + .empty(isNewUser: (startedAsNewUser && previousState == nil)) : + .loaded + ), + userProfile: userProfile, + showViewedSeedBanner: showViewedSeedBanner, + hasHiddenMessageRequests: hasHiddenMessageRequests, + unreadMessageRequestThreadCount: unreadMessageRequestThreadCount, + loadedPageInfo: loadResult.info, + sections: HomeViewModel.process( + hasHiddenMessageRequests: hasHiddenMessageRequests, + unreadMessageRequestThreadCount: unreadMessageRequestThreadCount, + conversations: loadResult.info.currentRowIds.compactMap { self.itemCache[$0] }, + loadedInfo: loadResult.info, + using: dependencies + ) ) - self?.hasReceivedInitialThreadData = true - }, - using: dependencies - ) - - // Run the initial query on a background thread so we don't block the main thread - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - // The `.pageBefore` will query from a `0` offset loading the first page - self?.pagedDataObserver?.load(.pageBefore) - } - } - - // MARK: - State - - /// This value is the current state of the view - public private(set) var state: State - - /// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise - /// performance https://github.com/groue/GRDB.swift#valueobservation-performance - /// - /// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`) - /// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own - /// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`) - /// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this - public lazy var observableState = ValueObservation - .trackingConstantRegion { [dependencies] db -> State in - try HomeViewModel.retrieveState(db, using: dependencies) - } - .removeDuplicates() - .handleEvents(didFail: { Log.error(.cat, "Observation failed with error: \($0)") }) - - private static func retrieveState( - _ db: Database, - excludingMessageRequestThreadCount: Bool = false, - using dependencies: Dependencies - ) throws -> State { - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let hasViewedSeed: Bool = db[.hasViewedSeed] - let hasHiddenMessageRequests: Bool = db[.hasHiddenMessageRequests] - let userProfile: Profile = Profile.fetchOrCreateCurrentUser(db, using: dependencies) - let unreadMessageRequestThreadCount: Int = (excludingMessageRequestThreadCount ? 0 : - try SessionThread - .unreadMessageRequestsCountQuery(userSessionId: userSessionId) - .fetchOne(db) - .defaulting(to: 0) - ) - - return State( - userSessionId: userSessionId, - showViewedSeedBanner: !hasViewedSeed, - hasHiddenMessageRequests: hasHiddenMessageRequests, - unreadMessageRequestThreadCount: unreadMessageRequestThreadCount, - userProfile: userProfile - ) - } - - public func updateState(_ updatedState: State) { - let oldState: State = self.state - self.state = updatedState - - // If the messageRequest content changed then we need to re-process the thread data (assuming - // we've received the initial thread data) - guard - self.hasReceivedInitialThreadData, - ( - oldState.hasHiddenMessageRequests != updatedState.hasHiddenMessageRequests || - oldState.unreadMessageRequestThreadCount != updatedState.unreadMessageRequestThreadCount - ), - let currentPageInfo: PagedData.PageInfo = self.pagedDataObserver?.pageInfo - else { return } - - /// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above - let currentData: [SectionModel] = (self.unobservedThreadDataChanges ?? self.threadData) - let updatedThreadData: [SectionModel] = self.process( - data: (currentData.first(where: { $0.model == .threads })?.elements ?? []), - for: currentPageInfo - ) - - PagedData.processAndTriggerUpdates( - updatedData: updatedThreadData, - currentDataRetriever: { [weak self] in (self?.unobservedThreadDataChanges ?? self?.threadData) }, - onDataChangeRetriever: { [weak self] in self?.onThreadChange }, - onUnobservedDataChange: { [weak self] updatedData in - self?.unobservedThreadDataChanges = updatedData + return updatedState } - ) + .assign { [weak self] updatedValue in self?.state = updatedValue } } - // MARK: - Thread Data - - private var hasReceivedInitialThreadData: Bool = false - public private(set) var unobservedThreadDataChanges: [SectionModel]? - public private(set) var threadData: [SectionModel] = [] - public private(set) var pagedDataObserver: PagedDatabaseObserver? - - public var onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? { - didSet { - guard onThreadChange != nil else { return } - - // When starting to observe interaction changes we want to trigger a UI update just in case the - // data was changed while we weren't observing - if let changes: [SectionModel] = self.unobservedThreadDataChanges { - PagedData.processAndTriggerUpdates( - updatedData: changes, - currentDataRetriever: { [weak self] in self?.threadData }, - onDataChangeRetriever: { [weak self] in self?.onThreadChange }, - onUnobservedDataChange: { [weak self] updatedData in - self?.unobservedThreadDataChanges = updatedData - } - ) - self.unobservedThreadDataChanges = nil + internal func extractRowIdsNeedingRequery( + events: Set, + cache: [Int64: SessionThreadViewModel] + ) -> Set { + let conversationIds: Set = events.reduce(into: []) { result, event in + switch (event.key.generic, event.value) { + case (.conversationUpdated, let event as ConversationEvent): result.insert(event.id) + case (.typingIndicator, let event as TypingIndicatorEvent): result.insert(event.threadId) + + case (.messageCreated, let event as MessageEvent), + (.messageUpdated, let event as MessageEvent), + (.messageDeleted, let event as MessageEvent): + result.insert(event.threadId) + + case (.profile, let event as ProfileEvent): + result.insert( + contentsOf: Set(cache.values + .filter { threadViewModel -> Bool in + threadViewModel.threadId == event.id || + threadViewModel.allProfileIds.contains(event.id) + } + .map { $0.threadId }) + ) + + case (.contact, let event as ContactEvent): + result.insert( + contentsOf: Set(cache.values + .filter { threadViewModel -> Bool in + threadViewModel.threadId == event.id || + threadViewModel.allProfileIds.contains(event.id) + } + .map { $0.threadId }) + ) + + default: break } } + + return Set(conversationIds.compactMap { conversationId in + cache.values.first { $0.threadId == conversationId }?.rowId + }) } + - private func process(data: [SessionThreadViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] { - let finalUnreadMessageRequestCount: Int = (self.state.hasHiddenMessageRequests ? - 0 : - self.state.unreadMessageRequestThreadCount - ) - let groupedOldData: [String: [SessionThreadViewModel]] = (self.threadData - .first(where: { $0.model == .threads })? - .elements) - .defaulting(to: []) - .grouped(by: \.threadId) - + private static func process( + hasHiddenMessageRequests: Bool, + unreadMessageRequestThreadCount: Int, + conversations: [SessionThreadViewModel], + loadedInfo: PagedData.LoadedInfo, + using dependencies: Dependencies + ) -> [SectionModel] { return [ - // If there are no unread message requests then hide the message request banner - (finalUnreadMessageRequestCount == 0 ? + /// If the message request section is hidden or there are no unread message requests then hide the message request banner + (hasHiddenMessageRequests || unreadMessageRequestThreadCount == 0 ? [] : [SectionModel( section: .messageRequests, elements: [ SessionThreadViewModel( threadId: SessionThreadViewModel.messageRequestsSectionId, - unreadCount: UInt(finalUnreadMessageRequestCount), + unreadCount: UInt(unreadMessageRequestThreadCount), using: dependencies ) ] @@ -350,11 +407,7 @@ public class HomeViewModel: NavigatableStateHolder { [ SectionModel( section: .threads, - elements: data - .filter { threadViewModel in - threadViewModel.id != SessionThreadViewModel.invalidId && - threadViewModel.id != SessionThreadViewModel.messageRequestsSectionId - } + elements: conversations .sorted { lhs, rhs -> Bool in guard lhs.threadPinnedPriority == rhs.threadPinnedPriority else { return lhs.threadPinnedPriority > rhs.threadPinnedPriority @@ -364,40 +417,65 @@ public class HomeViewModel: NavigatableStateHolder { } .map { viewModel -> SessionThreadViewModel in viewModel.populatingPostQueryData( - currentUserBlinded15SessionIdForThisThread: groupedOldData[viewModel.threadId]? - .first? - .currentUserBlinded15SessionId, - currentUserBlinded25SessionIdForThisThread: groupedOldData[viewModel.threadId]? - .first? - .currentUserBlinded25SessionId, + recentReactionEmoji: nil, + openGroupCapabilities: nil, + // TODO: [Database Relocation] Do we need all of these???? + currentUserSessionIds: [dependencies[cache: .general].sessionId.hexString], wasKickedFromGroup: ( viewModel.threadVariant == .group && - LibSession.wasKickedFromGroup( - groupSessionId: SessionId(.group, hex: viewModel.threadId), - using: dependencies - ) + dependencies.mutate(cache: .libSession) { cache in + cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: viewModel.threadId)) + } ), groupIsDestroyed: ( viewModel.threadVariant == .group && - LibSession.groupIsDestroyed( - groupSessionId: SessionId(.group, hex: viewModel.threadId), - using: dependencies - ) + dependencies.mutate(cache: .libSession) { cache in + cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: viewModel.threadId)) + } ), - threadCanWrite: false, // Irrelevant for the HomeViewModel - using: dependencies + threadCanWrite: false // Irrelevant for the HomeViewModel ) } ) ], - (!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ? + (!conversations.isEmpty && loadedInfo.hasNextPage ? [SectionModel(section: .loadMore)] : [] ) ].flatMap { $0 } } - public func updateThreadData(_ updatedData: [SectionModel]) { - self.threadData = updatedData + // MARK: - Functions + + public func loadNextPage() { + Task { [loadedPageInfo = state.loadedPageInfo, observationManager = dependencies[singleton: .observationManager]] in + await observationManager.notify( + .loadPage(HomeViewModel.observationName), + value: LoadPageEvent.nextPage(lastIndex: loadedPageInfo.lastIndex) + ) + } + } +} + +// MARK: - Convenience + +private extension ObservedEvent { + var requiresDatabaseQueryForHomeViewModel: Bool { + /// Any event requires a database query + switch self.key.generic { + case .loadPage: return true + case GenericObservableKey(.unreadMessageRequestMessageReceived): return true + case GenericObservableKey(.messageRequestAccepted): return true + case GenericObservableKey(.conversationCreated): return true + case .typingIndicator: return true + + /// We only observe events from records we have explicitly fetched so if we get an event for one of these then we need to + /// trigger an update + case .conversationUpdated, .conversationDeleted: return true + case .messageCreated, .messageUpdated, .messageDeleted: return true + case .profile: return true + case .contact: return true + default: return false + } } } diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index 9b5628a5c7..26115db7ac 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -71,7 +71,7 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O ), PagedData.ObservedChanges( table: Profile.self, - columns: [.name, .nickname, .profilePictureFileName], + columns: [.name, .nickname, .displayPictureUrl], joinToPagedType: { let profile: TypedTableAlias = TypedTableAlias() @@ -146,30 +146,26 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O .map { [dependencies] viewModel -> SessionCell.Info in SessionCell.Info( id: viewModel.populatingPostQueryData( - currentUserBlinded15SessionIdForThisThread: groupedOldData[viewModel.threadId]? + recentReactionEmoji: nil, + openGroupCapabilities: nil, + currentUserSessionIds: (groupedOldData[viewModel.threadId]? .first? .id - .currentUserBlinded15SessionId, - currentUserBlinded25SessionIdForThisThread: groupedOldData[viewModel.threadId]? - .first? - .id - .currentUserBlinded25SessionId, + .currentUserSessionIds) + .defaulting(to: [dependencies[cache: .general].sessionId.hexString]), wasKickedFromGroup: ( viewModel.threadVariant == .group && - LibSession.wasKickedFromGroup( - groupSessionId: SessionId(.group, hex: viewModel.threadId), - using: dependencies - ) + dependencies.mutate(cache: .libSession) { cache in + cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: viewModel.threadId)) + } ), groupIsDestroyed: ( viewModel.threadVariant == .group && - LibSession.groupIsDestroyed( - groupSessionId: SessionId(.group, hex: viewModel.threadId), - using: dependencies - ) + dependencies.mutate(cache: .libSession) { cache in + cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: viewModel.threadId)) + } ), - threadCanWrite: false, // Irrelevant for the MessageRequestsViewModel - using: dependencies + threadCanWrite: false // Irrelevant for the MessageRequestsViewModel ), accessibility: Accessibility( identifier: "Message request" diff --git a/Session/Media Viewing & Editing/AllMediaViewController.swift b/Session/Media Viewing & Editing/AllMediaViewController.swift index 1cc6e6afd1..008c21c169 100644 --- a/Session/Media Viewing & Editing/AllMediaViewController.swift +++ b/Session/Media Viewing & Editing/AllMediaViewController.swift @@ -158,13 +158,26 @@ extension AllMediaViewController: UIDocumentInteractionControllerDelegate { public func documentInteractionControllerViewControllerForPreview(_ controller: UIDocumentInteractionController) -> UIViewController { return self } + + public func documentInteractionControllerDidEndPreview(_ controller: UIDocumentInteractionController) { + guard let temporaryFileUrl: URL = controller.url else { return } + + /// Now that we are finished with it we want to remove the temporary file (just to be safe ensure that it starts with the + /// `temporaryDirectory` so we don't accidentally delete a proper file if logic elsewhere changes) + if temporaryFileUrl.path.starts(with: dependencies[singleton: .fileManager].temporaryDirectory) { + try? dependencies[singleton: .fileManager].removeItem(atPath: temporaryFileUrl.path) + } + } } // MARK: - DocumentTitleViewControllerDelegate extension AllMediaViewController: DocumentTileViewControllerDelegate { - public func share(fileUrl: URL) { - let shareVC = UIActivityViewController(activityItems: [ fileUrl ], applicationActivities: nil) + public func share(temporaryFileUrl: URL) { + let shareVC = UIActivityViewController(activityItems: [ temporaryFileUrl ], applicationActivities: nil) + shareVC.completionWithItemsHandler = { [dependencies] _, success, _, _ in + UIActivityViewController.notifyIfNeeded(success, using: dependencies) + } if UIDevice.current.isIPad { shareVC.excludedActivityTypes = [] @@ -173,11 +186,17 @@ extension AllMediaViewController: DocumentTileViewControllerDelegate { shareVC.popoverPresentationController?.sourceRect = self.view.bounds } - navigationController?.present(shareVC, animated: true, completion: nil) + navigationController?.present(shareVC, animated: true) { [dependencies] in + /// Now that we are finished with it we want to remove the temporary file (just to be safe ensure that it starts with the + /// `temporaryDirectory` so we don't accidentally delete a proper file if logic elsewhere changes) + if temporaryFileUrl.path.starts(with: dependencies[singleton: .fileManager].temporaryDirectory) { + try? dependencies[singleton: .fileManager].removeItem(atPath: temporaryFileUrl.path) + } + } } - public func preview(fileUrl: URL) { - let interactionController: UIDocumentInteractionController = UIDocumentInteractionController(url: fileUrl) + public func preview(temporaryFileUrl: URL) { + let interactionController: UIDocumentInteractionController = UIDocumentInteractionController(url: temporaryFileUrl) interactionController.delegate = self interactionController.presentPreview(animated: true) } @@ -238,8 +257,8 @@ extension AllMediaViewController: UIViewControllerTransitioningDelegate { // MARK: - MediaPresentationContextProvider extension AllMediaViewController: MediaPresentationContextProvider { - func mediaPresentationContext(mediaItem: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? { - return self.mediaTitleViewController.mediaPresentationContext(mediaItem: mediaItem, in: coordinateSpace) + func mediaPresentationContext(mediaId: String, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? { + return self.mediaTitleViewController.mediaPresentationContext(mediaId: mediaId, in: coordinateSpace) } func snapshotOverlayView(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? { diff --git a/Session/Media Viewing & Editing/DocumentTitleViewController.swift b/Session/Media Viewing & Editing/DocumentTitleViewController.swift index bc35bc00ff..ea1814950e 100644 --- a/Session/Media Viewing & Editing/DocumentTitleViewController.swift +++ b/Session/Media Viewing & Editing/DocumentTitleViewController.swift @@ -310,9 +310,15 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate, public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false) let attachment: Attachment = self.viewModel.galleryData[indexPath.section].elements[indexPath.row].attachment - guard let originalFilePath: String = attachment.originalFilePath(using: dependencies) else { return } + guard + let path: String = try? dependencies[singleton: .attachmentManager].createTemporaryFileForOpening( + downloadUrl: attachment.downloadUrl, + mimeType: attachment.contentType, + sourceFilename: attachment.sourceFilename + ) + else { return } - let fileUrl: URL = URL(fileURLWithPath: originalFilePath) + let fileUrl: URL = URL(fileURLWithPath: path) // Open a preview of the document for text, pdf or microsoft files if @@ -321,12 +327,12 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate, attachment.contentType == UTType.mimeTypePdf { - delegate?.preview(fileUrl: fileUrl) + delegate?.preview(temporaryFileUrl: fileUrl) return } // Otherwise share the file - delegate?.share(fileUrl: fileUrl) + delegate?.share(temporaryFileUrl: fileUrl) } } @@ -584,6 +590,6 @@ class DocumentStaticHeaderView: UIView { // MARK: - DocumentTitleViewControllerDelegate public protocol DocumentTileViewControllerDelegate: AnyObject { - func share(fileUrl: URL) - func preview(fileUrl: URL) + func share(temporaryFileUrl: URL) + func preview(temporaryFileUrl: URL) } diff --git a/Session/Media Viewing & Editing/ImagePickerController.swift b/Session/Media Viewing & Editing/ImagePickerController.swift index c8eebdec16..a7ea443621 100644 --- a/Session/Media Viewing & Editing/ImagePickerController.swift +++ b/Session/Media Viewing & Editing/ImagePickerController.swift @@ -527,13 +527,14 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat guard let delegate = delegate else { return UICollectionViewCell() } let cell: PhotoGridViewCell = collectionView.dequeue(type: PhotoGridViewCell.self, for: indexPath) + let size: ImageDataManager.ThumbnailSize = .small - guard let assetItem: PhotoPickerAssetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize) else { + guard let assetItem: PhotoPickerAssetItem = photoCollectionContents.assetItem(at: indexPath.item, size: size, pixelDimension: size.pixelDimension()) else { Log.error(.media, "Failed to style cell for asset at \(indexPath.item)") return cell } - cell.configure(item: assetItem) + cell.configure(item: assetItem, using: dependencies) cell.isAccessibilityElement = true cell.accessibilityIdentifier = "\(assetItem.asset.modificationDate.map { "\($0)" } ?? "Unknown Date")" cell.isSelected = delegate.imagePicker(self, isAssetSelected: assetItem.asset) diff --git a/Session/Media Viewing & Editing/MediaDetailViewController.swift b/Session/Media Viewing & Editing/MediaDetailViewController.swift index 551f1b5472..692baabe0c 100644 --- a/Session/Media Viewing & Editing/MediaDetailViewController.swift +++ b/Session/Media Viewing & Editing/MediaDetailViewController.swift @@ -97,20 +97,7 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate { super.init(nibName: nil, bundle: nil) - switch (galleryItem.attachment.isVideo, galleryItem.attachment.originalFilePath(using: dependencies)) { - case (false, .some(let filePath)): mediaView.loadImage(from: filePath) - default: - galleryItem.attachment.thumbnail( - size: .large, - using: dependencies, - success: { [weak self] thumbnailPath, _, _ in - self?.mediaView.loadImage(from: thumbnailPath) - }, - failure: { - Log.error(.media, "Could not load media.") - } - ) - } + mediaView.loadImage(attachment: galleryItem.attachment, using: dependencies) } required init?(coder: NSCoder) { @@ -246,13 +233,24 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate { @objc public func playVideo() { guard - let originalFilePath: String = self.galleryItem.attachment.originalFilePath(using: dependencies), - dependencies[singleton: .fileManager].fileExists(atPath: originalFilePath) + let path: String = try? dependencies[singleton: .attachmentManager].createTemporaryFileForOpening( + downloadUrl: self.galleryItem.attachment.downloadUrl, + mimeType: self.galleryItem.attachment.contentType, + sourceFilename: self.galleryItem.attachment.sourceFilename + ), + dependencies[singleton: .fileManager].fileExists(atPath: path) else { return Log.error(.media, "Missing video file") } - let videoUrl: URL = URL(fileURLWithPath: originalFilePath) + let videoUrl: URL = URL(fileURLWithPath: path) let player: AVPlayer = AVPlayer(url: videoUrl) - let viewController: AVPlayerViewController = AVPlayerViewController() + let viewController: DismissCallbackAVPlayerViewController = DismissCallbackAVPlayerViewController { [dependencies] in + /// Sanity check to make sure we don't unintentionally remove a proper attachment file + guard path.hasPrefix(dependencies[singleton: .fileManager].temporaryDirectory) else { + return + } + + try? dependencies[singleton: .fileManager].removeItem(atPath: path) + } viewController.player = player self.present(viewController, animated: true) { [weak player] in player?.play() diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift index 767a0791b4..aa2c294ea2 100644 --- a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -207,6 +207,7 @@ public class MediaGalleryViewModel { public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { case interactionId + case interactionThreadId case interactionVariant case interactionAuthorId case interactionTimestampMs @@ -220,6 +221,7 @@ public class MediaGalleryViewModel { public var differenceIdentifier: String { attachment.id } let interactionId: Int64 + let interactionThreadId: String let interactionVariant: Interaction.Variant let interactionAuthorId: String let interactionTimestampMs: Int64 @@ -354,15 +356,6 @@ public class MediaGalleryViewModel { fileprivate static func baseQuery(orderSQL: SQL, customFilters: SQL) -> AdaptedFetchRequest> { return Item.baseQuery(orderSQL: orderSQL, customFilters: customFilters)([]) } - - func thumbnailImage(using dependencies: Dependencies, async: @escaping (UIImage?) -> ()) { - attachment.thumbnail( - size: .small, - using: dependencies, - success: { _, imageRetriever, _ in async(imageRetriever()) }, - failure: {} - ) - } } // MARK: - Album diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index 673c8cb61e..a6a20ebec4 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -498,11 +498,16 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou Log.error("[MediaPageViewController] currentViewController was unexpectedly nil") return } - guard let originalFilePath: String = currentViewController.galleryItem.attachment.originalFilePath(using: viewModel.dependencies) else { - return - } + guard + let path: String = try? viewModel.dependencies[singleton: .attachmentManager].createTemporaryFileForOpening( + downloadUrl: currentViewController.galleryItem.attachment.downloadUrl, + mimeType: currentViewController.galleryItem.attachment.contentType, + sourceFilename: currentViewController.galleryItem.attachment.sourceFilename + ), + viewModel.dependencies[singleton: .fileManager].fileExists(atPath: path) + else { return } - let shareVC = UIActivityViewController(activityItems: [ URL(fileURLWithPath: originalFilePath) ], applicationActivities: nil) + let shareVC = UIActivityViewController(activityItems: [ URL(fileURLWithPath: path) ], applicationActivities: nil) if UIDevice.current.isIPad { shareVC.excludedActivityTypes = [] @@ -519,6 +524,14 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou Log.info("[MediaPageViewController] Did share with activityType: \(activityType.debugDescription)") } + /// Sanity check to make sure we don't unintentionally remove a proper attachment file + if path.hasPrefix(dependencies[singleton: .fileManager].temporaryDirectory) { + try? dependencies[singleton: .fileManager].removeItem(atPath: path) + } + + /// Notify any conversations to update if a message was sent via Session + UIActivityViewController.notifyIfNeeded(completed, using: dependencies) + guard let activityType = activityType, activityType == .saveToCameraRoll, @@ -529,7 +542,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou let threadId: String = self.viewModel.threadId let threadVariant: SessionThread.Variant = self.viewModel.threadVariant - dependencies[singleton: .storage].write { db in + dependencies[singleton: .storage].writeAsync { db in try MessageSender.send( db, message: DataExtractionNotification( @@ -854,12 +867,11 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou switch targetItem.interactionVariant { case .standardIncoming: return viewModel.dependencies[singleton: .storage] - .read { [dependencies = viewModel.dependencies] db in + .read { db in Profile.displayName( db, id: targetItem.interactionAuthorId, - threadVariant: threadVariant, - using: dependencies + threadVariant: threadVariant ) } .defaulting(to: Profile.truncated(id: targetItem.interactionAuthorId, truncating: .middle)) @@ -897,12 +909,12 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou extension MediaGalleryViewModel.Item: GalleryRailItem { public func buildRailItemView(using dependencies: Dependencies) -> UIView { - let imageView: UIImageView = UIImageView() + let imageView: SessionImageView = SessionImageView(dataManager: dependencies[singleton: .imageDataManager]) imageView.contentMode = .scaleAspectFill - self.thumbnailImage(using: dependencies) { [weak imageView] image in - DispatchQueue.main.async { - imageView?.image = image + if attachment.downloadUrl != nil { + Task(priority: .userInitiated) { + await imageView.loadThumbnail(size: .small, attachment: attachment, using: dependencies) } } @@ -966,7 +978,7 @@ extension MediaPageViewController: UIViewControllerTransitioningDelegate { guard let currentItem: MediaGalleryViewModel.Item = currentItem else { return nil } guard self == presented || self.navigationController == presented else { return nil } - return MediaZoomAnimationController(galleryItem: currentItem, using: viewModel.dependencies) + return MediaZoomAnimationController(attachment: currentItem.attachment, using: viewModel.dependencies) } public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { @@ -974,7 +986,7 @@ extension MediaPageViewController: UIViewControllerTransitioningDelegate { guard self == dismissed || self.navigationController == dismissed else { return nil } guard !self.viewModel.albumData.isEmpty else { return nil } - let animationController = MediaDismissAnimationController(galleryItem: currentItem, interactionController: mediaInteractiveDismiss, using: viewModel.dependencies) + let animationController = MediaDismissAnimationController(attachment: currentItem.attachment, interactionController: mediaInteractiveDismiss, using: viewModel.dependencies) mediaInteractiveDismiss?.interactiveDismissDelegate = animationController return animationController @@ -995,7 +1007,7 @@ extension MediaPageViewController: UIViewControllerTransitioningDelegate { // MARK: - MediaPresentationContextProvider extension MediaPageViewController: MediaPresentationContextProvider { - func mediaPresentationContext(mediaItem: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? { + func mediaPresentationContext(mediaId: String, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? { guard let mediaView: SessionImageView = currentViewController?.mediaView, let mediaSuperview: UIView = mediaView.superview, @@ -1011,21 +1023,10 @@ extension MediaPageViewController: MediaPresentationContextProvider { }() else { return nil } - var topInset: CGFloat = 0 - var leftInset: CGFloat = 0 - var scaledWidth: CGFloat = mediaSize.width - var scaledHeight: CGFloat = mediaSize.height - - if mediaSize.width > mediaSize.height { - scaledWidth = mediaSuperview.frame.width - scaledHeight = (mediaSize.height * (mediaSuperview.frame.width / mediaSize.width)) - topInset = ((mediaSuperview.frame.height - scaledHeight) / 2.0) - } - else if mediaSize.width < mediaSize.height { - scaledWidth = (mediaSize.width * (mediaSuperview.frame.height / mediaSize.height)) - scaledHeight = mediaSuperview.frame.height - leftInset = ((mediaSuperview.frame.width - scaledWidth) / 2.0) - } + let scaledWidth: CGFloat = mediaSuperview.frame.width + let scaledHeight: CGFloat = (mediaSize.height * (mediaSuperview.frame.width / mediaSize.width)) + let topInset: CGFloat = ((mediaSuperview.frame.height - scaledHeight) / 2.0) + let leftInset: CGFloat = ((mediaSuperview.frame.width - scaledWidth) / 2.0) return MediaPresentationContext( mediaView: mediaView, diff --git a/Session/Media Viewing & Editing/MediaTileViewController.swift b/Session/Media Viewing & Editing/MediaTileViewController.swift index 526fdfb078..de5111095b 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -481,7 +481,8 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour item: GalleryGridCellItem( galleryItem: section.elements[indexPath.row], using: dependencies - ) + ), + using: dependencies ) return cell @@ -708,16 +709,16 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour .putNumber(indexPaths.count) .localized() - let deleteAction = UIAlertAction(title: confirmationTitle, style: .destructive) { [weak self, dependencies = viewModel.dependencies] _ in + let deleteAction = UIAlertAction(title: confirmationTitle, style: .destructive) { [weak self, threadId = viewModel.threadId, dependencies = viewModel.dependencies] _ in dependencies[singleton: .storage].writeAsync { db in - let interactionIds: Set = items - .map { $0.interactionId } - .asSet() - _ = try Attachment .filter(ids: items.map { $0.attachment.id }) .deleteAll(db) + items.forEach { item in + db.addAttachmentEvent(id: item.attachment.id, messageId: item.interactionId, type: .deleted) + } + // Add the garbage collection job to delete orphaned attachment files dependencies[singleton: .jobRunner].add( db, @@ -732,10 +733,16 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour ) // Delete any interactions which had all of their attachments removed - _ = try Interaction - .filter(ids: interactionIds) - .having(Interaction.interactionAttachments.isEmpty) - .deleteAll(db) + try items.forEach { item in + let remainingAttachmentCount: Int = try InteractionAttachment + .filter(InteractionAttachment.Columns.interactionId == item.interactionId) + .fetchCount(db) + + if remainingAttachmentCount == 0 { + _ = try Interaction.deleteOne(db, id: item.interactionId) + db.addMessageEvent(id: item.interactionId, threadId: threadId, type: .deleted) + } + } } self?.endSelectMode() @@ -882,9 +889,13 @@ class GalleryGridCellItem: PhotoGridItem { return .photo } - - func asyncThumbnail(completion: @escaping (UIImage?) -> Void) { - galleryItem.thumbnailImage(using: dependencies, async: completion) + + var source: ImageDataManager.DataSource { + ImageDataManager.DataSource.thumbnailFrom( + attachment: galleryItem.attachment, + size: .medium, + using: dependencies + ) ?? .image("", nil) } } @@ -901,7 +912,10 @@ extension MediaTileViewController: UIViewControllerTransitioningDelegate { guard let focusedIndexPath: IndexPath = self.viewModel.focusedIndexPath else { return nil } return MediaDismissAnimationController( - galleryItem: self.viewModel.galleryData[focusedIndexPath.section].elements[focusedIndexPath.item], + attachment: self.viewModel + .galleryData[focusedIndexPath.section] + .elements[focusedIndexPath.item] + .attachment, using: dependencies ) } @@ -916,7 +930,10 @@ extension MediaTileViewController: UIViewControllerTransitioningDelegate { guard let focusedIndexPath: IndexPath = self.viewModel.focusedIndexPath else { return nil } return MediaZoomAnimationController( - galleryItem: self.viewModel.galleryData[focusedIndexPath.section].elements[focusedIndexPath.item], + attachment: self.viewModel + .galleryData[focusedIndexPath.section] + .elements[focusedIndexPath.item] + .attachment, shouldBounce: false, using: dependencies ) @@ -926,9 +943,7 @@ extension MediaTileViewController: UIViewControllerTransitioningDelegate { // MARK: - MediaPresentationContextProvider extension MediaTileViewController: MediaPresentationContextProvider { - func mediaPresentationContext(mediaItem: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? { - guard case let .gallery(galleryItem, _) = mediaItem else { return nil } - + func mediaPresentationContext(mediaId: String, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? { // Note: According to Apple's docs the 'indexPathsForVisibleRows' method returns an // unsorted array which means we can't use it to determine the desired 'visibleCell' // we are after, due to this we will need to iterate all of the visible cells to find @@ -938,7 +953,7 @@ extension MediaTileViewController: MediaPresentationContextProvider { guard let cell: PhotoGridViewCell = cell as? PhotoGridViewCell, let item: GalleryGridCellItem = cell.item as? GalleryGridCellItem, - item.galleryItem.attachment.id == galleryItem.attachment.id + item.galleryItem.attachment.id == mediaId else { return false } return true diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index a2ee2bd0e6..4e2d024309 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -284,7 +284,7 @@ struct MessageInfoScreen: View { size: .message, publicKey: messageViewModel.authorId, threadVariant: .contact, // Always show the display picture in 'contact' mode - displayPictureFilename: nil, + displayPictureUrl: nil, profile: messageViewModel.profile, profileIcon: (messageViewModel.isSenderModeratorOrAdmin ? .crown : .none), using: dependencies @@ -484,9 +484,7 @@ struct MessageBubble: View { authorId: quote.authorId, quotedText: quote.body, threadVariant: messageViewModel.threadVariant, - currentUserSessionId: messageViewModel.currentUserSessionId, - currentUserBlinded15SessionId: messageViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: messageViewModel.currentUserBlinded25SessionId, + currentUserSessionIds: (messageViewModel.currentUserSessionIds ?? []), direction: (messageViewModel.variant == .standardOutgoing ? .outgoing : .incoming), attachment: messageViewModel.quoteAttachment ), @@ -612,7 +610,10 @@ struct MessageInfoView_Previews: PreviewProvider { expiresInSeconds: nil, state: .failed, isSenderModeratorOrAdmin: false, - currentUserProfile: Profile.fetchOrCreateCurrentUser(using: dependencies), + currentUserProfile: Profile( + id: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b", + name: "TestUser" + ), quote: nil, quoteAttachment: nil, linkPreview: nil, diff --git a/Session/Media Viewing & Editing/PhotoCollectionPickerViewModel.swift b/Session/Media Viewing & Editing/PhotoCollectionPickerViewModel.swift index bc5c4a0e15..4b0ce936ac 100644 --- a/Session/Media Viewing & Editing/PhotoCollectionPickerViewModel.swift +++ b/Session/Media Viewing & Editing/PhotoCollectionPickerViewModel.swift @@ -14,11 +14,14 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel, ObservableTableSour public let observableState: ObservableTableSourceState = ObservableTableSourceState() private let library: PhotoLibrary + private let thumbnailSize: ImageDataManager.ThumbnailSize = .small + private let thumbnailPixelDimension: CGFloat private let onCollectionSelected: (PhotoCollection) -> Void private var photoCollections: CurrentValueSubject<[PhotoCollection], Error> // MARK: - Initialization + @MainActor init( library: PhotoLibrary, using dependencies: Dependencies, @@ -26,6 +29,7 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel, ObservableTableSour ) { self.dependencies = dependencies self.library = library + self.thumbnailPixelDimension = thumbnailSize.pixelDimension() self.onCollectionSelected = onCollectionSelected self.photoCollections = CurrentValueSubject(library.allPhotoCollections()) } @@ -59,31 +63,23 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel, ObservableTableSour let title: String = "notificationsSound".localized() - lazy var observation: TargetObservation = ObservationBuilder + lazy var observation: TargetObservation = ObservationBuilderOld .subject(photoCollections) - .map { collections -> [SectionModel] in + .map { [thumbnailSize, thumbnailPixelDimension] collections -> [SectionModel] in [ SectionModel( model: .content, elements: collections.map { collection in let contents: PhotoCollectionContents = collection.contents() - let photoMediaSize: PhotoMediaSize = PhotoMediaSize( - thumbnailSize: CGSize( - width: IconSize.extraLarge.size, - height: IconSize.extraLarge.size - ) - ) - let lastAssetItem: PhotoPickerAssetItem? = contents.lastAssetItem(photoMediaSize: photoMediaSize) + let lastAssetItem: PhotoPickerAssetItem? = contents.lastAssetItem(size: thumbnailSize, pixelDimension: thumbnailPixelDimension) return SessionCell.Info( id: TableItem(collection: collection), - leadingAccessory: .iconAsync(size: .extraLarge, shouldFill: true) { imageView in - // Note: We need to capture 'lastAssetItem' otherwise it'll be released and we won't - // be able to load the thumbnail - lastAssetItem?.asyncThumbnail { [weak imageView] image in - imageView?.image = image - } - }, + leadingAccessory: .iconAsync( + size: .extraLarge, + source: lastAssetItem?.source, + shouldFill: true + ), title: collection.localizedTitle(), subtitle: "\(contents.assetCount)", onTap: { [weak self] in diff --git a/Session/Media Viewing & Editing/PhotoGridViewCell.swift b/Session/Media Viewing & Editing/PhotoGridViewCell.swift index 80fa010b60..f32643c210 100644 --- a/Session/Media Viewing & Editing/PhotoGridViewCell.swift +++ b/Session/Media Viewing & Editing/PhotoGridViewCell.swift @@ -12,12 +12,11 @@ public enum PhotoGridItemType { public protocol PhotoGridItem: AnyObject { var type: PhotoGridItemType { get } - - func asyncThumbnail(completion: @escaping (UIImage?) -> Void) + var source: ImageDataManager.DataSource { get } } public class PhotoGridViewCell: UICollectionViewCell { - public let imageView: UIImageView + public let imageView: SessionImageView private let contentTypeBadgeView: UIImageView private let selectedBadgeView: UIImageView @@ -31,8 +30,6 @@ public class PhotoGridViewCell: UICollectionViewCell { private static let animatedBadgeImage = #imageLiteral(resourceName: "ic_gallery_badge_gif") private static let selectedBadgeImage = UIImage(systemName: "checkmark.circle.fill") - public var loadingColor: ThemeValue = .textSecondary - override public var isSelected: Bool { didSet { self.selectedBadgeView.isHidden = !self.isSelected @@ -47,7 +44,7 @@ public class PhotoGridViewCell: UICollectionViewCell { } override init(frame: CGRect) { - self.imageView = UIImageView() + self.imageView = SessionImageView() imageView.contentMode = .scaleAspectFill self.contentTypeBadgeView = UIImageView() @@ -104,42 +101,26 @@ public class PhotoGridViewCell: UICollectionViewCell { fatalError("init(coder:) has not been implemented") } - var image: UIImage? { - get { return imageView.image } - set { - imageView.image = newValue - imageView.themeBackgroundColor = (newValue == nil ? loadingColor : .clear) - } - } - - var contentTypeBadgeImage: UIImage? { - get { return contentTypeBadgeView.image } - set { - contentTypeBadgeView.image = newValue - contentTypeBadgeView.isHidden = newValue == nil - } - } - - public func configure(item: PhotoGridItem) { + public func configure(item: PhotoGridItem, using dependencies: Dependencies) { self.item = item - - item.asyncThumbnail { [weak self] image in - guard let currentItem = self?.item else { return } - guard currentItem === item else { return } - - if image == nil { - Log.debug("[PhotoGridViewCell] image == nil") - } - - DispatchQueue.main.async { - self?.image = image - } + imageView.setDataManager(dependencies[singleton: .imageDataManager]) + imageView.themeBackgroundColor = .textSecondary + imageView.loadImage(item.source) { [weak imageView] success in + imageView?.themeBackgroundColor = (success ? .clear : .textSecondary) } switch item.type { - case .video: self.contentTypeBadgeImage = PhotoGridViewCell.videoBadgeImage - case .animated: self.contentTypeBadgeImage = PhotoGridViewCell.animatedBadgeImage - case .photo: self.contentTypeBadgeImage = nil + case .video: + contentTypeBadgeView.image = PhotoGridViewCell.videoBadgeImage + contentTypeBadgeView.isHidden = false + + case .animated: + contentTypeBadgeView.image = PhotoGridViewCell.animatedBadgeImage + contentTypeBadgeView.isHidden = false + + case .photo: + contentTypeBadgeView.image = nil + contentTypeBadgeView.isHidden = true } } diff --git a/Session/Media Viewing & Editing/PhotoLibrary.swift b/Session/Media Viewing & Editing/PhotoLibrary.swift index 9faf344067..c14bc612ad 100644 --- a/Session/Media Viewing & Editing/PhotoLibrary.swift +++ b/Session/Media Viewing & Editing/PhotoLibrary.swift @@ -6,6 +6,7 @@ import Photos import CoreServices import UniformTypeIdentifiers import SignalUtilitiesKit +import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit @@ -29,12 +30,19 @@ class PhotoPickerAssetItem: PhotoGridItem { let asset: PHAsset let photoCollectionContents: PhotoCollectionContents - let photoMediaSize: PhotoMediaSize - - init(asset: PHAsset, photoCollectionContents: PhotoCollectionContents, photoMediaSize: PhotoMediaSize) { + let size: ImageDataManager.ThumbnailSize + let pixelDimension: CGFloat + + init( + asset: PHAsset, + photoCollectionContents: PhotoCollectionContents, + size: ImageDataManager.ThumbnailSize, + pixelDimension: CGFloat + ) { self.asset = asset self.photoCollectionContents = photoCollectionContents - self.photoMediaSize = photoMediaSize + self.size = size + self.pixelDimension = pixelDimension } // MARK: PhotoGridItem @@ -48,24 +56,14 @@ class PhotoPickerAssetItem: PhotoGridItem { return .photo } - - func asyncThumbnail(completion: @escaping (UIImage?) -> Void) { - var hasLoadedImage = false - - // Surprisingly, iOS will opportunistically run the completion block sync if the image is - // already available. - photoCollectionContents.requestThumbnail(for: self.asset, thumbnailSize: photoMediaSize.thumbnailSize) { image, _ in - Threading.dispatchMainThreadSafe { - // Once we've _successfully_ completed (e.g. invoked the completion with - // a non-nil image), don't invoke the completion again with a nil argument. - if !hasLoadedImage || image != nil { - completion(image) - - if image != nil { - hasLoadedImage = true - } - } - } + + var source: ImageDataManager.DataSource { + return .closureThumbnail(self.asset.localIdentifier, size) { [photoCollectionContents, asset, size, pixelDimension] in + await photoCollectionContents.requestThumbnail( + for: asset, + size: size, + thumbnailSize: CGSize(width: pixelDimension, height: pixelDimension) + ) } } } @@ -115,28 +113,80 @@ class PhotoCollectionContents { // MARK: - AssetItem Accessors - func assetItem(at index: Int, photoMediaSize: PhotoMediaSize) -> PhotoPickerAssetItem? { + func assetItem(at index: Int, size: ImageDataManager.ThumbnailSize, pixelDimension: CGFloat) -> PhotoPickerAssetItem? { guard let mediaAsset: PHAsset = asset(at: index) else { return nil } - return PhotoPickerAssetItem(asset: mediaAsset, photoCollectionContents: self, photoMediaSize: photoMediaSize) + return PhotoPickerAssetItem( + asset: mediaAsset, + photoCollectionContents: self, + size: size, + pixelDimension: pixelDimension + ) } - func firstAssetItem(photoMediaSize: PhotoMediaSize) -> PhotoPickerAssetItem? { + func firstAssetItem(size: ImageDataManager.ThumbnailSize, pixelDimension: CGFloat) -> PhotoPickerAssetItem? { guard let mediaAsset = firstAsset else { return nil } - return PhotoPickerAssetItem(asset: mediaAsset, photoCollectionContents: self, photoMediaSize: photoMediaSize) + return PhotoPickerAssetItem( + asset: mediaAsset, + photoCollectionContents: self, + size: size, + pixelDimension: pixelDimension + ) } - func lastAssetItem(photoMediaSize: PhotoMediaSize) -> PhotoPickerAssetItem? { + func lastAssetItem(size: ImageDataManager.ThumbnailSize, pixelDimension: CGFloat) -> PhotoPickerAssetItem? { guard let mediaAsset = lastAsset else { return nil } - return PhotoPickerAssetItem(asset: mediaAsset, photoCollectionContents: self, photoMediaSize: photoMediaSize) + return PhotoPickerAssetItem( + asset: mediaAsset, + photoCollectionContents: self, + size: size, + pixelDimension: pixelDimension + ) } // MARK: ImageManager + + func requestThumbnail(for asset: PHAsset, size: ImageDataManager.ThumbnailSize, thumbnailSize: CGSize) async -> UIImage? { + var hasResumed: Bool = false + + return await withCheckedContinuation { [imageManager] continuation in + let options = PHImageRequestOptions() + + switch size { + case .small: options.deliveryMode = .fastFormat + case .medium, .large: options.deliveryMode = .highQualityFormat + } + + imageManager.requestImage( + for: asset, + targetSize: thumbnailSize, + contentMode: .aspectFill, + options: options + ) { image, info in + guard !hasResumed else { return } + guard + info?[PHImageErrorKey] == nil, + (info?[PHImageCancelledKey] as? Bool) != true + else { + hasResumed = true + return continuation.resume(returning: nil) + } + + switch size { + case .small: break // We want the first image, whether it is degraded or not + case .medium, .large: + // For medium and large thumbnails we want the full image so ignore any + // degraded images + guard (info?[PHImageResultIsDegradedKey] as? Bool) != true else { return } - func requestThumbnail(for asset: PHAsset, thumbnailSize: CGSize, resultHandler: @escaping (UIImage?, [AnyHashable: Any]?) -> Void) { - _ = imageManager.requestImage(for: asset, targetSize: thumbnailSize, contentMode: .aspectFill, options: nil, resultHandler: resultHandler) + } + + continuation.resume(returning: image) + hasResumed = true + } + } } private func requestImageDataSource(for asset: PHAsset, using dependencies: Dependencies) -> AnyPublisher<(dataSource: (any DataSource), type: UTType), Error> { @@ -144,8 +194,22 @@ class PhotoCollectionContents { Future { [weak self] resolver in let options: PHImageRequestOptions = PHImageRequestOptions() options.isNetworkAccessAllowed = true + options.deliveryMode = .highQualityFormat _ = self?.imageManager.requestImageData(for: asset, options: options) { imageData, dataUTI, orientation, info in + if let error: Error = info?[PHImageErrorKey] as? Error { + return resolver(.failure(error)) + } + + if (info?[PHImageCancelledKey] as? Bool) == true { + return resolver(.failure(PhotoLibraryError.assertionError(description: "Image request cancelled"))) + } + + // If we get a degraded image then we want to wait for the next callback (which will + // be the non-degraded version) + guard (info?[PHImageResultIsDegradedKey] as? Bool) != true else { + return + } guard let imageData = imageData else { resolver(Result.failure(PhotoLibraryError.assertionError(description: "imageData was unexpectedly nil"))) @@ -175,7 +239,11 @@ class PhotoCollectionContents { let options: PHVideoRequestOptions = PHVideoRequestOptions() options.isNetworkAccessAllowed = true - _ = self?.imageManager.requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetMediumQuality) { exportSession, foo in + _ = self?.imageManager.requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetMediumQuality) { exportSession, info in + + if let error: Error = info?[PHImageErrorKey] as? Error { + return resolver(.failure(error)) + } guard let exportSession = exportSession else { resolver(Result.failure(PhotoLibraryError.assertionError(description: "exportSession was unexpectedly nil"))) diff --git a/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift b/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift index d6de7bb38d..3e388d7609 100644 --- a/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift +++ b/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift @@ -2,10 +2,12 @@ import UIKit import SessionUIKit +import SessionMessagingKit import SessionUtilitiesKit class MediaDismissAnimationController: NSObject { - private let mediaItem: Media + private let dependencies: Dependencies + private let attachment: Attachment public let interactionController: MediaInteractiveDismiss? var fromView: UIView? @@ -15,13 +17,9 @@ class MediaDismissAnimationController: NSObject { var fromMediaFrame: CGRect? var pendingCompletion: (() -> ())? - init(galleryItem: MediaGalleryViewModel.Item, interactionController: MediaInteractiveDismiss? = nil, using dependencies: Dependencies) { - self.mediaItem = .gallery(galleryItem, dependencies) - self.interactionController = interactionController - } - - init(image: UIImage, interactionController: MediaInteractiveDismiss? = nil) { - self.mediaItem = .image(image) + init(attachment: Attachment, interactionController: MediaInteractiveDismiss? = nil, using dependencies: Dependencies) { + self.dependencies = dependencies + self.attachment = attachment self.interactionController = interactionController } } @@ -36,14 +34,10 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning let fromContextProvider: MediaPresentationContextProvider let toContextProvider: MediaPresentationContextProvider - guard let fromVC: UIViewController = transitionContext.viewController(forKey: .from) else { - transitionContext.completeTransition(false) - return - } - guard let toVC: UIViewController = transitionContext.viewController(forKey: .to) else { - transitionContext.completeTransition(false) - return - } + guard + let fromVC: UIViewController = transitionContext.viewController(forKey: .from), + let toVC: UIViewController = transitionContext.viewController(forKey: .to) + else { return fallbackTransition(context: transitionContext) } switch fromVC { case let contextProvider as MediaPresentationContextProvider: @@ -54,24 +48,18 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning let firstChild: UIViewController = topBannerController.children.first, let navController: UINavigationController = firstChild as? UINavigationController, let contextProvider = navController.topViewController as? MediaPresentationContextProvider - else { - transitionContext.completeTransition(false) - return - } + else { return fallbackTransition(context: transitionContext) } fromContextProvider = contextProvider case let navController as UINavigationController: - guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { - transitionContext.completeTransition(false) - return - } + guard + let contextProvider = navController.topViewController as? MediaPresentationContextProvider + else { return fallbackTransition(context: transitionContext) } fromContextProvider = contextProvider - default: - transitionContext.completeTransition(false) - return + default: return fallbackTransition(context: transitionContext) } switch toVC { @@ -84,38 +72,33 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning let firstChild: UIViewController = topBannerController.children.first, let navController: UINavigationController = firstChild as? UINavigationController, let contextProvider = navController.topViewController as? MediaPresentationContextProvider - else { - transitionContext.completeTransition(false) - return - } + else { return fallbackTransition(context: transitionContext) } toVC.view.layoutIfNeeded() toContextProvider = contextProvider case let navController as UINavigationController: - guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { - transitionContext.completeTransition(false) - return - } + guard + let contextProvider = navController.topViewController as? MediaPresentationContextProvider + else { return fallbackTransition(context: transitionContext) } toVC.view.layoutIfNeeded() toContextProvider = contextProvider - default: - transitionContext.completeTransition(false) - return - } - - guard let fromMediaContext: MediaPresentationContext = fromContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView) else { - transitionContext.completeTransition(false) - return - } - - guard let presentationImage: UIImage = mediaItem.image else { - transitionContext.completeTransition(true) - return + default: return fallbackTransition(context: transitionContext) } + guard + let fromMediaContext: MediaPresentationContext = fromContextProvider.mediaPresentationContext( + mediaId: attachment.id, + in: containerView + ), + let presentationSource: ImageDataManager.DataSource = ImageDataManager.DataSource.from( + attachment: attachment, + using: dependencies + ) + else { return fallbackTransition(context: transitionContext) } + // fromView will be nil if doing a presentation, in which case we don't want to add the view - // it will automatically be added to the view hierarchy, in front of the VC we're presenting from if let fromView: UIView = transitionContext.view(forKey: .from) { @@ -129,19 +112,34 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning containerView.insertSubview(toView, at: 0) } - let toMediaContext: MediaPresentationContext? = toContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView) + let toMediaContext: MediaPresentationContext? = toContextProvider.mediaPresentationContext(mediaId: attachment.id, in: containerView) let duration: CGFloat = transitionDuration(using: transitionContext) fromMediaContext.mediaView.alpha = 0 toMediaContext?.mediaView.alpha = 0 - let transitionView = UIImageView(image: presentationImage) + let transitionView: SessionImageView = SessionImageView( + dataManager: dependencies[singleton: .imageDataManager] + ) + transitionView.loadImage(presentationSource) transitionView.frame = fromMediaContext.presentationFrame transitionView.contentMode = MediaView.contentMode transitionView.layer.masksToBounds = true transitionView.layer.cornerRadius = fromMediaContext.cornerRadius transitionView.layer.maskedCorners = (toMediaContext?.cornerMask ?? fromMediaContext.cornerMask) containerView.addSubview(transitionView) + + // Set the currently loaded image to prevent any odd delay and try to match the animation + // state to the source + transitionView.image = fromMediaContext.mediaView.image + + if fromMediaContext.mediaView.isAnimating { + transitionView.startAnimationLoop() + transitionView.setAnimationPoint( + index: fromMediaContext.mediaView.currentFrameIndex, + time: fromMediaContext.mediaView.accumulatedTime + ) + } // Add any UI elements which should appear above the media view self.fromTransitionalOverlayView = { @@ -252,6 +250,29 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning self.pendingCompletion?() self.pendingCompletion = nil } + + private func fallbackTransition(context: UIViewControllerContextTransitioning) { + let containerView = context.containerView + + /// iOS won't automatically handle failure cases so if we can't get the "from" context then we want to just complete + /// the change instantly so the user doesn't permanently get stuck on the screen + if context.transitionWasCancelled { + context.view(forKey: .from)?.isUserInteractionEnabled = true + } + else { + if let toView: UIView = context.view(forKey: .to) { + containerView.insertSubview(toView, at: 0) + } + + context.view(forKey: .from)?.removeFromSuperview() + + // Note: We shouldn't need to do this but for some reason it's not + // automatically getting re-enabled so we manually enable it + context.view(forKey: .to)?.isUserInteractionEnabled = true + } + + context.completeTransition(!context.transitionWasCancelled) + } } extension MediaDismissAnimationController: InteractiveDismissDelegate { diff --git a/Session/Media Viewing & Editing/Transitions/MediaPresentationContext.swift b/Session/Media Viewing & Editing/Transitions/MediaPresentationContext.swift index 4b99b0b95a..735d8b294d 100644 --- a/Session/Media Viewing & Editing/Transitions/MediaPresentationContext.swift +++ b/Session/Media Viewing & Editing/Transitions/MediaPresentationContext.swift @@ -1,31 +1,12 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit -enum Media { - case gallery(MediaGalleryViewModel.Item, Dependencies) - case image(UIImage) - - var image: UIImage? { - switch self { - case let .gallery(item, dependencies): - // For videos attempt to load a large thumbnail, for other items just try to load - // the source file directly - switch (item.isVideo, item.attachment.originalFilePath(using: dependencies)) { - case (false, .some(let filePath)): return UIImage(contentsOfFile: filePath) - default: - return item.attachment.existingThumbnail(size: .large, using: dependencies) - } - - case let .image(image): return image - } - } -} - struct MediaPresentationContext { - let mediaView: UIView + let mediaView: SessionImageView let presentationFrame: CGRect let cornerRadius: CGFloat let cornerMask: CACornerMask @@ -48,7 +29,7 @@ struct MediaPresentationContext { // The other animation controller, the MediaDismissAnimationController is used when we're going to // stop showing the media pager. This can be a pop to the tile view, or a modal dismiss. protocol MediaPresentationContextProvider { - func mediaPresentationContext(mediaItem: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? + func mediaPresentationContext(mediaId: String, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? // The transitionView will be presented below this view. // If nil, the transitionView will be presented above all diff --git a/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift b/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift index 90ae359a64..5092ec8a25 100644 --- a/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift +++ b/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift @@ -2,14 +2,17 @@ import UIKit import SessionUIKit +import SessionMessagingKit import SessionUtilitiesKit class MediaZoomAnimationController: NSObject { - private let mediaItem: Media + private let dependencies: Dependencies + private let attachment: Attachment private let shouldBounce: Bool - init(galleryItem: MediaGalleryViewModel.Item, shouldBounce: Bool = true, using dependencies: Dependencies) { - self.mediaItem = .gallery(galleryItem, dependencies) + init(attachment: Attachment, shouldBounce: Bool = true, using dependencies: Dependencies) { + self.dependencies = dependencies + self.attachment = attachment self.shouldBounce = shouldBounce } } @@ -24,15 +27,23 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning { let fromContextProvider: MediaPresentationContextProvider let toContextProvider: MediaPresentationContextProvider - guard let fromVC: UIViewController = transitionContext.viewController(forKey: .from) else { - transitionContext.completeTransition(false) - return - } - guard let toVC: UIViewController = transitionContext.viewController(forKey: .to) else { - transitionContext.completeTransition(false) - return - } - + /// Can't recover if we don't have an origin or destination so don't bother trying + guard + let fromVC: UIViewController = transitionContext.viewController(forKey: .from), + let toVC: UIViewController = transitionContext.viewController(forKey: .to) + else { return transitionContext.completeTransition(false) } + + /// `view(forKey: .to)` will be nil when using this transition for a modal dismiss, in which case we want to use the + /// `toVC.view` but need to ensure we add it back to it's original parent afterwards so we don't break the view hierarchy + /// + /// **Note:** We *MUST* call 'layoutIfNeeded' prior to `toContextProvider.mediaPresentationContext` as + /// the `toContextProvider.mediaPresentationContext` is dependant on it having the correct positioning (and + /// the navBar sizing isn't correct until after layout) + let toView: UIView = (transitionContext.view(forKey: .to) ?? toVC.view) + let duration: CGFloat = transitionDuration(using: transitionContext) + let oldToViewSuperview: UIView? = toView.superview + toView.layoutIfNeeded() + switch fromVC { case let contextProvider as MediaPresentationContextProvider: fromContextProvider = contextProvider @@ -42,24 +53,18 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning { let firstChild: UIViewController = topBannerController.children.first, let navController: UINavigationController = firstChild as? UINavigationController, let contextProvider = navController.topViewController as? MediaPresentationContextProvider - else { - transitionContext.completeTransition(false) - return - } + else { return fallbackTransition(toView: toView, context: transitionContext) } fromContextProvider = contextProvider case let navController as UINavigationController: - guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { - transitionContext.completeTransition(false) - return - } + guard + let contextProvider = navController.topViewController as? MediaPresentationContextProvider + else { return fallbackTransition(toView: toView, context: transitionContext) } fromContextProvider = contextProvider - default: - transitionContext.completeTransition(false) - return + default: return fallbackTransition(toView: toView, context: transitionContext) } switch toVC { @@ -71,66 +76,30 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning { let firstChild: UIViewController = topBannerController.children.first, let navController: UINavigationController = firstChild as? UINavigationController, let contextProvider = navController.topViewController as? MediaPresentationContextProvider - else { - transitionContext.completeTransition(false) - return - } + else { return fallbackTransition(toView: toView, context: transitionContext) } toContextProvider = contextProvider case let navController as UINavigationController: - guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { - transitionContext.completeTransition(false) - return - } + guard + let contextProvider = navController.topViewController as? MediaPresentationContextProvider + else { return fallbackTransition(toView: toView, context: transitionContext) } toContextProvider = contextProvider - default: - transitionContext.completeTransition(false) - return + default: return fallbackTransition(toView: toView, context: transitionContext) } - - // 'view(forKey: .to)' will be nil when using this transition for a modal dismiss, in which - // case we want to use the 'toVC.view' but need to ensure we add it back to it's original - // parent afterwards so we don't break the view hierarchy - // - // Note: We *MUST* call 'layoutIfNeeded' prior to 'toContextProvider.mediaPresentationContext' - // as the 'toContextProvider.mediaPresentationContext' is dependant on it having the correct - // positioning (and the navBar sizing isn't correct until after layout) - let toView: UIView = (transitionContext.view(forKey: .to) ?? toVC.view) - let duration: CGFloat = transitionDuration(using: transitionContext) - let oldToViewSuperview: UIView? = toView.superview - toView.layoutIfNeeded() // If we can't retrieve the contextual info we need to perform the proper zoom animation then // just fade the destination in (otherwise the user would get stuck on a blank screen) guard - let fromMediaContext: MediaPresentationContext = fromContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView), - let toMediaContext: MediaPresentationContext = toContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView), - let presentationImage: UIImage = mediaItem.image - else { - - toView.frame = containerView.bounds - toView.alpha = 0 - containerView.addSubview(toView) - - UIView.animate( - withDuration: (duration / 2), - delay: 0, - options: .curveEaseInOut, - animations: { - toView.alpha = 1 - }, - completion: { _ in - // Need to ensure we add the 'toView' back to it's old superview if it had one - oldToViewSuperview?.addSubview(toView) - - transitionContext.completeTransition(!transitionContext.transitionWasCancelled) - } + let fromMediaContext: MediaPresentationContext = fromContextProvider.mediaPresentationContext(mediaId: attachment.id, in: containerView), + let toMediaContext: MediaPresentationContext = toContextProvider.mediaPresentationContext(mediaId: attachment.id, in: containerView), + let presentationSource: ImageDataManager.DataSource = ImageDataManager.DataSource.from( + attachment: attachment, + using: dependencies ) - return - } + else { return fallbackTransition(toView: toView, context: transitionContext) } fromMediaContext.mediaView.alpha = 0 toMediaContext.mediaView.alpha = 0 @@ -139,7 +108,10 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning { toView.alpha = 0 containerView.addSubview(toView) - let transitionView: UIImageView = UIImageView(image: presentationImage) + let transitionView: SessionImageView = SessionImageView( + dataManager: dependencies[singleton: .imageDataManager] + ) + transitionView.loadImage(presentationSource) transitionView.frame = fromMediaContext.presentationFrame transitionView.contentMode = MediaView.contentMode transitionView.layer.masksToBounds = true @@ -147,6 +119,18 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning { transitionView.layer.maskedCorners = fromMediaContext.cornerMask containerView.addSubview(transitionView) + // Set the currently loaded image to prevent any odd delay and try to match the animation + // state to the source + transitionView.image = fromMediaContext.mediaView.image + + if fromMediaContext.mediaView.isAnimating { + transitionView.startAnimationLoop() + transitionView.setAnimationPoint( + index: fromMediaContext.mediaView.currentFrameIndex, + time: fromMediaContext.mediaView.accumulatedTime + ) + } + // Note: We need to do this after adding the 'transitionView' and insert it at the back // otherwise the screen can flicker since we have 'afterScreenUpdates: true' (if we use // 'afterScreenUpdates: false' then the 'fromMediaContext.mediaView' won't be hidden @@ -187,7 +171,7 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning { return overlayView }() - + UIView.animate( withDuration: (duration / 2), delay: 0, @@ -230,4 +214,28 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning { } ) } + + private func fallbackTransition(toView: UIView, context: UIViewControllerContextTransitioning) { + let duration: CGFloat = transitionDuration(using: context) + let containerView = context.containerView + let oldToViewSuperview: UIView? = toView.superview + toView.frame = containerView.bounds + toView.alpha = 0 + containerView.addSubview(toView) + + UIView.animate( + withDuration: (duration / 2), + delay: 0, + options: .curveEaseInOut, + animations: { + toView.alpha = 1 + }, + completion: { _ in + // Need to ensure we add the 'toView' back to it's old superview if it had one + oldToViewSuperview?.addSubview(toView) + + context.completeTransition(!context.transitionWasCancelled) + } + ) + } } diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 71f3a9ac5e..9ac74250c4 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -39,6 +39,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD DeveloperSettingsViewModel.processUnitTestEnvVariablesIfNeeded(using: dependencies) #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 + /// interfere with the unit tests) + guard !SNUtilitiesKit.isRunningTests else { return true } + /// If we are running a Preview then we don't want to setup the application (previews are generally self contained individual views so /// doing all this application setup is a waste or work, and could even cause crashes for the preview) if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { // stringlint:ignore @@ -126,8 +130,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD /// to `SessionUIKit` and expose a mechanism to save updated settings - this is done here (once the migrations complete) SNUIKit.configure( with: SessionSNUIKitConfig(using: dependencies), - themeSettings: dependencies[singleton: .storage].read { db in - (db[.theme], db[.themePrimaryColor], db[.themeMatchSystemDayNightCycle]) + themeSettings: dependencies.mutate(cache: .libSession) { cache -> ThemeSettings in + ( + cache.get(.theme), + cache.get(.themePrimaryColor), + cache.get(.themeMatchSystemDayNightCycle) + ) } ) @@ -138,7 +146,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD /// 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[singleton: .storage, key: .areCallsEnabled] + 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) @@ -298,7 +308,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // On every activation, clear old temp directories. dependencies[singleton: .fileManager].clearOldTemporaryDirectories() - if dependencies[singleton: .storage, key: .areCallsEnabled] && dependencies[defaults: .standard, key: .hasRequestedLocalNetworkPermission] { + if dependencies.mutate(cache: .libSession, { $0.get(.areCallsEnabled) }) && dependencies[defaults: .standard, key: .hasRequestedLocalNetworkPermission] { Permissions.checkLocalNetworkPermission(using: dependencies) } } @@ -404,6 +414,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD 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 } @@ -440,6 +456,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD /// hasn't been setup yet then the conversation screen can show stale (ie. deleted) interactions incorrectly DisappearingMessagesJob.cleanExpiredMessagesOnLaunch(using: dependencies) + /// Now that the database is setup we can load in any messages which were processed by the extensions (flag that we will load + /// them in this thread and create a task to _actually_ load them asynchronously + /// + /// **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 // If we didn't successfully ensure the rootViewController then don't continue as @@ -485,7 +513,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD /// /// **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 { - dependencies.mutate(cache: .libSession) { $0.syncAllPendingChanges(db) } + dependencies.mutate(cache: .libSession) { $0.syncAllPendingPushes(db) } } } @@ -766,8 +794,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD dependencies.warmCache(cache: .onboarding) switch dependencies[cache: .onboarding].state { - case .noUser, .noUserFailedIdentity: - if dependencies[cache: .onboarding].state == .noUserFailedIdentity { + case .noUser, .noUserInvalidKeyPair, .noUserInvalidSeedGeneration: + if dependencies[cache: .onboarding].state == .noUserInvalidKeyPair { + Log.critical(.cat, "Failed to load credentials for existing user, generated a new identity.") + } + else if dependencies[cache: .onboarding].state == .noUserInvalidSeedGeneration { Log.critical(.cat, "Failed to create an initial identity for a potentially new user.") } @@ -797,18 +828,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD case .completed: DispatchQueue.main.async { [dependencies] in - let viewController: HomeVC = HomeVC(using: dependencies) - /// 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 - DispatchQueue.global(qos: .userInitiated).async { - viewController.startObservingChanges { - longRunningStartupTimoutCancellable.cancel() - - DispatchQueue.main.async { - rootViewControllerSetupComplete(viewController) - } - } + let viewController: HomeVC = HomeVC(using: dependencies) + viewController.afterInitialConversationsLoaded { + longRunningStartupTimoutCancellable.cancel() + rootViewControllerSetupComplete(viewController) } } } @@ -817,15 +842,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // MARK: - Notifications func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + Log.info(.syncPushTokensJob, "Received push token.") dependencies[singleton: .pushRegistrationManager].didReceiveVanillaPushToken(deviceToken) - Log.info(.cat, "Registering for push notifications.") } func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - Log.error(.cat, "Failed to register push token with error: \(error).") + Log.error(.syncPushTokensJob, "Failed to register push token with error: \(error).") #if DEBUG - Log.warn(.cat, "We're in debug mode. Faking success for remote registration with a fake push identifier.") + Log.warn(.syncPushTokensJob, "We're in debug mode. Faking success for remote registration with a fake push identifier.") dependencies[singleton: .pushRegistrationManager].didReceiveVanillaPushToken(Data(count: 32)) #else dependencies[singleton: .pushRegistrationManager].didFailToReceiveVanillaPushToken(error: error) @@ -842,14 +867,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD /// read pools (up to a few seconds), since this read is blocking we want to dispatch it to run async to ensure /// we don't block user interaction while it's running DispatchQueue.global(qos: .default).async { - guard - let unreadCount: Int = dependencies[singleton: .storage].read({ db in try - Interaction.fetchAppBadgeUnreadCount(db, using: dependencies) + if + let unreadCount: Int = dependencies[singleton: .storage].read({ db in + try Interaction.fetchAppBadgeUnreadCount(db, using: dependencies) }) - else { return } - - DispatchQueue.main.async(using: dependencies) { - UIApplication.shared.applicationIconBadgeNumber = unreadCount + { + 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 + } } } } @@ -890,10 +921,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD /// application:didFinishLaunchingWithOptions:. func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { dependencies[singleton: .appReadiness].runNowOrWhenAppDidBecomeReady { [dependencies] in - dependencies[singleton: .notificationActionHandler].handleNotificationResponse( - response, - completionHandler: completionHandler - ) + /// Give the app 3 seconds to load notification messages into the database before trying to handle the notification response + Task(priority: .userInitiated) { + await dependencies[singleton: .extensionHelper].waitUntilMessagesAreLoaded(timeout: .seconds(3)) + await MainActor.run { + dependencies[singleton: .notificationActionHandler].handleNotificationResponse( + response, + completionHandler: completionHandler + ) + } + } } } diff --git a/Session/Meta/AudioFiles/messageReceivedSounds/classic-quiet.aifc b/Session/Meta/AudioFiles/messageReceivedSounds/classic-quiet.aifc deleted file mode 100644 index f85c07ea51..0000000000 Binary files a/Session/Meta/AudioFiles/messageReceivedSounds/classic-quiet.aifc and /dev/null differ diff --git a/Session/Meta/AudioFiles/messageReceivedSounds/classic.aifc b/Session/Meta/AudioFiles/messageReceivedSounds/classic.aifc deleted file mode 100644 index 694ff1c238..0000000000 Binary files a/Session/Meta/AudioFiles/messageReceivedSounds/classic.aifc and /dev/null differ diff --git a/Session/Meta/MainAppContext.swift b/Session/Meta/MainAppContext.swift index 6453eb0aae..2b84e28ad5 100644 --- a/Session/Meta/MainAppContext.swift +++ b/Session/Meta/MainAppContext.swift @@ -130,7 +130,7 @@ final class MainAppContext: AppContext { // MARK: - AppContext Functions - func setMainWindow(_ mainWindow: UIWindow) { + @MainActor func setMainWindow(_ mainWindow: UIWindow) { self.mainWindow = mainWindow // Store in SessionUIKit to avoid needing the SessionUtilitiesKit dependency diff --git a/Session/Meta/Session+SNUIKit.swift b/Session/Meta/Session+SNUIKit.swift index c1f4ebe6ad..f4bad88109 100644 --- a/Session/Meta/Session+SNUIKit.swift +++ b/Session/Meta/Session+SNUIKit.swift @@ -1,6 +1,7 @@ // Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. import UIKit +import AVFoundation import SessionUIKit import SessionSnodeKit import SessionUtilitiesKit @@ -22,10 +23,16 @@ internal struct SessionSNUIKitConfig: SNUIKit.ConfigType { // MARK: - Functions func themeChanged(_ theme: Theme, _ primaryColor: Theme.PrimaryColor, _ matchSystemNightModeSetting: Bool) { - dependencies[singleton: .storage].write { db in - db[.theme] = theme - db[.themePrimaryColor] = primaryColor - db[.themeMatchSystemDayNightCycle] = matchSystemNightModeSetting + let mutation: LibSession.Mutation? = try? dependencies.mutate(cache: .libSession) { cache in + try cache.perform(for: .local) { + cache.set(.theme, theme) + cache.set(.themePrimaryColor, primaryColor) + cache.set(.themeMatchSystemDayNightCycle, matchSystemNightModeSetting) + } + } + + dependencies[singleton: .storage].writeAsync { db in + try mutation?.upsert(db) } } @@ -76,36 +83,16 @@ internal struct SessionSNUIKitConfig: SNUIKit.ConfigType { } } - func placeholderIconCacher(cacheKey: String, generator: @escaping () -> UIImage) -> UIImage { - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - var cachedIcon: UIImage? - - Task { - switch await dependencies[singleton: .imageDataManager].cachedImage(identifier: cacheKey)?.type { - case .staticImage(let image): cachedIcon = image - case .animatedImage(let frames, _): cachedIcon = frames.first // Shouldn't be possible - case .none: break - } - - semaphore.signal() - } - semaphore.wait() - - switch cachedIcon { - case .some(let image): return image - case .none: - let generatedImage: UIImage = generator() - Task { - await dependencies[singleton: .imageDataManager].cacheImage( - generatedImage, - for: cacheKey - ) - } - return generatedImage - } - } - func shouldShowStringKeys() -> Bool { return dependencies[feature: .showStringKeys] } + + func asset(for path: String, mimeType: String, sourceFilename: String?) -> (asset: AVURLAsset, cleanup: () -> Void)? { + return AVURLAsset.asset( + for: path, + mimeType: mimeType, + sourceFilename: sourceFilename, + using: dependencies + ) + } } diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index d4be294dbe..ec57a65332 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -67,7 +67,7 @@ public class SessionApp: SessionAppType { self.homeViewController = homeViewController } - public func presentConversationCreatingIfNeeded( + @MainActor public func presentConversationCreatingIfNeeded( for threadId: String, variant: SessionThread.Variant, action: ConversationViewModel.Action = .none, @@ -79,23 +79,8 @@ public class SessionApp: SessionAppType { return } - let threadInfo: (threadExists: Bool, isMessageRequest: Bool)? = dependencies[singleton: .storage].read { [dependencies] db in - let isMessageRequest: Bool = { - switch variant { - case .contact, .group: - return SessionThread - .isMessageRequest( - db, - threadId: threadId, - userSessionId: dependencies[cache: .general].sessionId, - includeNonVisible: true - ) - - default: return false - } - }() - - return (SessionThread.filter(id: threadId).isNotEmpty(db), isMessageRequest) + let threadExists: Bool? = dependencies[singleton: .storage].read { db in + SessionThread.filter(id: threadId).isNotEmpty(db) } /// The thread should generally exist at the time of calling this method, but on the off chance it doesn't then we need to @@ -104,12 +89,14 @@ public class SessionApp: SessionAppType { creatingThreadIfNeededThenRunOnMain( threadId: threadId, variant: variant, - threadExists: (threadInfo?.threadExists == true), - onComplete: { [weak self] in + threadExists: (threadExists == true), + onComplete: { [weak self, dependencies] in self?.showConversation( threadId: threadId, threadVariant: variant, - isMessageRequest: (threadInfo?.isMessageRequest == true), + isMessageRequest: dependencies.mutate(cache: .libSession) { cache in + cache.isMessageRequest(threadId: threadId, threadVariant: variant) + }, action: action, dismissing: presentingViewController, homeViewController: homeViewController, @@ -146,7 +133,7 @@ public class SessionApp: SessionAppType { } dependencies[singleton: .storage].resetAllStorage() dependencies[singleton: .displayPictureManager].resetStorage() - Attachment.resetAttachmentStorage(using: dependencies) + dependencies[singleton: .attachmentManager].resetStorage() dependencies[singleton: .notificationsManager].clearAllNotifications() try? dependencies[singleton: .keychain].removeAll() @@ -182,45 +169,35 @@ public class SessionApp: SessionAppType { // MARK: - Internal Functions - private func creatingThreadIfNeededThenRunOnMain( + @MainActor private func creatingThreadIfNeededThenRunOnMain( threadId: String, variant: SessionThread.Variant, threadExists: Bool, onComplete: @escaping () -> Void ) { guard !threadExists else { - switch Thread.isMainThread { - case true: return onComplete() - case false: return DispatchQueue.main.async(using: dependencies) { onComplete() } - } - } - guard !Thread.isMainThread else { - return DispatchQueue.global(qos: .userInitiated).async(using: dependencies) { [weak self] in - self?.creatingThreadIfNeededThenRunOnMain( - threadId: threadId, - variant: variant, - threadExists: threadExists, - onComplete: onComplete - ) - } + return onComplete() } - dependencies[singleton: .storage].write { [dependencies] db in - try SessionThread.upsert( - db, - id: threadId, - variant: variant, - values: SessionThread.TargetValues( - shouldBeVisible: .useLibSession, - isDraft: .useExistingOrSetTo(true) - ), - using: dependencies + Task(priority: .userInitiated) { [storage = dependencies[singleton: .storage], dependencies] in + storage.writeAsync( + updates: { db in + try SessionThread.upsert( + db, + id: threadId, + variant: variant, + values: SessionThread.TargetValues( + shouldBeVisible: .useLibSession, + isDraft: .useExistingOrSetTo(true) + ), + using: dependencies + ) + }, + completion: { _ in + Task { @MainActor in onComplete() } + } ) } - - DispatchQueue.main.async(using: dependencies) { - onComplete() - } } private func showConversation( @@ -258,7 +235,7 @@ public class SessionApp: SessionAppType { public protocol SessionAppType { func setHomeViewController(_ homeViewController: HomeVC) func showHomeView() - func presentConversationCreatingIfNeeded( + @MainActor func presentConversationCreatingIfNeeded( for threadId: String, variant: SessionThread.Variant, action: ConversationViewModel.Action, diff --git a/Session/Notifications/NotificationActionHandler.swift b/Session/Notifications/NotificationActionHandler.swift index cc6260310c..12249e7fef 100644 --- a/Session/Notifications/NotificationActionHandler.swift +++ b/Session/Notifications/NotificationActionHandler.swift @@ -30,11 +30,10 @@ public class NotificationActionHandler { // MARK: - Handling - func handleNotificationResponse( + @MainActor func handleNotificationResponse( _ response: UNNotificationResponse, completionHandler: @escaping () -> Void ) { - Log.assertOnMainThread() handleNotificationResponse(response) .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) .receive(on: DispatchQueue.main, using: dependencies) @@ -51,8 +50,7 @@ public class NotificationActionHandler { ) } - func handleNotificationResponse(_ response: UNNotificationResponse) -> AnyPublisher { - Log.assertOnMainThread() + @MainActor func handleNotificationResponse(_ response: UNNotificationResponse) -> AnyPublisher { assert(dependencies[singleton: .appReadiness].isAppReady) let userInfo: [AnyHashable: Any] = response.notification.request.content.userInfo @@ -63,7 +61,7 @@ public class NotificationActionHandler { case UNNotificationDefaultActionIdentifier: Log.debug("[NotificationActionHandler] Default action") switch categoryIdentifier { - case AppNotificationCategory.info.identifier: + case NotificationCategory.info.identifier: return showPromotedScreen() .setFailureType(to: Error.self) .eraseToAnyPublisher() @@ -123,7 +121,7 @@ public class NotificationActionHandler { // MARK: - Actions func markAsRead(userInfo: [AnyHashable: Any]) -> AnyPublisher { - guard let threadId: String = userInfo[AppNotificationUserInfoKey.threadId] as? String else { + guard let threadId: String = userInfo[NotificationUserInfoKey.threadId] as? String else { return Fail(error: NotificationError.failDebug("threadId was unexpectedly nil")) .eraseToAnyPublisher() } @@ -141,18 +139,21 @@ public class NotificationActionHandler { replyText: String, applicationState: UIApplication.State ) -> AnyPublisher { - guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else { - return Fail(error: NotificationError.failDebug("threadId was unexpectedly nil")) - .eraseToAnyPublisher() - } - - guard let thread: SessionThread = dependencies[singleton: .storage].read({ db in try SessionThread.fetchOne(db, id: threadId) }) else { - return Fail(error: NotificationError.failDebug("unable to find thread with id: \(threadId)")) + guard + let threadId = userInfo[NotificationUserInfoKey.threadId] as? String, + let threadVariantRaw = userInfo[NotificationUserInfoKey.threadVariantRaw] as? Int, + let threadVariant: SessionThread.Variant = SessionThread.Variant(rawValue: threadVariantRaw) + else { + return Fail(error: NotificationError.failDebug("thread information was unexpectedly nil")) .eraseToAnyPublisher() } return dependencies[singleton: .storage] - .writePublisher { [dependencies] db -> Network.PreparedRequest in + .writePublisher { [dependencies] db -> (Message, Message.Destination, Int64?, AuthenticationMethod) in + guard (try? SessionThread.exists(db, id: threadId)) == true else { + throw NotificationError.failDebug("unable to find thread with id: \(threadId)") + } + let sentTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() let destinationDisappearingMessagesConfiguration: DisappearingMessagesConfiguration? = try? DisappearingMessagesConfiguration .filter(id: threadId) @@ -160,7 +161,7 @@ public class NotificationActionHandler { .fetchOne(db) let interaction: Interaction = try Interaction( threadId: threadId, - threadVariant: thread.variant, + threadVariant: threadVariant, authorId: dependencies[cache: .general].sessionId.hexString, variant: .standardOutgoing, body: replyText, @@ -177,50 +178,65 @@ public class NotificationActionHandler { db, interactionId: interaction.id, threadId: threadId, - threadVariant: thread.variant, + threadVariant: threadVariant, includingOlder: true, trySendReadReceipt: try SessionThread.canSendReadReceipt( db, threadId: threadId, - threadVariant: thread.variant, + threadVariant: threadVariant, using: dependencies ), using: dependencies ) - return try MessageSender.preparedSend( + let visibleMessage: VisibleMessage = VisibleMessage.from(db, interaction: interaction) + let destination: Message.Destination = try Message.Destination.from( + db, + threadId: threadId, + threadVariant: threadVariant + ) + let authMethod: AuthenticationMethod = try Authentication.with( db, - interaction: interaction, - fileIds: [], threadId: threadId, - threadVariant: thread.variant, + threadVariant: threadVariant, using: dependencies ) + + return (visibleMessage, destination, interaction.id, authMethod) + } + .tryFlatMap { [dependencies] message, destination, interactionId, authMethod -> AnyPublisher<(ResponseInfoType, Message), Error> in + try MessageSender.preparedSend( + message: message, + to: destination, + namespace: destination.defaultNamespace, + interactionId: interactionId, + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), + using: dependencies + ).send(using: dependencies) } - .flatMap { [dependencies] request in request.send(using: dependencies) } .map { _ in () } .handleEvents( receiveCompletion: { [dependencies] result in switch result { case .finished: break case .failure: - dependencies[singleton: .storage].read { db in - dependencies[singleton: .notificationsManager].notifyForFailedSend( - db, - in: thread, - applicationState: applicationState - ) - } + dependencies[singleton: .notificationsManager].notifyForFailedSend( + threadId: threadId, + threadVariant: threadVariant, + applicationState: applicationState + ) } } ) .eraseToAnyPublisher() } - func showThread(userInfo: [AnyHashable: Any]) -> AnyPublisher { + @MainActor func showThread(userInfo: [AnyHashable: Any]) -> AnyPublisher { guard - let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String, - let threadVariantRaw = userInfo[AppNotificationUserInfoKey.threadVariantRaw] as? Int, + let threadId = userInfo[NotificationUserInfoKey.threadId] as? String, + let threadVariantRaw = userInfo[NotificationUserInfoKey.threadVariantRaw] as? Int, let threadVariant: SessionThread.Variant = SessionThread.Variant(rawValue: threadVariantRaw) else { return showHomeVC() } diff --git a/Session/Notifications/NotificationPresenter.swift b/Session/Notifications/NotificationPresenter.swift index bff4d2210b..17ec5ac5ce 100644 --- a/Session/Notifications/NotificationPresenter.swift +++ b/Session/Notifications/NotificationPresenter.swift @@ -14,15 +14,74 @@ public class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate, private static let audioNotificationsThrottleCount = 2 private static let audioNotificationsThrottleInterval: TimeInterval = 5 - private let dependencies: Dependencies + public let dependencies: Dependencies private let notificationCenter: UNUserNotificationCenter = UNUserNotificationCenter.current() @ThreadSafeObject private var notifications: [String: UNNotificationRequest] = [:] @ThreadSafeObject private var mostRecentNotifications: TruncatedList = TruncatedList(maxLength: NotificationPresenter.audioNotificationsThrottleCount) + @ThreadSafeObject private var settingsStorage: [String: Preferences.NotificationSettings] = [:] + @ThreadSafe private var notificationSound: Preferences.Sound = .defaultNotificationSound + @ThreadSafe private var notificationPreviewType: Preferences.NotificationPreviewType = .defaultPreviewType // MARK: - Initialization required public init(using dependencies: Dependencies) { self.dependencies = dependencies + + super.init() + + /// Populate the notification settings from `libSession` and the database + Task.detached(priority: .high) { [weak self] in + typealias GlobalSettings = ( + sound: Preferences.Sound, + previewType: Preferences.NotificationPreviewType + ) + struct ThreadSettings: Codable, FetchableRecord { + let id: String + let variant: SessionThread.Variant + let mutedUntilTimestamp: TimeInterval? + let onlyNotifyForMentions: Bool + } + + let prefs: GlobalSettings = dependencies.mutate(cache: .libSession) { + ( + $0.get(.defaultNotificationSound).defaulting(to: .defaultNotificationSound), + $0.get(.preferencesNotificationPreviewType).defaulting(to: .defaultPreviewType) + ) + } + let allSettings: [ThreadSettings] = (try? await dependencies[singleton: .storage] + .readAsync { db in + try SessionThread + .select(.id, .variant, .mutedUntilTimestamp, .onlyNotifyForMentions) + .asRequest(of: ThreadSettings.self) + .fetchAll(db) + }) + .defaulting(to: []) + let notificationSettings: [String: Preferences.NotificationSettings] = allSettings + .reduce(into: [:]) { result, setting in + result[setting.id] = Preferences.NotificationSettings( + previewType: prefs.previewType, + sound: prefs.sound, + mentionsOnly: setting.onlyNotifyForMentions, + mutedUntil: setting.mutedUntilTimestamp + ) + } + + /// Store the settings in memory + self?.notificationSound = prefs.sound + self?.notificationPreviewType = prefs.previewType + self?._settingsStorage.set(to: notificationSettings) + + /// Replicate the settings for the PN extension if needed + do { + try dependencies[singleton: .extensionHelper].replicate( + settings: notificationSettings, + replaceExisting: false + ) + } + catch { + Log.error("[NotificationPresenter] Failed to replicate settings due to error: \(error)") + } + } } // MARK: - Registration @@ -31,7 +90,7 @@ public class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate, notificationCenter.delegate = delegate } - public func registerNotificationSettings() -> AnyPublisher { + public func registerSystemNotificationSettings() -> AnyPublisher { return Deferred { [notificationCenter] in Future { resolver in notificationCenter.requestAuthorization(options: [.badge, .sound, .alert]) { (granted, error) in @@ -52,512 +111,311 @@ public class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate, }.eraseToAnyPublisher() } - // MARK: - Presentation + // MARK: - Unique Logic - public func notifyUser( - _ db: Database, - for interaction: Interaction, - in thread: SessionThread, - applicationState: UIApplication.State - ) { - let isMessageRequest: Bool = SessionThread.isMessageRequest( - db, - threadId: thread.id, - userSessionId: dependencies[cache: .general].sessionId, - includeNonVisible: true + public func settings(threadId: String? = nil, threadVariant: SessionThread.Variant) -> Preferences.NotificationSettings { + return settingsStorage[threadId].defaulting( + to: Preferences.NotificationSettings( + previewType: notificationPreviewType, + sound: notificationSound, + mentionsOnly: false, + mutedUntil: nil + ) ) + } + + public func updateSettings( + threadId: String, + threadVariant: SessionThread.Variant, + mentionsOnly: Bool, + mutedUntil: TimeInterval? + ) { + /// Update the in-memory cache first + var oldMentionsOnly: Bool? + var oldMutedUntil: TimeInterval? - // Ensure we should be showing a notification for the thread - guard thread.shouldShowNotification(db, for: interaction, isMessageRequest: isMessageRequest, using: dependencies) else { - return + _settingsStorage.performUpdate { settings in + oldMentionsOnly = settings[threadId]?.mentionsOnly + oldMutedUntil = settings[threadId]?.mutedUntil + + return settings.setting( + threadId, + Preferences.NotificationSettings( + previewType: notificationPreviewType, + sound: notificationSound, + mentionsOnly: mentionsOnly, + mutedUntil: mutedUntil + ) + ) } - // Try to group notifications for interactions from open groups - let identifier: String = Interaction.notificationIdentifier( - for: (interaction.id ?? 0), - threadId: thread.id, - shouldGroupMessagesForThread: (thread.variant == .community) - ) - - // While batch processing, some of the necessary changes have not been commited. - let rawMessageText: String = Interaction.notificationPreviewText(db, interaction: interaction, using: dependencies) - - // iOS strips anything that looks like a printf formatting character from - // the notification body, so if we want to dispay a literal "%" in a notification - // it must be escaped. - // see https://developer.apple.com/documentation/uikit/uilocalnotification/1616646-alertbody - // for more details. - let messageText: String? = String.filterNotificationText(rawMessageText) - let notificationTitle: String? - var notificationBody: String? - - let senderName = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant, using: dependencies) - let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] - .defaulting(to: .defaultPreviewType) - let groupName: String = SessionThread.displayName( - threadId: thread.id, - variant: thread.variant, - closedGroupName: try? thread.closedGroup - .select(.name) - .asRequest(of: String.self) - .fetchOne(db), - openGroupName: try? thread.openGroup - .select(.name) - .asRequest(of: String.self) - .fetchOne(db) - ) + /// Update the database with the changes + let changes: [ConfigColumnAssignment] = [ + (mentionsOnly == oldMentionsOnly ? nil : + SessionThread.Columns.onlyNotifyForMentions.set(to: mentionsOnly) + ), + (mutedUntil == oldMutedUntil ? nil : + SessionThread.Columns.mutedUntilTimestamp.set(to: mutedUntil) + ) + ].compactMap { $0 } - switch previewType { - case .noNameNoPreview: - notificationTitle = Constants.app_name + if !changes.isEmpty { + dependencies[singleton: .storage].writeAsync { db in + try SessionThread + .filter(id: threadId) + .updateAll(db, changes) - case .nameNoPreview, .nameAndPreview: - switch (thread.variant, isMessageRequest) { - case (.contact, true), (.group, true): notificationTitle = Constants.app_name - case (.contact, false): notificationTitle = senderName - - case (.legacyGroup, _), (.group, false), (.community, _): - notificationTitle = "notificationsIosGroup" - .put(key: "name", value: senderName) - .put(key: "conversation_name", value: groupName) - .localized() + if mentionsOnly == oldMentionsOnly { + db.addConversationEvent(id: threadId, type: .updated(.onlyNotifyForMentions(mentionsOnly))) } + + if mutedUntil != oldMutedUntil { + db.addConversationEvent(id: threadId, type: .updated(.mutedUntilTimestamp(mutedUntil))) + } + } } - switch previewType { - case .noNameNoPreview, .nameNoPreview: notificationBody = "messageNewYouveGot" - .putNumber(1) - .localized() - case .nameAndPreview: notificationBody = messageText - } - - // If it's a message request then overwrite the body to be something generic (only show a notification - // when receiving a new message request if there aren't any others or the user had hidden them) - if isMessageRequest { - notificationBody = "messageRequestsNew".localized() + /// Replicate the settings across to the PN extension + do { + try dependencies[singleton: .extensionHelper].replicate( + settings: settingsStorage, + replaceExisting: true + ) } - - guard notificationBody != nil || notificationTitle != nil else { - Log.info("AppNotifications error: No notification content") - return + catch { + Log.error("[NotificationPresenter] Failed to replicate settings due to error: \(error)") } - - // Don't reply from lockscreen if anyone in this conversation is - // "no longer verified". - let category = AppNotificationCategory.incomingMessage - - let userInfo: [AnyHashable: Any] = [ - AppNotificationUserInfoKey.threadId: thread.id, - AppNotificationUserInfoKey.threadVariantRaw: thread.variant.rawValue - ] - - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let userBlinded15SessionId: SessionId? = SessionThread.getCurrentUserBlindedSessionId( - db, - threadId: thread.id, - threadVariant: thread.variant, - blindingPrefix: .blinded15, - using: dependencies - ) - let userBlinded25SessionId: SessionId? = SessionThread.getCurrentUserBlindedSessionId( - db, - threadId: thread.id, - threadVariant: thread.variant, - blindingPrefix: .blinded25, - using: dependencies - ) - let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] - .defaulting(to: Preferences.Sound.defaultNotificationSound) - - let sound: Preferences.Sound? = requestSound( - thread: thread, - fallbackSound: fallbackSound, - applicationState: applicationState - ) - - notificationBody = MentionUtilities.highlightMentionsNoAttributes( - in: (notificationBody ?? ""), - threadVariant: thread.variant, - currentUserSessionId: userSessionId.hexString, - currentUserBlinded15SessionId: userBlinded15SessionId?.hexString, - currentUserBlinded25SessionId: userBlinded25SessionId?.hexString, - using: dependencies - ) - - notify( - category: category, - title: notificationTitle, - body: (notificationBody ?? ""), - userInfo: userInfo, - previewType: previewType, - sound: sound, - threadVariant: thread.variant, - threadName: groupName, - applicationState: applicationState, - replacingIdentifier: identifier - ) } - public func notifyUser( - _ db: Database, - forIncomingCall interaction: Interaction, - in thread: SessionThread, - applicationState: UIApplication.State - ) { - // No call notifications for muted or group threads - guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } - guard - thread.variant != .legacyGroup && - thread.variant != .group && - thread.variant != .community - else { return } - guard - interaction.variant == .infoCall, - let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8), - let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode( - CallMessage.MessageInfo.self, - from: infoMessageData - ) - else { return } - - // Only notify missed calls - switch messageInfo.state { - case .missed, .permissionDenied, .permissionDeniedMicrophone: break - default: return - } - - let category = AppNotificationCategory.errorMessage - let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] - .defaulting(to: .nameAndPreview) - - let userInfo: [AnyHashable: Any] = [ - AppNotificationUserInfoKey.threadId: thread.id, - AppNotificationUserInfoKey.threadVariantRaw: thread.variant.rawValue + public func notificationUserInfo(threadId: String, threadVariant: SessionThread.Variant) -> [String: Any] { + return [ + NotificationUserInfoKey.threadId: threadId, + NotificationUserInfoKey.threadVariantRaw: threadVariant.rawValue ] - - let notificationTitle: String = Constants.app_name - let senderName: String = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant, using: dependencies) - let notificationBody: String? = { - switch messageInfo.state { - case .permissionDenied: - return "callsYouMissedCallPermissions" - .put(key: "name", value: senderName) - .localizedDeformatted() - case .permissionDeniedMicrophone, .missed: - return "callsMissedCallFrom" - .put(key: "name", value: senderName) - .localized() - default: - return nil - } - }() - - let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] - .defaulting(to: Preferences.Sound.defaultNotificationSound) - let sound = self.requestSound( - thread: thread, - fallbackSound: fallbackSound, - applicationState: applicationState - ) - - notify( - category: category, - title: notificationTitle, - body: (notificationBody ?? ""), - userInfo: userInfo, - previewType: previewType, - sound: sound, - threadVariant: thread.variant, - threadName: senderName, - applicationState: applicationState, - replacingIdentifier: UUID().uuidString - ) } - public func notifyUser( - _ db: Database, - forReaction reaction: Reaction, - in thread: SessionThread, - applicationState: UIApplication.State - ) { - let isMessageRequest: Bool = SessionThread.isMessageRequest( - db, - threadId: thread.id, - userSessionId: dependencies[cache: .general].sessionId, - includeNonVisible: true - ) - - // No reaction notifications for muted, group threads or message requests - guard dependencies.dateNow.timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } - guard - thread.variant != .legacyGroup && - thread.variant != .group && - thread.variant != .community - else { return } - guard !isMessageRequest else { return } - - let notificationTitle = Profile.displayName(db, id: reaction.authorId, threadVariant: thread.variant, using: dependencies) - var notificationBody = "emojiReactsNotification" - .put(key: "emoji", value: reaction.emoji) - .localized() - - // Title & body - let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] - .defaulting(to: .nameAndPreview) - - switch previewType { - case .nameAndPreview: break - default: notificationBody = "messageNewYouveGot" - .putNumber(1) - .localized() + public func notificationShouldPlaySound(applicationState: UIApplication.State) -> Bool { + guard applicationState == .active else { return true } + guard dependencies.mutate(cache: .libSession, { $0.get(.playNotificationSoundInForeground) }) else { + return false } - - let category = AppNotificationCategory.incomingMessage - - let userInfo: [AnyHashable: Any] = [ - AppNotificationUserInfoKey.threadId: thread.id, - AppNotificationUserInfoKey.threadVariantRaw: thread.variant.rawValue - ] - - let threadName: String = SessionThread.displayName( - threadId: thread.id, - variant: thread.variant, - closedGroupName: nil, // Not supported - openGroupName: nil // Not supported - ) - let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] - .defaulting(to: Preferences.Sound.defaultNotificationSound) - let sound = self.requestSound( - thread: thread, - fallbackSound: fallbackSound, - applicationState: applicationState - ) - - notify( - category: category, - title: notificationTitle, - body: notificationBody, - userInfo: userInfo, - previewType: previewType, - sound: sound, - threadVariant: thread.variant, - threadName: threadName, - applicationState: applicationState, - replacingIdentifier: UUID().uuidString - ) + + let nowMs: UInt64 = UInt64(floor(Date().timeIntervalSince1970 * 1000)) + let recentThreshold = nowMs - UInt64(NotificationPresenter.audioNotificationsThrottleInterval * 1000) + + let recentNotifications = mostRecentNotifications.filter { $0 > recentThreshold } + + guard recentNotifications.count < NotificationPresenter.audioNotificationsThrottleCount else { return false } + + _mostRecentNotifications.performUpdate { $0.appending(nowMs) } + return true } + // MARK: - Presentation + public func notifyForFailedSend( - _ db: Database, - in thread: SessionThread, + threadId: String, + threadVariant: SessionThread.Variant, applicationState: UIApplication.State ) { - let notificationTitle: String? - let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] - .defaulting(to: .defaultPreviewType) - let threadName: String = SessionThread.displayName( - threadId: thread.id, - variant: thread.variant, - closedGroupName: try? thread.closedGroup - .select(.name) - .asRequest(of: String.self) - .fetchOne(db), - openGroupName: try? thread.openGroup - .select(.name) - .asRequest(of: String.self) - .fetchOne(db), - isNoteToSelf: (thread.isNoteToSelf(db, using: dependencies) == true), - profile: try? Profile.fetchOne(db, id: thread.id) + let notificationSettings: Preferences.NotificationSettings = settings(threadId: threadId, threadVariant: threadVariant) + var content: NotificationContent = NotificationContent( + threadId: threadId, + threadVariant: threadVariant, + identifier: threadId, + category: .errorMessage, + body: "messageErrorDelivery".localized(), + sound: notificationSettings.sound, + userInfo: notificationUserInfo(threadId: threadId, threadVariant: threadVariant), + applicationState: applicationState ) - switch previewType { - case .noNameNoPreview: notificationTitle = nil - case .nameNoPreview, .nameAndPreview: notificationTitle = threadName + /// Add the title if needed + switch notificationSettings.previewType { + case .noNameNoPreview: content = content.with(title: Constants.app_name) + case .nameNoPreview, .nameAndPreview: + typealias ThreadInfo = (profile: Profile?, openGroupName: String?, openGroupUrlInfo: LibSession.OpenGroupUrlInfo?) + let threadInfo: ThreadInfo? = dependencies[singleton: .storage].read { db in + return ( + (threadVariant != .contact ? nil : + try? Profile.fetchOne(db, id: threadId) + ), + (threadVariant != .community ? nil : + try? OpenGroup + .select(.name) + .filter(id: threadId) + .asRequest(of: String.self) + .fetchOne(db) + ), + (threadVariant != .community ? nil : + try? LibSession.OpenGroupUrlInfo.fetchOne(db, id: threadId) + ) + ) + } + + content = content.with( + title: dependencies.mutate(cache: .libSession) { cache in + cache.conversationDisplayName( + threadId: threadId, + threadVariant: threadVariant, + contactProfile: threadInfo?.profile, + visibleMessage: nil, /// This notification is unrelated to the received message + openGroupName: threadInfo?.openGroupName, + openGroupUrlInfo: threadInfo?.openGroupUrlInfo + ) + } + ) } - - let notificationBody = "messageErrorDelivery".localized() - let userInfo: [AnyHashable: Any] = [ - AppNotificationUserInfoKey.threadId: thread.id, - AppNotificationUserInfoKey.threadVariantRaw: thread.variant.rawValue - ] - let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] - .defaulting(to: Preferences.Sound.defaultNotificationSound) - let sound: Preferences.Sound? = self.requestSound( - thread: thread, - fallbackSound: fallbackSound, - applicationState: applicationState - ) - notify( - category: .errorMessage, - title: notificationTitle, - body: notificationBody, - userInfo: userInfo, - previewType: previewType, - sound: sound, - threadVariant: thread.variant, - threadName: threadName, - applicationState: applicationState + addNotificationRequest( + content: content, + notificationSettings: notificationSettings, + extensionBaseUnreadCount: nil ) } - // MARK: - Clearing - - public func cancelNotifications(identifiers: [String]) { - _notifications.performUpdate { notifications in - var updatedNotifications: [String: UNNotificationRequest] = notifications - identifiers.forEach { updatedNotifications.removeValue(forKey: $0) } - return updatedNotifications - } - notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers) - notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiers) - } - - public func clearAllNotifications() { - notificationCenter.removePendingNotificationRequests(withIdentifiers: notifications.keys.map{$0}) - notificationCenter.removeAllDeliveredNotifications() - } - // MARK: - Schedule New Session Network Page local notifcation public func scheduleSessionNetworkPageLocalNotifcation(force: Bool) { - guard dependencies[defaults: .standard, key: .isSessionNetworkPageNotificationScheduled] != true || force else { return } + guard + force || + dependencies[defaults: .standard, key: .isSessionNetworkPageNotificationScheduled] != true + else { return } + let notificationSettings: Preferences.NotificationSettings = settings(threadVariant: .contact) let identifier: String = "sessionNetworkPageLocalNotifcation_\(UUID().uuidString)" // stringlint:disable // Schedule the notification after 1 hour - scheduleNotification( - category: AppNotificationCategory.info, + let content: NotificationContent = NotificationContent( + threadId: nil, + threadVariant: nil, + identifier: identifier, + category: .info, title: Constants.app_name, body: "sessionNetworkNotificationLive" .put(key: "token_name_long", value: Constants.token_name_long) .put(key: "network_name", value: Constants.network_name) .localized(), - after: (force ? 10 : 3600), + delay: (force ? 10 : 3600), + sound: notificationSettings.sound, userInfo: [:], - sound: Preferences.Sound.defaultNotificationSound, - applicationState: dependencies[singleton: .appContext].reportedApplicationState, - identifier: identifier + applicationState: dependencies[singleton: .appContext].reportedApplicationState ) + addNotificationRequest( + content: content, + notificationSettings: notificationSettings, + extensionBaseUnreadCount: nil + ) dependencies[defaults: .standard, key: .isSessionNetworkPageNotificationScheduled] = true } -} - -// MARK: - Convenience - -private extension NotificationPresenter { - func notify( - category: AppNotificationCategory, - title: String?, - body: String, - userInfo: [AnyHashable: Any], - previewType: Preferences.NotificationPreviewType, - sound: Preferences.Sound?, - threadVariant: SessionThread.Variant, - threadName: String, - applicationState: UIApplication.State, - replacingIdentifier: String? = nil + + public func addNotificationRequest( + content: NotificationContent, + notificationSettings: Preferences.NotificationSettings, + extensionBaseUnreadCount: Int? ) { - let threadIdentifier: String? = (userInfo[AppNotificationUserInfoKey.threadId] as? String) - let content = UNMutableNotificationContent() - content.categoryIdentifier = category.identifier - content.userInfo = userInfo - content.threadIdentifier = (threadIdentifier ?? content.threadIdentifier) - - let shouldGroupNotification: Bool = ( - threadVariant == .community && - replacingIdentifier == threadIdentifier - ) - if let sound = sound, sound != .none { - content.sound = sound.notificationSound(isQuiet: (applicationState == .active)) + var trigger: UNNotificationTrigger? = content.delay.map { delayInterval in + UNTimeIntervalNotificationTrigger( + timeInterval: delayInterval, + repeats: false + ) } - - let notificationIdentifier: String = (replacingIdentifier ?? UUID().uuidString) - let isReplacingNotification: Bool = (notifications[notificationIdentifier] != nil) let shouldPresentNotification: Bool = shouldPresentNotification( - category: category, - applicationState: applicationState, - userInfo: userInfo, + threadId: content.threadId, + category: content.category, + applicationState: content.applicationState, using: dependencies ) - var trigger: UNNotificationTrigger? - - if shouldPresentNotification { - if let displayableTitle = title?.filteredForDisplay { - content.title = displayableTitle - } - content.body = body.filteredForDisplay - - if shouldGroupNotification { - trigger = UNTimeIntervalNotificationTrigger( - timeInterval: Notifications.delayForGroupedNotifications, - repeats: false + let mutableContent: UNMutableNotificationContent = content.toMutableContent( + shouldPlaySound: notificationShouldPlaySound(applicationState: content.applicationState) + ) + + switch shouldPresentNotification { + case true: + let shouldGroupNotification: Bool = ( + content.threadVariant == .community && + content.identifier == content.threadId ) - let numberExistingNotifications: Int? = notifications[notificationIdentifier]? - .content - .userInfo[AppNotificationUserInfoKey.threadNotificationCounter] - .asType(Int.self) - var numberOfNotifications: Int = (numberExistingNotifications ?? 1) - - if numberExistingNotifications != nil { - numberOfNotifications += 1 // Add one for the current notification + if shouldGroupNotification { + /// Only set a trigger for grouped notifications if we don't already have one + if trigger == nil { + trigger = UNTimeIntervalNotificationTrigger( + timeInterval: Notifications.delayForGroupedNotifications, + repeats: false + ) + } - content.title = (previewType == .noNameNoPreview ? - content.title : - threadName - ) - content.body = "messageNewYouveGot" - .putNumber(numberOfNotifications) - .localized() + let numberExistingNotifications: Int? = notifications[content.identifier]? + .content + .userInfo[NotificationUserInfoKey.threadNotificationCounter] + .asType(Int.self) + var numberOfNotifications: Int = (numberExistingNotifications ?? 1) + + if numberExistingNotifications != nil { + numberOfNotifications += 1 // Add one for the current notification + mutableContent.body = "messageNewYouveGot" + .putNumber(numberOfNotifications) + .localized() + } + + mutableContent.userInfo[NotificationUserInfoKey.threadNotificationCounter] = numberOfNotifications } - content.userInfo[AppNotificationUserInfoKey.threadNotificationCounter] = numberOfNotifications - } + case false: + // Play sound and vibrate, but without a `title` and `body` so the banner won't show + mutableContent.title = "" + mutableContent.body = "" + Log.debug("supressing notification body") } - else { - // Play sound and vibrate, but without a `body` no banner will show. - Log.debug("supressing notification body") - } - + let request = UNNotificationRequest( - identifier: notificationIdentifier, - content: content, + identifier: content.identifier, + content: mutableContent, trigger: trigger ) - Log.debug("presenting notification with identifier: \(notificationIdentifier)") + Log.debug("presenting notification with identifier: \(content.identifier)") - if isReplacingNotification { cancelNotifications(identifiers: [notificationIdentifier]) } + /// If we are replacing a notification then cancel the original one + if notifications[content.identifier] != nil { + cancelNotifications(identifiers: [content.identifier]) + } notificationCenter.add(request) - _notifications.performUpdate { $0.setting(notificationIdentifier, request) } + _notifications.performUpdate { $0.setting(content.identifier, request) } } - private func requestSound( - thread: SessionThread, - fallbackSound: Preferences.Sound, - applicationState: UIApplication.State - ) -> Preferences.Sound? { - guard checkIfShouldPlaySound(applicationState: applicationState) else { return nil } - - return (thread.notificationSound ?? fallbackSound) + // MARK: - Clearing + + public func cancelNotifications(identifiers: [String]) { + _notifications.performUpdate { notifications in + var updatedNotifications: [String: UNNotificationRequest] = notifications + identifiers.forEach { updatedNotifications.removeValue(forKey: $0) } + return updatedNotifications + } + notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers) + notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiers) } + public func clearAllNotifications() { + notificationCenter.removePendingNotificationRequests(withIdentifiers: notifications.keys.map{$0}) + notificationCenter.removeAllDeliveredNotifications() + } +} + +// MARK: - Convenience + +private extension NotificationPresenter { private func shouldPresentNotification( - category: AppNotificationCategory, + threadId: String?, + category: NotificationCategory, applicationState: UIApplication.State, - userInfo: [AnyHashable: Any], using dependencies: Dependencies ) -> Bool { guard applicationState == .active else { return true } guard category == .incomingMessage || category == .errorMessage else { return true } - - guard let notificationThreadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else { - Log.error("[UserNotificationPresenter] threadId was unexpectedly nil") - return true - } /// Check whether the current `frontMostViewController` is a `ConversationVC` for the conversation this notification /// would belong to then we don't want to show the notification, so retrieve the `frontMostViewController` (from the main @@ -570,100 +428,7 @@ private extension NotificationPresenter { else { return true } /// Show notifications for any **other** threads - return (conversationViewController.viewModel.threadData.threadId != notificationThreadId) - } - - private func checkIfShouldPlaySound(applicationState: UIApplication.State) -> Bool { - guard applicationState == .active else { return true } - guard dependencies[singleton: .storage, key: .playNotificationSoundInForeground] else { return false } - - let nowMs: UInt64 = UInt64(floor(Date().timeIntervalSince1970 * 1000)) - let recentThreshold = nowMs - UInt64(NotificationPresenter.audioNotificationsThrottleInterval * 1000) - - let recentNotifications = mostRecentNotifications.filter { $0 > recentThreshold } - - guard recentNotifications.count < NotificationPresenter.audioNotificationsThrottleCount else { return false } - - _mostRecentNotifications.performUpdate { $0.appending(nowMs) } - return true - } - - private func scheduleNotification( - category: AppNotificationCategory, - title: String?, - body: String, - date: DateComponents, - userInfo: [AnyHashable : Any], - sound: Preferences.Sound?, - applicationState: UIApplication.State, - identifier: String? - ) { - let content = UNMutableNotificationContent() - content.categoryIdentifier = category.identifier - content.userInfo = userInfo - content.title = title ?? Constants.app_name - content.body = body - - if let sound = sound, sound != .none { - content.sound = sound.notificationSound(isQuiet: (applicationState == .active)) - } - - let notificationIdentifier: String = (identifier ?? UUID().uuidString) - - let trigger = UNCalendarNotificationTrigger(dateMatching: date, repeats: false) - - let request = UNNotificationRequest( - identifier: notificationIdentifier, - content: content, - trigger: trigger - ) - - notificationCenter.add(request) { error in - if let error = error { - Log.debug("Failed to schedule notification: \(error.localizedDescription)") - } else { - Log.debug("Schedule notification successful with id: \(notificationIdentifier)") - } - } - } - - private func scheduleNotification( - category: AppNotificationCategory, - title: String?, - body: String, - after timeInterval: TimeInterval, - userInfo: [AnyHashable: Any], - sound: Preferences.Sound?, - applicationState: UIApplication.State, - identifier: String? - ) { - let content = UNMutableNotificationContent() - content.categoryIdentifier = category.identifier - content.userInfo = userInfo - content.title = title ?? Constants.app_name - content.body = body - - if let sound = sound, sound != .none { - content.sound = sound.notificationSound(isQuiet: (applicationState == .active)) - } - - let notificationIdentifier: String = (identifier ?? UUID().uuidString) - - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeInterval, repeats: false) - - let request = UNNotificationRequest( - identifier: notificationIdentifier, - content: content, - trigger: trigger - ) - - notificationCenter.add(request) { error in - if let error = error { - Log.debug("Failed to schedule notification: \(error.localizedDescription)") - } else { - Log.debug("Schedule notification successful with id: \(notificationIdentifier)") - } - } + return (conversationViewController.viewModel.threadData.threadId != threadId) } } diff --git a/Session/Notifications/PushRegistrationManager.swift b/Session/Notifications/PushRegistrationManager.swift index 3e36c25133..8a64f2e822 100644 --- a/Session/Notifications/PushRegistrationManager.swift +++ b/Session/Notifications/PushRegistrationManager.swift @@ -48,7 +48,9 @@ public class PushRegistrationManager: NSObject, PKPushRegistryDelegate { #else return self.registerForVanillaPushToken() .flatMap { vanillaPushToken -> AnyPublisher<(pushToken: String, voipToken: String), Error> in - self.registerForVoipPushToken() + Log.info(.syncPushTokensJob, "Registering for voip token") + + return self.registerForVoipPushToken() .map { voipPushToken in (vanillaPushToken, (voipPushToken ?? "")) } .eraseToAnyPublisher() } @@ -63,7 +65,7 @@ public class PushRegistrationManager: NSObject, PKPushRegistryDelegate { /// Vanilla push token is obtained from the system via AppDelegate public func didReceiveVanillaPushToken(_ tokenData: Data) { guard let vanillaTokenResolver = self.vanillaTokenResolver else { - Log.error("[PushRegistrationManager] Publisher completion in \(#function) unexpectedly nil") + Log.error(.syncPushTokensJob, "Publisher completion in \(#function) unexpectedly nil") return } @@ -75,7 +77,7 @@ public class PushRegistrationManager: NSObject, PKPushRegistryDelegate { /// Vanilla push token is obtained from the system via AppDelegate public func didFailToReceiveVanillaPushToken(error: Error) { guard let vanillaTokenResolver = self.vanillaTokenResolver else { - Log.error("[PushRegistrationManager] Publisher completion in \(#function) unexpectedly nil") + Log.error(.syncPushTokensJob, "Publisher completion in \(#function) unexpectedly nil") return } @@ -88,7 +90,7 @@ public class PushRegistrationManager: NSObject, PKPushRegistryDelegate { /// User notification settings must be registered *before* AppDelegate will return any requested push tokens. public func registerUserNotificationSettings() -> AnyPublisher { - return dependencies[singleton: .notificationsManager].registerNotificationSettings() + return dependencies[singleton: .notificationsManager].registerSystemNotificationSettings() } /** @@ -181,7 +183,7 @@ public class PushRegistrationManager: NSObject, PKPushRegistryDelegate { .map { tokenData -> String in if self.isSusceptibleToFailedPushRegistration { // Sentinal in case this bug is fixed - Log.debug("Device was unexpectedly able to complete push registration even though it was susceptible to failure.") + Log.debug(.syncPushTokensJob, "Device was unexpectedly able to complete push registration even though it was susceptible to failure.") } return tokenData.toHexString() @@ -217,7 +219,7 @@ public class PushRegistrationManager: NSObject, PKPushRegistryDelegate { createVoipRegistryIfNecessary() guard let voipRegistry: PKPushRegistry = self.voipRegistry else { - Log.error("[PushRegistrationManager] Failed to initialize voipRegistry") + Log.error(.syncPushTokensJob, "Failed to initialize voipRegistry") return Fail( error: PushRegistrationError.assertionError(description: "failed to initialize voipRegistry") ).eraseToAnyPublisher() @@ -226,7 +228,7 @@ public class PushRegistrationManager: NSObject, PKPushRegistryDelegate { // If we've already completed registering for a voip token, resolve it immediately, // rather than waiting for the delegate method to be called. if let voipTokenData: Data = voipRegistry.pushToken(for: .voIP) { - Log.info("[PushRegistrationManager] Using pre-registered voIP token") + Log.info(.syncPushTokensJob, "Using pre-registered voIP token") return Just(voipTokenData.toHexString()) .setFailureType(to: Error.self) .eraseToAnyPublisher() @@ -241,7 +243,7 @@ public class PushRegistrationManager: NSObject, PKPushRegistryDelegate { return publisher .map { voipTokenData -> String? in - Log.info("[PushRegistrationManager] Successfully registered for voip push notifications") + Log.info(.syncPushTokensJob, "Successfully registered for voip push notifications") return voipTokenData?.toHexString() } .handleEvents( @@ -264,25 +266,22 @@ public class PushRegistrationManager: NSObject, PKPushRegistryDelegate { // NOTE: This function MUST report an incoming call. public func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType) { - Log.info("[PushRegistrationManager] Receive new voip notification.") + Log.info(.calls, "Receive new voip notification.") Log.assert(dependencies[singleton: .appContext].isMainApp) Log.assert(type == .voIP) let payload = payload.dictionaryPayload guard - let uuid: String = payload["uuid"] as? String, - let caller: String = payload["caller"] as? String, - let timestampMs: UInt64 = payload["timestamp"] as? UInt64, - let contactName: String = payload["contactName"] as? String, + let uuid: String = payload[VoipPayloadKey.uuid.rawValue] as? String, + let caller: String = payload[VoipPayloadKey.caller.rawValue] as? String, + let timestampMs: UInt64 = payload[VoipPayloadKey.timestamp.rawValue] as? UInt64, + let contactName: String = payload[VoipPayloadKey.contactName.rawValue] as? String, TimestampUtils.isWithinOneMinute(timestampMs: timestampMs) else { dependencies[singleton: .callManager].reportFakeCall(info: "Missing payload data") // stringlint:ignore return } - dependencies[singleton: .storage].resumeDatabaseAccess() - dependencies.mutate(cache: .libSessionNetwork) { $0.resumeNetworkAccess() } - let call: SessionCall = SessionCall( for: caller, contactName: contactName, @@ -293,18 +292,22 @@ public class PushRegistrationManager: NSObject, PKPushRegistryDelegate { Log.info(.calls, "Calls created with UUID: \(uuid), caller: \(caller), contactName: \(contactName)") - dependencies[singleton: .jobRunner].appDidBecomeActive() - - dependencies[singleton: .appReadiness].runNowOrWhenAppDidBecomeReady { [dependencies] in - // NOTE: Just start 1-1 poller so that it won't wait for polling group messages - dependencies[singleton: .currentUserPoller].startIfNeeded(forceStartInBackground: true) - } - - call.reportIncomingCallIfNeeded { error in + call.reportIncomingCallIfNeeded { [dependencies] error in if let error = error { Log.error(.calls, "Failed to report incoming call to CallKit due to error: \(error)") - } else { - Log.info(.calls, "Succeeded to report incoming call to CallKit") + return + } + + Log.info(.calls, "Succeeded to report incoming call to CallKit") + + dependencies[singleton: .storage].resumeDatabaseAccess() + dependencies.mutate(cache: .libSessionNetwork) { $0.resumeNetworkAccess() } + + dependencies[singleton: .jobRunner].appDidBecomeActive() + + dependencies[singleton: .appReadiness].runNowOrWhenAppDidBecomeReady { [dependencies] in + // NOTE: Just start 1-1 poller so that it won't wait for polling group messages + dependencies[singleton: .currentUserPoller].startIfNeeded(forceStartInBackground: true) } } } diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index 0784248745..6a885dfd2c 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -9,8 +9,8 @@ import SessionUtilitiesKit // MARK: - Log.Category -private extension Log.Category { - static let cat: Log.Category = .create("SyncPushTokensJob", defaultLevel: .info) +public extension Log.Category { + static let syncPushTokensJob: Log.Category = .create("SyncPushTokensJob", defaultLevel: .info) } // MARK: - SyncPushTokensJob @@ -35,7 +35,7 @@ public enum SyncPushTokensJob: JobExecutor { return deferred(job) // Don't need to do anything if it's not the main app } guard dependencies[cache: .onboarding].state == .completed else { - Log.info(.cat, "Deferred due to incomplete registration") + Log.info(.syncPushTokensJob, "Deferred due to incomplete registration") return deferred(job) } @@ -56,7 +56,7 @@ public enum SyncPushTokensJob: JobExecutor { .upserted(db) } - Log.info(.cat, "Deferred due to in progress job") + Log.info(.syncPushTokensJob, "Deferred due to in progress job") return deferred(updatedJob ?? job) } @@ -66,8 +66,8 @@ public enum SyncPushTokensJob: JobExecutor { // If the job is running and 'Fast Mode' is disabled then we should try to unregister the existing // token guard isUsingFullAPNs else { - Just(dependencies[singleton: .storage, key: .lastRecordedPushToken]) - .setFailureType(to: Error.self) + 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 @@ -81,14 +81,14 @@ public enum SyncPushTokensJob: JobExecutor { // Unregister from our server if let existingToken: String = lastRecordedPushToken { - Log.info(.cat, "Unregister using last recorded push token: \(redact(existingToken))") + Log.info(.syncPushTokensJob, "Unregister using last recorded push token: \(redact(existingToken))") return PushNotificationAPI .unsubscribeAll(token: Data(hex: existingToken), using: dependencies) .map { _ in () } .eraseToAnyPublisher() } - Log.info(.cat, "No previous token stored just triggering device unregister") + Log.info(.syncPushTokensJob, "No previous token stored just triggering device unregister") return Just(()) .setFailureType(to: Error.self) .eraseToAnyPublisher() @@ -97,8 +97,8 @@ public enum SyncPushTokensJob: JobExecutor { .sinkUntilComplete( receiveCompletion: { result in switch result { - case .finished: Log.info(.cat, "Unregister Completed") - case .failure: Log.error(.cat, "Unregister Failed") + 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 @@ -112,10 +112,12 @@ 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(.cat, "Re-registering for remote notifications") + Log.info(.syncPushTokensJob, "Re-registering for remote notifications") dependencies[singleton: .pushRegistrationManager].requestPushTokens() .flatMap { (pushToken: String, voipToken: String) -> AnyPublisher<(String, String)?, Error> in - dependencies[cache: .libSessionNetwork].paths + 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) } @@ -128,7 +130,7 @@ public enum SyncPushTokensJob: JobExecutor { .catch { error -> AnyPublisher<(String, String)?, Error> in switch error { case NetworkError.timeout: - Log.info(.cat, "OS subscription completed, skipping server subscription due to path build timeout") + Log.info(.syncPushTokensJob, "OS subscription completed, skipping server subscription due to path build timeout") return Just(nil).setFailureType(to: Error.self).eraseToAnyPublisher() default: return Fail(error: error).eraseToAnyPublisher() @@ -136,7 +138,10 @@ public enum SyncPushTokensJob: JobExecutor { } .eraseToAnyPublisher() } - .flatMap { (tokenInfo: (String, String)?) -> AnyPublisher in + .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) @@ -162,15 +167,16 @@ public enum SyncPushTokensJob: JobExecutor { guard timeSinceLastSuccessfulUpload >= SyncPushTokensJob.maxFrequency || - dependencies[singleton: .storage, key: .lastRecordedPushToken] != pushToken || + lastRecordedPushToken != pushToken || uploadOnlyIfStale == false else { - Log.info(.cat, "OS subscription completed, skipping server subscription due to frequency") + 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 PushNotificationAPI .subscribeAll( token: Data(hex: pushToken), @@ -182,11 +188,11 @@ public enum SyncPushTokensJob: JobExecutor { receiveCompletion: { result in switch result { case .failure(let error): - Log.error(.cat, "Failed to register due to error: \(error)") + Log.error(.syncPushTokensJob, "Failed to register due to error: \(error)") case .finished: - Log.debug(.cat, "Recording push tokens locally. pushToken: \(redact(pushToken)), voipToken: \(redact(voipToken))") - Log.info(.cat, "Completed") + 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 diff --git a/Session/Notifications/Types/AppNotificationAction.swift b/Session/Notifications/Types/AppNotificationAction.swift index ed7c168e9b..ac3b6d82dd 100644 --- a/Session/Notifications/Types/AppNotificationAction.swift +++ b/Session/Notifications/Types/AppNotificationAction.swift @@ -3,6 +3,7 @@ // stringlint:disable import Foundation +import SessionMessagingKit enum AppNotificationAction: CaseIterable { case markAsRead @@ -26,3 +27,17 @@ extension AppNotificationAction { } } } + +extension NotificationCategory { + var actions: [AppNotificationAction] { + switch self { + case .incomingMessage: return [.markAsRead, .reply] + case .errorMessage: return [] + case .threadlessErrorMessage: return [] + case .info: return [] + + // TODO: Remove in future release + case .deprecatedIncomingMessage: return [.markAsRead, .reply] + } + } +} diff --git a/Session/Notifications/Types/AppNotificationUserInfoKey.swift b/Session/Notifications/Types/AppNotificationUserInfoKey.swift deleted file mode 100644 index 101b062e49..0000000000 --- a/Session/Notifications/Types/AppNotificationUserInfoKey.swift +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. -// -// stringlint:disable - -import Foundation - -struct AppNotificationUserInfoKey { - static let threadId = "Signal.AppNotificationsUserInfoKey.threadId" - static let threadVariantRaw = "Signal.AppNotificationsUserInfoKey.threadVariantRaw" - static let callBackNumber = "Signal.AppNotificationsUserInfoKey.callBackNumber" - static let localCallId = "Signal.AppNotificationsUserInfoKey.localCallId" - static let threadNotificationCounter = "Session.AppNotificationsUserInfoKey.threadNotificationCounter" -} diff --git a/Session/Notifications/UserNotificationConfig.swift b/Session/Notifications/UserNotificationConfig.swift index 33814e9923..939f81bf26 100644 --- a/Session/Notifications/UserNotificationConfig.swift +++ b/Session/Notifications/UserNotificationConfig.swift @@ -9,15 +9,15 @@ import SessionUtilitiesKit class UserNotificationConfig { class var allNotificationCategories: Set { - let categories = AppNotificationCategory.allCases.map { notificationCategory($0) } + let categories = NotificationCategory.allCases.map { notificationCategory($0) } return Set(categories) } - class func notificationActions(for category: AppNotificationCategory) -> [UNNotificationAction] { + class func notificationActions(for category: NotificationCategory) -> [UNNotificationAction] { return category.actions.map { notificationAction($0) } } - class func notificationCategory(_ category: AppNotificationCategory) -> UNNotificationCategory { + class func notificationCategory(_ category: NotificationCategory) -> UNNotificationCategory { return UNNotificationCategory( identifier: category.identifier, actions: notificationActions(for: category), diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index a43e3e2f38..caed766340 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -29,7 +29,8 @@ public extension Log.Category { public enum Onboarding { public enum State: CustomStringConvertible { case noUser - case noUserFailedIdentity + case noUserInvalidKeyPair + case noUserInvalidSeedGeneration case missingName case completed @@ -37,14 +38,15 @@ public enum Onboarding { public var description: String { switch self { case .noUser: return "No User" - case .noUserFailedIdentity: return "No User Failed Identity" + case .noUserInvalidKeyPair: return "No User Invalid Key Pair" + case .noUserInvalidSeedGeneration: return "No User Invalid Seed Generation" case .missingName: return "Missing Name" case .completed: return "Completed" } } } - public enum Flow { + public enum Flow: CaseIterable { case none case register case restore @@ -69,7 +71,7 @@ public enum Onboarding { extension Onboarding { class Cache: OnboardingCacheType { private let dependencies: Dependencies - public let id: UUID = UUID() + public let id: UUID public let initialFlow: Onboarding.Flow public var state: State private let completionSubject: CurrentValueSubject = CurrentValueSubject(false) @@ -81,7 +83,8 @@ extension Onboarding { public var useAPNS: Bool public var displayName: String - public var _displayNamePublisher: AnyPublisher? + private var _displayNamePublisher: AnyPublisher? + private var hasInitialDisplayName: Bool private var userProfileConfigMessage: ProcessedMessage? private var disposables: Set = Set() @@ -100,38 +103,59 @@ extension Onboarding { init(flow: Onboarding.Flow, using dependencies: Dependencies) { self.dependencies = dependencies + self.id = dependencies.randomUUID() self.initialFlow = flow - /// Determine the current state based on what's in the database - typealias StoredData = ( - state: State, - displayName: String, - ed25519KeyPair: KeyPair, - x25519KeyPair: KeyPair + /// Try to load the users `ed25519KeyPair` from the database and generate the `x25519KeyPair` from it + var ed25519KeyPair: KeyPair = .empty + let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + dependencies[singleton: .storage].readAsync( + retrieve: { db in Identity.fetchUserEd25519KeyPair(db) }, + completion: { result in + ed25519KeyPair = ((try? result.successOrThrow()) ?? .empty) + semaphore.signal() + } ) - let storedData: StoredData = dependencies[singleton: .storage].read { db -> StoredData in - // If we have no ed25519KeyPair then the user doesn't have an account + semaphore.wait() + let x25519KeyPair: KeyPair = { guard - let x25519KeyPair: KeyPair = Identity.fetchUserKeyPair(db), - let ed25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) - else { return (.noUser, "", KeyPair.empty, KeyPair.empty) } + ed25519KeyPair != .empty, + let x25519PublicKey: [UInt8] = dependencies[singleton: .crypto].generate( + .x25519(ed25519Pubkey: ed25519KeyPair.publicKey) + ), + let x25519SecretKey: [UInt8] = dependencies[singleton: .crypto].generate( + .x25519(ed25519Seckey: ed25519KeyPair.secretKey) + ) + else { return .empty } - // If we have no display name then collect one (this can happen if the - // app crashed during onboarding which would leave the user in an invalid - // state with no display name) - let displayName: String = Profile.fetchOrCreateCurrentUser(db, using: dependencies).name - guard !displayName.isEmpty else { return (.missingName, "anonymous".localized(), x25519KeyPair, ed25519KeyPair) } + return KeyPair(publicKey: x25519PublicKey, secretKey: x25519SecretKey) + }() + + /// 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 ? + SessionId(.standard, publicKey: x25519KeyPair.publicKey) : + .invalid + ) + self.state = { + guard ed25519KeyPair != .empty else { return .noUser } + guard x25519KeyPair != .empty else { return .noUserInvalidKeyPair } + guard hasInitialDisplayName else { return .missingName } - // Otherwise we have enough for a full user and can start the app - return (.completed, displayName, x25519KeyPair, ed25519KeyPair) - }.defaulting(to: (.noUser, "", KeyPair.empty, KeyPair.empty)) - - /// Store the initial `displayName` value in case we need it - self.displayName = storedData.displayName + return .completed + }() + self.seed = Data() /// Overwritten below + self.useAPNS = false /// Overwritten below /// Update the cached values depending on the `initialState` - switch storedData.state { - case .noUser, .noUserFailedIdentity: + switch state { + case .noUser, .noUserInvalidKeyPair, .noUserInvalidSeedGeneration: /// Remove the `LibSession.Cache` just in case (to ensure no previous state remains) dependencies.remove(cache: .libSession) @@ -145,29 +169,19 @@ 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 = .noUserFailedIdentity - self.seed = Data() - self.ed25519KeyPair = KeyPair(publicKey: [], secretKey: []) - self.x25519KeyPair = KeyPair(publicKey: [], secretKey: []) - self.userSessionId = .invalid - self.useAPNS = false + self.state = .noUserInvalidSeedGeneration return } /// The identity data was successfully generated so store it for the onboarding process - self.state = .noUser + self.state = .noUserInvalidKeyPair self.seed = finalSeedData self.ed25519KeyPair = identity.ed25519KeyPair self.x25519KeyPair = identity.x25519KeyPair - self.userSessionId = SessionId(.standard, publicKey: x25519KeyPair.publicKey) - self.useAPNS = false + self.userSessionId = SessionId(.standard, publicKey: identity.x25519KeyPair.publicKey) + self.displayName = "" case .missingName, .completed: - self.state = storedData.state - self.seed = Data() - self.ed25519KeyPair = storedData.ed25519KeyPair - self.x25519KeyPair = storedData.x25519KeyPair - self.userSessionId = dependencies[cache: .general].sessionId self.useAPNS = dependencies[defaults: .standard, key: .isUsingFullAPNs] /// If we are already in a completed state then updated the completion subject accordingly @@ -185,6 +199,7 @@ extension Onboarding { using dependencies: Dependencies ) { self.dependencies = dependencies + self.id = dependencies.randomUUID() self.state = .completed self.initialFlow = .devSettings self.seed = Data() @@ -193,6 +208,7 @@ extension Onboarding { self.userSessionId = SessionId(.standard, publicKey: x25519KeyPair.publicKey) self.useAPNS = dependencies[defaults: .standard, key: .isUsingFullAPNs] self.displayName = displayName + self.hasInitialDisplayName = !displayName.isEmpty self._displayNamePublisher = nil } @@ -224,27 +240,31 @@ extension Onboarding { logStartAndStopCalls: false, customAuthMethod: Authentication.standard( sessionId: userSessionId, - ed25519KeyPair: identity.ed25519KeyPair + ed25519PublicKey: identity.ed25519KeyPair.publicKey, + ed25519SecretKey: identity.ed25519KeyPair.secretKey ), using: dependencies ) - typealias PollResult = (configMessage: ProcessedMessage, displayName: String) + typealias PollResult = (configMessage: ProcessedMessage, displayName: String?) let publisher: AnyPublisher = poller .poll(forceSynchronousProcessing: true) .tryMap { [userSessionId, dependencies] messages, _, _, _ -> PollResult? in guard let targetMessage: ProcessedMessage = messages.last, /// Just in case there are multiple - case let .config(_, _, serverHash, serverTimestampMs, data) = targetMessage + case let .config(_, _, serverHash, serverTimestampMs, data, _) = targetMessage else { return nil } /// 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 - let cache: LibSession.Cache = LibSession.Cache(userSessionId: userSessionId, using: dependencies) + let cache: LibSession.Cache = LibSession.Cache( + userSessionId: userSessionId, + using: dependencies + ) cache.loadDefaultStateFor( variant: .userProfile, sessionId: userSessionId, - userEd25519KeyPair: identity.ed25519KeyPair, + userEd25519SecretKey: identity.ed25519KeyPair.secretKey, groupEd25519SecretKey: nil ) try cache.unsafeDirectMergeConfigMessage( @@ -259,7 +279,7 @@ extension Onboarding { ] ) - return (targetMessage, cache.userProfileDisplayName) + return (targetMessage, cache.displayName) } .handleEvents( receiveOutput: { [weak self] result in @@ -268,8 +288,12 @@ extension Onboarding { /// 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?.displayName.isEmpty == true { - self?.displayName = result.displayName + if + self?.hasInitialDisplayName != true, + let displayName: String = result.displayName, + !displayName.isEmpty + { + self?.displayName = displayName } self?.userProfileConfigMessage = result.configMessage @@ -293,7 +317,7 @@ extension Onboarding { .store(in: &disposables) } - func setUserAPNS(_ useAPNS: Bool) { + func setUseAPNS(_ useAPNS: Bool) { self.useAPNS = useAPNS } @@ -305,7 +329,7 @@ extension Onboarding { DispatchQueue.global(qos: .userInitiated).async(using: dependencies) { [weak self, initialFlow, 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.setCachedSessionId(sessionId: userSessionId) + $0.setSecretKey(ed25519SecretKey: ed25519KeyPair.secretKey) } /// If we had a proper `initialFlow` then create a new `libSession` cache for the user @@ -319,108 +343,128 @@ extension Onboarding { ) } - dependencies[singleton: .storage].write { 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) - - /// No need to show the seed again if the user is restoring - db[.hasViewedSeed] = (initialFlow == .restore) - - /// 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) - ) - - /// 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) { - $0.loadState(db) + 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) - /// 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? $0.handleConfigMessages( + /// 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, - swarmPublicKey: userSessionId.hexString, - messages: ConfigMessageReceiveJob - .Details(messages: [userProfileConfigMessage]) - .messages + 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) { + db.addEventIfNotNull( + try? cache.updateProfile(displayName: displayName), + forKey: .profile(userSessionId.hexString) + ) + } } + + /// 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) } - - /// 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 - .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 - ) - } - - /// Now that the onboarding process is completed we can enable the Share and Notification extensions (prior to - /// this point the account is in an invalid state so there is no point enabling them) - db[.isReadyForAppExtensions] = true - /// 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 - - /// 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 - .filter(id: userSessionId.hexString) - .updateAllAndConfig( + /// 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 + + /// 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( db, - SessionThread.Columns.shouldBeVisible.set(to: false), - SessionThread.Columns.pinnedPriority.set(to: LibSession.hiddenPriority), + threadId: userSessionId.hexString, + isVisible: false, using: dependencies ) + } + }, + 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 + ) + } catch { Log.error(.onboarding, "Falied to save user metadata: \(error)") } + + /// Store whether the user wants to use APNS + dependencies[defaults: .standard, key: .isUsingFullAPNs] = useAPNS + + /// Send an event indicating that registration is complete + self?.completionSubject.send(true) + + DispatchQueue.main.async(using: dependencies) { + onComplete() + } } - } - - /// Store whether the user wants to use APNS - dependencies[defaults: .standard, key: .isUsingFullAPNs] = useAPNS - - /// Set `hasSyncedInitialConfiguration` to true so that when we hit the home screen a configuration sync is - /// triggered (yes, the logic is a bit weird). This is needed so that if the user registers and immediately links a device, - /// there'll be a configuration in their swarm. - dependencies[defaults: .standard, key: .hasSyncedInitialConfiguration] = (initialFlow == .register) - - /// Send an event indicating that registration is complete - self?.completionSubject.send(true) - - DispatchQueue.main.async(using: dependencies) { - onComplete() - } + ) } } } @@ -461,7 +505,7 @@ public protocol OnboardingCacheType: OnboardingImmutableCacheType, MutableCacheT var onboardingCompletePublisher: AnyPublisher { get } func setSeedData(_ seedData: Data) throws - func setUserAPNS(_ useAPNS: Bool) + func setUseAPNS(_ useAPNS: Bool) func setDisplayName(_ displayName: String) /// Complete the registration process storing the created/updated user state in the database and creating diff --git a/Session/Onboarding/PNModeScreen.swift b/Session/Onboarding/PNModeScreen.swift index 788257e720..16a35cd990 100644 --- a/Session/Onboarding/PNModeScreen.swift +++ b/Session/Onboarding/PNModeScreen.swift @@ -127,7 +127,7 @@ struct PNModeScreen: View { private func register() { // Store whether we want to use APNS - dependencies.mutate(cache: .onboarding) { $0.setUserAPNS(currentSelection == .fast) } + 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 { diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index 6c1bf58419..ace6225588 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -75,7 +75,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC super.viewDidLoad() setNavBarTitle("communityJoin".localized()) - view.themeBackgroundColor = .newConversation_background + view.themeBackgroundColor = .backgroundSecondary let navBarHeight: CGFloat = (navigationController?.navigationBar.frame.size.height ?? 0) let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close)) @@ -188,17 +188,44 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC shouldOpenCommunity: Bool, onError: (() -> ())? ) { - guard !isJoining, let navigationController: UINavigationController = navigationController else { return } - - guard dependencies[singleton: .openGroupManager].hasExistingOpenGroup( - roomToken: roomToken, - server: server, - publicKey: publicKey - ) != true else { - self.showToast( - text: "communityJoinedAlready".localized(), - backgroundColor: .backgroundSecondary + Task.detached(priority: .userInitiated) { [weak self, dependencies] in + let hasExistingOpenGroup: Bool = try await dependencies[singleton: .storage].readAsync { db in + dependencies[singleton: .openGroupManager].hasExistingOpenGroup( + db, + roomToken: roomToken, + server: server, + publicKey: publicKey + ) + } + + guard !hasExistingOpenGroup else { + await MainActor.run { [weak self] in + self?.showToast( + text: "communityJoinedAlready".localized(), + backgroundColor: .backgroundSecondary + ) + } + return + } + + await self?.joinOpenGroupAfterExistingCheck( + roomToken: roomToken, + server: server, + publicKey: publicKey, + shouldOpenCommunity: shouldOpenCommunity, + onError: onError ) + } + } + + @MainActor private func joinOpenGroupAfterExistingCheck( + roomToken: String, + server: String, + publicKey: String, + shouldOpenCommunity: Bool, + onError: (() -> ())? + ) { + guard !isJoining, let navigationController: UINavigationController = navigationController else { return } diff --git a/Session/Open Groups/OpenGroupSuggestionGrid.swift b/Session/Open Groups/OpenGroupSuggestionGrid.swift index a6e0c1bd62..606a091bb7 100644 --- a/Session/Open Groups/OpenGroupSuggestionGrid.swift +++ b/Session/Open Groups/OpenGroupSuggestionGrid.swift @@ -183,7 +183,7 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle regions: [ OpenGroup.select(.name).filter(ids: openGroupIds), OpenGroup.select(.roomDescription).filter(ids: openGroupIds), - OpenGroup.select(.displayPictureFilename).filter(ids: openGroupIds) + OpenGroup.select(.displayPictureOriginalUrl).filter(ids: openGroupIds) ], fetch: { db in try OpenGroup.filter(ids: openGroupIds).fetchAll(db) } ) @@ -357,8 +357,8 @@ extension OpenGroupSuggestionGrid { fileprivate func update(with room: OpenGroupAPI.Room, openGroup: OpenGroup, using dependencies: Dependencies) { label.text = room.name - let maybePath: String? = openGroup.displayPictureFilename - .map { try? dependencies[singleton: .displayPictureManager].filepath(for: $0) } + let maybePath: String? = openGroup.displayPictureOriginalUrl + .map { try? dependencies[singleton: .displayPictureManager].path(for: $0) } switch maybePath { case .some(let path): diff --git a/Session/Settings/AppIconViewModel.swift b/Session/Settings/AppIconViewModel.swift index 256f5c2ffb..9e02f0612d 100644 --- a/Session/Settings/AppIconViewModel.swift +++ b/Session/Settings/AppIconViewModel.swift @@ -114,7 +114,7 @@ class AppIconViewModel: SessionTableViewModel, NavigatableStateHolder, Observabl let title: String = "sessionAppearance".localized() - lazy var observation: TargetObservation = ObservationBuilder + lazy var observation: TargetObservation = ObservationBuilderOld .subject(selectedOptionsSubject) .mapWithPrevious { [weak self, dependencies] previous, current -> [SectionModel] in return [ diff --git a/Session/Settings/AppearanceViewModel.swift b/Session/Settings/AppearanceViewModel.swift index d6eaf99fce..d86559e1a9 100644 --- a/Session/Settings/AppearanceViewModel.swift +++ b/Session/Settings/AppearanceViewModel.swift @@ -47,7 +47,7 @@ class AppearanceViewModel: SessionTableViewModel, NavigatableStateHolder, Observ } public enum TableItem: Equatable, Hashable, Differentiable { - case theme(String) + case theme(Int) case primaryColorPreview case primaryColorSelectionView case darkModeMatchSystemSettings @@ -63,12 +63,12 @@ class AppearanceViewModel: SessionTableViewModel, NavigatableStateHolder, Observ let title: String = "sessionAppearance".localized() - lazy var observation: TargetObservation = ObservationBuilder - .databaseObservation(self) { db -> State in + lazy var observation: TargetObservation = ObservationBuilderOld + .libSessionObservation(self) { cache -> State in State( - theme: db[.theme].defaulting(to: .classicDark), - primaryColor: db[.themePrimaryColor].defaulting(to: .green), - authDarkModeEnabled: db[.themeMatchSystemDayNightCycle] + theme: cache.get(.theme).defaulting(to: .classicDark), + primaryColor: cache.get(.themePrimaryColor).defaulting(to: .green), + authDarkModeEnabled: cache.get(.themeMatchSystemDayNightCycle) ) } .map { [weak self, dependencies] state -> [SectionModel] in @@ -86,7 +86,7 @@ class AppearanceViewModel: SessionTableViewModel, NavigatableStateHolder, Observ isSelected: (state.theme == theme) ), onTap: { - ThemeManager.updateThemeState(theme: theme) + Task { @MainActor in ThemeManager.updateThemeState(theme: theme) } } ) } @@ -111,7 +111,7 @@ class AppearanceViewModel: SessionTableViewModel, NavigatableStateHolder, Observ info: PrimaryColorSelectionView.Info( primaryColor: state.primaryColor, onChange: { color in - ThemeManager.updateThemeState(primaryColor: color) + Task { @MainActor in ThemeManager.updateThemeState(primaryColor: color) } } ) ), @@ -136,9 +136,11 @@ class AppearanceViewModel: SessionTableViewModel, NavigatableStateHolder, Observ oldValue: ThemeManager.matchSystemNightModeSetting ), onTap: { - ThemeManager.updateThemeState( - matchSystemNightModeSetting: !state.authDarkModeEnabled - ) + Task { @MainActor in + ThemeManager.updateThemeState( + matchSystemNightModeSetting: !state.authDarkModeEnabled + ) + } } ) ] diff --git a/Session/Settings/BlockedContactsViewModel.swift b/Session/Settings/BlockedContactsViewModel.swift index ad7542ee30..e4e4232fdd 100644 --- a/Session/Settings/BlockedContactsViewModel.swift +++ b/Session/Settings/BlockedContactsViewModel.swift @@ -44,7 +44,7 @@ public class BlockedContactsViewModel: SessionTableViewModel, NavigatableStateHo .id, .name, .nickname, - .profilePictureFileName + .displayPictureUrl ], joinToPagedType: { let contact: TypedTableAlias = TypedTableAlias() @@ -208,17 +208,23 @@ public class BlockedContactsViewModel: SessionTableViewModel, NavigatableStateHo cancelStyle: .alert_text ) { [weak self, dependencies] _ in // Unblock the contacts - dependencies[singleton: .storage].write { db in - _ = try Contact - .filter(ids: contactIds) - .updateAllAndConfig( - db, - Contact.Columns.isBlocked.set(to: false), - using: dependencies - ) - } - - self?.selectedIdsSubject.send([]) + dependencies[singleton: .storage].writeAsync( + updates: { db in + _ = try Contact + .filter(ids: contactIds) + .updateAllAndConfig( + db, + Contact.Columns.isBlocked.set(to: false), + using: dependencies + ) + contactIds.forEach { id in + db.addContactEvent(id: id, change: .isBlocked(false)) + } + }, + completion: { _ in + self?.selectedIdsSubject.send([]) + } + ) } ) self.transitionToScreen(confirmationModal, transitionType: .present) diff --git a/Session/Settings/ConversationSettingsViewModel.swift b/Session/Settings/ConversationSettingsViewModel.swift index 248072d686..e9a31dbef8 100644 --- a/Session/Settings/ConversationSettingsViewModel.swift +++ b/Session/Settings/ConversationSettingsViewModel.swift @@ -53,11 +53,11 @@ class ConversationSettingsViewModel: SessionTableViewModel, NavigatableStateHold let title: String = "sessionConversations".localized() - lazy var observation: TargetObservation = ObservationBuilder - .databaseObservation(self) { [weak self] db -> State in + lazy var observation: TargetObservation = ObservationBuilderOld + .libSessionObservation(self) { cache -> State in State( - trimOpenGroupMessagesOlderThanSixMonths: db[.trimOpenGroupMessagesOlderThanSixMonths], - shouldAutoPlayConsecutiveAudioMessages: db[.shouldAutoPlayConsecutiveAudioMessages] + trimOpenGroupMessagesOlderThanSixMonths: cache.get(.trimOpenGroupMessagesOlderThanSixMonths), + shouldAutoPlayConsecutiveAudioMessages: cache.get(.shouldAutoPlayConsecutiveAudioMessages) ) } .mapWithPrevious { [dependencies] previous, current -> [SectionModel] in @@ -77,9 +77,10 @@ class ConversationSettingsViewModel: SessionTableViewModel, NavigatableStateHold ) ), onTap: { - dependencies[singleton: .storage].write { db in - db[.trimOpenGroupMessagesOlderThanSixMonths] = !db[.trimOpenGroupMessagesOlderThanSixMonths] - } + dependencies.setAsync( + .trimOpenGroupMessagesOlderThanSixMonths, + !current.trimOpenGroupMessagesOlderThanSixMonths + ) } ) ] @@ -99,9 +100,10 @@ class ConversationSettingsViewModel: SessionTableViewModel, NavigatableStateHold ) ), onTap: { - dependencies[singleton: .storage].write { db in - db[.shouldAutoPlayConsecutiveAudioMessages] = !db[.shouldAutoPlayConsecutiveAudioMessages] - } + dependencies.setAsync( + .shouldAutoPlayConsecutiveAudioMessages, + !current.shouldAutoPlayConsecutiveAudioMessages + ) } ) ] diff --git a/Session/Settings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettingsViewModel.swift index f65de435be..52c327cce1 100644 --- a/Session/Settings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettingsViewModel.swift @@ -72,6 +72,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case animationsEnabled case showStringKeys + case copyDocumentsPath + case copyAppGroupPath case defaultLogLevel case advancedLogging @@ -95,7 +97,6 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case updatedGroupsDeleteAttachmentsBeforeNow case createMockContacts - case copyDatabasePath case forceSlowDatabaseQueries case exportDatabase case importDatabase @@ -109,6 +110,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .developerMode: return "developerMode" case .animationsEnabled: return "animationsEnabled" case .showStringKeys: return "showStringKeys" + case .copyDocumentsPath: return "copyDocumentsPath" + case .copyAppGroupPath: return "copyAppGroupPath" case .defaultLogLevel: return "defaultLogLevel" case .advancedLogging: return "advancedLogging" @@ -135,7 +138,6 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .scheduleLocalNotification: return "scheduleLocalNotification" case .createMockContacts: return "createMockContacts" - case .copyDatabasePath: return "copyDatabasePath" case .forceSlowDatabaseQueries: return "forceSlowDatabaseQueries" case .exportDatabase: return "exportDatabase" case .importDatabase: return "importDatabase" @@ -152,6 +154,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .developerMode: result.append(.developerMode); fallthrough case .animationsEnabled: result.append(.animationsEnabled); fallthrough case .showStringKeys: result.append(.showStringKeys); fallthrough + case .copyDocumentsPath: result.append(.copyDocumentsPath); fallthrough + case .copyAppGroupPath: result.append(.copyAppGroupPath); fallthrough case .defaultLogLevel: result.append(.defaultLogLevel); fallthrough case .advancedLogging: result.append(.advancedLogging); fallthrough @@ -179,7 +183,6 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .scheduleLocalNotification: result.append(.scheduleLocalNotification); fallthrough case .createMockContacts: result.append(.createMockContacts); fallthrough - case .copyDatabasePath: result.append(.copyDatabasePath); fallthrough case .forceSlowDatabaseQueries: result.append(.forceSlowDatabaseQueries); fallthrough case .exportDatabase: result.append(.exportDatabase); fallthrough case .importDatabase: result.append(.importDatabase) @@ -223,7 +226,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, let title: String = "Developer Settings" - lazy var observation: TargetObservation = ObservationBuilder + lazy var observation: TargetObservation = ObservationBuilderOld .refreshableData(self) { [weak self, dependencies] () -> State in let versionBlindedID: String? = { guard @@ -239,7 +242,9 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, return State( - developerMode: dependencies[singleton: .storage, key: .developerModeEnabled], + developerMode: dependencies.mutate(cache: .libSession) { cache in + cache.get(.developerModeEnabled) + }, versionBlindedID: versionBlindedID, animationsEnabled: dependencies[feature: .animationsEnabled], showStringKeys: dependencies[feature: .showStringKeys], @@ -337,6 +342,28 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, to: !current.showStringKeys ) } + ), + SessionCell.Info( + id: .copyDocumentsPath, + title: "Copy Documents Path", + subtitle: """ + Copies the path to the Documents directory (quick way to access it for the simulator for debugging) + """, + trailingAccessory: .highlightingBackgroundLabel(title: "Copy"), + onTap: { [weak self] in + self?.copyDocumentsPath() + } + ), + SessionCell.Info( + id: .copyAppGroupPath, + title: "Copy AppGroup Path", + subtitle: """ + Copies the path to the AppGroup directory (quick way to access it for the simulator for debugging) + """, + trailingAccessory: .highlightingBackgroundLabel(title: "Copy"), + onTap: { [weak self] in + self?.copyAppGroupPath() + } ) ] ) @@ -705,17 +732,6 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, self?.createContacts() } ), - SessionCell.Info( - id: .copyDatabasePath, - title: "Copy database path", - subtitle: """ - Copies the path to the database file (quick way to access it for the simulator for debugging). - """, - trailingAccessory: .highlightingBackgroundLabel(title: "Copy"), - onTap: { [weak self] in - self?.copyDatabasePath() - } - ), SessionCell.Info( id: .forceSlowDatabaseQueries, title: "Force slow database queries", @@ -831,10 +847,11 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, guard dependencies.hasSet(feature: .showStringKeys) else { return } updateFlag(for: .showStringKeys, to: nil) - + + case .copyDocumentsPath: break // Not a feature + case .copyAppGroupPath: break // Not a feature case .resetSnodeCache: break // Not a feature case .createMockContacts: break // Not a feature - case .copyDatabasePath: break // Not a feature case .exportDatabase: break // Not a feature case .importDatabase: break // Not a feature case .advancedLogging: break // Not a feature @@ -919,10 +936,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, } /// Disable developer mode - dependencies[singleton: .storage].write { db in - db[.developerModeEnabled] = false - } - + dependencies.setAsync(.developerModeEnabled, false) self.dismissScreen(type: .pop) } @@ -967,12 +981,14 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, /// Disable push notifications to trigger the unsubscribe, then re-enable them after updating the feature setting dependencies[defaults: .standard, key: .isUsingFullAPNs] = false + dependencies.notifyAsync(.isUsingFullAPNs, value: 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 + dependencies.notifyAsync(.isUsingFullAPNs, value: true) self?.forceRefresh(type: .databaseQuery) } ) @@ -1034,7 +1050,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, /// 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, key: .lastRecordedPushToken] { + if let existingToken: String = dependencies[singleton: .storage].read({ db in db[.lastRecordedPushToken] }) { PushNotificationAPI .unsubscribeAll(token: Data(hex: existingToken), using: dependencies) .sinkUntilComplete() @@ -1052,7 +1068,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, _ = try SnodeReceivedMessageInfo.deleteAll(db) _ = try SessionThread.deleteAll(db) - _ = try ControlMessageProcessRecord.deleteAll(db) + _ = try MessageDeduplication.deleteAll(db) _ = try ClosedGroup.deleteAll(db) _ = try OpenGroup.deleteAll(db) _ = try Capability.deleteAll(db) @@ -1067,6 +1083,9 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, _ = 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` @@ -1079,7 +1098,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Onboarding.Cache( ed25519KeyPair: identityData.ed25519KeyPair, x25519KeyPair: identityData.x25519KeyPair, - displayName: Profile.fetchOrCreateCurrentUser(using: dependencies) + displayName: dependencies + .mutate(cache: .libSession) { $0.profile } .name .nullIfEmpty .defaulting(to: "Anonymous"), @@ -1213,6 +1233,10 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Contact.Columns.isApproved.set(to: true), using: dependencies ) + db.addContactEvent( + id: sessionId.hexString, + change: .isApproved(true) + ) } }, completion: { _ in @@ -1238,8 +1262,17 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, ) } - private func copyDatabasePath() { - UIPasteboard.general.string = Storage.sharedDatabaseDirectoryPath + private func copyDocumentsPath() { + UIPasteboard.general.string = dependencies[singleton: .fileManager].documentsDirectoryPath + + showToast( + text: "copied".localized(), + backgroundColor: .backgroundSecondary + ) + } + + private func copyAppGroupPath() { + UIPasteboard.general.string = dependencies[singleton: .fileManager].appSharedDataDirectoryPath showToast( text: "copied".localized(), @@ -1408,7 +1441,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, ) } - private func performExport( + @MainActor private func performExport( viaShareSheet: Bool, targetView: UIView? ) { @@ -1488,7 +1521,9 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, activityItems: [ URL(fileURLWithPath: backupFile) ], applicationActivities: nil ) - shareVC.completionWithItemsHandler = { _, _, _, _ in } + shareVC.completionWithItemsHandler = { _, success, _, _ in + UIActivityViewController.notifyIfNeeded(success, using: dependencies) + } if UIDevice.current.isIPad { shareVC.excludedActivityTypes = [] diff --git a/Session/Settings/HelpViewModel.swift b/Session/Settings/HelpViewModel.swift index 19744dddf8..15dd86307e 100644 --- a/Session/Settings/HelpViewModel.swift +++ b/Session/Settings/HelpViewModel.swift @@ -225,7 +225,10 @@ class HelpViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTa activityItems: [ URL(fileURLWithPath: latestLogFilePath) ], applicationActivities: nil ) - shareVC.completionWithItemsHandler = { _, _, _, _ in onShareComplete?() } + shareVC.completionWithItemsHandler = { _, success, _, _ in + UIActivityViewController.notifyIfNeeded(success, using: dependencies) + onShareComplete?() + } if UIDevice.current.isIPad { shareVC.excludedActivityTypes = [] diff --git a/Session/Settings/NotificationContentViewModel.swift b/Session/Settings/NotificationContentViewModel.swift index 8780665426..51d03b0ba0 100644 --- a/Session/Settings/NotificationContentViewModel.swift +++ b/Session/Settings/NotificationContentViewModel.swift @@ -31,9 +31,9 @@ class NotificationContentViewModel: SessionTableViewModel, NavigatableStateHolde let title: String = "notificationsContent".localized() - lazy var observation: TargetObservation = ObservationBuilder - .databaseObservation(self) { db -> Preferences.NotificationPreviewType in - db[.preferencesNotificationPreviewType].defaulting(to: .defaultPreviewType) + lazy var observation: TargetObservation = ObservationBuilderOld + .libSessionObservation(self) { cache -> Preferences.NotificationPreviewType in + cache.get(.preferencesNotificationPreviewType).defaulting(to: .defaultPreviewType) } .map { [weak self, dependencies] currentSelection -> [SectionModel] in return [ @@ -48,11 +48,11 @@ class NotificationContentViewModel: SessionTableViewModel, NavigatableStateHolde isSelected: (currentSelection == previewType) ), onTap: { - dependencies[singleton: .storage].writeAsync { db in - db[.preferencesNotificationPreviewType] = previewType + dependencies.setAsync(.preferencesNotificationPreviewType, previewType) { + Task { @MainActor in + self?.dismissScreen() + } } - - self?.dismissScreen() } ) } diff --git a/Session/Settings/NotificationSettingsViewModel.swift b/Session/Settings/NotificationSettingsViewModel.swift index 81d4252386..7f70714b4b 100644 --- a/Session/Settings/NotificationSettingsViewModel.swift +++ b/Session/Settings/NotificationSettingsViewModel.swift @@ -61,25 +61,20 @@ class NotificationSettingsViewModel: SessionTableViewModel, NavigatableStateHold let title: String = "sessionNotifications".localized() - lazy var observation: TargetObservation = ObservationBuilder - .databaseObservation(self) { db -> State in - State( - isUsingFullAPNs: false, // Set later the the data flow - notificationSound: db[.defaultNotificationSound] + lazy var observation: TargetObservation = ObservationBuilderOld + .libSessionObservation(self) { [dependencies] cache -> State in + /// Listen for `isUsingFullAPNs` changes + cache.register(.isUsingFullAPNs) + + return State( + isUsingFullAPNs: dependencies[defaults: .standard, key: .isUsingFullAPNs], + notificationSound: cache.get(.defaultNotificationSound) .defaulting(to: Preferences.Sound.defaultNotificationSound), - playNotificationSoundInForeground: db[.playNotificationSoundInForeground], - previewType: db[.preferencesNotificationPreviewType] + playNotificationSoundInForeground: cache.get(.playNotificationSoundInForeground), + previewType: cache.get(.preferencesNotificationPreviewType) .defaulting(to: Preferences.NotificationPreviewType.defaultPreviewType) ) } - .map { [dependencies] dbState -> State in - State( - isUsingFullAPNs: dependencies[defaults: .standard, key: .isUsingFullAPNs], - notificationSound: dbState.notificationSound, - playNotificationSoundInForeground: dbState.playNotificationSoundInForeground, - previewType: dbState.previewType - ) - } .mapWithPrevious { [dependencies] previous, current -> [SectionModel] in return [ SectionModel( @@ -102,7 +97,9 @@ class NotificationSettingsViewModel: SessionTableViewModel, NavigatableStateHold ), // stringlint:ignore_contents onTap: { [weak self] in - dependencies[defaults: .standard, key: .isUsingFullAPNs] = !dependencies[defaults: .standard, key: .isUsingFullAPNs] + let updatedValue: Bool = !dependencies[defaults: .standard, key: .isUsingFullAPNs] + dependencies[defaults: .standard, key: .isUsingFullAPNs] = updatedValue + dependencies.notifyAsync(.isUsingFullAPNs, value: updatedValue) // Force sync the push tokens on change SyncPushTokensJob @@ -152,9 +149,10 @@ class NotificationSettingsViewModel: SessionTableViewModel, NavigatableStateHold ) ), onTap: { - dependencies[singleton: .storage].write { db in - db[.playNotificationSoundInForeground] = !db[.playNotificationSoundInForeground] - } + dependencies.setAsync( + .playNotificationSoundInForeground, + !current.playNotificationSoundInForeground + ) } ) ] diff --git a/Session/Settings/NotificationSoundViewModel.swift b/Session/Settings/NotificationSoundViewModel.swift index 3f6c4e060b..9b4b30b9dc 100644 --- a/Session/Settings/NotificationSoundViewModel.swift +++ b/Session/Settings/NotificationSoundViewModel.swift @@ -25,7 +25,8 @@ class NotificationSoundViewModel: SessionTableViewModel, NavigationItemSource, N init(using dependencies: Dependencies) { self.dependencies = dependencies - let originalSelection: Preferences.Sound = dependencies[singleton: .storage, key: .defaultNotificationSound] + let originalSelection: Preferences.Sound = dependencies + .mutate(cache: .libSession, { $0.get(.defaultNotificationSound) }) .defaulting(to: .defaultNotificationSound) self.originalSelection = originalSelection self.currentSelection = CurrentValueSubject(originalSelection) @@ -80,7 +81,7 @@ class NotificationSoundViewModel: SessionTableViewModel, NavigationItemSource, N let title: String = "notificationsSound".localized() - lazy var observation: TargetObservation = ObservationBuilder + lazy var observation: TargetObservation = ObservationBuilderOld .subject(currentSelection) .map { [weak self] selectedSound in return [ @@ -123,8 +124,6 @@ class NotificationSoundViewModel: SessionTableViewModel, NavigationItemSource, N // MARK: - Functions private func saveChanges() { - dependencies[singleton: .storage].writeAsync { [currentSelection] db in - db[.defaultNotificationSound] = currentSelection.value - } + dependencies.setAsync(.defaultNotificationSound, currentSelection.value) } } diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index b30cc17e66..03f39b71d0 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -178,16 +178,11 @@ final class NukeDataModal: Modal { ModalActivityIndicatorViewController .present(fromViewController: presentedViewController, canCancel: false) { [weak self, dependencies] _ in dependencies[singleton: .storage] - .readPublisher { db -> PreparedClearRequests in + .readPublisher { db -> (AuthenticationMethod, [AuthenticationMethod]) in ( - try SnodeAPI.preparedDeleteAllMessages( - namespace: .all, - requestAndPathBuildTimeout: Network.defaultTimeout, - authMethod: try Authentication.with( - db, - swarmPublicKey: dependencies[cache: .general].sessionId.hexString, - using: dependencies - ), + try Authentication.with( + db, + swarmPublicKey: dependencies[cache: .general].sessionId.hexString, using: dependencies ), try OpenGroup @@ -196,28 +191,40 @@ final class NukeDataModal: Modal { .distinct() .asRequest(of: String.self) .fetchSet(db) - .map { server in - try OpenGroupAPI - .preparedClearInbox( - db, - on: server, - requestAndPathBuildTimeout: Network.defaultTimeout, - using: dependencies - ) - .map { _, _ in server } - } + .map { try Authentication.with(db, server: $0, using: dependencies) } ) } .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) - .flatMap { preparedRequests -> AnyPublisher<(Network.PreparedRequest<[String: Bool]>, [String]), Error> in + .tryFlatMap { (userAuth: AuthenticationMethod, communityAuth: [AuthenticationMethod]) -> AnyPublisher<(AuthenticationMethod, [String]), Error> in Publishers - .MergeMany(preparedRequests.inboxRequestInfo.map { $0.send(using: dependencies) }) + .MergeMany( + try communityAuth.compactMap { authMethod in + switch authMethod.info { + case .community(let server, _, _, _, _): + return try OpenGroupAPI.preparedClearInbox( + requestAndPathBuildTimeout: Network.defaultTimeout, + authMethod: authMethod, + using: dependencies + ) + .map { _, _ in server } + .send(using: dependencies) + + default: return nil + } + } + ) .collect() - .map { response in (preparedRequests.deleteAll, response.map { $0.1 }) } + .map { response in (userAuth, response.map { $0.1 }) } .eraseToAnyPublisher() } - .flatMap { preparedDeleteAllRequest, clearedServers in - preparedDeleteAllRequest + .tryFlatMap { authMethod, clearedServers in + try SnodeAPI + .preparedDeleteAllMessages( + namespace: .all, + requestAndPathBuildTimeout: Network.defaultTimeout, + authMethod: authMethod, + using: dependencies + ) .send(using: dependencies) .map { _, data in clearedServers.reduce(into: data) { result, next in result[next] = true } diff --git a/Session/Settings/PrivacySettingsViewModel.swift b/Session/Settings/PrivacySettingsViewModel.swift index 99767d476a..76e0e6ca9f 100644 --- a/Session/Settings/PrivacySettingsViewModel.swift +++ b/Session/Settings/PrivacySettingsViewModel.swift @@ -95,165 +95,164 @@ class PrivacySettingsViewModel: SessionTableViewModel, NavigationItemSource, Nav let title: String = "sessionPrivacy".localized() - lazy var observation: TargetObservation = ObservationBuilder - .databaseObservation(self) { [weak self] db -> State in + lazy var observation: TargetObservation = ObservationBuilderOld + .libSessionObservation(self) { cache -> State in State( - isScreenLockEnabled: db[.isScreenLockEnabled], - checkForCommunityMessageRequests: db[.checkForCommunityMessageRequests], - areReadReceiptsEnabled: db[.areReadReceiptsEnabled], - typingIndicatorsEnabled: db[.typingIndicatorsEnabled], - areLinkPreviewsEnabled: db[.areLinkPreviewsEnabled], - areCallsEnabled: db[.areCallsEnabled], - localNetworkPermission: db[.lastSeenHasLocalNetworkPermission] + isScreenLockEnabled: cache.get(.isScreenLockEnabled), + checkForCommunityMessageRequests: cache.get(.checkForCommunityMessageRequests), + areReadReceiptsEnabled: cache.get(.areReadReceiptsEnabled), + typingIndicatorsEnabled: cache.get(.typingIndicatorsEnabled), + areLinkPreviewsEnabled: cache.get(.areLinkPreviewsEnabled), + areCallsEnabled: cache.get(.areCallsEnabled), + localNetworkPermission: cache.get(.lastSeenHasLocalNetworkPermission) ) } .mapWithPrevious { [dependencies] previous, current -> [SectionModel] in - return [ - SectionModel( - model: .calls, - elements: [ - SessionCell.Info( - id: .calls, - title: "callsVoiceAndVideo".localized(), - subtitle: "callsVoiceAndVideoToggleDescription".localized(), - trailingAccessory: .toggle( - current.areCallsEnabled, - oldValue: (previous ?? current).areCallsEnabled, - accessibility: Accessibility( - identifier: "Voice and Video Calls - Switch" + var sections: [SectionModel] = [] + + var callsSection = SectionModel(model: .calls, elements: []) + callsSection.elements.append( + SessionCell.Info( + id: .calls, + title: "callsVoiceAndVideo".localized(), + subtitle: "callsVoiceAndVideoToggleDescription".localized(), + trailingAccessory: .toggle( + current.areCallsEnabled, + oldValue: (previous ?? current).areCallsEnabled, + accessibility: Accessibility( + identifier: "Voice and Video Calls - Switch" + ) + ), + accessibility: Accessibility( + label: "Allow voice and video calls" + ), + confirmationInfo: ConfirmationModal.Info( + title: "callsVoiceAndVideoBeta".localized(), + body: .text("callsVoiceAndVideoModalDescription".localized()), + showCondition: .disabled, + confirmTitle: "theContinue".localized(), + confirmStyle: .danger, + cancelStyle: .alert_text, + onConfirm: { _ in + Permissions.requestPermissionsForCalls(using: dependencies) + } + ), + onTap: { + dependencies.setAsync(.areCallsEnabled, !current.areCallsEnabled) + } + ) + ) + + if current.areCallsEnabled { + callsSection.elements.append( + SessionCell.Info( + id: .microphone, + title: "permissionsMicrophone".localized(), + subtitle: "permissionsMicrophoneDescriptionIos".localized(), + trailingAccessory: .toggle( + Permissions.microphone == .granted, + oldValue: Permissions.microphone == .granted, + accessibility: Accessibility( + identifier: "Microphone Permission - Switch" + ) + ), + accessibility: Accessibility( + label: "Grant microphone permission" + ), + confirmationInfo: ConfirmationModal.Info( + title: ( + current.localNetworkPermission ? + "permissionChange".localized() : + "permissionsRequired".localized() + ), + body: .text( + ( + current.localNetworkPermission ? + "permissionsMicrophoneChangeDescriptionIos".localized() : + "permissionsMicrophoneAccessRequiredCallsIos".localized() ) ), + confirmTitle: "sessionSettings".localized(), + onConfirm: { _ in + UIApplication.shared.openSystemSettings() + } + ) + ) + ) + callsSection.elements.append( + SessionCell.Info( + id: .camera, + title: "contentDescriptionCamera".localized(), + subtitle: "permissionsCameraDescriptionIos".localized(), + trailingAccessory: .toggle( + Permissions.camera == .granted, + oldValue: Permissions.camera == .granted, accessibility: Accessibility( - label: "Allow voice and video calls" + identifier: "Camera Permission - Switch" + ) + ), + accessibility: Accessibility( + label: "Grant camera permission" + ), + confirmationInfo: ConfirmationModal.Info( + title: ( + current.localNetworkPermission ? + "permissionChange".localized() : + "permissionsRequired".localized() ), - confirmationInfo: ConfirmationModal.Info( - title: "callsVoiceAndVideoBeta".localized(), - body: .text("callsVoiceAndVideoModalDescription".localized()), - showCondition: .disabled, - confirmTitle: "theContinue".localized(), - confirmStyle: .danger, - cancelStyle: .alert_text, - onConfirm: { _ in - Permissions.requestPermissionsForCalls(using: dependencies) - } + body: .text( + ( + current.localNetworkPermission ? + "permissionsCameraChangeDescriptionIos".localized() : + "permissionsCameraAccessRequiredCallsIos".localized() + ) ), - onTap: { [weak self] in - dependencies[singleton: .storage].write { db in - try db.setAndUpdateConfig( - .areCallsEnabled, - to: !db[.areCallsEnabled], - using: dependencies - ) - } + confirmTitle: "sessionSettings".localized(), + onConfirm: { _ in + UIApplication.shared.openSystemSettings() } ) - ].appending( - contentsOf: ( - !current.areCallsEnabled ? nil : - [ - SessionCell.Info( - id: .microphone, - title: "permissionsMicrophone".localized(), - subtitle: "permissionsMicrophoneDescriptionIos".localized(), - trailingAccessory: .toggle( - Permissions.microphone == .granted, - oldValue: Permissions.microphone == .granted, - accessibility: Accessibility( - identifier: "Microphone Permission - Switch" - ) - ), - accessibility: Accessibility( - label: "Grant microphone permission" - ), - confirmationInfo: ConfirmationModal.Info( - title: ( - current.localNetworkPermission ? - "permissionChange".localized() : - "permissionsRequired".localized() - ), - body: .text( - ( - current.localNetworkPermission ? - "permissionsMicrophoneChangeDescriptionIos".localized() : - "permissionsMicrophoneAccessRequiredCallsIos".localized() - ) - ), - confirmTitle: "sessionSettings".localized(), - onConfirm: { _ in - UIApplication.shared.openSystemSettings() - } - ) - ), - SessionCell.Info( - id: .camera, - title: "contentDescriptionCamera".localized(), - subtitle: "permissionsCameraDescriptionIos".localized(), - trailingAccessory: .toggle( - Permissions.camera == .granted, - oldValue: Permissions.camera == .granted, - accessibility: Accessibility( - identifier: "Camera Permission - Switch" - ) - ), - accessibility: Accessibility( - label: "Grant camera permission" - ), - confirmationInfo: ConfirmationModal.Info( - title: ( - current.localNetworkPermission ? - "permissionChange".localized() : - "permissionsRequired".localized() - ), - body: .text( - ( - current.localNetworkPermission ? - "permissionsCameraChangeDescriptionIos".localized() : - "permissionsCameraAccessRequiredCallsIos".localized() - ) - ), - confirmTitle: "sessionSettings".localized(), - onConfirm: { _ in - UIApplication.shared.openSystemSettings() - } - ) - ), - SessionCell.Info( - id: .localNetwork, - title: "permissionsLocalNetworkIos".localized(), - subtitle: "permissionsLocalNetworkDescriptionIos".localized(), - trailingAccessory: .toggle( - current.localNetworkPermission, - oldValue: (previous ?? current).localNetworkPermission, - accessibility: Accessibility( - identifier: "Local Network Permission - Switch" - ) - ), - accessibility: Accessibility( - label: "Grant local network permission" - ), - confirmationInfo: ConfirmationModal.Info( - title: ( - current.localNetworkPermission ? - "permissionChange".localized() : - "permissionsRequired".localized() - ), - body: .text( - ( - current.localNetworkPermission ? - "permissionsLocalNetworkChangeDescriptionIos".localized() : - "permissionsLocalNetworkAccessRequiredCallsIos".localized() - ) - ), - confirmTitle: "sessionSettings".localized(), - onConfirm: { _ in - UIApplication.shared.openSystemSettings() - } - ) - ) - ] + ) + ) + callsSection.elements.append( + SessionCell.Info( + id: .localNetwork, + title: "permissionsLocalNetworkIos".localized(), + subtitle: "permissionsLocalNetworkDescriptionIos".localized(), + trailingAccessory: .toggle( + current.localNetworkPermission, + oldValue: (previous ?? current).localNetworkPermission, + accessibility: Accessibility( + identifier: "Local Network Permission - Switch" + ) + ), + accessibility: Accessibility( + label: "Grant local network permission" + ), + confirmationInfo: ConfirmationModal.Info( + title: ( + current.localNetworkPermission ? + "permissionChange".localized() : + "permissionsRequired".localized() + ), + body: .text( + ( + current.localNetworkPermission ? + "permissionsLocalNetworkChangeDescriptionIos".localized() : + "permissionsLocalNetworkAccessRequiredCallsIos".localized() + ) + ), + confirmTitle: "sessionSettings".localized(), + onConfirm: { _ in + UIApplication.shared.openSystemSettings() + } ) ) - ), + ) + } + + sections.append(callsSection) + sections.append( SectionModel( model: .screenSecurity, elements: [ @@ -287,17 +286,13 @@ class PrivacySettingsViewModel: SessionTableViewModel, NavigationItemSource, Nav return } - dependencies[singleton: .storage].write { db in - try db.setAndUpdateConfig( - .isScreenLockEnabled, - to: !db[.isScreenLockEnabled], - using: dependencies - ) - } + dependencies.setAsync(.isScreenLockEnabled, !current.isScreenLockEnabled) } ) ] - ), + ) + ) + sections.append( SectionModel( model: .messageRequests, elements: [ @@ -312,18 +307,17 @@ class PrivacySettingsViewModel: SessionTableViewModel, NavigationItemSource, Nav identifier: "Community Message Requests - Switch" ) ), - onTap: { [weak self] in - dependencies[singleton: .storage].write { db in - try db.setAndUpdateConfig( - .checkForCommunityMessageRequests, - to: !db[.checkForCommunityMessageRequests], - using: dependencies - ) - } + onTap: { + dependencies.setAsync( + .checkForCommunityMessageRequests, + !current.checkForCommunityMessageRequests + ) } ) ] - ), + ) + ) + sections.append( SectionModel( model: .readReceipts, elements: [ @@ -339,17 +333,13 @@ class PrivacySettingsViewModel: SessionTableViewModel, NavigationItemSource, Nav ) ), onTap: { - dependencies[singleton: .storage].write { db in - try db.setAndUpdateConfig( - .areReadReceiptsEnabled, - to: !db[.areReadReceiptsEnabled], - using: dependencies - ) - } + dependencies.setAsync(.areReadReceiptsEnabled, !current.areReadReceiptsEnabled) } ) ] - ), + ) + ) + sections.append( SectionModel( model: .typingIndicators, elements: [ @@ -400,17 +390,13 @@ class PrivacySettingsViewModel: SessionTableViewModel, NavigationItemSource, Nav ) ), onTap: { - dependencies[singleton: .storage].write { db in - try db.setAndUpdateConfig( - .typingIndicatorsEnabled, - to: !db[.typingIndicatorsEnabled], - using: dependencies - ) - } + dependencies.setAsync(.typingIndicatorsEnabled, !current.typingIndicatorsEnabled) } ) ] - ), + ) + ) + sections.append( SectionModel( model: .linkPreviews, elements: [ @@ -426,18 +412,14 @@ class PrivacySettingsViewModel: SessionTableViewModel, NavigationItemSource, Nav ) ), onTap: { - dependencies[singleton: .storage].write { db in - try db.setAndUpdateConfig( - .areLinkPreviewsEnabled, - to: !db[.areLinkPreviewsEnabled], - using: dependencies - ) - } + dependencies.setAsync(.areLinkPreviewsEnabled, !current.areLinkPreviewsEnabled) } ) ] ) - ] + ) + + return sections } func onAppear(targetViewController: BaseVC) { @@ -452,13 +434,7 @@ class PrivacySettingsViewModel: SessionTableViewModel, NavigationItemSource, Nav cancelStyle: .alert_text, onConfirm: { [dependencies] _ in Permissions.requestPermissionsForCalls(using: dependencies) - dependencies[singleton: .storage].write { db in - try db.setAndUpdateConfig( - .areCallsEnabled, - to: !db[.areCallsEnabled], - using: dependencies - ) - } + dependencies.setAsync(.areCallsEnabled, true) } ) ) diff --git a/Session/Settings/RecoveryPasswordScreen.swift b/Session/Settings/RecoveryPasswordScreen.swift index 680cf2feb9..603878d164 100644 --- a/Session/Settings/RecoveryPasswordScreen.swift +++ b/Session/Settings/RecoveryPasswordScreen.swift @@ -242,7 +242,7 @@ struct RecoveryPasswordScreen: View { } .backgroundColor(themeColor: .backgroundPrimary) .onAppear { - dependencies[singleton: .storage].writeAsync { db in db[.hasViewedSeed] = true } + dependencies.setAsync(.hasViewedSeed, true) } } @@ -274,9 +274,7 @@ struct RecoveryPasswordScreen: View { cancelStyle: .danger, onCancel: { modal in modal.dismiss(animated: true) { - dependencies[singleton: .storage].writeAsync { db in - db[.hideRecoveryPasswordPermanently] = true - } + dependencies.setAsync(.hideRecoveryPasswordPermanently, true) self.host.controller?.navigationController?.popViewController(animated: true) } } diff --git a/Session/Settings/SeedModal.swift b/Session/Settings/SeedModal.swift index 4ed338e6b4..cb22b3d205 100644 --- a/Session/Settings/SeedModal.swift +++ b/Session/Settings/SeedModal.swift @@ -124,7 +124,7 @@ final class SeedModal: Modal { mnemonicLabel.pin(to: mnemonicLabelContainer, withInset: isIPhone6OrSmaller ? 4 : Values.smallSpacing) // Mark seed as viewed - dependencies[singleton: .storage].writeAsync { db in db[.hasViewedSeed] = true } + dependencies.setAsync(.hasViewedSeed, true) } // MARK: - Interaction diff --git a/Session/Settings/SessionNetworkScreen/SessionNetworkScreen+ViewModel.swift b/Session/Settings/SessionNetworkScreen/SessionNetworkScreen+ViewModel.swift index dd4db28cfa..2446f852f2 100644 --- a/Session/Settings/SessionNetworkScreen/SessionNetworkScreen+ViewModel.swift +++ b/Session/Settings/SessionNetworkScreen/SessionNetworkScreen+ViewModel.swift @@ -29,8 +29,8 @@ extension SessionNetworkScreenContent { self.dataModel = DataModel() let userSessionId: SessionId = dependencies[cache: .general].sessionId - self.observationCancellable = ValueObservation - .tracking { [dependencies] db in + 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 @@ -64,7 +64,6 @@ extension SessionNetworkScreenContent { lastUpdatedTimestampMs: db[.lastUpdatedTimestampMs] ) } - .publisher(in: dependencies[singleton: .storage], scheduling: .immediate) .sink( receiveCompletion: { _ in /* ignore error */ }, receiveValue: { [weak self] dataModel in diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index cc6b8fc400..f2cc2d48f5 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -131,12 +131,12 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl let title: String = "sessionSettings".localized() - lazy var observation: TargetObservation = ObservationBuilder - .databaseObservation(self) { [weak self, dependencies] db -> State in + lazy var observation: TargetObservation = ObservationBuilderOld + .libSessionObservation(self) { cache -> State in State( - profile: Profile.fetchOrCreateCurrentUser(db, using: dependencies), - developerModeEnabled: db[.developerModeEnabled], - hideRecoveryPasswordPermanently: db[.hideRecoveryPasswordPermanently] + profile: cache.profile, + developerModeEnabled: cache.get(.developerModeEnabled), + hideRecoveryPasswordPermanently: cache.get(.hideRecoveryPasswordPermanently) ) } .compactMap { [weak self] state -> [SectionModel]? in self?.content(state) } @@ -169,7 +169,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl label: "Profile picture" ), onTap: { [weak self] in - self?.updateProfilePicture(currentFileName: state.profile.profilePictureFileName) + self?.updateProfilePicture(currentUrl: state.profile.displayPictureUrl) } ), SessionCell.Info( @@ -182,15 +182,16 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ), trailingAccessory: .icon( .pencil, - size: .mediumAspectFill, - customTint: .textSecondary, - shouldFill: true + size: .small, + customTint: .textSecondary ), styling: SessionCell.StyleInfo( alignment: .centerHugging, customPadding: SessionCell.Padding( top: Values.smallSpacing, - bottom: Values.mediumSpacing + leading: IconSize.small.size, + bottom: Values.mediumSpacing, + interItem: 0 ), backgroundStyle: .noBackground ), @@ -479,11 +480,9 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl logoTapCallback: { [weak self] in self?.openTokenUrl() }, versionTapCallback: { [dependencies] in /// Do nothing if developer mode is already enabled - guard !dependencies[singleton: .storage, key: .developerModeEnabled] else { return } + guard !dependencies.mutate(cache: .libSession, { $0.get(.developerModeEnabled) }) else { return } - dependencies[singleton: .storage].write { db in - db[.developerModeEnabled] = true - } + dependencies.setAsync(.developerModeEnabled, true) } )).eraseToAnyPublisher() @@ -543,7 +542,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ) } - private func updateProfilePicture(currentFileName: String?) { + private func updateProfilePicture(currentUrl: String?) { let iconName: String = "profile_placeholder" // stringlint:ignore self.transitionToScreen( @@ -551,9 +550,8 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl info: ConfirmationModal.Info( title: "profileDisplayPictureSet".localized(), body: .image( - identifier: (currentFileName ?? iconName), - source: currentFileName - .map { try? dependencies[singleton: .displayPictureManager].filepath(for: $0) } + source: currentUrl + .map { try? dependencies[singleton: .displayPictureManager].path(for: $0) } .map { ImageDataManager.DataSource.url(URL(fileURLWithPath: $0)) }, placeholder: UIImage(named: iconName).map { ImageDataManager.DataSource.image(iconName, $0) @@ -573,17 +571,17 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl confirmTitle: "save".localized(), confirmEnabled: .afterChange { info in switch info.body { - case .image(_, let source, _, _, _, _, _, _): return (source?.imageData != nil) + case .image(let source, _, _, _, _, _, _): return (source?.imageData != nil) default: return false } }, cancelTitle: "remove".localized(), - cancelEnabled: .bool(currentFileName != nil), + cancelEnabled: .bool(currentUrl != nil), hasCloseButton: true, dismissOnConfirm: false, onConfirm: { [weak self] modal in switch modal.info.body { - case .image(_, .some(let source), _, _, _, _, _, _): + case .image(.some(let source), _, _, _, _, _, _): guard let imageData: Data = source.imageData else { return } self?.updateProfile( @@ -619,7 +617,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } } - fileprivate func updateProfile( + @MainActor fileprivate func updateProfile( displayNameUpdate: Profile.DisplayNameUpdate = .none, displayPictureUpdate: DisplayPictureManager.Update = .none, onComplete: @escaping () -> () diff --git a/Session/Settings/Views/ThemeMessagePreviewView.swift b/Session/Settings/Views/ThemeMessagePreviewView.swift index 62b00e1531..04cfb8fc30 100644 --- a/Session/Settings/Views/ThemeMessagePreviewView.swift +++ b/Session/Settings/Views/ThemeMessagePreviewView.swift @@ -23,8 +23,7 @@ final class ThemeMessagePreviewView: UIView { interactionId: -1, authorId: "", timestampMs: 0, - body: "appearancePreview1".localized(), - attachmentId: nil + body: "appearancePreview1".localized() ), cellType: .textOnlyMessage ), diff --git a/Session/Shared/BaseVC.swift b/Session/Shared/BaseVC.swift index 6ca1c9ee80..2e3d122f45 100644 --- a/Session/Shared/BaseVC.swift +++ b/Session/Shared/BaseVC.swift @@ -37,7 +37,6 @@ public class BaseVC: UIViewController { navigationItem.backButtonTitle = "" view.themeBackgroundColor = .backgroundPrimary - ThemeManager.applyNavigationStylingIfNeeded(to: self) setNeedsStatusBarAppearanceUpdate() } @@ -45,6 +44,9 @@ public class BaseVC: UIViewController { public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + /// Apply the nav styling in `viewWillAppear` instead of `viewDidLoad` as it's possible the nav stack isn't fully setup + /// and could crash when trying to access it (whereas by the time `viewWillAppear` is called it should be setup) + ThemeManager.applyNavigationStylingIfNeeded(to: self) onViewWillAppear?(self) } diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index 8b073df5e9..dc3aecf9d1 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -48,6 +48,8 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC private lazy var unreadCountLabel: UILabel = { let result: UILabel = UILabel() + result.setContentHuggingPriority(.required, for: .horizontal) + result.setContentCompressionResistancePriority(.required, for: .horizontal) result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) result.themeTextColor = .conversationButton_unreadBubbleText result.textAlignment = .center @@ -281,7 +283,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC profilePictureView.update( publicKey: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant, - displayPictureFilename: cellViewModel.displayPictureFilename, + displayPictureUrl: cellViewModel.threadDisplayPictureUrl, profile: cellViewModel.profile, additionalProfile: cellViewModel.additionalProfile, using: dependencies @@ -309,7 +311,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC profilePictureView.update( publicKey: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant, - displayPictureFilename: cellViewModel.displayPictureFilename, + displayPictureUrl: cellViewModel.threadDisplayPictureUrl, profile: cellViewModel.profile, additionalProfile: cellViewModel.additionalProfile, using: dependencies @@ -336,13 +338,11 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true), using: dependencies ), - authorName: (cellViewModel.authorId != cellViewModel.currentUserSessionId ? + authorName: (!(cellViewModel.currentUserSessionIds ?? []).contains(cellViewModel.authorId ?? "") ? cellViewModel.authorName(for: .contact) : nil ), - currentUserSessionId: cellViewModel.currentUserSessionId, - currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId, + currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), searchText: searchText.lowercased(), fontSize: Values.smallFontSize, textColor: .textPrimary, @@ -359,7 +359,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC profilePictureView.update( publicKey: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant, - displayPictureFilename: cellViewModel.displayPictureFilename, + displayPictureUrl: cellViewModel.threadDisplayPictureUrl, profile: cellViewModel.profile, additionalProfile: cellViewModel.additionalProfile, using: dependencies @@ -372,9 +372,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC timestampLabel.isHidden = true displayNameLabel.themeAttributedText = getHighlightedSnippet( content: cellViewModel.displayName, - currentUserSessionId: cellViewModel.currentUserSessionId, - currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId, + currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), searchText: searchText.lowercased(), fontSize: Values.mediumFontSize, textColor: .textPrimary, @@ -388,9 +386,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC bottomLabelStackView.isHidden = (cellViewModel.threadMemberNames ?? "").isEmpty snippetLabel.themeAttributedText = getHighlightedSnippet( content: (cellViewModel.threadMemberNames ?? ""), - currentUserSessionId: cellViewModel.currentUserSessionId, - currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId, + currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), searchText: searchText.lowercased(), fontSize: Values.smallFontSize, textColor: .textPrimary, @@ -438,7 +434,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC profilePictureView.update( publicKey: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant, - displayPictureFilename: cellViewModel.displayPictureFilename, + displayPictureUrl: cellViewModel.threadDisplayPictureUrl, profile: cellViewModel.profile, additionalProfile: cellViewModel.additionalProfile, using: dependencies @@ -631,9 +627,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC string: MentionUtilities.highlightMentionsNoAttributes( in: previewText, threadVariant: cellViewModel.threadVariant, - currentUserSessionId: cellViewModel.currentUserSessionId, - currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId, + currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), using: dependencies ), attributes: [ .themeForegroundColor: textColor ] @@ -645,9 +639,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC private func getHighlightedSnippet( content: String, authorName: String? = nil, - currentUserSessionId: String, - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String?, + currentUserSessionIds: Set, searchText: String, fontSize: CGFloat, textColor: ThemeValue, @@ -677,9 +669,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC let mentionReplacedContent: String = MentionUtilities.highlightMentionsNoAttributes( in: content, threadVariant: .contact, - currentUserSessionId: currentUserSessionId, - currentUserBlinded15SessionId: currentUserBlinded15SessionId, - currentUserBlinded25SessionId: currentUserBlinded25SessionId, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) let result: ThemedAttributedString = ThemedAttributedString( diff --git a/Session/Shared/ScreenLockWindow.swift b/Session/Shared/ScreenLockWindow.swift index e772c19882..0a2c4e9a9e 100644 --- a/Session/Shared/ScreenLockWindow.swift +++ b/Session/Shared/ScreenLockWindow.swift @@ -118,7 +118,7 @@ public class ScreenLockWindow { /// /// It's not safe to access `isScreenLockEnabled` in `storage` until the app is ready dependencies[singleton: .appReadiness].runNowOrWhenAppWillBecomeReady { [weak self, dependencies] in - self?.isScreenLockLocked = (dependencies[singleton: .storage, key: .isScreenLockEnabled] == true) + self?.isScreenLockLocked = dependencies.mutate(cache: .libSession, { $0.get(.isScreenLockEnabled) }) switch Thread.isMainThread { case true: self?.ensureUI() @@ -164,7 +164,7 @@ public class ScreenLockWindow { Log.verbose(.screenLock, "tryToActivateScreenLockUponBecomingActive NO 0") return } - guard dependencies[singleton: .storage, key: .isScreenLockEnabled] else { + guard dependencies.mutate(cache: .libSession, { $0.get(.isScreenLockEnabled) }) else { /// Screen lock is not enabled. Log.verbose(.screenLock, "tryToActivateScreenLockUponBecomingActive NO 1") return @@ -351,7 +351,7 @@ public class ScreenLockWindow { } DispatchQueue.global(qos: .background).async { [dependencies] in - self.isScreenLockLocked = (dependencies[singleton: .storage, key: .isScreenLockEnabled] == true) + self.isScreenLockLocked = dependencies.mutate(cache: .libSession, { $0.get(.playNotificationSoundInForeground) }) DispatchQueue.main.async { // NOTE: this notifications fires _before_ applicationDidBecomeActive, diff --git a/Session/Shared/SessionListViewModel.swift b/Session/Shared/SessionListViewModel.swift index ef063db091..d49dfbafbf 100644 --- a/Session/Shared/SessionListViewModel.swift +++ b/Session/Shared/SessionListViewModel.swift @@ -134,7 +134,7 @@ class SessionListViewModel: SessionTableViewModel, NavigationItemSo // MARK: - Content - lazy var observation: TargetObservation = ObservationBuilder + lazy var observation: TargetObservation = ObservationBuilderOld .subject(selectedOptionsSubject) .map { [weak self, options, behaviour] currentSelections -> [SectionModel] in return [ diff --git a/Session/Shared/SessionTableViewController.swift b/Session/Shared/SessionTableViewController.swift index 0e690f18b9..d1faf6b933 100644 --- a/Session/Shared/SessionTableViewController.swift +++ b/Session/Shared/SessionTableViewController.swift @@ -615,28 +615,20 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa return (!cell.leadingAccessoryView.isHidden ? cell.leadingAccessoryView : cell) case (_, is SessionCell.AccessoryConfig.HighlightingBackgroundLabelAndRadio): - guard let touchLocation: UITouch = touchLocation else { return cell } - - let localPoint: CGPoint = touchLocation.location(in: cell.trailingAccessoryView.highlightingBackgroundLabel) - guard - !cell.trailingAccessoryView.isHidden && - cell.trailingAccessoryView.highlightingBackgroundLabel.bounds.contains(localPoint) - else { return (!cell.trailingAccessoryView.isHidden ? cell.trailingAccessoryView : cell) } + let touchLocation: UITouch = touchLocation, + !cell.trailingAccessoryView.isHidden + else { return cell } - return cell.trailingAccessoryView.highlightingBackgroundLabel + return cell.trailingAccessoryView.touchedView(touchLocation) case (is SessionCell.AccessoryConfig.HighlightingBackgroundLabelAndRadio, _): - guard let touchLocation: UITouch = touchLocation else { return cell } - - let localPoint: CGPoint = touchLocation.location(in: cell.trailingAccessoryView.highlightingBackgroundLabel) - guard - !cell.leadingAccessoryView.isHidden && - cell.leadingAccessoryView.highlightingBackgroundLabel.bounds.contains(localPoint) - else { return (!cell.leadingAccessoryView.isHidden ? cell.leadingAccessoryView : cell) } + let touchLocation: UITouch = touchLocation, + !cell.leadingAccessoryView.isHidden + else { return cell } - return cell.leadingAccessoryView.highlightingBackgroundLabel + return cell.leadingAccessoryView.touchedView(touchLocation) default: return cell diff --git a/Session/Shared/Types/NavigatableState.swift b/Session/Shared/Types/NavigatableState.swift index 4bb8e5b590..89a7d3f895 100644 --- a/Session/Shared/Types/NavigatableState.swift +++ b/Session/Shared/Types/NavigatableState.swift @@ -122,37 +122,32 @@ public extension Publisher { return self.eraseToAnyPublisher() } - var modalActivityIndicator: ModalActivityIndicatorViewController? - - switch Thread.isMainThread { - case true: modalActivityIndicator = ModalActivityIndicatorViewController(onAppear: { _ in }) - case false: - DispatchQueue.main.sync { - modalActivityIndicator = ModalActivityIndicatorViewController(onAppear: { _ in }) + return Deferred { + Future { promise in + Task { @MainActor in + promise(.success(ModalActivityIndicatorViewController(onAppear: { _ in }))) } - + } } - - return self - .handleEvents( - receiveSubscription: { _ in - guard let indicator: ModalActivityIndicatorViewController = modalActivityIndicator else { - return + .flatMap { indicator -> AnyPublisher in + self + .handleEvents( + receiveSubscription: { _ in + navigatableState._transitionToScreen.send((indicator, .present)) } - - navigatableState._transitionToScreen.send((indicator, .present)) + ) + .asResult() + .flatMap { result -> AnyPublisher in + Deferred { + Future { resolver in + indicator.dismiss(completion: { + resolver(result) + }) + } + }.eraseToAnyPublisher() } - ) - .asResult() - .flatMap { result -> AnyPublisher in - Deferred { - Future { resolver in - modalActivityIndicator?.dismiss(completion: { - resolver(result) - }) - } - }.eraseToAnyPublisher() - } - .eraseToAnyPublisher() + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() } } diff --git a/Session/Shared/Types/ObservableTableSource.swift b/Session/Shared/Types/ObservableTableSource.swift index d6e18b9c1f..407e324b2e 100644 --- a/Session/Shared/Types/ObservableTableSource.swift +++ b/Session/Shared/Types/ObservableTableSource.swift @@ -4,6 +4,7 @@ import Foundation import GRDB import Combine import DifferenceKit +import SessionMessagingKit import SessionUtilitiesKit // MARK: - Log.Category @@ -40,7 +41,7 @@ extension ObservableTableSource { self.observableState.pendingTableDataSubject } public var observation: TargetObservation { - ObservationBuilder.subject(self.observableState.pendingTableDataSubject) + ObservationBuilderOld.subject(self.observableState.pendingTableDataSubject) } public var tableDataPublisher: TargetPublisher { self.observation.finalPublisher(self, using: dependencies) } @@ -148,9 +149,9 @@ extension TableObservation: ExpressibleByArrayLiteral where T: Collection { } } -// MARK: - ObservationBuilder +// MARK: - ObservationBuilderOld -public enum ObservationBuilder { +public enum ObservationBuilderOld { /// The `subject` will emit immediately when there is a subscriber and store the most recent value to be emitted whenever a new subscriber is /// added static func subject(_ subject: CurrentValueSubject) -> TableObservation { @@ -174,7 +175,7 @@ public enum ObservationBuilder { /// The `ValueObserveration` will trigger whenever any of the data fetched in the closure is updated, please see the following link for tips /// to help optimise performance https://github.com/groue/GRDB.swift#valueobservation-performance - static func databaseObservation(_ source: S, fetch: @escaping (Database) throws -> T) -> TableObservation { + static func databaseObservation(_ source: S, fetch: @escaping (ObservingDatabase) throws -> T) -> TableObservation { /// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`) /// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own /// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`) @@ -206,7 +207,9 @@ public enum ObservationBuilder { observationCancellable?.cancel() observationCancellable = dependencies[singleton: .storage].start( ValueObservation - .trackingConstantRegion(fetch) + .trackingConstantRegion { db in + try fetch(ObservingDatabase.create(db, using: dependencies)) + } .removeDuplicates(), scheduling: dependencies[singleton: .scheduler], onError: { error in @@ -233,7 +236,7 @@ public enum ObservationBuilder { } } - static func databaseObservation(_ source: S, fetch: @escaping (Database) throws -> [T]) -> TableObservation<[T]> { + static func databaseObservation(_ source: S, fetch: @escaping (ObservingDatabase) throws -> [T]) -> TableObservation<[T]> { return TableObservation { viewModel, dependencies in let subject: CurrentValueSubject<[T]?, Error> = CurrentValueSubject(nil) var forcedRefreshCancellable: AnyCancellable? @@ -261,7 +264,9 @@ public enum ObservationBuilder { observationCancellable?.cancel() observationCancellable = dependencies[singleton: .storage].start( ValueObservation - .trackingConstantRegion(fetch) + .trackingConstantRegion { db in + try fetch(ObservingDatabase.create(db, using: dependencies)) + } .removeDuplicates(), scheduling: dependencies[singleton: .scheduler], onError: { error in @@ -288,6 +293,23 @@ public enum ObservationBuilder { } } + static func databaseObservation(_ dependencies: Dependencies, fetch: @escaping (ObservingDatabase) throws -> T) -> AnyPublisher { + return ValueObservation + .trackingConstantRegion { db in + try fetch(ObservingDatabase.create(db, using: dependencies)) + } + .removeDuplicates() + .publisher(in: dependencies[singleton: .storage], scheduling: .immediate) + } + + static func databaseObservation(_ dependencies: Dependencies, fetch: @escaping (ObservingDatabase) throws -> T) -> ValueObservation>> { + return ValueObservation + .trackingConstantRegion { db in + try fetch(ObservingDatabase.create(db, using: dependencies)) + } + .removeDuplicates() + } + static func refreshableData(_ source: S, fetch: @escaping () -> T) -> TableObservation { return TableObservation { viewModel, dependencies in source.observableState.forcedRequery @@ -297,6 +319,122 @@ public enum ObservationBuilder { .manualRefreshFrom(source.observableState.forcedPostQueryRefresh) } } + + static func libSessionObservation( + _ source: S, + debounceInterval: DispatchTimeInterval = .milliseconds(10), + fetch: @escaping (ObservationCollector) throws -> T + ) -> TableObservation { + return TableObservation { viewModel, dependencies in + let subject: CurrentValueSubject = CurrentValueSubject(nil) + let stream: AsyncThrowingStream = ObservationBuilderOld.libSessionObservation( + dependencies, + debounceInterval: debounceInterval, + fetch: fetch + ) + + let mainObservationTask = Task(priority: .userInitiated) { + do { + for try await value in stream { + if Task.isCancelled { break } + subject.send(value) + } + + if !Task.isCancelled { + subject.send(completion: .finished) + } + } + catch is CancellationError { subject.send(completion: .finished) } + catch { subject.send(completion: .failure(error)) } + } + + return subject + .compactMap { $0 } + .handleEvents( + receiveCancel: { + mainObservationTask.cancel() + } + ) + .manualRefreshFrom(source.observableState.forcedPostQueryRefresh) + .shareReplay(1) /// Share to prevent multiple subscribers resulting in multiple ValueObservations + .eraseToAnyPublisher() + } + } + + static func libSessionObservation( + _ dependencies: Dependencies, + debounceInterval: DispatchTimeInterval = .milliseconds(10), + fetch: @escaping (ObservationCollector) throws -> T + ) -> AsyncThrowingStream { + return AsyncThrowingStream { continuation in + let debouncer: DebounceTaskManager = DebounceTaskManager(debounceInterval: debounceInterval) + let taskManager: MultiTaskManager = MultiTaskManager() + + let mainObservationTask = Task(priority: .userInitiated) { + let initialInfo: (initialValue: T, keys: Set) + + do { + /// Retrieve the initial value and they keys to observe + initialInfo = try dependencies.mutate(cache: .libSession) { cache in + let collector: ObservationCollector = ObservationCollector(store: cache) + let value: T = try fetch(collector) + + return (value, collector.collectedKeys) + } + guard !Task.isCancelled else { throw CancellationError() } + continuation.yield(initialInfo.initialValue) + + } + catch { + continuation.finish(throwing: error) + await debouncer.reset() + await taskManager.cancelAll() + return + } + + /// After debouncing we want to re-fetch the data + await debouncer.setAction { _ in + do { + let newValue: T = try dependencies.mutate(cache: .libSession) { cache in + try fetch(ObservationCollector(store: cache)) + } + + guard !Task.isCancelled else { throw CancellationError() } + continuation.yield(newValue) + } + catch { continuation.finish(throwing: error) } + } + + /// Start observing the streams for updates + var streams: [AsyncStream] = [] + + for key in initialInfo.keys { + streams.append(await dependencies[singleton: .observationManager].observe(key)) + } + + for stream in streams { + guard !Task.isCancelled else { break } + + let keyTask = Task { + for await event in stream { + guard !Task.isCancelled else { break } + await debouncer.signal(event: event) + } + } + await taskManager.add(keyTask) + } + } + + continuation.onTermination = { @Sendable _ in + mainObservationTask.cancel() + + Task { + await debouncer.reset() + await taskManager.cancelAll() + } + } + } + } } // MARK: - Convenience Transforms @@ -360,3 +498,65 @@ public extension Publisher { .eraseToAnyPublisher() } } + +// MARK: - ObservationCollector + +public class ObservationCollector: ValueFetcher { + private let store: ValueFetcher + private(set) var collectedKeys: Set = [] + + public var userSessionId: SessionId { store.userSessionId } + + init(store: ValueFetcher) { + self.store = store + } + + func register(_ key: ObservableKey) { + collectedKeys.insert(key) + } + + func register(_ key: Setting.BoolKey) { + collectedKeys.insert(.setting(key)) + } + + func register(_ key: Setting.EnumKey) { + collectedKeys.insert(.setting(key)) + } + + // MARK: - ValueFetcher + + public func has(_ key: Setting.BoolKey) -> Bool { + collectedKeys.insert(.setting(key)) + return store.has(key) + } + + public func has(_ key: Setting.EnumKey) -> Bool { + collectedKeys.insert(.setting(key)) + return store.has(key) + } + + public func get(_ key: Setting.BoolKey) -> Bool { + collectedKeys.insert(.setting(key)) + return store.get(key) + } + + public func get(_ key: Setting.EnumKey) -> T? { + collectedKeys.insert(.setting(key)) + return store.get(key) + } + + public func profile( + contactId: String, + threadId: String?, + threadVariant: SessionThread.Variant?, + visibleMessage: VisibleMessage? + ) -> Profile? { + collectedKeys.insert(.profile(contactId)) + return store.profile( + contactId: contactId, + threadId: threadId, + threadVariant: threadVariant, + visibleMessage: visibleMessage + ) + } +} diff --git a/Session/Shared/Types/SessionCell+Accessory.swift b/Session/Shared/Types/SessionCell+Accessory.swift index 2cc1c1b6fe..277f50416d 100644 --- a/Session/Shared/Types/SessionCell+Accessory.swift +++ b/Session/Shared/Types/SessionCell+Accessory.swift @@ -11,6 +11,10 @@ public extension SessionCell { enum AccessoryConfig {} class Accessory: Hashable, Equatable { + open var viewIdentifier: String { + fatalError("Subclasses of Accessory must provide a viewIdentifier.") + } + public let accessibility: Accessibility? public var shouldFitToEdge: Bool { false } public var currentBoolValue: Bool { false } @@ -67,16 +71,16 @@ public extension SessionCell.Accessory { static func iconAsync( size: IconSize = .medium, + source: ImageDataManager.DataSource?, customTint: ThemeValue? = nil, shouldFill: Bool = false, - accessibility: Accessibility? = nil, - setter: @escaping (UIImageView) -> Void + accessibility: Accessibility? = nil ) -> SessionCell.Accessory { return SessionCell.AccessoryConfig.IconAsync( iconSize: size, + source: source, customTint: customTint, shouldFill: shouldFill, - setter: setter, accessibility: accessibility ) } @@ -153,7 +157,7 @@ public extension SessionCell.Accessory { id: String, size: ProfilePictureView.Size = .list, threadVariant: SessionThread.Variant = .contact, - displayPictureFilename: String? = nil, + displayPictureUrl: String? = nil, profile: Profile? = nil, profileIcon: ProfilePictureView.ProfileIcon = .none, additionalProfile: Profile? = nil, @@ -164,7 +168,7 @@ public extension SessionCell.Accessory { id: id, size: size, threadVariant: threadVariant, - displayPictureFilename: displayPictureFilename, + displayPictureUrl: displayPictureUrl, profile: profile, profileIcon: profileIcon, additionalProfile: additionalProfile, @@ -212,10 +216,15 @@ public extension SessionCell.Accessory { // MARK: Structs +// stringlint:ignore_contents public extension SessionCell.AccessoryConfig { // MARK: - Icon class Icon: SessionCell.Accessory { + override public var viewIdentifier: String { + "icon-\(iconSize.size)\(shouldFill ? "-fill" : "")" + } + public let icon: Lucide.Icon? public let image: UIImage? public let iconSize: IconSize @@ -267,22 +276,24 @@ public extension SessionCell.AccessoryConfig { // MARK: - IconAsync class IconAsync: SessionCell.Accessory { + override public var viewIdentifier: String { "iconAsync" } + public let iconSize: IconSize + public let source: ImageDataManager.DataSource? public let customTint: ThemeValue? public let shouldFill: Bool - public let setter: (UIImageView) -> Void fileprivate init( iconSize: IconSize, + source: ImageDataManager.DataSource?, customTint: ThemeValue?, shouldFill: Bool, - setter: @escaping (UIImageView) -> Void, accessibility: Accessibility? ) { self.iconSize = iconSize + self.source = source self.customTint = customTint self.shouldFill = shouldFill - self.setter = setter super.init(accessibility: accessibility) } @@ -291,6 +302,7 @@ public extension SessionCell.AccessoryConfig { override public func hash(into hasher: inout Hasher) { iconSize.hash(into: &hasher) + source?.hash(into: &hasher) customTint.hash(into: &hasher) shouldFill.hash(into: &hasher) accessibility.hash(into: &hasher) @@ -301,6 +313,7 @@ public extension SessionCell.AccessoryConfig { return ( iconSize == rhs.iconSize && + source == rhs.source && customTint == rhs.customTint && shouldFill == rhs.shouldFill && accessibility == rhs.accessibility @@ -311,6 +324,8 @@ public extension SessionCell.AccessoryConfig { // MARK: - Toggle class Toggle: SessionCell.Accessory { + override public var viewIdentifier: String { "toggle" } + public let value: Bool public let oldValue: Bool @@ -349,6 +364,8 @@ public extension SessionCell.AccessoryConfig { // MARK: - DropDown class DropDown: SessionCell.Accessory { + override public var viewIdentifier: String { "dropDown" } + public let dynamicString: () -> String? fileprivate init( @@ -380,6 +397,8 @@ public extension SessionCell.AccessoryConfig { // MARK: - Radio class Radio: SessionCell.Accessory { + override public var viewIdentifier: String { "radio-\(size.selectionSize)" } + public enum Size: Hashable, Equatable { case small case medium @@ -445,6 +464,8 @@ public extension SessionCell.AccessoryConfig { // MARK: - HighlightingBackgroundLabel class HighlightingBackgroundLabel: SessionCell.Accessory { + override public var viewIdentifier: String { "highlightingBackgroundLabel" } + public let title: String init( @@ -478,6 +499,10 @@ public extension SessionCell.AccessoryConfig { // MARK: - HighlightingBackgroundLabelAndRadio class HighlightingBackgroundLabelAndRadio: SessionCell.Accessory { + override public var viewIdentifier: String { + "highlightingBackgroundLabelAndRadio-\(size.selectionSize)" + } + public enum Size: Hashable, Equatable { case small case medium @@ -553,10 +578,12 @@ public extension SessionCell.AccessoryConfig { // MARK: - DisplayPicture class DisplayPicture: SessionCell.Accessory { + override public var viewIdentifier: String { "displayPicture-\(size.viewSize)" } + public let id: String public let size: ProfilePictureView.Size public let threadVariant: SessionThread.Variant - public let displayPictureFilename: String? + public let displayPictureUrl: String? public let profile: Profile? public let profileIcon: ProfilePictureView.ProfileIcon public let additionalProfile: Profile? @@ -566,7 +593,7 @@ public extension SessionCell.AccessoryConfig { id: String, size: ProfilePictureView.Size, threadVariant: SessionThread.Variant, - displayPictureFilename: String?, + displayPictureUrl: String?, profile: Profile?, profileIcon: ProfilePictureView.ProfileIcon, additionalProfile: Profile?, @@ -576,7 +603,7 @@ public extension SessionCell.AccessoryConfig { self.id = id self.size = size self.threadVariant = threadVariant - self.displayPictureFilename = displayPictureFilename + self.displayPictureUrl = displayPictureUrl self.profile = profile self.profileIcon = profileIcon self.additionalProfile = additionalProfile @@ -591,7 +618,7 @@ public extension SessionCell.AccessoryConfig { id.hash(into: &hasher) size.hash(into: &hasher) threadVariant.hash(into: &hasher) - displayPictureFilename.hash(into: &hasher) + displayPictureUrl.hash(into: &hasher) profile.hash(into: &hasher) profileIcon.hash(into: &hasher) additionalProfile.hash(into: &hasher) @@ -606,7 +633,7 @@ public extension SessionCell.AccessoryConfig { id == rhs.id && size == rhs.size && threadVariant == rhs.threadVariant && - displayPictureFilename == rhs.displayPictureFilename && + displayPictureUrl == rhs.displayPictureUrl && profile == rhs.profile && profileIcon == rhs.profileIcon && additionalProfile == rhs.additionalProfile && @@ -617,6 +644,8 @@ public extension SessionCell.AccessoryConfig { } class Search: SessionCell.Accessory { + override public var viewIdentifier: String { "search" } + public let placeholder: String public let searchTermChanged: (String?) -> Void @@ -648,6 +677,8 @@ public extension SessionCell.AccessoryConfig { } class Button: SessionCell.Accessory { + override public var viewIdentifier: String { "button" } + public let style: SessionButton.Style public let title: String public let run: (SessionButton?) -> Void @@ -684,6 +715,8 @@ public extension SessionCell.AccessoryConfig { } class Custom: SessionCell.Accessory, AnyCustom { + override public var viewIdentifier: String { "custom" } + public let info: T fileprivate init( diff --git a/Session/Shared/UserListViewModel.swift b/Session/Shared/UserListViewModel.swift index 017e925e19..31739016fb 100644 --- a/Session/Shared/UserListViewModel.swift +++ b/Session/Shared/UserListViewModel.swift @@ -22,6 +22,7 @@ class UserListViewModel: SessionTableVie public let emptyState: String? private let showProfileIcons: Bool private let request: (any FetchRequest) + private let customSorter: ((WithProfile, WithProfile) -> Bool)? private let footerTitle: String? private let footerAccessibility: Accessibility? private let onTapAction: OnTapAction @@ -35,6 +36,7 @@ class UserListViewModel: SessionTableVie emptyState: String? = nil, showProfileIcons: Bool, request: (any FetchRequest), + customSorter: ((WithProfile, WithProfile) -> Bool)? = nil, footerTitle: String? = nil, footerAccessibility: Accessibility? = nil, onTap: OnTapAction = .radio, @@ -48,6 +50,7 @@ class UserListViewModel: SessionTableVie self.emptyState = emptyState self.showProfileIcons = showProfileIcons self.request = request + self.customSorter = customSorter self.footerTitle = footerTitle self.footerAccessibility = footerAccessibility self.onTapAction = onTap @@ -90,103 +93,104 @@ class UserListViewModel: SessionTableVie var bannerInfo: AnyPublisher { Just(infoBanner).eraseToAnyPublisher() } var emptyStateTextPublisher: AnyPublisher { Just(emptyState).eraseToAnyPublisher() } - lazy var observation: TargetObservation = ObservationBuilder + lazy var observation: TargetObservation = ObservationBuilderOld .databaseObservation(self) { [request, dependencies] db -> [WithProfile] in try request.fetchAllWithProfiles(db, using: dependencies) } - .map { [weak self, dependencies, showProfileIcons, onTapAction, selectedUsersSubject] (users: [WithProfile]) -> [SectionModel] in + .map { [weak self, dependencies, showProfileIcons, onTapAction, selectedUsersSubject, customSorter] (users: [WithProfile]) -> [SectionModel] in let userSessionId: SessionId = dependencies[cache: .general].sessionId + let sortedUsers: [WithProfile] = customSorter + .map { compare in users.sorted(by: { lhs, rhs in compare(lhs, rhs) }) } + .defaulting(to: users.sorted()) return [ SectionModel( model: .users, - elements: users - .sorted() - .map { userInfo -> SessionCell.Info in - func finalAction(for action: OnTapAction) -> OnTapAction { - switch action { - case .conditionalAction(let targetAction): - return finalAction(for: targetAction(userInfo)) - - default: return action - } + elements: sortedUsers.map { userInfo -> SessionCell.Info in + func finalAction(for action: OnTapAction) -> OnTapAction { + switch action { + case .conditionalAction(let targetAction): + return finalAction(for: targetAction(userInfo)) + + default: return action } - func generateAccessory(_ action: OnTapAction) -> SessionCell.Accessory? { - switch action { - case .none, .callback: return nil - case .custom(let accessoryGenerator, _): return accessoryGenerator(userInfo) - case .conditionalAction(let targetAction): - return generateAccessory(targetAction(userInfo)) - - case .radio: - return .radio( - isSelected: selectedUsersSubject.value.contains(where: { selectedUserInfo in - selectedUserInfo.profileId == userInfo.profileId - }) - ) - } + } + func generateAccessory(_ action: OnTapAction) -> SessionCell.Accessory? { + switch action { + case .none, .callback: return nil + case .custom(let accessoryGenerator, _): return accessoryGenerator(userInfo) + case .conditionalAction(let targetAction): + return generateAccessory(targetAction(userInfo)) + + case .radio: + return .radio( + isSelected: selectedUsersSubject.value.contains(where: { selectedUserInfo in + selectedUserInfo.profileId == userInfo.profileId + }) + ) } + } + + let finalAction: OnTapAction = finalAction(for: onTapAction) + let trailingAccessory: SessionCell.Accessory? = generateAccessory(finalAction) + let title: String = { + guard userInfo.profileId != userSessionId.hexString else { return "you".localized() } - let finalAction: OnTapAction = finalAction(for: onTapAction) - let trailingAccessory: SessionCell.Accessory? = generateAccessory(finalAction) - let title: String = { - guard userInfo.profileId != userSessionId.hexString else { return "you".localized() } - - return ( - userInfo.profile?.displayName() ?? - Profile.truncated(id: userInfo.profileId, truncating: .middle) - ) - }() - - return SessionCell.Info( - id: .user(userInfo.profileId), - leadingAccessory: .profile( - id: userInfo.profileId, - profile: userInfo.profile, - profileIcon: (showProfileIcons ? userInfo.value.profileIcon : .none) - ), - title: title, - subtitle: userInfo.itemDescription(using: dependencies), - trailingAccessory: trailingAccessory, - styling: SessionCell.StyleInfo( - subtitleTintColor: userInfo.itemDescriptionColor(using: dependencies), - allowedSeparators: [], - customPadding: SessionCell.Padding( - top: Values.smallSpacing, - bottom: Values.smallSpacing - ), - backgroundStyle: .noBackgroundEdgeToEdge - ), - accessibility: Accessibility( - identifier: "Contact", - label: title + return ( + userInfo.profile?.displayName() ?? + Profile.truncated(id: userInfo.profileId, truncating: .middle) + ) + }() + + return SessionCell.Info( + id: .user(userInfo.profileId), + leadingAccessory: .profile( + id: userInfo.profileId, + profile: userInfo.profile, + profileIcon: (showProfileIcons ? userInfo.value.profileIcon : .none) + ), + title: title, + subtitle: userInfo.itemDescription(using: dependencies), + trailingAccessory: trailingAccessory, + styling: SessionCell.StyleInfo( + subtitleTintColor: userInfo.itemDescriptionColor(using: dependencies), + allowedSeparators: [], + customPadding: SessionCell.Padding( + top: Values.smallSpacing, + bottom: Values.smallSpacing ), - onTap: { - // Trigger any 'onTap' actions - switch finalAction { - case .none: return - case .callback(let callback): callback(self, userInfo) - case .custom(_, let callback): callback(self, userInfo) - case .radio: break - case .conditionalAction(_): return // Shouldn't hit this case - } - - // Only update the selection if the accessory is a 'radio' - guard trailingAccessory is SessionCell.AccessoryConfig.Radio else { return } - - // Toggle the selection - if !selectedUsersSubject.value.contains(userInfo) { - selectedUsersSubject.send(selectedUsersSubject.value.inserting(userInfo)) - } - else { - selectedUsersSubject.send(selectedUsersSubject.value.removing(userInfo)) - } - - // Force the table data to be refreshed (the database wouldn't have been changed) - self?.forceRefresh(type: .postDatabaseQuery) + backgroundStyle: .noBackgroundEdgeToEdge + ), + accessibility: Accessibility( + identifier: "Contact", + label: title + ), + onTap: { + // Trigger any 'onTap' actions + switch finalAction { + case .none: return + case .callback(let callback): callback(self, userInfo) + case .custom(_, let callback): callback(self, userInfo) + case .radio: break + case .conditionalAction(_): return // Shouldn't hit this case } - ) - } + + // Only update the selection if the accessory is a 'radio' + guard trailingAccessory is SessionCell.AccessoryConfig.Radio else { return } + + // Toggle the selection + if !selectedUsersSubject.value.contains(userInfo) { + selectedUsersSubject.send(selectedUsersSubject.value.inserting(userInfo)) + } + else { + selectedUsersSubject.send(selectedUsersSubject.value.removing(userInfo)) + } + + // Force the table data to be refreshed (the database wouldn't have been changed) + self?.forceRefresh(type: .postDatabaseQuery) + } + ) + } ) ] } @@ -208,7 +212,7 @@ class UserListViewModel: SessionTableVie // MARK: - Functions - private func submit(with selectedUsers: Set>) { + @MainActor private func submit(with selectedUsers: Set>) { switch onSubmitAction { case .none: return diff --git a/Session/Shared/Views/SessionCell+AccessoryView.swift b/Session/Shared/Views/SessionCell+AccessoryView.swift index cc1c02acac..0fb0059551 100644 --- a/Session/Shared/Views/SessionCell+AccessoryView.swift +++ b/Session/Shared/Views/SessionCell+AccessoryView.swift @@ -16,638 +16,745 @@ extension SessionCell { private var onTap: ((SessionButton?) -> Void)? private var searchTermChanged: ((String?) -> Void)? - // MARK: - UI + private var currentContentView: UIView? + private var currentAccessoryIdentifier: String? private lazy var minWidthConstraint: NSLayoutConstraint = self.widthAnchor .constraint(greaterThanOrEqualToConstant: AccessoryView.minWidth) private lazy var fixedWidthConstraint: NSLayoutConstraint = self.set(.width, to: AccessoryView.minWidth) - private lazy var imageViewConstraints: [NSLayoutConstraint] = [ - imageView.pin(.top, to: .top, of: self), - imageView.pin(.bottom, to: .bottom, of: self) - ] - private lazy var imageViewLeadingConstraint: NSLayoutConstraint = imageView.pin(.leading, to: .leading, of: self) - private lazy var imageViewTrailingConstraint: NSLayoutConstraint = imageView.pin(.trailing, to: .trailing, of: self) - private lazy var imageViewWidthConstraint: NSLayoutConstraint = imageView.set(.width, to: 0) - private lazy var imageViewHeightConstraint: NSLayoutConstraint = imageView.set(.height, to: 0) - private lazy var toggleSwitchConstraints: [NSLayoutConstraint] = [ - toggleSwitch.pin(.top, to: .top, of: self), - toggleSwitch.pin(.leading, to: .leading, of: self), - toggleSwitch.pin(.trailing, to: .trailing, of: self), - toggleSwitch.pin(.bottom, to: .bottom, of: self) - ] - private lazy var dropDownStackViewConstraints: [NSLayoutConstraint] = [ - dropDownStackView.pin(.top, to: .top, of: self), - dropDownStackView.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing), - dropDownStackView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing), - dropDownStackView.pin(.bottom, to: .bottom, of: self) - ] - private lazy var radioViewWidthConstraint: NSLayoutConstraint = radioView.set(.width, to: 0) - private lazy var radioViewHeightConstraint: NSLayoutConstraint = radioView.set(.height, to: 0) - private lazy var radioBorderViewWidthConstraint: NSLayoutConstraint = radioBorderView.set(.width, to: 0) - private lazy var radioBorderViewHeightConstraint: NSLayoutConstraint = radioBorderView.set(.height, to: 0) - private lazy var radioBorderViewConstraints: [NSLayoutConstraint] = [ - radioBorderView.pin(.top, to: .top, of: self), - radioBorderView.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing), - radioBorderView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing), - radioBorderView.pin(.bottom, to: .bottom, of: self) - ] - private lazy var highlightingBackgroundLabelConstraints: [NSLayoutConstraint] = [ - highlightingBackgroundLabel.pin(.top, to: .top, of: self), - highlightingBackgroundLabel.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing), - highlightingBackgroundLabel.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing), - highlightingBackgroundLabel.pin(.bottom, to: .bottom, of: self) - ] - private lazy var highlightingBackgroundLabelAndRadioConstraints: [NSLayoutConstraint] = [ - highlightingBackgroundLabel.pin(.top, to: .top, of: self), - highlightingBackgroundLabel.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing), - highlightingBackgroundLabel.pin(.trailing, to: .leading, of: radioBorderView, withInset: -Values.smallSpacing), - highlightingBackgroundLabel.pin(.bottom, to: .bottom, of: self), - radioBorderView.center(.vertical, in: self), - radioBorderView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing), - ] - private lazy var profilePictureViewConstraints: [NSLayoutConstraint] = [ - profilePictureView.pin(.top, to: .top, of: self), - profilePictureView.pin(.leading, to: .leading, of: self), - profilePictureView.pin(.trailing, to: .trailing, of: self), - profilePictureView.pin(.bottom, to: .bottom, of: self) - ] - private lazy var searchBarConstraints: [NSLayoutConstraint] = [ - searchBar.pin(.top, to: .top, of: self), - searchBar.pin(.leading, to: .leading, of: self, withInset: -8), // Removing default inset - searchBar.pin(.trailing, to: .trailing, of: self, withInset: 8), // Removing default inset - searchBar.pin(.bottom, to: .bottom, of: self) - ] - private lazy var buttonConstraints: [NSLayoutConstraint] = [ - button.pin(.top, to: .top, of: self), - button.pin(.leading, to: .leading, of: self), - button.pin(.trailing, to: .trailing, of: self), - button.pin(.bottom, to: .bottom, of: self) - ] - - private let imageView: UIImageView = { - let result: UIImageView = UIImageView() + + // MARK: - Content + + func prepareForReuse() { + isHidden = true + onTap = nil + searchTermChanged = nil + currentContentView?.removeFromSuperview() + currentContentView = nil + currentAccessoryIdentifier = nil + + minWidthConstraint.constant = AccessoryView.minWidth + minWidthConstraint.isActive = false + fixedWidthConstraint.constant = AccessoryView.minWidth + fixedWidthConstraint.isActive = false + } + + public func update( + with accessory: Accessory?, + tintColor: ThemeValue, + isEnabled: Bool, + maxContentWidth: CGFloat, + isManualReload: Bool, + using dependencies: Dependencies + ) { + guard let accessory: Accessory = accessory else { return } + + /// If the identifier hasn't changed then no need to reconstruct the content + guard accessory.viewIdentifier != currentAccessoryIdentifier else { + configure( + view: currentContentView, + accessory: accessory, + tintColor: tintColor, + isEnabled: isEnabled, + maxContentWidth: maxContentWidth, + isManualReload: isManualReload, + using: dependencies + ) + return + } + + /// Otherwise we do need to reconstruct and layout the content + prepareForReuse() + self.isHidden = false + + let maybeView: UIView? = createView( + accessory: accessory, + maxContentWidth: maxContentWidth, + using: dependencies + ) + + if let newView: UIView = maybeView { + addSubview(newView) + newView.pin(to: self) + layout(view: newView, accessory: accessory) + } + + configure( + view: maybeView, + accessory: accessory, + tintColor: tintColor, + isEnabled: isEnabled, + maxContentWidth: maxContentWidth, + isManualReload: isManualReload, + using: dependencies + ) + + currentContentView = maybeView + currentAccessoryIdentifier = accessory.viewIdentifier + } + + // MARK: - Interaction + + func touchedView(_ touch: UITouch) -> UIView { + switch (currentContentView, currentContentView?.subviews.first) { + case (let label as SessionHighlightingBackgroundLabel, _), + (_, let label as SessionHighlightingBackgroundLabel): + let localPoint: CGPoint = touch.location(in: label) + + return (label.bounds.contains(localPoint) ? label : self) + + default: return self + } + } + + func setHighlighted(_ highlighted: Bool, animated: Bool) { + switch (currentContentView, currentContentView?.subviews.first) { + case (let label as SessionHighlightingBackgroundLabel, _), + (_, let label as SessionHighlightingBackgroundLabel): + label.setHighlighted(highlighted, animated: animated) + + default: break + } + } + + func setSelected(_ selected: Bool, animated: Bool) { + switch (currentContentView, currentContentView?.subviews.first) { + case (let label as SessionHighlightingBackgroundLabel, _), + (_, let label as SessionHighlightingBackgroundLabel): + label.setSelected(selected, animated: animated) + + default: break + } + } + + @objc private func buttonTapped() { + guard let button: SessionButton = currentContentView as? SessionButton else { return } + + onTap?(button) + } + + // MARK: - UISearchBarDelegate + + public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + searchTermChanged?(searchText) + } + + public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + searchBar.setShowsCancelButton(true, animated: true) + } + + public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { + searchBar.setShowsCancelButton(false, animated: true) + } + + public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + searchBar.endEditing(true) + } + + // MARK: - View Construction + + private func createView( + accessory: Accessory, + maxContentWidth: CGFloat, + using dependencies: Dependencies + ) -> UIView? { + switch accessory { + case is SessionCell.AccessoryConfig.Icon: + return createIconView(using: dependencies) + + case is SessionCell.AccessoryConfig.IconAsync: + return createIconView(using: dependencies) + + case is SessionCell.AccessoryConfig.Toggle: return createToggleView() + case is SessionCell.AccessoryConfig.DropDown: return createDropDownView() + case is SessionCell.AccessoryConfig.Radio: return createRadioView() + + case is SessionCell.AccessoryConfig.HighlightingBackgroundLabel: + return createHighlightingBackgroundLabelView() + + case is SessionCell.AccessoryConfig.HighlightingBackgroundLabelAndRadio: + return createHighlightingBackgroundLabelAndRadioView() + + case is SessionCell.AccessoryConfig.DisplayPicture: return createDisplayPictureView() + case is SessionCell.AccessoryConfig.Search: return createSearchView() + + case is SessionCell.AccessoryConfig.Button: return createButtonView() + case let accessory as SessionCell.AccessoryConfig.AnyCustom: + return accessory.createView( + maxContentWidth: maxContentWidth, + using: dependencies + ) + + default: + /// If we get an unknown case then just hide again + self.isHidden = true + return nil + } + } + + private func layout(view: UIView?, accessory: Accessory) { + switch accessory { + case let accessory as SessionCell.AccessoryConfig.Icon: + layoutIconView(view, iconSize: accessory.iconSize, shouldFill: accessory.shouldFill) + + case let accessory as SessionCell.AccessoryConfig.IconAsync: + layoutIconView(view, iconSize: accessory.iconSize, shouldFill: accessory.shouldFill) + + case is SessionCell.AccessoryConfig.Toggle: layoutToggleView(view) + case is SessionCell.AccessoryConfig.DropDown: layoutDropDownView(view) + case let accessory as SessionCell.AccessoryConfig.Radio: + layoutRadioView(view, size: accessory.size) + + case is SessionCell.AccessoryConfig.HighlightingBackgroundLabel: + layoutHighlightingBackgroundLabelView(view) + + case let accessory as SessionCell.AccessoryConfig.HighlightingBackgroundLabelAndRadio: + layoutHighlightingBackgroundLabelAndRadioView(view, size: accessory.size) + + case let accessory as SessionCell.AccessoryConfig.DisplayPicture: + layoutDisplayPictureView(view, size: accessory.size) + + case is SessionCell.AccessoryConfig.Search: layoutSearchView(view) + + case is SessionCell.AccessoryConfig.Button: layoutButtonView(view) + case is SessionCell.AccessoryConfig.AnyCustom: layoutCustomView(view) + + // If we get an unknown case then just hide again + default: self.isHidden = true + } + } + + private func configure( + view: UIView?, + accessory: Accessory, + tintColor: ThemeValue, + isEnabled: Bool, + maxContentWidth: CGFloat, + isManualReload: Bool, + using dependencies: Dependencies + ) { + switch accessory { + case let accessory as SessionCell.AccessoryConfig.Icon: + configureIconView(view, accessory, tintColor: tintColor) + + case let accessory as SessionCell.AccessoryConfig.IconAsync: + configureIconView(view, accessory, tintColor: tintColor) + + case let accessory as SessionCell.AccessoryConfig.Toggle: + configureToggleView( + view, + accessory, + isEnabled: isEnabled, + isManualReload: isManualReload + ) + + case let accessory as SessionCell.AccessoryConfig.DropDown: + configureDropDown(view, accessory) + + case let accessory as SessionCell.AccessoryConfig.Radio: + configureRadioView(view, accessory, isEnabled: isEnabled) + + case let accessory as SessionCell.AccessoryConfig.HighlightingBackgroundLabel: + configureHighlightingBackgroundLabelView(view, accessory, tintColor: tintColor) + + case let accessory as SessionCell.AccessoryConfig.HighlightingBackgroundLabelAndRadio: + configureHighlightingBackgroundLabelAndRadioView( + view, + accessory, + tintColor: tintColor, + isEnabled: isEnabled + ) + + case let accessory as SessionCell.AccessoryConfig.DisplayPicture: + configureDisplayPictureView(view, accessory, using: dependencies) + + case let accessory as SessionCell.AccessoryConfig.Search: + configureSearchView(view, accessory) + + case let accessory as SessionCell.AccessoryConfig.Button: + configureButtonView(view, accessory) + + case let accessory as SessionCell.AccessoryConfig.AnyCustom: + configureCustomView(view, accessory) + + // If we get an unknown case then just hide again + default: self.isHidden = true + } + } + + // MARK: -- Icon + + private func createIconView(using dependencies: Dependencies) -> SessionImageView { + let result: SessionImageView = SessionImageView( + dataManager: dependencies[singleton: .imageDataManager] + ) result.translatesAutoresizingMaskIntoConstraints = false result.clipsToBounds = true - result.contentMode = .scaleAspectFit - result.themeTintColor = .textPrimary result.layer.minificationFilter = .trilinear result.layer.magnificationFilter = .trilinear - result.isHidden = true return result - }() + } + + private func layoutIconView(_ view: UIView?, iconSize: IconSize, shouldFill: Bool) { + guard let imageView: SessionImageView = view as? SessionImageView else { return } + + imageView.set(.width, to: iconSize.size) + imageView.set(.height, to: iconSize.size) + imageView.pin(.leading, to: .leading, of: self, withInset: (shouldFill ? 0 : Values.smallSpacing)) + imageView.pin(.trailing, to: .trailing, of: self, withInset: (shouldFill ? 0 : -Values.smallSpacing)) + fixedWidthConstraint.isActive = (iconSize.size <= fixedWidthConstraint.constant) + minWidthConstraint.isActive = !fixedWidthConstraint.isActive + } + + private func configureIconView(_ view: UIView?, _ accessory: SessionCell.AccessoryConfig.Icon, tintColor: ThemeValue) { + guard let imageView: SessionImageView = view as? SessionImageView else { return } + + imageView.accessibilityIdentifier = accessory.accessibility?.identifier + imageView.accessibilityLabel = accessory.accessibility?.label + imageView.themeTintColor = (accessory.customTint ?? tintColor) + imageView.contentMode = (accessory.shouldFill ? .scaleAspectFill : .scaleAspectFit) + + switch (accessory.icon, accessory.image) { + case (.some(let icon), _): + imageView.image = Lucide + .image(icon: icon, size: accessory.iconSize.size)? + .withRenderingMode(.alwaysTemplate) + + case (.none, .some(let image)): imageView.image = image + case (.none, .none): imageView.image = nil + } + } + + // MARK: -- IconAsync + + private func configureIconView(_ view: UIView?, _ accessory: SessionCell.AccessoryConfig.IconAsync, tintColor: ThemeValue) { + guard let imageView: SessionImageView = view as? SessionImageView else { return } + + imageView.accessibilityIdentifier = accessory.accessibility?.identifier + imageView.accessibilityLabel = accessory.accessibility?.label + imageView.themeTintColor = (accessory.customTint ?? tintColor) + imageView.contentMode = (accessory.shouldFill ? .scaleAspectFill : .scaleAspectFit) + + switch accessory.source { + case .none: imageView.image = nil + case .some(let source): imageView.loadImage(source) + } + } + + // MARK: -- Toggle - private let toggleSwitch: UISwitch = { + private func createToggleView() -> UISwitch { let result: UISwitch = UISwitch() result.translatesAutoresizingMaskIntoConstraints = false result.isUserInteractionEnabled = false // Triggered by didSelectCell instead result.themeOnTintColor = .primary - result.isHidden = true result.setContentHugging(to: .required) result.setCompressionResistance(to: .required) return result - }() + } + + private func layoutToggleView(_ view: UIView?) { + guard let toggleSwitch: UISwitch = view as? UISwitch else { return } + + toggleSwitch.pin(to: self) + fixedWidthConstraint.isActive = true + } - private let dropDownStackView: UIStackView = { + private func configureToggleView( + _ view: UIView?, + _ accessory: SessionCell.AccessoryConfig.Toggle, + isEnabled: Bool, + isManualReload: Bool + ) { + guard let toggleSwitch: UISwitch = view as? UISwitch else { return } + + toggleSwitch.accessibilityIdentifier = accessory.accessibility?.identifier + toggleSwitch.accessibilityLabel = accessory.accessibility?.label + toggleSwitch.isEnabled = isEnabled + + if !isManualReload { + toggleSwitch.setOn(accessory.oldValue, animated: false) + + // Dispatch so the cell reload doesn't conflict with the setting change animation + if accessory.oldValue != accessory.value { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { [weak toggleSwitch] in + toggleSwitch?.setOn(accessory.value, animated: true) + } + } + } + } + + // MARK: -- DropDown + + private func createDropDownView() -> UIView { let result: UIStackView = UIStackView() result.translatesAutoresizingMaskIntoConstraints = false result.axis = .horizontal result.distribution = .fill result.alignment = .center result.spacing = Values.verySmallSpacing - result.isHidden = true + + let imageView: UIImageView = UIImageView(image: UIImage(systemName: "arrowtriangle.down.fill")) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.themeTintColor = .textPrimary + imageView.set(.width, to: 10) + imageView.set(.height, to: 10) + + let label: UILabel = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: Values.smallFontSize, weight: .medium) + label.themeTextColor = .textPrimary + label.setContentHugging(to: .required) + label.setCompressionResistance(to: .required) + + result.addArrangedSubview(imageView) + result.addArrangedSubview(label) return result - }() + } - private let dropDownImageView: UIImageView = { - let result: UIImageView = UIImageView(image: UIImage(systemName: "arrowtriangle.down.fill")) - result.translatesAutoresizingMaskIntoConstraints = false - result.themeTintColor = .textPrimary - result.set(.width, to: 10) - result.set(.height, to: 10) + private func layoutDropDownView(_ view: UIView?) { + guard let stackView: UIStackView = view as? UIStackView else { return } - return result - }() + stackView.pin(.top, to: .top, of: self) + stackView.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing) + stackView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing) + stackView.pin(.bottom, to: .bottom, of: self) + minWidthConstraint.isActive = true + } - private let dropDownLabel: UILabel = { - let result: UILabel = UILabel() - result.translatesAutoresizingMaskIntoConstraints = false - result.font = .systemFont(ofSize: Values.smallFontSize, weight: .medium) - result.themeTextColor = .textPrimary - result.setContentHugging(to: .required) - result.setCompressionResistance(to: .required) + private func configureDropDown(_ view: UIView?, _ accessory: SessionCell.AccessoryConfig.DropDown) { + guard + let stackView: UIStackView = view as? UIStackView, + let label: UILabel = stackView.arrangedSubviews.last as? UILabel + else { return } - return result - }() + label.accessibilityIdentifier = accessory.accessibility?.identifier + label.accessibilityLabel = accessory.accessibility?.label + label.text = accessory.dynamicString() + } - private let radioBorderView: UIView = { + // MARK: -- Radio + + private func createRadioView() -> UIView { let result: UIView = UIView() result.translatesAutoresizingMaskIntoConstraints = false result.isUserInteractionEnabled = false + result.isAccessibilityElement = true result.layer.borderWidth = 1 - result.themeBorderColor = .radioButton_unselectedBorder - result.isHidden = true - return result - }() - - private let radioView: UIView = { - let result: UIView = UIView() - result.translatesAutoresizingMaskIntoConstraints = false - result.isUserInteractionEnabled = false - result.themeBackgroundColor = .radioButton_unselectedBackground - result.isHidden = true + let radioView: UIView = UIView() + radioView.translatesAutoresizingMaskIntoConstraints = false + radioView.isUserInteractionEnabled = false + radioView.isHidden = true - return result - }() - - public lazy var highlightingBackgroundLabel: SessionHighlightingBackgroundLabel = { - let result: SessionHighlightingBackgroundLabel = SessionHighlightingBackgroundLabel() - result.translatesAutoresizingMaskIntoConstraints = false - result.isHidden = true + result.addSubview(radioView) + radioView.center(in: result) return result - }() + } - private lazy var profilePictureView: ProfilePictureView = { - let result: ProfilePictureView = ProfilePictureView(size: .list, dataManager: nil) - result.translatesAutoresizingMaskIntoConstraints = false - result.isHidden = true + private func layoutRadioView(_ view: UIView?, size: SessionCell.AccessoryConfig.Radio.Size) { + guard + let radioBorderView: UIView = view, + let radioView: UIView = radioBorderView.subviews.first + else { return } - return result - }() + radioBorderView.layer.cornerRadius = (size.borderSize / 2) + radioView.layer.cornerRadius = (size.selectionSize / 2) + radioView.set(.width, to: size.selectionSize) + radioView.set(.height, to: size.selectionSize) + radioBorderView.set(.width, to: size.borderSize) + radioBorderView.set(.height, to: size.borderSize) + radioBorderView.pin(.top, to: .top, of: self) + radioBorderView.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing) + radioBorderView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing) + radioBorderView.pin(.bottom, to: .bottom, of: self) + } - private lazy var searchBar: UISearchBar = { - let result: ContactsSearchBar = ContactsSearchBar() - result.themeTintColor = .textPrimary - result.themeBackgroundColor = .clear - result.searchTextField.themeBackgroundColor = .backgroundSecondary - result.delegate = self - result.isHidden = true + private func configureRadioView(_ view: UIView?, _ accessory: SessionCell.AccessoryConfig.Radio, isEnabled: Bool) { + guard + let radioBorderView: UIView = view, + let radioView: UIView = radioBorderView.subviews.first + else { return } - return result - }() - - private lazy var button: SessionButton = { - let result: SessionButton = SessionButton(style: .bordered, size: .medium) - result.translatesAutoresizingMaskIntoConstraints = false - result.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) - result.isHidden = true + let isSelected: Bool = accessory.liveIsSelected() + let wasOldSelection: Bool = (!isSelected && accessory.wasSavedSelection) - return result - }() + radioBorderView.accessibilityIdentifier = accessory.accessibility?.identifier + radioBorderView.accessibilityLabel = accessory.accessibility?.label + + if isSelected || wasOldSelection { + radioBorderView.accessibilityTraits.insert(.selected) + radioBorderView.accessibilityValue = "selected" + } else { + radioBorderView.accessibilityTraits.remove(.selected) + radioBorderView.accessibilityValue = nil + } + + radioBorderView.themeBorderColor = { + guard isEnabled else { return .radioButton_disabledBorder } + + return (isSelected ? + .radioButton_selectedBorder : + .radioButton_unselectedBorder + ) + }() + + radioView.alpha = (wasOldSelection ? 0.3 : 1) + radioView.isHidden = (!isSelected && !accessory.wasSavedSelection) + radioView.themeBackgroundColor = { + guard isEnabled else { + return (isSelected || wasOldSelection ? + .radioButton_disabledSelectedBackground : + .radioButton_disabledUnselectedBackground + ) + } + + return (isSelected || wasOldSelection ? + .radioButton_selectedBackground : + .radioButton_unselectedBackground + ) + }() + } - private var customView: UIView? + // MARK: -- HighlightingBackgroundLabel - // MARK: - Initialization + private func createHighlightingBackgroundLabelView() -> UIView { + return SessionHighlightingBackgroundLabel() + } - override init(frame: CGRect) { - super.init(frame: frame) + private func layoutHighlightingBackgroundLabelView(_ view: UIView?) { + guard let label: SessionHighlightingBackgroundLabel = view as? SessionHighlightingBackgroundLabel else { + return + } - setupViewHierarchy() + label.pin(.top, to: .top, of: self) + label.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing) + label.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing) + label.pin(.bottom, to: .bottom, of: self) + minWidthConstraint.isActive = true } - - required init?(coder: NSCoder) { - super.init(coder: coder) + + private func configureHighlightingBackgroundLabelView(_ view: UIView?, _ accessory: SessionCell.AccessoryConfig.HighlightingBackgroundLabel, tintColor: ThemeValue) { + guard let label: SessionHighlightingBackgroundLabel = view as? SessionHighlightingBackgroundLabel else { + return + } - setupViewHierarchy() + label.isAccessibilityElement = (accessory.accessibility != nil) + label.accessibilityIdentifier = accessory.accessibility?.identifier + label.accessibilityLabel = accessory.accessibility?.label + label.text = accessory.title + label.themeTextColor = tintColor } - - private func setupViewHierarchy() { - addSubview(imageView) - addSubview(toggleSwitch) - addSubview(dropDownStackView) - addSubview(radioBorderView) - addSubview(highlightingBackgroundLabel) - addSubview(profilePictureView) - addSubview(button) - addSubview(searchBar) + + // MARK: -- HighlightingBackgroundLabelAndRadio + + private func createHighlightingBackgroundLabelAndRadioView() -> UIView { + let result: UIView = UIView() + let label: SessionHighlightingBackgroundLabel = SessionHighlightingBackgroundLabel() + let radio: UIView = createRadioView() - dropDownStackView.addArrangedSubview(dropDownImageView) - dropDownStackView.addArrangedSubview(dropDownLabel) + result.addSubview(label) + result.addSubview(radio) - radioBorderView.addSubview(radioView) - radioView.center(in: radioBorderView) + return result } - // MARK: - Content - - func prepareForReuse() { - isHidden = true - onTap = nil - searchTermChanged = nil + private func layoutHighlightingBackgroundLabelAndRadioView(_ view: UIView?, size: SessionCell.AccessoryConfig.HighlightingBackgroundLabelAndRadio.Size) { + guard + let view: UIView = view, + let label: SessionHighlightingBackgroundLabel = view.subviews.first as? SessionHighlightingBackgroundLabel, + let radioBorderView: UIView = view.subviews.last, + let radioView: UIView = radioBorderView.subviews.first + else { return } - imageView.image = nil - imageView.themeTintColor = .textPrimary - imageView.contentMode = .scaleAspectFit - dropDownImageView.themeTintColor = .textPrimary - dropDownLabel.text = "" - dropDownLabel.themeTextColor = .textPrimary - radioBorderView.themeBorderColor = .radioButton_unselectedBorder - radioView.themeBackgroundColor = .radioButton_unselectedBackground - highlightingBackgroundLabel.text = "" - highlightingBackgroundLabel.themeTextColor = .textPrimary - customView?.removeFromSuperview() - - imageView.isHidden = true - toggleSwitch.isHidden = true - dropDownStackView.isHidden = true - radioBorderView.isHidden = true - radioView.alpha = 1 - radioView.isHidden = true - highlightingBackgroundLabel.isHidden = true - profilePictureView.isHidden = true - button.isHidden = true - searchBar.isHidden = true + label.pin(.top, to: .top, of: self) + label.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing) + label.pin(.trailing, to: .leading, of: radioBorderView, withInset: -Values.smallSpacing) + label.pin(.bottom, to: .bottom, of: self) - minWidthConstraint.constant = AccessoryView.minWidth - minWidthConstraint.isActive = false - fixedWidthConstraint.constant = AccessoryView.minWidth - fixedWidthConstraint.isActive = false - imageViewLeadingConstraint.isActive = false - imageViewTrailingConstraint.isActive = false - imageViewWidthConstraint.isActive = false - imageViewHeightConstraint.isActive = false - imageViewConstraints.forEach { $0.isActive = false } - toggleSwitchConstraints.forEach { $0.isActive = false } - dropDownStackViewConstraints.forEach { $0.isActive = false } - radioViewWidthConstraint.isActive = false - radioViewHeightConstraint.isActive = false - radioBorderViewWidthConstraint.isActive = false - radioBorderViewHeightConstraint.isActive = false - radioBorderViewConstraints.forEach { $0.isActive = false } - highlightingBackgroundLabelConstraints.forEach { $0.isActive = false } - highlightingBackgroundLabelAndRadioConstraints.forEach { $0.isActive = false } - profilePictureViewConstraints.forEach { $0.isActive = false } - searchBarConstraints.forEach { $0.isActive = false } - buttonConstraints.forEach { $0.isActive = false } + radioBorderView.layer.cornerRadius = (size.borderSize / 2) + radioView.layer.cornerRadius = (size.selectionSize / 2) + radioView.set(.width, to: size.selectionSize) + radioView.set(.height, to: size.selectionSize) + radioBorderView.set(.width, to: size.borderSize) + radioBorderView.set(.height, to: size.borderSize) + radioBorderView.center(.vertical, in: self) + radioBorderView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing) + minWidthConstraint.isActive = true } - public func update( - with accessory: Accessory?, + private func configureHighlightingBackgroundLabelAndRadioView( + _ view: UIView?, + _ accessory: SessionCell.AccessoryConfig.HighlightingBackgroundLabelAndRadio, tintColor: ThemeValue, - isEnabled: Bool, - maxContentWidth: CGFloat, - isManualReload: Bool, - using dependencies: Dependencies + isEnabled: Bool ) { - guard let accessory: Accessory = accessory else { return } + guard + let view: UIView = view, + let label: SessionHighlightingBackgroundLabel = view.subviews.first as? SessionHighlightingBackgroundLabel, + let radioBorderView: UIView = view.subviews.last, + let radioView: UIView = radioBorderView.subviews.first + else { return } - // If we have an accessory value then this shouldn't be hidden - self.isHidden = false - - switch accessory { - // MARK: -- Icon - case let accessory as SessionCell.AccessoryConfig.Icon: - imageView.accessibilityIdentifier = accessory.accessibility?.identifier - imageView.accessibilityLabel = accessory.accessibility?.label - imageView.themeTintColor = (accessory.customTint ?? tintColor) - imageView.contentMode = (accessory.shouldFill ? .scaleAspectFill : .scaleAspectFit) - imageView.isHidden = false - - switch (accessory.icon, accessory.image) { - case (.some(let icon), _): - imageView.image = Lucide - .image(icon: icon, size: accessory.iconSize.size)? - .withRenderingMode(.alwaysTemplate) - - case (.none, .some(let image)): imageView.image = image - case (.none, .none): imageView.image = nil - } - - switch accessory.iconSize { - case .fit: - imageView.sizeToFit() - fixedWidthConstraint.constant = (imageView.bounds.width + (accessory.shouldFill ? 0 : (Values.smallSpacing * 2))) - fixedWidthConstraint.isActive = true - imageViewWidthConstraint.constant = imageView.bounds.width - imageViewHeightConstraint.constant = imageView.bounds.height - - case .smallAspectFill, .mediumAspectFill: - imageView.sizeToFit() - - imageViewWidthConstraint.constant = (imageView.bounds.width > imageView.bounds.height ? - (accessory.iconSize.size * (imageView.bounds.width / imageView.bounds.height)) : - accessory.iconSize.size - ) - imageViewHeightConstraint.constant = (imageView.bounds.width > imageView.bounds.height ? - accessory.iconSize.size : - (accessory.iconSize.size * (imageView.bounds.height / imageView.bounds.width)) - ) - fixedWidthConstraint.constant = imageViewWidthConstraint.constant - fixedWidthConstraint.isActive = true - - default: - fixedWidthConstraint.isActive = (accessory.iconSize.size <= fixedWidthConstraint.constant) - imageViewWidthConstraint.constant = accessory.iconSize.size - imageViewHeightConstraint.constant = accessory.iconSize.size - } - - minWidthConstraint.isActive = !fixedWidthConstraint.isActive - imageViewLeadingConstraint.constant = (accessory.shouldFill ? 0 : Values.smallSpacing) - imageViewTrailingConstraint.constant = (accessory.shouldFill ? 0 : -Values.smallSpacing) - imageViewLeadingConstraint.isActive = true - imageViewTrailingConstraint.isActive = true - imageViewWidthConstraint.isActive = true - imageViewHeightConstraint.isActive = true - imageViewConstraints.forEach { $0.isActive = true } - - // MARK: -- IconAsync - case let accessory as SessionCell.AccessoryConfig.IconAsync: - accessory.setter(imageView) - imageView.accessibilityIdentifier = accessory.accessibility?.identifier - imageView.accessibilityLabel = accessory.accessibility?.label - imageView.themeTintColor = (accessory.customTint ?? tintColor) - imageView.contentMode = (accessory.shouldFill ? .scaleAspectFill : .scaleAspectFit) - imageView.isHidden = false - - switch accessory.iconSize { - case .fit: - imageView.sizeToFit() - fixedWidthConstraint.constant = (imageView.bounds.width + (accessory.shouldFill ? 0 : (Values.smallSpacing * 2))) - fixedWidthConstraint.isActive = true - imageViewWidthConstraint.constant = imageView.bounds.width - imageViewHeightConstraint.constant = imageView.bounds.height - - default: - fixedWidthConstraint.isActive = (accessory.iconSize.size <= fixedWidthConstraint.constant) - imageViewWidthConstraint.constant = accessory.iconSize.size - imageViewHeightConstraint.constant = accessory.iconSize.size - } - - minWidthConstraint.isActive = !fixedWidthConstraint.isActive - imageViewLeadingConstraint.constant = (accessory.shouldFill ? 0 : Values.smallSpacing) - imageViewTrailingConstraint.constant = (accessory.shouldFill ? 0 : -Values.smallSpacing) - imageViewLeadingConstraint.isActive = true - imageViewTrailingConstraint.isActive = true - imageViewWidthConstraint.isActive = true - imageViewHeightConstraint.isActive = true - imageViewConstraints.forEach { $0.isActive = true } - - // MARK: -- Toggle - case let accessory as SessionCell.AccessoryConfig.Toggle: - toggleSwitch.accessibilityIdentifier = accessory.accessibility?.identifier - toggleSwitch.accessibilityLabel = accessory.accessibility?.label - toggleSwitch.isHidden = false - toggleSwitch.isEnabled = isEnabled - - fixedWidthConstraint.isActive = true - toggleSwitchConstraints.forEach { $0.isActive = true } - - if !isManualReload { - toggleSwitch.setOn(accessory.oldValue, animated: false) - - // Dispatch so the cell reload doesn't conflict with the setting change animation - if accessory.oldValue != accessory.value { - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { [weak toggleSwitch] in - toggleSwitch?.setOn(accessory.value, animated: true) - } - } - } - - // MARK: -- DropDown - case let accessory as SessionCell.AccessoryConfig.DropDown: - dropDownLabel.accessibilityIdentifier = accessory.accessibility?.identifier - dropDownLabel.accessibilityLabel = accessory.accessibility?.label - dropDownLabel.text = accessory.dynamicString() - dropDownStackView.isHidden = false - dropDownStackViewConstraints.forEach { $0.isActive = true } - minWidthConstraint.isActive = true - - // MARK: -- Radio - case let accessory as SessionCell.AccessoryConfig.Radio: - let isSelected: Bool = accessory.liveIsSelected() - let wasOldSelection: Bool = (!isSelected && accessory.wasSavedSelection) - - radioBorderView.isAccessibilityElement = true - radioBorderView.accessibilityIdentifier = accessory.accessibility?.identifier - radioBorderView.accessibilityLabel = accessory.accessibility?.label - - if isSelected || wasOldSelection { - radioBorderView.accessibilityTraits.insert(.selected) - radioBorderView.accessibilityValue = "selected" - } else { - radioBorderView.accessibilityTraits.remove(.selected) - radioBorderView.accessibilityValue = nil - } - - radioBorderView.isHidden = false - radioBorderView.themeBorderColor = { - guard isEnabled else { return .radioButton_disabledBorder } - - return (isSelected ? - .radioButton_selectedBorder : - .radioButton_unselectedBorder - ) - }() - - radioBorderView.layer.cornerRadius = (accessory.size.borderSize / 2) - - radioView.alpha = (wasOldSelection ? 0.3 : 1) - radioView.isHidden = (!isSelected && !accessory.wasSavedSelection) - radioView.themeBackgroundColor = { - guard isEnabled else { - return (isSelected || wasOldSelection ? - .radioButton_disabledSelectedBackground : - .radioButton_disabledUnselectedBackground - ) - } - - return (isSelected || wasOldSelection ? - .radioButton_selectedBackground : - .radioButton_unselectedBackground - ) - }() - radioView.layer.cornerRadius = (accessory.size.selectionSize / 2) - - radioViewWidthConstraint.constant = accessory.size.selectionSize - radioViewHeightConstraint.constant = accessory.size.selectionSize - radioBorderViewWidthConstraint.constant = accessory.size.borderSize - radioBorderViewHeightConstraint.constant = accessory.size.borderSize - - radioViewWidthConstraint.isActive = true - radioViewHeightConstraint.isActive = true - radioBorderViewWidthConstraint.isActive = true - radioBorderViewHeightConstraint.isActive = true - radioBorderViewConstraints.forEach { $0.isActive = true } - - // MARK: -- HighlightingBackgroundLabel - case let accessory as SessionCell.AccessoryConfig.HighlightingBackgroundLabel: - highlightingBackgroundLabel.isAccessibilityElement = (accessory.accessibility != nil) - highlightingBackgroundLabel.accessibilityIdentifier = accessory.accessibility?.identifier - highlightingBackgroundLabel.accessibilityLabel = accessory.accessibility?.label - highlightingBackgroundLabel.text = accessory.title - highlightingBackgroundLabel.themeTextColor = tintColor - highlightingBackgroundLabel.isHidden = false - highlightingBackgroundLabelConstraints.forEach { $0.isActive = true } - minWidthConstraint.isActive = true - - // MARK: -- HighlightingBackgroundLabelAndRadio - case let accessory as SessionCell.AccessoryConfig.HighlightingBackgroundLabelAndRadio: - let isSelected: Bool = accessory.liveIsSelected() - let wasOldSelection: Bool = (!isSelected && accessory.wasSavedSelection) - highlightingBackgroundLabel.isAccessibilityElement = (accessory.labelAccessibility != nil) - highlightingBackgroundLabel.accessibilityIdentifier = accessory.labelAccessibility?.identifier - highlightingBackgroundLabel.accessibilityLabel = accessory.labelAccessibility?.label - - radioBorderView.isAccessibilityElement = true - radioBorderView.accessibilityIdentifier = accessory.accessibility?.identifier - radioBorderView.accessibilityLabel = accessory.accessibility?.label + let isSelected: Bool = accessory.liveIsSelected() + let wasOldSelection: Bool = (!isSelected && accessory.wasSavedSelection) + + label.isAccessibilityElement = (accessory.accessibility != nil) + label.accessibilityIdentifier = accessory.accessibility?.identifier + label.accessibilityLabel = accessory.accessibility?.label + label.text = accessory.title + label.themeTextColor = tintColor + + radioBorderView.isAccessibilityElement = true + radioBorderView.accessibilityIdentifier = accessory.accessibility?.identifier + radioBorderView.accessibilityLabel = accessory.accessibility?.label + + if isSelected || wasOldSelection { + radioView.accessibilityTraits.insert(.selected) + radioView.accessibilityValue = "selected" + } else { + radioView.accessibilityTraits.remove(.selected) + radioView.accessibilityValue = nil + } + + radioBorderView.themeBorderColor = { + guard isEnabled else { return .radioButton_disabledBorder } - if isSelected || wasOldSelection { - radioView.accessibilityTraits.insert(.selected) - radioView.accessibilityValue = "selected" - } else { - radioView.accessibilityTraits.remove(.selected) - radioView.accessibilityValue = nil - } - - highlightingBackgroundLabel.text = accessory.title - highlightingBackgroundLabel.themeTextColor = tintColor - highlightingBackgroundLabel.isHidden = false - radioBorderView.isHidden = false - radioBorderView.themeBorderColor = { - guard isEnabled else { return .radioButton_disabledBorder } - - return (isSelected ? - .radioButton_selectedBorder : - .radioButton_unselectedBorder - ) - }() - - radioBorderView.layer.cornerRadius = (accessory.size.borderSize / 2) - - radioView.alpha = (wasOldSelection ? 0.3 : 1) - radioView.isHidden = (!isSelected && !accessory.wasSavedSelection) - radioView.themeBackgroundColor = { - guard isEnabled else { - return (isSelected || wasOldSelection ? - .radioButton_disabledSelectedBackground : - .radioButton_disabledUnselectedBackground - ) - } - - return (isSelected || wasOldSelection ? - .radioButton_selectedBackground : - .radioButton_unselectedBackground - ) - }() - radioView.layer.cornerRadius = (accessory.size.selectionSize / 2) - - radioViewWidthConstraint.constant = accessory.size.selectionSize - radioViewHeightConstraint.constant = accessory.size.selectionSize - radioBorderViewWidthConstraint.constant = accessory.size.borderSize - radioBorderViewHeightConstraint.constant = accessory.size.borderSize - - radioViewWidthConstraint.isActive = true - radioViewHeightConstraint.isActive = true - radioBorderViewWidthConstraint.isActive = true - radioBorderViewHeightConstraint.isActive = true - highlightingBackgroundLabelAndRadioConstraints.forEach { $0.isActive = true } - minWidthConstraint.isActive = true - - // MARK: -- DisplayPicture - case let accessory as SessionCell.AccessoryConfig.DisplayPicture: - // Note: We MUST set the 'size' property before triggering the 'update' - // function or the profile picture won't layout correctly - profilePictureView.accessibilityIdentifier = accessory.accessibility?.identifier - profilePictureView.accessibilityLabel = accessory.accessibility?.label - profilePictureView.isAccessibilityElement = (accessory.accessibility != nil) - profilePictureView.size = accessory.size - profilePictureView.setDataManager(dependencies[singleton: .imageDataManager]) - profilePictureView.update( - publicKey: accessory.id, - threadVariant: accessory.threadVariant, - displayPictureFilename: accessory.displayPictureFilename, - profile: accessory.profile, - profileIcon: accessory.profileIcon, - additionalProfile: accessory.additionalProfile, - additionalProfileIcon: accessory.additionalProfileIcon, - using: dependencies + return (isSelected ? + .radioButton_selectedBorder : + .radioButton_unselectedBorder + ) + }() + radioView.alpha = (wasOldSelection ? 0.3 : 1) + radioView.isHidden = (!isSelected && !accessory.wasSavedSelection) + radioView.themeBackgroundColor = { + guard isEnabled else { + return (isSelected || wasOldSelection ? + .radioButton_disabledSelectedBackground : + .radioButton_disabledUnselectedBackground ) - profilePictureView.isHidden = false - - fixedWidthConstraint.constant = accessory.size.viewSize - fixedWidthConstraint.isActive = true - profilePictureViewConstraints.forEach { $0.isActive = true } - - // MARK: -- Search - case let accessory as SessionCell.AccessoryConfig.Search: - self.searchTermChanged = accessory.searchTermChanged - searchBar.accessibilityIdentifier = accessory.accessibility?.identifier - searchBar.accessibilityLabel = accessory.accessibility?.label - searchBar.placeholder = accessory.placeholder - searchBar.isHidden = false - searchBarConstraints.forEach { $0.isActive = true } - - // MARK: -- Button - case let accessory as SessionCell.AccessoryConfig.Button: - self.onTap = accessory.run - button.accessibilityIdentifier = accessory.accessibility?.identifier - button.accessibilityLabel = accessory.accessibility?.label - button.setTitle(accessory.title, for: .normal) - button.style = accessory.style - button.isHidden = false - minWidthConstraint.isActive = true - buttonConstraints.forEach { $0.isActive = true } - - // MARK: -- Custom + } - case let accessory as SessionCell.AccessoryConfig.AnyCustom: - let generatedView: UIView = accessory.createView( - maxContentWidth: maxContentWidth, - using: dependencies - ) - generatedView.accessibilityIdentifier = accessory.accessibility?.identifier - generatedView.accessibilityLabel = accessory.accessibility?.label - addSubview(generatedView) - - generatedView.pin(.top, to: .top, of: self) - generatedView.pin(.leading, to: .leading, of: self) - generatedView.pin(.trailing, to: .trailing, of: self) - generatedView.pin(.bottom, to: .bottom, of: self) - - customView?.removeFromSuperview() // Just in case - customView = generatedView - minWidthConstraint.isActive = true - - // If we get an unknown case then just hide again - default: self.isHidden = true - } + return (isSelected || wasOldSelection ? + .radioButton_selectedBackground : + .radioButton_unselectedBackground + ) + }() } + + // MARK: -- DisplayPicture - // MARK: - Interaction + private func createDisplayPictureView() -> ProfilePictureView { + return ProfilePictureView(size: .list, dataManager: nil) + } - func setHighlighted(_ highlighted: Bool, animated: Bool) { - highlightingBackgroundLabel.setHighlighted(highlighted, animated: animated) + private func layoutDisplayPictureView(_ view: UIView?, size: ProfilePictureView.Size) { + guard let profilePictureView: ProfilePictureView = view as? ProfilePictureView else { return } + + profilePictureView.pin(to: self) + fixedWidthConstraint.constant = size.viewSize + fixedWidthConstraint.isActive = true } - func setSelected(_ selected: Bool, animated: Bool) { - highlightingBackgroundLabel.setSelected(selected, animated: animated) + private func configureDisplayPictureView( + _ view: UIView?, + _ accessory: SessionCell.AccessoryConfig.DisplayPicture, + using dependencies: Dependencies + ) { + guard let profilePictureView: ProfilePictureView = view as? ProfilePictureView else { return } + + // Note: We MUST set the 'size' property before triggering the 'update' + // function or the profile picture won't layout correctly + profilePictureView.accessibilityIdentifier = accessory.accessibility?.identifier + profilePictureView.accessibilityLabel = accessory.accessibility?.label + profilePictureView.isAccessibilityElement = (accessory.accessibility != nil) + profilePictureView.size = accessory.size + profilePictureView.setDataManager(dependencies[singleton: .imageDataManager]) + profilePictureView.update( + publicKey: accessory.id, + threadVariant: accessory.threadVariant, + displayPictureUrl: accessory.displayPictureUrl, + profile: accessory.profile, + profileIcon: accessory.profileIcon, + additionalProfile: accessory.additionalProfile, + additionalProfileIcon: accessory.additionalProfileIcon, + using: dependencies + ) } - @objc private func buttonTapped() { - onTap?(button) + // MARK: -- Search + + private func createSearchView() -> ContactsSearchBar { + let result: ContactsSearchBar = ContactsSearchBar() + result.themeTintColor = .textPrimary + result.themeBackgroundColor = .clear + result.searchTextField.themeBackgroundColor = .backgroundSecondary + result.delegate = self + + return result } - // MARK: - UISearchBarDelegate + private func layoutSearchView(_ view: UIView?) { + guard let searchBar: ContactsSearchBar = view as? ContactsSearchBar else { return } + + searchBar.pin(.top, to: .top, of: self) + searchBar.pin(.leading, to: .leading, of: self, withInset: -8) // Removing default inset + searchBar.pin(.trailing, to: .trailing, of: self, withInset: 8) // Removing default inset + searchBar.pin(.bottom, to: .bottom, of: self) + } - public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { - searchTermChanged?(searchText) + private func configureSearchView(_ view: UIView?, _ accessory: SessionCell.AccessoryConfig.Search) { + guard let searchBar: ContactsSearchBar = view as? ContactsSearchBar else { return } + + self.searchTermChanged = accessory.searchTermChanged + searchBar.accessibilityIdentifier = accessory.accessibility?.identifier + searchBar.accessibilityLabel = accessory.accessibility?.label + searchBar.placeholder = accessory.placeholder } + + // MARK: -- Button - public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { - searchBar.setShowsCancelButton(true, animated: true) + private func createButtonView() -> SessionButton { + let result: SessionButton = SessionButton(style: .bordered, size: .medium) + result.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + + return result } - public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { - searchBar.setShowsCancelButton(false, animated: true) + private func layoutButtonView(_ view: UIView?) { + guard let button: SessionButton = view as? SessionButton else { return } + + button.pin(to: self) + minWidthConstraint.isActive = true } - public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { - searchBar.endEditing(true) + private func configureButtonView(_ view: UIView?, _ accessory: SessionCell.AccessoryConfig.Button) { + guard let button: SessionButton = view as? SessionButton else { return } + + self.onTap = accessory.run + button.accessibilityIdentifier = accessory.accessibility?.identifier + button.accessibilityLabel = accessory.accessibility?.label + button.setTitle(accessory.title, for: .normal) + button.style = accessory.style + button.isHidden = false + } + + // MARK: -- Custom + + private func layoutCustomView(_ view: UIView?) { + guard let view: UIView = view else { return } + + view.pin(to: self) + minWidthConstraint.isActive = true + } + + private func configureCustomView(_ view: UIView?, _ accessory: SessionCell.AccessoryConfig.AnyCustom) { + view?.accessibilityIdentifier = accessory.accessibility?.identifier + view?.accessibilityLabel = accessory.accessibility?.label } } } diff --git a/Session/Shared/Views/SessionCell.swift b/Session/Shared/Views/SessionCell.swift index c6bf976ea0..b20ab2e7f0 100644 --- a/Session/Shared/Views/SessionCell.swift +++ b/Session/Shared/Views/SessionCell.swift @@ -347,6 +347,11 @@ public class SessionCell: UITableViewCell { onToggleExpansion: (() -> Void)? = nil, using dependencies: Dependencies ) { + /// Need to do this here as `prepareForReuse` doesn't always seem to get called + titleExtraView?.removeFromSuperview() + subtitleExtraView?.removeFromSuperview() + + /// Do other configuration interactionMode = (info.title?.interaction ?? .none) shouldHighlightTitle = (info.title?.interaction != .copy) titleExtraView = info.title?.extraViewGenerator?() diff --git a/Session/Utilities/DismissCallbackAVPlayerViewController.swift b/Session/Utilities/DismissCallbackAVPlayerViewController.swift new file mode 100644 index 0000000000..7bac6d83c4 --- /dev/null +++ b/Session/Utilities/DismissCallbackAVPlayerViewController.swift @@ -0,0 +1,24 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import AVKit + +class DismissCallbackAVPlayerViewController: AVPlayerViewController { + private let onDismiss: () -> Void + + init(onDismiss: @escaping () -> Void) { + self.onDismiss = onDismiss + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + self.onDismiss() + } +} diff --git a/Session/Utilities/ImageLoading+Convenience.swift b/Session/Utilities/ImageLoading+Convenience.swift new file mode 100644 index 0000000000..31cbba83d1 --- /dev/null +++ b/Session/Utilities/ImageLoading+Convenience.swift @@ -0,0 +1,203 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SwiftUI +import SessionUIKit +import SessionMessagingKit +import SessionUtilitiesKit + +// MARK: - ImageDataManager.DataSource Convenience + +public extension ImageDataManager.DataSource { + static func from( + attachment: Attachment, + using dependencies: Dependencies + ) -> ImageDataManager.DataSource? { + guard + attachment.isVisualMedia, + let path: String = try? dependencies[singleton: .attachmentManager] + .path(for: attachment.downloadUrl) + else { return nil } + + if attachment.isVideo { + /// Videos need special handling so handle those specially + return .videoUrl( + URL(fileURLWithPath: path), + attachment.contentType, + attachment.sourceFilename, + dependencies[singleton: .attachmentManager] + ) + } + + return .url(URL(fileURLWithPath: path)) + } + + static func thumbnailFrom( + attachment: Attachment, + size: ImageDataManager.ThumbnailSize, + using dependencies: Dependencies + ) -> ImageDataManager.DataSource? { + guard + attachment.isVisualMedia, + let path: String = try? dependencies[singleton: .attachmentManager] + .path(for: attachment.downloadUrl) + else { return nil } + + /// Can't thumbnail animated images so just load the full file in this case + if attachment.isAnimated { + return .url(URL(fileURLWithPath: path)) + } + + /// Videos have a custom method for generating their thumbnails so use that instead + if attachment.isVideo { + return .videoUrl( + URL(fileURLWithPath: path), + attachment.contentType, + attachment.sourceFilename, + dependencies[singleton: .attachmentManager] + ) + } + + return .urlThumbnail( + URL(fileURLWithPath: path), + size, + dependencies[singleton: .attachmentManager] + ) + } +} + +// MARK: - ImageDataManagerType Convenience + +public extension ImageDataManagerType { + func loadImage( + attachment: Attachment, + using dependencies: Dependencies, + onComplete: @escaping (ImageDataManager.ProcessedImageData?) -> Void = { _ in } + ) { + guard let source: ImageDataManager.DataSource = ImageDataManager.DataSource.from( + attachment: attachment, + using: dependencies + ) else { return onComplete(nil) } + + load(source, onComplete: onComplete) + } + + func loadThumbnail( + size: ImageDataManager.ThumbnailSize, + attachment: Attachment, + using dependencies: Dependencies, + onComplete: @escaping (ImageDataManager.ProcessedImageData?) -> Void = { _ in } + ) { + guard let source: ImageDataManager.DataSource = ImageDataManager.DataSource.thumbnailFrom( + attachment: attachment, + size: size, + using: dependencies + ) else { return onComplete(nil) } + + load(source, onComplete: onComplete) + } + + func cachedImage( + attachment: Attachment, + using dependencies: Dependencies + ) -> UIImage? { + guard let source: ImageDataManager.DataSource = ImageDataManager.DataSource.from( + attachment: attachment, + using: dependencies + ) else { return nil } + + let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + var result: ImageDataManager.ProcessedImageData? = nil + + load(source) { imageData in + result = imageData + semaphore.signal() + } + + /// We don't really want to wait at all but it's async logic so give it a very time timeout so it has the chance + /// to deal with other logic running + _ = semaphore.wait(timeout: .now() + .milliseconds(10)) + + switch result?.type { + case .staticImage(let image): return image + case .animatedImage(let frames, _): return frames.first + case .none: return nil + } + } +} + +// MARK: - SessionImageView Convenience + +public extension SessionImageView { + @MainActor + func loadImage(from path: String, onComplete: ((Bool) -> Void)? = nil) { + loadImage(.url(URL(fileURLWithPath: path)), onComplete: onComplete) + } + + @MainActor + func loadImage( + attachment: Attachment, + using dependencies: Dependencies, + onComplete: ((Bool) -> Void)? = nil + ) { + guard let source: ImageDataManager.DataSource = ImageDataManager.DataSource.from( + attachment: attachment, + using: dependencies + ) else { + onComplete?(false) + return + } + + loadImage(source, onComplete: onComplete) + } + + @MainActor + func loadThumbnail( + size: ImageDataManager.ThumbnailSize, + attachment: Attachment, + using dependencies: Dependencies, + onComplete: ((Bool) -> Void)? = nil + ) { + guard let source: ImageDataManager.DataSource = ImageDataManager.DataSource.thumbnailFrom( + attachment: attachment, + size: size, + using: dependencies + ) else { + onComplete?(false) + return + } + + loadImage(source, onComplete: onComplete) + } + + @MainActor + func loadPlaceholder(seed: String, text: String, size: CGFloat, onComplete: ((Bool) -> Void)? = nil) { + loadImage(.placeholderIcon(seed: seed, text: text, size: size), onComplete: onComplete) + } +} + +// MARK: - SessionAsyncImage Convenience + +public extension SessionAsyncImage { + init( + attachment: Attachment, + thumbnailSize: ImageDataManager.ThumbnailSize, + using dependencies: Dependencies, + @ViewBuilder content: @escaping (Image) -> Content, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + let source: ImageDataManager.DataSource? = ImageDataManager.DataSource.thumbnailFrom( + attachment: attachment, + size: thumbnailSize, + using: dependencies + ) + + /// Fallback in case we don't have a valid source + self.init( + source: (source ?? .image("", nil)), + dataManager: dependencies[singleton: .imageDataManager], + content: content, + placeholder: placeholder + ) + } +} diff --git a/Session/Utilities/MentionUtilities+DisplayName.swift b/Session/Utilities/MentionUtilities+DisplayName.swift new file mode 100644 index 0000000000..79379e0b5d --- /dev/null +++ b/Session/Utilities/MentionUtilities+DisplayName.swift @@ -0,0 +1,55 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import GRDB +import SessionUIKit +import SessionMessagingKit +import SessionUtilitiesKit + +public extension MentionUtilities { + static func highlightMentionsNoAttributes( + in string: String, + threadVariant: SessionThread.Variant, + currentUserSessionIds: Set, + using dependencies: Dependencies + ) -> String { + return MentionUtilities.highlightMentionsNoAttributes( + in: string, + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: { sessionId in + // FIXME: This does a database query and is happening when populating UI - should try to refactor it somehow (ideally resolve a set of mentioned profiles as part of the database query) + return Profile.displayNameNoFallback( + id: sessionId, + threadVariant: threadVariant, + using: dependencies + ) + } + ) + } + + static func highlightMentions( + in string: String, + threadVariant: SessionThread.Variant, + currentUserSessionIds: Set, + location: MentionLocation, + textColor: ThemeValue, + attributes: [NSAttributedString.Key: Any], + using dependencies: Dependencies + ) -> ThemedAttributedString { + return MentionUtilities.highlightMentions( + in: string, + currentUserSessionIds: currentUserSessionIds, + location: location, + textColor: textColor, + attributes: attributes, + displayNameRetriever: { sessionId in + // FIXME: This does a database query and is happening when populating UI - should try to refactor it somehow (ideally resolve a set of mentioned profiles as part of the database query) + return Profile.displayNameNoFallback( + id: sessionId, + threadVariant: threadVariant, + using: dependencies + ) + } + ) + } +} diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index 96a25c75ff..5c21963801 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -13,7 +13,7 @@ enum MockDataGenerator { static var printProgress: Bool = true static var hasStartedGenerationThisRun: Bool = false - static func generateMockData(_ db: Database, using dependencies: Dependencies) throws { + static func generateMockData(_ db: ObservingDatabase, using dependencies: Dependencies) throws { // Don't re-generate the mock data if it already exists guard !hasStartedGenerationThisRun && diff --git a/Session/Utilities/Permissions.swift b/Session/Utilities/Permissions.swift index 8a521e23bf..4dfd2539b6 100644 --- a/Session/Utilities/Permissions.swift +++ b/Session/Utilities/Permissions.swift @@ -173,7 +173,7 @@ extension Permissions { // MARK: - Local Network Premission public static func localNetwork(using dependencies: Dependencies) -> Status { - let status: Bool = dependencies[singleton: .storage, key: .lastSeenHasLocalNetworkPermission] + let status: Bool = dependencies.mutate(cache: .libSession, { $0.get(.lastSeenHasLocalNetworkPermission) }) return status ? .granted : .denied } @@ -187,14 +187,10 @@ extension Permissions { do { if try await checkLocalNetworkPermissionWithBonjour() { // Permission is granted, continue to next onboarding step - dependencies[singleton: .storage].writeAsync { db in - db[.lastSeenHasLocalNetworkPermission] = true - } + dependencies.setAsync(.lastSeenHasLocalNetworkPermission, true) } else { // Permission denied, explain why we need it and show button to open Settings - dependencies[singleton: .storage].writeAsync { db in - db[.lastSeenHasLocalNetworkPermission] = false - } + dependencies.setAsync(.lastSeenHasLocalNetworkPermission, false) } } catch { // Networking failure, handle error diff --git a/Session/Utilities/UIActivityViewController+Utilities.swift b/Session/Utilities/UIActivityViewController+Utilities.swift new file mode 100644 index 0000000000..e25bf2d66e --- /dev/null +++ b/Session/Utilities/UIActivityViewController+Utilities.swift @@ -0,0 +1,25 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionMessagingKit +import SessionUtilitiesKit + +public extension UIActivityViewController { + static func notifyIfNeeded(_ success: Bool, using dependencies: Dependencies) { + if success { + if let threadId: String = dependencies[defaults: .appGroup, key: .lastSharedThreadId] { + let interactionId: Int64 = Int64(dependencies[defaults: .appGroup, key: .lastSharedMessageId]) + + dependencies[singleton: .storage].readAsync( + retrieve: { db in + db.addMessageEvent(id: interactionId, threadId: threadId, type: .created) + }, + completion: { _ in } + ) + } + + dependencies[defaults: .appGroup].removeObject(forKey: .lastSharedThreadId) + dependencies[defaults: .appGroup].removeObject(forKey: .lastSharedMessageId) + } + } +} diff --git a/Session/Utilities/UIContextualAction+Utilities.swift b/Session/Utilities/UIContextualAction+Utilities.swift index 656e964c60..08fb2fd883 100644 --- a/Session/Utilities/UIContextualAction+Utilities.swift +++ b/Session/Utilities/UIContextualAction+Utilities.swift @@ -174,9 +174,7 @@ public extension UIContextualAction { ) { _, _, completionHandler in switch threadViewModel.threadId { case SessionThreadViewModel.messageRequestsSectionId: - dependencies[singleton: .storage].write { db in - db[.hasHiddenMessageRequests] = true - } + dependencies.setAsync(.hasHiddenMessageRequests, true) completionHandler(true) default: @@ -288,18 +286,24 @@ public extension UIContextualAction { .select(.mutedUntilTimestamp) .asRequest(of: TimeInterval.self) .fetchOne(db) + let newValue: TimeInterval? = (currentValue == nil ? + Date.distantFuture.timeIntervalSince1970 : + nil + ) try SessionThread .filter(id: threadViewModel.threadId) .updateAll( db, - SessionThread.Columns.mutedUntilTimestamp.set( - to: (currentValue == nil ? - Date.distantFuture.timeIntervalSince1970 : - nil - ) - ) + SessionThread.Columns.mutedUntilTimestamp.set(to: newValue) ) + + if currentValue != newValue { + db.addConversationEvent( + id: threadViewModel.threadId, + type: .updated(.mutedUntilTimestamp(newValue)) + ) + } } } } @@ -365,6 +369,9 @@ public extension UIContextualAction { (!threadIsContactMessageRequest ? nil : Contact.Columns.didApproveMe.set(to: true)), (!threadIsContactMessageRequest ? nil : Contact.Columns.isApproved.set(to: false)) ].compactMap { $0 } + let contactChangeEvents: [ContactEvent.Change] = (!threadIsContactMessageRequest ? [] : + [.isApproved(false), .didApproveMe(true)] + ) let nameToUse: String = { switch threadViewModel.threadVariant { case .group: @@ -429,6 +436,12 @@ public extension UIContextualAction { contactChanges, using: dependencies ) + contactChangeEvents.forEach { change in + db.addContactEvent( + id: threadViewModel.threadId, + change: change + ) + } case .group: try Contact @@ -445,6 +458,12 @@ public extension UIContextualAction { contactChanges, using: dependencies ) + contactChangeEvents.forEach { change in + db.addContactEvent( + id: profileInfo.id, + change: change + ) + } default: break } @@ -680,7 +699,8 @@ public extension UIContextualAction { case .deleteContact: return UIContextualAction( title: "contactDelete".localized(), - icon: Lucide.image(icon: .trash2, size: 24, color: .white), + icon: UIImage(named: "ic_user_round_trash")? + .withRenderingMode(.alwaysTemplate), themeTintColor: .white, themeBackgroundColor: themeBackgroundColor, accessibility: Accessibility(identifier: "Delete button"), diff --git a/SessionMessagingKit/Calls/CurrentCallProtocol.swift b/SessionMessagingKit/Calls/CurrentCallProtocol.swift index 1016db0e26..06c86cf67b 100644 --- a/SessionMessagingKit/Calls/CurrentCallProtocol.swift +++ b/SessionMessagingKit/Calls/CurrentCallProtocol.swift @@ -17,7 +17,7 @@ public protocol CurrentCallProtocol { func updateCallMessage(mode: EndCallMode, using dependencies: Dependencies) func didReceiveRemoteSDP(sdp: RTCSessionDescription) - func startSessionCall(_ db: Database) + func startSessionCall(_ db: ObservingDatabase) } // MARK: - CallMode diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index 3b4086a1da..43e4bc9fcc 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -42,7 +42,13 @@ public enum SNMessagingKit: MigratableTarget { // Just to make the external API _022_GroupsRebuildChanges.self, _023_GroupsExpiredFlag.self, _024_FixBustedInteractionVariant.self, - _025_DropLegacyClosedGroupKeyPairTable.self + _025_DropLegacyClosedGroupKeyPairTable.self, + _026_MessageDeduplicationTable.self + ], + [], // Renamed `Setting` to `KeyValueStore` + [ + _027_MoveSettingsToLibSession.self, + _028_RenameAttachments.self ] ] ) diff --git a/SessionMessagingKit/Crypto/Crypto+Attachments.swift b/SessionMessagingKit/Crypto/Crypto+Attachments.swift index 254ae6916f..1595125fe1 100644 --- a/SessionMessagingKit/Crypto/Crypto+Attachments.swift +++ b/SessionMessagingKit/Crypto/Crypto+Attachments.swift @@ -15,13 +15,12 @@ public extension Crypto.Generator { private static var aesKeySize: Int { 32 } static func encryptAttachment( - plaintext: Data, - using dependencies: Dependencies + plaintext: Data ) -> Crypto.Generator<(ciphertext: Data, encryptionKey: Data, digest: Data)> { return Crypto.Generator( id: "encryptAttachment", args: [plaintext] - ) { + ) { dependencies in // Due to paddedSize, we need to divide by two. guard plaintext.count < (UInt.max / 2) else { Log.error("[Crypto] Attachment data too long to encrypt.") diff --git a/SessionMessagingKit/Crypto/Crypto+LibSession.swift b/SessionMessagingKit/Crypto/Crypto+LibSession.swift index 7ca859eb22..ff4bb93852 100644 --- a/SessionMessagingKit/Crypto/Crypto+LibSession.swift +++ b/SessionMessagingKit/Crypto/Crypto+LibSession.swift @@ -14,7 +14,9 @@ public extension Crypto.Generator { id: "tokenSubaccount", args: [config, groupSessionId, memberId] ) { - guard case .groupKeys(let conf, _, _) = config else { throw LibSessionError.invalidConfigObject } + guard case .groupKeys(let conf, _, _) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) + } var cMemberId: [CChar] = try memberId.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() var tokenData: [UInt8] = [UInt8](repeating: 0, count: LibSession.sizeSubaccountBytes) @@ -38,7 +40,9 @@ public extension Crypto.Generator { id: "memberAuthData", args: [config, groupSessionId, memberId] ) { - guard case .groupKeys(let conf, _, _) = config else { throw LibSessionError.invalidConfigObject } + guard case .groupKeys(let conf, _, _) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) + } var cMemberId: [CChar] = try memberId.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() var authData: [UInt8] = [UInt8](repeating: 0, count: LibSession.sizeAuthDataBytes) @@ -62,7 +66,9 @@ public extension Crypto.Generator { id: "signatureSubaccount", args: [config, verificationBytes, memberAuthData] ) { - guard case .groupKeys(let conf, _, _) = config else { throw LibSessionError.invalidConfigObject } + guard case .groupKeys(let conf, _, _) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) + } var verificationBytes: [UInt8] = verificationBytes var memberAuthData: [UInt8] = Array(memberAuthData) @@ -97,8 +103,11 @@ public extension Crypto.Generator { args: [groupSessionId, message] ) { dependencies in return try dependencies.mutate(cache: .libSession) { cache in - guard case .groupKeys(let conf, _, _) = cache.config(for: .groupKeys, sessionId: groupSessionId) else { - throw LibSessionError.invalidConfigObject + guard let config: LibSession.Config = cache.config(for: .groupKeys, sessionId: groupSessionId) else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: nil) + } + guard case .groupKeys(let conf, _, _) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) } var maybeCiphertext: UnsafeMutablePointer? = nil @@ -131,8 +140,11 @@ public extension Crypto.Generator { args: [groupSessionId, ciphertext] ) { dependencies in return try dependencies.mutate(cache: .libSession) { cache in - guard case .groupKeys(let conf, _, _) = cache.config(for: .groupKeys, sessionId: groupSessionId) else { - throw LibSessionError.invalidConfigObject + guard let config: LibSession.Config = cache.config(for: .groupKeys, sessionId: groupSessionId) else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: nil) + } + guard case .groupKeys(let conf, _, _) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) } var cSessionId: [CChar] = [CChar](repeating: 0, count: 67) @@ -175,7 +187,10 @@ public extension Crypto.Verification { id: "memberAuthData", args: [groupSessionId, ed25519SecretKey, memberAuthData] ) { - guard var cGroupId: [CChar] = groupSessionId.hexString.cString(using: .utf8) else { return false } + guard + var cGroupId: [CChar] = groupSessionId.hexString.cString(using: .utf8), + ed25519SecretKey.count == 64 + else { return false } var cEd25519SecretKey: [UInt8] = ed25519SecretKey var cAuthData: [UInt8] = Array(memberAuthData) diff --git a/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift b/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift index 569ce960e2..bea6331051 100644 --- a/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift +++ b/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift @@ -4,7 +4,6 @@ import Foundation import CryptoKit -import GRDB import SessionSnodeKit import SessionUtil import SessionUtilitiesKit @@ -13,33 +12,29 @@ import SessionUtilitiesKit public extension Crypto.Generator { static func ciphertextWithSessionProtocol( - _ db: Database, plaintext: Data, - destination: Message.Destination, - using dependencies: Dependencies + destination: Message.Destination ) -> Crypto.Generator { return Crypto.Generator( id: "ciphertextWithSessionProtocol", args: [plaintext, destination] - ) { - let ed25519KeyPair: KeyPair = try Identity.fetchUserEd25519KeyPair(db) ?? { - throw MessageSenderError.noUserED25519KeyPair - }() + ) { dependencies in let destinationX25519PublicKey: Data = try { switch destination { case .contact(let publicKey): return Data(SessionId(.standard, hex: publicKey).publicKey) case .syncMessage: return Data(dependencies[cache: .general].sessionId.publicKey) - case .closedGroup(let groupPublicKey): throw MessageSenderError.deprecatedLegacyGroup + case .closedGroup: throw MessageSenderError.deprecatedLegacyGroup default: throw MessageSenderError.signingFailed } }() var cPlaintext: [UInt8] = Array(plaintext) - var cEd25519SecretKey: [UInt8] = ed25519KeyPair.secretKey + var cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey var cDestinationPubKey: [UInt8] = Array(destinationX25519PublicKey) var maybeCiphertext: UnsafeMutablePointer? = nil var ciphertextLen: Int = 0 + guard !cEd25519SecretKey.isEmpty else { throw MessageSenderError.noUserED25519KeyPair } guard cEd25519SecretKey.count == 64, cDestinationPubKey.count == 32, @@ -101,30 +96,54 @@ public extension Crypto.Generator { } } } + + static func ciphertextWithXChaCha20(plaintext: Data, encKey: [UInt8]) -> Crypto.Generator { + return Crypto.Generator( + id: "ciphertextWithXChaCha20", + args: [plaintext, encKey] + ) { + var cPlaintext: [UInt8] = Array(plaintext) + var cEncKey: [UInt8] = encKey + var maybeCiphertext: UnsafeMutablePointer? = nil + var ciphertextLen: Int = 0 + + guard + cEncKey.count == 32, + session_encrypt_xchacha20( + &cPlaintext, + cPlaintext.count, + &cEncKey, + &maybeCiphertext, + &ciphertextLen + ), + ciphertextLen > 0, + let ciphertext: Data = maybeCiphertext.map({ Data(bytes: $0, count: ciphertextLen) }) + else { throw MessageSenderError.encryptionFailed } + + free(UnsafeMutableRawPointer(mutating: maybeCiphertext)) + + return ciphertext + } + } } // MARK: - Decryption public extension Crypto.Generator { static func plaintextWithSessionProtocol( - _ db: Database, - ciphertext: Data, - using dependencies: Dependencies + ciphertext: Data ) -> Crypto.Generator<(plaintext: Data, senderSessionIdHex: String)> { return Crypto.Generator( id: "plaintextWithSessionProtocol", args: [ciphertext] - ) { - let ed25519KeyPair: KeyPair = try Identity.fetchUserEd25519KeyPair(db) ?? { - throw MessageSenderError.noUserED25519KeyPair - }() - + ) { dependencies in var cCiphertext: [UInt8] = Array(ciphertext) - var cEd25519SecretKey: [UInt8] = ed25519KeyPair.secretKey + var cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey var cSenderSessionId: [CChar] = [CChar](repeating: 0, count: 67) var maybePlaintext: UnsafeMutablePointer? = nil var plaintextLen: Int = 0 + guard !cEd25519SecretKey.isEmpty else { throw MessageSenderError.noUserED25519KeyPair } guard cEd25519SecretKey.count == 64, session_decrypt_incoming( @@ -187,6 +206,8 @@ public extension Crypto.Generator { id: "plaintextWithMultiEncrypt", args: [ciphertext, senderSessionId, ed25519PrivateKey, domain] ) { + guard ed25519PrivateKey.count == 64 else { throw CryptoError.missingUserSecretKey } + var outLen: Int = 0 var cEncryptedData: [UInt8] = Array(ciphertext) var cEd25519PrivateKey: [UInt8] = ed25519PrivateKey @@ -228,6 +249,35 @@ public extension Crypto.Generator { return String(cString: cHash) } } + + static func plaintextWithXChaCha20(ciphertext: Data, encKey: [UInt8]) -> Crypto.Generator { + return Crypto.Generator( + id: "plaintextWithXChaCha20", + args: [ciphertext, encKey] + ) { + var cCiphertext: [UInt8] = Array(ciphertext) + var cEncKey: [UInt8] = encKey + var maybePlaintext: UnsafeMutablePointer? = nil + var plaintextLen: Int = 0 + + guard + cEncKey.count == 32, + session_decrypt_xchacha20( + &cCiphertext, + cCiphertext.count, + &cEncKey, + &maybePlaintext, + &plaintextLen + ), + plaintextLen > 0, + let plaintext: Data = maybePlaintext.map({ Data(bytes: $0, count: plaintextLen) }) + else { throw MessageReceiverError.decryptionFailed } + + free(UnsafeMutableRawPointer(mutating: maybePlaintext)) + + return plaintext + } + } } // MARK: - DisplayPicture @@ -235,10 +285,12 @@ public extension Crypto.Generator { public extension Crypto.Generator { static func encryptedDataDisplayPicture( data: Data, - key: Data, - using dependencies: Dependencies + key: Data ) -> Crypto.Generator { - return Crypto.Generator(id: "encryptedDataDisplayPicture", args: [data, key]) { + return Crypto.Generator( + id: "encryptedDataDisplayPicture", + args: [data, key] + ) { dependencies in // The key structure is: nonce || ciphertext || authTag guard key.count == DisplayPictureManager.aes256KeyByteLength, @@ -259,11 +311,15 @@ public extension Crypto.Generator { static func decryptedDataDisplayPicture( data: Data, - key: Data, - using dependencies: Dependencies + key: Data ) -> Crypto.Generator { - return Crypto.Generator(id: "decryptedDataDisplayPicture", args: [data, key]) { - guard key.count == DisplayPictureManager.aes256KeyByteLength else { throw CryptoError.failedToGenerateOutput } + return Crypto.Generator( + id: "decryptedDataDisplayPicture", + args: [data, key] + ) { dependencies in + guard key.count == DisplayPictureManager.aes256KeyByteLength else { + throw CryptoError.failedToGenerateOutput + } // The key structure is: nonce || ciphertext || authTag let cipherTextLength: Int = (data.count - (DisplayPictureManager.nonceLength + DisplayPictureManager.tagLength)) diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 2e3793a6fd..2823930730 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -14,7 +14,7 @@ enum _001_InitialSetupMigration: Migration { Contact.self, Profile.self, SessionThread.self, DisappearingMessagesConfiguration.self, ClosedGroup.self, OpenGroup.self, Capability.self, BlindedIdLookup.self, GroupMember.self, Interaction.self, Attachment.self, InteractionAttachment.self, Quote.self, - LinkPreview.self, ControlMessageProcessRecord.self, ThreadTypingIndicator.self + LinkPreview.self, ThreadTypingIndicator.self ] public static let fullTextSearchTokenizer: FTS5TokenizerDescriptor = { @@ -23,7 +23,7 @@ enum _001_InitialSetupMigration: Migration { return .porter(wrapping: .unicode61()) }() - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.create(table: "contact") { t in t.column("id", .text) .notNull() @@ -385,6 +385,6 @@ enum _001_InitialSetupMigration: Migration { t.column("timestampMs", .integer).notNull() } - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift index 08b2f3da10..bfcdbea5d3 100644 --- a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -13,7 +13,14 @@ enum _002_SetupStandardJobs: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { + /// Only insert jobs if the `jobs` table exists or we aren't running tests (when running tests this allows us to skip running the + /// SNUtilitiesKit migrations) + guard + !SNUtilitiesKit.isRunningTests || + ((try? db.tableExists("job")) == true) + else { return MigrationExecution.updateProgress(1) } + // Start by adding the jobs that don't have collections (in the jobs like these // will be added via migrations) try db.execute(sql: """ @@ -51,6 +58,6 @@ enum _002_SetupStandardJobs: Migration { ) """) - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 4e4249d29e..13ecc5df76 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -10,11 +10,11 @@ enum _003_YDBToGRDBMigration: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { guard !SNUtilitiesKit.isRunningTests, MigrationHelper.userExists(db) - else { return Storage.update(progress: 1, for: self, in: target, using: dependencies) } + else { return MigrationExecution.updateProgress(1) } Log.error(.migration, "Attempted to perform legacy migation") throw StorageError.migrationNoLongerSupported diff --git a/SessionMessagingKit/Database/Migrations/_004_RemoveLegacyYDB.swift b/SessionMessagingKit/Database/Migrations/_004_RemoveLegacyYDB.swift index 1f399adf74..07db8962d8 100644 --- a/SessionMessagingKit/Database/Migrations/_004_RemoveLegacyYDB.swift +++ b/SessionMessagingKit/Database/Migrations/_004_RemoveLegacyYDB.swift @@ -12,7 +12,7 @@ enum _004_RemoveLegacyYDB: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { - Storage.update(progress: 1, for: self, in: target, using: dependencies) + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_005_FixDeletedMessageReadState.swift b/SessionMessagingKit/Database/Migrations/_005_FixDeletedMessageReadState.swift index 56f851360a..efe33c321d 100644 --- a/SessionMessagingKit/Database/Migrations/_005_FixDeletedMessageReadState.swift +++ b/SessionMessagingKit/Database/Migrations/_005_FixDeletedMessageReadState.swift @@ -11,7 +11,7 @@ enum _005_FixDeletedMessageReadState: Migration { static let minExpectedRunDuration: TimeInterval = 0.01 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.execute( sql: """ UPDATE interaction @@ -24,6 +24,6 @@ enum _005_FixDeletedMessageReadState: Migration { Interaction.Variant.infoDisappearingMessagesUpdate.rawValue ]) - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_006_FixHiddenModAdminSupport.swift b/SessionMessagingKit/Database/Migrations/_006_FixHiddenModAdminSupport.swift index f0415a9a2b..006b04c283 100644 --- a/SessionMessagingKit/Database/Migrations/_006_FixHiddenModAdminSupport.swift +++ b/SessionMessagingKit/Database/Migrations/_006_FixHiddenModAdminSupport.swift @@ -12,7 +12,7 @@ enum _006_FixHiddenModAdminSupport: Migration { static let minExpectedRunDuration: TimeInterval = 0.01 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.alter(table: "groupMember") { t in t.add(column: "isHidden", .boolean) .notNull() @@ -24,6 +24,6 @@ enum _006_FixHiddenModAdminSupport: Migration { // added/changed fields try db.execute(sql: "UPDATE openGroup SET infoUpdates = 0") - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_007_HomeQueryOptimisationIndexes.swift b/SessionMessagingKit/Database/Migrations/_007_HomeQueryOptimisationIndexes.swift index dcfa0ca4b3..bf8ded493e 100644 --- a/SessionMessagingKit/Database/Migrations/_007_HomeQueryOptimisationIndexes.swift +++ b/SessionMessagingKit/Database/Migrations/_007_HomeQueryOptimisationIndexes.swift @@ -13,7 +13,7 @@ enum _007_HomeQueryOptimisationIndexes: Migration { static let minExpectedRunDuration: TimeInterval = 0.01 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.create( indexOn: "interaction", columns: ["wasRead", "hasMention", "threadId"] @@ -23,6 +23,6 @@ enum _007_HomeQueryOptimisationIndexes: Migration { columns: ["threadId", "timestampMs", "variant"] ) - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_008_EmojiReacts.swift b/SessionMessagingKit/Database/Migrations/_008_EmojiReacts.swift index 00c9bb9858..bcd9c2f84b 100644 --- a/SessionMessagingKit/Database/Migrations/_008_EmojiReacts.swift +++ b/SessionMessagingKit/Database/Migrations/_008_EmojiReacts.swift @@ -11,7 +11,7 @@ enum _008_EmojiReacts: Migration { static let minExpectedRunDuration: TimeInterval = 0.01 static let createdTables: [(TableRecord & FetchableRecord).Type] = [Reaction.self] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.create(table: "reaction") { t in t.column("interactionId", .numeric) .notNull() @@ -37,6 +37,6 @@ enum _008_EmojiReacts: Migration { t.uniqueKey(["interactionId", "emoji", "authorId"]) } - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_009_OpenGroupPermission.swift b/SessionMessagingKit/Database/Migrations/_009_OpenGroupPermission.swift index 79e4f2310e..b8e7c47efb 100644 --- a/SessionMessagingKit/Database/Migrations/_009_OpenGroupPermission.swift +++ b/SessionMessagingKit/Database/Migrations/_009_OpenGroupPermission.swift @@ -10,7 +10,7 @@ enum _009_OpenGroupPermission: Migration { static let minExpectedRunDuration: TimeInterval = 0.01 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.alter(table: "openGroup") { t in t.add(column: "permissions", .integer) .defaults(to: OpenGroup.Permissions.all) @@ -21,6 +21,6 @@ enum _009_OpenGroupPermission: Migration { // added/changed fields try db.execute(sql: "UPDATE openGroup SET infoUpdates = 0") - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_010_AddThreadIdToFTS.swift b/SessionMessagingKit/Database/Migrations/_010_AddThreadIdToFTS.swift index 62eaf246fb..9c2aea1207 100644 --- a/SessionMessagingKit/Database/Migrations/_010_AddThreadIdToFTS.swift +++ b/SessionMessagingKit/Database/Migrations/_010_AddThreadIdToFTS.swift @@ -12,7 +12,7 @@ enum _010_AddThreadIdToFTS: Migration { static let minExpectedRunDuration: TimeInterval = 3 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { // Can't actually alter a virtual table in SQLite so we need to drop and recreate it, // luckily this is actually pretty quick if try db.tableExists("interaction_fts") { @@ -28,6 +28,6 @@ enum _010_AddThreadIdToFTS: Migration { t.column("threadId") } - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_011_AddPendingReadReceipts.swift b/SessionMessagingKit/Database/Migrations/_011_AddPendingReadReceipts.swift index 87933f1d64..5f51432095 100644 --- a/SessionMessagingKit/Database/Migrations/_011_AddPendingReadReceipts.swift +++ b/SessionMessagingKit/Database/Migrations/_011_AddPendingReadReceipts.swift @@ -12,7 +12,7 @@ enum _011_AddPendingReadReceipts: Migration { static let minExpectedRunDuration: TimeInterval = 0.01 static let createdTables: [(TableRecord & FetchableRecord).Type] = [PendingReadReceipt.self] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.create(table: "pendingReadReceipt") { t in t.column("threadId", .text) .notNull() @@ -29,6 +29,6 @@ enum _011_AddPendingReadReceipts: Migration { t.primaryKey(["threadId", "interactionTimestampMs"]) } - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_012_AddFTSIfNeeded.swift b/SessionMessagingKit/Database/Migrations/_012_AddFTSIfNeeded.swift index c22ce272f0..a030deed3f 100644 --- a/SessionMessagingKit/Database/Migrations/_012_AddFTSIfNeeded.swift +++ b/SessionMessagingKit/Database/Migrations/_012_AddFTSIfNeeded.swift @@ -11,7 +11,7 @@ enum _012_AddFTSIfNeeded: Migration { static let minExpectedRunDuration: TimeInterval = 0.01 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { // Fix an issue that the fullTextSearchTable was dropped unintentionally and global search won't work. // This issue only happens to internal test users. if try db.tableExists("interaction_fts") == false { @@ -24,6 +24,6 @@ enum _012_AddFTSIfNeeded: Migration { } } - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_013_SessionUtilChanges.swift b/SessionMessagingKit/Database/Migrations/_013_SessionUtilChanges.swift index 6e4c76e45f..cb67ad5bf5 100644 --- a/SessionMessagingKit/Database/Migrations/_013_SessionUtilChanges.swift +++ b/SessionMessagingKit/Database/Migrations/_013_SessionUtilChanges.swift @@ -15,7 +15,7 @@ enum _013_SessionUtilChanges: Migration { static let minExpectedRunDuration: TimeInterval = 0.4 static let createdTables: [(TableRecord & FetchableRecord).Type] = [ConfigDump.self] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { // Add `markedAsUnread` to the thread table try db.alter(table: "thread") { t in t.add(column: "markedAsUnread", .boolean) @@ -225,7 +225,7 @@ enum _013_SessionUtilChanges: Migration { } } - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_014_GenerateInitialUserConfigDumps.swift b/SessionMessagingKit/Database/Migrations/_014_GenerateInitialUserConfigDumps.swift index eec37126c3..c81a813e12 100644 --- a/SessionMessagingKit/Database/Migrations/_014_GenerateInitialUserConfigDumps.swift +++ b/SessionMessagingKit/Database/Migrations/_014_GenerateInitialUserConfigDumps.swift @@ -12,15 +12,12 @@ enum _014_GenerateInitialUserConfigDumps: Migration { static let minExpectedRunDuration: TimeInterval = 4.0 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { // If we have no ed25519 key then there is no need to create cached dump data guard MigrationHelper.userExists(db), let userEd25519SecretKey: Data = MigrationHelper.fetchIdentityValue(db, key: "ed25519SecretKey") - else { - Storage.update(progress: 1, for: self, in: target, using: dependencies) - return - } + else { return MigrationExecution.updateProgress(1) } // Create the initial config state let userSessionId: SessionId = MigrationHelper.userSessionId(db) @@ -53,6 +50,8 @@ enum _014_GenerateInitialUserConfigDumps: Migration { groupEd25519SecretKey: nil, cachedData: nil ) + cache.setConfig(for: .userProfile, sessionId: userSessionId, to: userProfileConfig) + let userProfile: Row? = try? Row.fetchOne( db, sql: """ @@ -62,13 +61,10 @@ enum _014_GenerateInitialUserConfigDumps: Migration { """, arguments: [userSessionId.hexString] ) - try LibSession.update( - profileInfo: LibSession.ProfileInfo( - name: (userProfile?["name"] ?? ""), - profilePictureUrl: userProfile?["profilePictureUrl"], - profileEncryptionKey: userProfile?["profileEncryptionKey"] - ), - in: userProfileConfig + try cache.updateProfile( + displayName: (userProfile?["name"] ?? ""), + displayPictureUrl: userProfile?["profilePictureUrl"], + displayPictureEncryptionKey: userProfile?["profileEncryptionKey"] ) try LibSession.updateNoteToSelf( @@ -106,6 +102,8 @@ enum _014_GenerateInitialUserConfigDumps: Migration { groupEd25519SecretKey: nil, cachedData: nil ) + cache.setConfig(for: .contacts, sessionId: userSessionId, to: contactsConfig) + let validContactIds: [String] = allThreads .values .filter { thread in @@ -142,15 +140,15 @@ enum _014_GenerateInitialUserConfigDumps: Migration { .map { row in let contactId: String = row["id"] - return LibSession.SyncedContactInfo( + return LibSession.ContactUpdateInfo( id: contactId, isApproved: row["isApproved"], isBlocked: row["isBlocked"], didApproveMe: row["didApproveMe"], name: row["name"], nickname: row["nickname"], - profilePictureUrl: row["profilePictureUrl"], - profileEncryptionKey: row["profileEncryptionKey"], + displayPictureUrl: row["profilePictureUrl"], + displayPictureEncryptionKey: row["profileEncryptionKey"], priority: { guard allThreads[contactId]?["shouldBeVisible"] == true else { return -1 // Hidden priority @@ -165,7 +163,7 @@ enum _014_GenerateInitialUserConfigDumps: Migration { .appending( contentsOf: threadIdsNeedingContacts .map { contactId in - LibSession.SyncedContactInfo( + LibSession.ContactUpdateInfo( id: contactId, isApproved: false, isBlocked: false, @@ -199,6 +197,8 @@ enum _014_GenerateInitialUserConfigDumps: Migration { groupEd25519SecretKey: nil, cachedData: nil ) + cache.setConfig(for: .convoInfoVolatile, sessionId: userSessionId, to: convoInfoVolatileConfig) + let volatileThreadInfo: [Row] = try Row.fetchAll(db, sql: """ SELECT thread.id, @@ -287,6 +287,8 @@ enum _014_GenerateInitialUserConfigDumps: Migration { groupEd25519SecretKey: nil, cachedData: nil ) + cache.setConfig(for: .userGroups, sessionId: userSessionId, to: userGroupsConfig) + let legacyGroupInfo: [Row] = try Row.fetchAll(db, sql: """ SELECT closedGroup.threadId, @@ -359,7 +361,7 @@ enum _014_GenerateInitialUserConfigDumps: Migration { let threadId: String = info["threadId"] let pinnedPriority: Int32? = allThreads[threadId]?["pinnedPriority"] - return LibSession.CommunityInfo( + return LibSession.CommunityUpdateInfo( urlInfo: LibSession.OpenGroupUrlInfo( threadId: threadId, server: info["server"], @@ -385,6 +387,6 @@ enum _014_GenerateInitialUserConfigDumps: Migration { ) } - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_015_BlockCommunityMessageRequests.swift b/SessionMessagingKit/Database/Migrations/_015_BlockCommunityMessageRequests.swift index a18a281293..dd58e13355 100644 --- a/SessionMessagingKit/Database/Migrations/_015_BlockCommunityMessageRequests.swift +++ b/SessionMessagingKit/Database/Migrations/_015_BlockCommunityMessageRequests.swift @@ -11,7 +11,7 @@ enum _015_BlockCommunityMessageRequests: Migration { static let minExpectedRunDuration: TimeInterval = 0.01 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { // Add the new 'Profile' properties try db.alter(table: "profile") { t in t.add(column: "blocksCommunityMessageRequests", .boolean) @@ -42,7 +42,7 @@ enum _015_BlockCommunityMessageRequests: Migration { """, arguments: [userSessionId.hexString] ) - let userProfileConfig: LibSession.Config = try cache.loadState( + try cache.loadState( for: .userProfile, sessionId: userSessionId, userEd25519SecretKey: Array(userEd25519SecretKey), @@ -50,17 +50,15 @@ enum _015_BlockCommunityMessageRequests: Migration { cachedData: configDump ) - let rawBlindedMessageRequestValue: Int32 = try LibSession.rawBlindedMessageRequestValue(in: userProfileConfig) - // Use the value in the config if we happen to have one, otherwise use the default try db.execute(sql: """ DELETE FROM setting WHERE key = 'checkForCommunityMessageRequests' """) - var targetValue: Bool = (rawBlindedMessageRequestValue < 0 ? + var targetValue: Bool = (!cache.has(.checkForCommunityMessageRequests) ? true : - (rawBlindedMessageRequestValue > 0) + cache.get(.checkForCommunityMessageRequests) ) let boolAsData: Data = withUnsafeBytes(of: &targetValue) { Data($0) } try db.execute( @@ -72,6 +70,6 @@ enum _015_BlockCommunityMessageRequests: Migration { ) } - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_016_MakeBrokenProfileTimestampsNullable.swift b/SessionMessagingKit/Database/Migrations/_016_MakeBrokenProfileTimestampsNullable.swift index 62ba79a12f..82816602ca 100644 --- a/SessionMessagingKit/Database/Migrations/_016_MakeBrokenProfileTimestampsNullable.swift +++ b/SessionMessagingKit/Database/Migrations/_016_MakeBrokenProfileTimestampsNullable.swift @@ -12,7 +12,7 @@ enum _016_MakeBrokenProfileTimestampsNullable: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.create(table: "tmpProfile") { t in t.column("id", .text) .notNull() @@ -38,6 +38,6 @@ enum _016_MakeBrokenProfileTimestampsNullable: Migration { try db.drop(table: "profile") try db.rename(table: "tmpProfile", to: "profile") - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_017_RebuildFTSIfNeeded_2_4_5.swift b/SessionMessagingKit/Database/Migrations/_017_RebuildFTSIfNeeded_2_4_5.swift index 9008a4aa45..c9c9240fde 100644 --- a/SessionMessagingKit/Database/Migrations/_017_RebuildFTSIfNeeded_2_4_5.swift +++ b/SessionMessagingKit/Database/Migrations/_017_RebuildFTSIfNeeded_2_4_5.swift @@ -13,8 +13,8 @@ enum _017_RebuildFTSIfNeeded_2_4_5: Migration { static let minExpectedRunDuration: TimeInterval = 0.01 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { - func ftsIsValid(_ db: Database, _ tableName: String) -> Bool { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { + func ftsIsValid(_ db: ObservingDatabase, _ tableName: String) -> Bool { return ( ((try? db.tableExists(tableName)) == true) && // Table itself ((try? db.triggerExists("__\(tableName)_ai")) == true) && // Insert trigger @@ -77,6 +77,6 @@ enum _017_RebuildFTSIfNeeded_2_4_5: Migration { } } - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_018_DisappearingMessagesConfiguration.swift b/SessionMessagingKit/Database/Migrations/_018_DisappearingMessagesConfiguration.swift index b2ede9e47e..809c426e56 100644 --- a/SessionMessagingKit/Database/Migrations/_018_DisappearingMessagesConfiguration.swift +++ b/SessionMessagingKit/Database/Migrations/_018_DisappearingMessagesConfiguration.swift @@ -10,7 +10,7 @@ enum _018_DisappearingMessagesConfiguration: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.alter(table: "disappearingMessagesConfiguration") { t in t.add(column: "type", .integer) } @@ -33,7 +33,7 @@ enum _018_DisappearingMessagesConfiguration: Migration { guard MigrationHelper.userExists(db), let userEd25519SecretKey: Data = MigrationHelper.fetchIdentityValue(db, key: "ed25519SecretKey") - else { return Storage.update(progress: 1, for: self, in: target, using: dependencies) } + else { return MigrationExecution.updateProgress(1) } // Set the disappearing messages type per conversation let userSessionId: SessionId = MigrationHelper.userSessionId(db) @@ -115,7 +115,7 @@ enum _018_DisappearingMessagesConfiguration: Migration { $0["variant"] == SessionThread.Variant.contact.rawValue } .map { - LibSession.SyncedContactInfo( + LibSession.ContactUpdateInfo( id: $0["id"], disappearingMessagesConfig: DisappearingMessagesConfiguration( threadId: $0["id"], @@ -167,7 +167,6 @@ enum _018_DisappearingMessagesConfiguration: Migration { ) } - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } - diff --git a/SessionMessagingKit/Database/Migrations/_019_ScheduleAppUpdateCheckJob.swift b/SessionMessagingKit/Database/Migrations/_019_ScheduleAppUpdateCheckJob.swift index 8253e6e134..9f5bd4c724 100644 --- a/SessionMessagingKit/Database/Migrations/_019_ScheduleAppUpdateCheckJob.swift +++ b/SessionMessagingKit/Database/Migrations/_019_ScheduleAppUpdateCheckJob.swift @@ -10,12 +10,19 @@ enum _019_ScheduleAppUpdateCheckJob: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { + /// Only insert jobs if the `jobs` table exists or we aren't running tests (when running tests this allows us to skip running the + /// SNUtilitiesKit migrations) + guard + !SNUtilitiesKit.isRunningTests || + ((try? db.tableExists("job")) == true) + else { return MigrationExecution.updateProgress(1) } + try db.execute(sql: """ INSERT INTO job (variant, behaviour) VALUES (\(Job.Variant.checkForAppUpdates.rawValue), \(Job.Behaviour.recurring.rawValue)) """) - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_020_AddMissingWhisperFlag.swift b/SessionMessagingKit/Database/Migrations/_020_AddMissingWhisperFlag.swift index aec11737b8..90dbfc4fbd 100644 --- a/SessionMessagingKit/Database/Migrations/_020_AddMissingWhisperFlag.swift +++ b/SessionMessagingKit/Database/Migrations/_020_AddMissingWhisperFlag.swift @@ -10,7 +10,7 @@ enum _020_AddMissingWhisperFlag: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { /// We should have had this column from the very beginning but it was missed, so add it in now for when we eventually /// support whispers in Community conversations try db.alter(table: "interaction") { t in @@ -19,6 +19,6 @@ enum _020_AddMissingWhisperFlag: Migration { .defaults(to: false) } - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_021_ReworkRecipientState.swift b/SessionMessagingKit/Database/Migrations/_021_ReworkRecipientState.swift index e054313200..a47d202666 100644 --- a/SessionMessagingKit/Database/Migrations/_021_ReworkRecipientState.swift +++ b/SessionMessagingKit/Database/Migrations/_021_ReworkRecipientState.swift @@ -10,7 +10,7 @@ enum _021_ReworkRecipientState: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { /// First we need to add the new columns to the `Interaction` table try db.alter(table: "interaction") { t in t.add(column: "state", .integer) @@ -128,14 +128,21 @@ enum _021_ReworkRecipientState: Migration { /// Any interactions which didn't have a `recipientState` or a `MessageSendJob` should be considered `sent` (as /// the old UI behaviour was to render any messages without a `recipientState` as `sent`) - let interactionIdsWithMessageSendJobs: Set = try Int64.fetchSet(db, sql: """ - SELECT interactionId - FROM job - WHERE ( - variant = \(Job.Variant.messageSend.rawValue) AND - interactionId IS NOT NULL - ) - """) + var interactionIdsWithMessageSendJobs: Set = [] + + /// Only fetch from the `jobs` table if it exists or we aren't running tests (when running tests this allows us to skip running the + /// SNUtilitiesKit migrations) + if !SNUtilitiesKit.isRunningTests || ((try? db.tableExists("job")) == true) { + interactionIdsWithMessageSendJobs = try Int64.fetchSet(db, sql: """ + SELECT interactionId + FROM job + WHERE ( + variant = \(Job.Variant.messageSend.rawValue) AND + interactionId IS NOT NULL + ) + """) + } + let interactionIdsToExclude: Set = Set(recipientStateInfo .map { info -> Int64 in info["interactionId"] }) .union(interactionIdsWithMessageSendJobs) @@ -169,7 +176,7 @@ enum _021_ReworkRecipientState: Migration { /// Finally we can drop the old recipient states table try db.drop(table: "recipientState") - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_022_GroupsRebuildChanges.swift b/SessionMessagingKit/Database/Migrations/_022_GroupsRebuildChanges.swift index ad13d01fa4..3f60a24d4a 100644 --- a/SessionMessagingKit/Database/Migrations/_022_GroupsRebuildChanges.swift +++ b/SessionMessagingKit/Database/Migrations/_022_GroupsRebuildChanges.swift @@ -14,7 +14,7 @@ enum _022_GroupsRebuildChanges: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static var createdTables: [(FetchableRecord & TableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.alter(table: "thread") { t in t.add(column: "isDraft", .boolean).defaults(to: false) } @@ -45,7 +45,7 @@ enum _022_GroupsRebuildChanges: Migration { guard MigrationHelper.userExists(db), let userEd25519SecretKey: Data = MigrationHelper.fetchIdentityValue(db, key: "ed25519SecretKey") - else { return Storage.update(progress: 1, for: self, in: target, using: dependencies) } + else { return MigrationExecution.updateProgress(1) } let userSessionId: SessionId = MigrationHelper.userSessionId(db) @@ -145,15 +145,17 @@ enum _022_GroupsRebuildChanges: Migration { /// 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] { - db.afterNextTransaction { db in - try? PushNotificationAPI - .preparedSubscribe( - db, - token: Data(hex: token), - sessionIds: [SessionId(.group, hex: group.groupSessionId)], - using: dependencies - ) - .send(using: dependencies) + db.afterCommit { + dependencies[singleton: .storage] + .readPublisher { db in + try PushNotificationAPI.preparedSubscribe( + db, + token: Data(hex: token), + sessionIds: [SessionId(.group, hex: group.groupSessionId)], + using: dependencies + ) + } + .flatMap { $0.send(using: dependencies) } .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) .sinkUntilComplete() } @@ -179,14 +181,10 @@ enum _022_GroupsRebuildChanges: Migration { return } - let fileName: String = dependencies[singleton: .displayPictureManager].generateFilename( - format: imageData.guessedImageFormat - ) - - guard let filePath: String = try? dependencies[singleton: .displayPictureManager].filepath(for: fileName) else { - Log.error("[GroupsRebuildChanges] Failed to generate community file path for current file name") - return - } + let filename: String = generateFilename(format: imageData.guessedImageFormat, using: dependencies) + let filePath: String = URL(fileURLWithPath: dependencies[singleton: .displayPictureManager].sharedDataDisplayPictureDirPath()) + .appendingPathComponent(filename) + .path // Save the decrypted display picture to disk try? imageData.write(to: URL(fileURLWithPath: filePath), options: [.atomic]) @@ -201,13 +199,22 @@ enum _022_GroupsRebuildChanges: Migration { UPDATE openGroup SET imageData = NULL, - displayPictureFilename = '\(fileName)', + displayPictureFilename = '\(filename)', lastDisplayPictureUpdate = \(timestampMs) WHERE threadId = '\(threadId)' """) } - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } +private extension _022_GroupsRebuildChanges { + static func generateFilename(format: ImageFormat = .jpeg, using dependencies: Dependencies) -> String { + return dependencies[singleton: .crypto] + .generate(.uuid()) + .defaulting(to: UUID()) + .uuidString + .appendingFileExtension(format.fileExtension) + } +} diff --git a/SessionMessagingKit/Database/Migrations/_023_GroupsExpiredFlag.swift b/SessionMessagingKit/Database/Migrations/_023_GroupsExpiredFlag.swift index 4dee77739f..2bffc37639 100644 --- a/SessionMessagingKit/Database/Migrations/_023_GroupsExpiredFlag.swift +++ b/SessionMessagingKit/Database/Migrations/_023_GroupsExpiredFlag.swift @@ -10,12 +10,12 @@ enum _023_GroupsExpiredFlag: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static var createdTables: [(FetchableRecord & TableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.alter(table: "closedGroup") { t in t.add(column: "expired", .boolean).defaults(to: false) } - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_024_FixBustedInteractionVariant.swift b/SessionMessagingKit/Database/Migrations/_024_FixBustedInteractionVariant.swift index c58a0f4ee2..9b65965362 100644 --- a/SessionMessagingKit/Database/Migrations/_024_FixBustedInteractionVariant.swift +++ b/SessionMessagingKit/Database/Migrations/_024_FixBustedInteractionVariant.swift @@ -13,14 +13,14 @@ enum _024_FixBustedInteractionVariant: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static var createdTables: [(FetchableRecord & TableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.execute(sql: """ UPDATE interaction SET variant = \(Interaction.Variant.standardIncomingDeleted.rawValue) WHERE variant = \(Interaction.Variant._legacyStandardIncomingDeleted.rawValue) """) - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_025_DropLegacyClosedGroupKeyPairTable.swift b/SessionMessagingKit/Database/Migrations/_025_DropLegacyClosedGroupKeyPairTable.swift index 4842d1df4a..afc0dd376d 100644 --- a/SessionMessagingKit/Database/Migrations/_025_DropLegacyClosedGroupKeyPairTable.swift +++ b/SessionMessagingKit/Database/Migrations/_025_DropLegacyClosedGroupKeyPairTable.swift @@ -12,10 +12,10 @@ enum _025_DropLegacyClosedGroupKeyPairTable: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static var createdTables: [(FetchableRecord & TableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.drop(table: "closedGroupKeyPair") - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionMessagingKit/Database/Migrations/_026_MessageDeduplicationTable.swift b/SessionMessagingKit/Database/Migrations/_026_MessageDeduplicationTable.swift new file mode 100644 index 0000000000..fd993ac86b --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_026_MessageDeduplicationTable.swift @@ -0,0 +1,395 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit +import SessionSnodeKit + +/// The different platforms use different approaches for message deduplication but in the future we want to shift the database logic into +/// `libSession` so it makes sense to try to define a longer-term deduplication approach we we can use in `libSession`, additonally +/// the PN extension will need to replicate this deduplication data so having a single source-of-truth for the data will make things easier +enum _026_MessageDeduplicationTable: Migration { + static let target: TargetMigrations.Identifier = .messagingKit + static let identifier: String = "MessageDeduplicationTable" + static let minExpectedRunDuration: TimeInterval = 5 + static var createdTables: [(FetchableRecord & TableRecord).Type] = [ + MessageDeduplication.self + ] + + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { + typealias DedupeRecord = ( + threadId: String, + identifier: String, + timestampMs: Int64, + finalExpirationTimestampSeconds: Int64?, + shouldDeleteWhenDeletingThread: Bool + ) + + /// Pre-calculate the required timestamps + /// + /// **oldestSnodeTimestampMs:** Messages on a snode expire after ~14 days so exclude older messages + /// **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 oldestNotificationDedupeTimestampMs: Int64 = ((timestampNowInSec - (60 * 60)) * 1000) + + try db.create(table: "messageDeduplication") { t in + t.column("threadId", .text) + .notNull() + .indexed() // Quicker querying + t.column("uniqueIdentifier", .text) + .notNull() + .indexed() // Quicker querying + t.column("expirationTimestampSeconds", .integer) + .indexed() // Quicker querying + t.column("shouldDeleteWhenDeletingThread", .boolean) + .notNull() + .defaults(to: false) + t.primaryKey(["threadId", "uniqueIdentifier"]) + } + + /// Pre-create the insertion SQL to avoid having to construct it in every iteration + let insertSQL = """ + INSERT INTO messageDeduplication (threadId, uniqueIdentifier, expirationTimestampSeconds, shouldDeleteWhenDeletingThread) + VALUES (?, ?, ?, ?) + ON CONFLICT(threadId, uniqueIdentifier) DO NOTHING + """ + let insertStatement = try db.makeStatement(sql: insertSQL) + + /// Retrieve existing de-duplication information + let threadInfo: [Row] = try Row.fetchAll(db, sql: """ + SELECT + id AS threadId, + variant AS threadVariant + FROM thread + """) + let interactionInfo: [Row] = try Row.fetchAll(db, sql: """ + SELECT + threadId, + variant AS interactionVariant, + timestampMs, + serverHash, + openGroupServerMessageId, + expiresInSeconds, + expiresStartedAtMs + FROM interaction + WHERE ( + timestampMs > \(oldestSnodeTimestampMs) OR NOT ( + -- Quick way to include all community messages without joining the thread table + LENGTH(threadId) = 66 AND ( + (threadId >= '03' AND threadId < '04') OR + (threadId >= '05' AND threadId < '06') OR + (threadId >= '15' AND threadId < '16') OR + (threadId >= '25' AND threadId < '26') + ) + ) + ) + """) + let controlMessageProcessRecords: [Row] = try Row.fetchAll(db, sql: """ + SELECT + threadId, + variant, + timestampMs, + serverExpirationTimestamp + FROM controlMessageProcessRecord + WHERE ( + serverExpirationTimestamp IS NULL OR + serverExpirationTimestamp > \(timestampNowInSec) + ) + """) + + /// Put the known hashes into a temporary table (if we got interactions with hashes + var expirationByHash: [String: Int64] = [:] + let allHashes: Set = Set(interactionInfo.compactMap { row in row["serverHash"] }) + + if !allHashes.isEmpty { + try db.execute(sql: "CREATE TEMP TABLE tmpHashes (hash TEXT PRIMARY KEY NOT NULL)") + let insertHashSQL = "INSERT OR IGNORE INTO tmpHashes (hash) VALUES (?)" + let insertHashStatement = try db.makeStatement(sql: insertHashSQL) + try allHashes.forEach { try insertHashStatement.execute(arguments: [$0]) } + + /// Query the `snodeReceivedMessageInfo` table to extract the expiration for only the know hashes + let receivedMessageInfo: [Row] = try Row.fetchAll(db, sql: """ + SELECT + snodeReceivedMessageInfo.hash, + MIN(snodeReceivedMessageInfo.expirationDateMs) AS expirationDateMs + FROM snodeReceivedMessageInfo + JOIN tmpHashes ON tmpHashes.hash = snodeReceivedMessageInfo.hash + GROUP BY snodeReceivedMessageInfo.hash + """) + receivedMessageInfo.forEach { row in + expirationByHash[row["hash"]] = row["expirationDateMs"] + } + try db.execute(sql: "DROP TABLE tmpHashes") + } + + let threadVariants: [String: SessionThread.Variant] = threadInfo + .reduce(into: [:]) { result, row in + guard + let threadId: String = row["threadId"], + let rawThreadVariant: Int = row["threadVariant"], + let threadVariant: SessionThread.Variant = SessionThread.Variant(rawValue: rawThreadVariant) + else { return } + + result[threadId] = threadVariant + } + + /// Update the progress (from testing the above fetching took ~60% of the duration of the migration) + MigrationExecution.updateProgress(0.6) + + var recordsToInsert: [DedupeRecord] = [] + var processedKeys: Set = [] + + /// Process interactions + interactionInfo.forEach { row in + guard + let threadId: String = row["threadId"], + let rawInteractionVariant: Int = row["interactionVariant"], + let threadVariant: SessionThread.Variant = threadVariants[threadId], + let interactionVariant: Interaction.Variant = Interaction.Variant(rawValue: rawInteractionVariant), + let identifier: String = { + /// Messages stored on a snode should always have a `serverHash` value (aside from old control messages + /// which may not have because they were created locally and the hash wasn't attached during creation) + if let hash: String = row["serverHash"] { return hash } + + /// Outgoing blinded message requests are sent via a community so actually have a `openGroupServerMessageId` + /// instead of a `serverHash` even though they are considered `contact` conversations so we need to handle + /// both values to ensure we don't miss the deduplication record + if let id: Int64 = row["openGroupServerMessageId"] { return "\(id)" } + + /// Some control messages (and even buggy "proper" messages) could be inserted into the database without + /// either a `serverHash` or `openGroupServerMessageId` but still create a + /// `ControlMessageProcessRecord`, for those cases we want + if let variant: Int64 = row["variant"], let timestampMs: Int64 = row["timestampMs"] { + return "\(variant)-\(timestampMs)" + } + + /// If we have none of the above values then we can't dedupe this message at all + return nil + }() + else { return } + + let expirationTimestampSeconds: Int64? = { + /// Messages in a community conversation don't expire + guard threadVariant != .community else { return nil } + + /// If we have a server expiration for the hash then we should use that value as the priority + if + let hash: String = row["serverHash"], + let expirationTimestampMs: Int64 = expirationByHash[hash] + { + return (expirationTimestampMs / 1000) + } + + /// If this is a disappearing message then fallback to using that value + if + let expiresStartedAtMs: Int64 = row["expiresStartedAtMs"], + let expiresInSeconds: Int64 = row["expiresInSeconds"] + { + return ((expiresStartedAtMs / 1000) + expiresInSeconds) + } + + /// 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` + /// + /// 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) + } + + /// 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)) + }() + + /// Add `(SnodeReceivedMessage.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) } + + /// If this record would have already expired then there is no need to insert a record for it + guard (finalExpiryTimestampSeconds ?? timestampNowInSec) < timestampNowInSec else { return } + + /// 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) + /// + /// For `community` conversations we only poll while the conversation exists and have a `seqNo` to poll from in order + /// to prevent retrieving old messages + /// + /// Updated `group` conversations are a bit special because we want to delete _most_ records, but there are a few that + /// can cause issues if we process them again so we hold on to those just in case + let shouldDeleteWhenDeletingThread: Bool = { + switch threadVariant { + case .contact: return false + case .community, .legacyGroup: return true + case .group: return (interactionVariant != .infoGroupInfoInvited) + } + }() + + /// Add the record + recordsToInsert.append(( + threadId, + identifier, + ((row["timestampMs"] as? Int64) ?? timestampNowInSec), + finalExpiryTimestampSeconds, + shouldDeleteWhenDeletingThread + )) + + /// Store the legacy identifier if there would be one + guard let timestampMs: Int64 = row["timestampMs"] else { return } + + processedKeys.insert("\(threadId):\(legacyDedupeIdentifier(variant: interactionVariant, timestampMs: timestampMs))") + } + + /// Some control messages could be inserted into the database without either a `serverHash` or + /// `openGroupServerMessageId` but still create a `ControlMessageProcessRecord` in which case we still want + /// to dedupe the messages so we need to add these "legacy" deduplication records + controlMessageProcessRecords.forEach { row in + guard + let threadId: String = row["threadId"], + let threadVariant: SessionThread.Variant = threadVariants[threadId], + let rawVariant: Int = row["variant"], + let variant: ControlMessageProcessRecordVariant = ControlMessageProcessRecordVariant(rawValue: rawVariant), + let timestampMs: Int64 = row["timestampMs"] + else { return } + + /// Create a custom unique identifier for the legacy record (these will be deprecated and stop being added in a + /// subsequent release + let identifier: String = "LegacyRecord-\(rawVariant)-\(timestampMs)" + + guard !processedKeys.contains("\(threadId):\(identifier)") else { return } + + let expirationTimestampSeconds: Int64? = { + /// Messages in a community conversation don't expire + guard threadVariant != .community else { return nil } + + /// If we have a server expiration for the hash then we should use that value as the priority + if let serverExpirationTimestamp: TimeInterval = row["serverExpirationTimestamp"] { + return Int64(serverExpirationTimestamp) + } + + /// 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` + /// + /// 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) + }() + + /// Add `(SnodeReceivedMessage.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) } + + /// If this record would have already expired then there is no need to insert a record for it + guard (finalExpiryTimestampSeconds ?? timestampNowInSec) < timestampNowInSec else { return } + + /// 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) + /// + /// For `community` conversations we only poll while the conversation exists and have a `seqNo` to poll from in order + /// to prevent retrieving old messages + /// + /// Updated `group` conversations are a bit special because we want to delete _most_ records, but there are a few that + /// can cause issues if we process them again so we hold on to those just in case + let shouldDeleteWhenDeletingThread: Bool = { + switch variant { + case .groupUpdateInvite, .groupUpdatePromote, .groupUpdateMemberLeft, + .groupUpdateInviteResponse: + return false + default: return true + } + }() + + /// Add the record + recordsToInsert.append(( + threadId, + identifier, + timestampMs, + finalExpiryTimestampSeconds, + shouldDeleteWhenDeletingThread + )) + } + + /// Insert all of the dedupe records + try recordsToInsert.forEach { record in + try insertStatement.execute(arguments: [ + record.threadId, + record.identifier, + record.finalExpirationTimestampSeconds, + record.shouldDeleteWhenDeletingThread + ]) + + /// Create dedupe records for the PN extension + if record.timestampMs > oldestNotificationDedupeTimestampMs { + try dependencies[singleton: .extensionHelper].createDedupeRecord( + threadId: record.threadId, + uniqueIdentifier: record.identifier + ) + } + } + + /// Drop the old `controlMessageProcessRecord` table (since we no longer need it) + try db.execute(sql: "DROP TABLE controlMessageProcessRecord") + + MigrationExecution.updateProgress(1) + } +} + +internal extension _026_MessageDeduplicationTable { + static func legacyDedupeIdentifier( + variant: Interaction.Variant, + timestampMs: Int64 + ) -> String { + let processRecordVariant: ControlMessageProcessRecordVariant = { + switch variant { + case .standardOutgoing, .standardIncoming, ._legacyStandardIncomingDeleted, + .standardIncomingDeleted, .standardIncomingDeletedLocally, .standardOutgoingDeleted, + .standardOutgoingDeletedLocally, .infoLegacyGroupCreated: + return .visibleMessageDedupe + + case .infoLegacyGroupUpdated, .infoLegacyGroupCurrentUserLeft: return .legacyGroupControlMessage + case .infoDisappearingMessagesUpdate: return .expirationTimerUpdate + case .infoScreenshotNotification, .infoMediaSavedNotification: return .dataExtractionNotification + case .infoMessageRequestAccepted: return .messageRequestResponse + case .infoCall: return .call + case .infoGroupInfoUpdated: return .groupUpdateInfoChange + case .infoGroupInfoInvited, .infoGroupMembersUpdated: return .groupUpdateMemberChange + + case .infoGroupCurrentUserLeaving, .infoGroupCurrentUserErrorLeaving: + return .groupUpdateMemberLeft + } + }() + + return "LegacyRecord-\(processRecordVariant.rawValue)-\(timestampMs)" + } +} + +internal extension _026_MessageDeduplicationTable { + enum ControlMessageProcessRecordVariant: Int { + case readReceipt = 1 + case typingIndicator = 2 + case legacyGroupControlMessage = 3 + case dataExtractionNotification = 4 + case expirationTimerUpdate = 5 + case unsendRequest = 7 + case messageRequestResponse = 8 + case call = 9 + case visibleMessageDedupe = 10 + case groupUpdateInvite = 11 + case groupUpdatePromote = 12 + case groupUpdateInfoChange = 13 + case groupUpdateMemberChange = 14 + case groupUpdateMemberLeft = 15 + case groupUpdateMemberLeftNotification = 16 + case groupUpdateInviteResponse = 17 + case groupUpdateDeleteMemberContent = 18 + } +} diff --git a/SessionMessagingKit/Database/Migrations/_027_MoveSettingsToLibSession.swift b/SessionMessagingKit/Database/Migrations/_027_MoveSettingsToLibSession.swift new file mode 100644 index 0000000000..3f6353e72c --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_027_MoveSettingsToLibSession.swift @@ -0,0 +1,175 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUIKit +import SessionUtilitiesKit + +/// This migration extracts an old settings from the database and saves them into libSession +enum _027_MoveSettingsToLibSession: Migration { + static let target: TargetMigrations.Identifier = .messagingKit + static let identifier: String = "MoveSettingsToLibSession" + static let minExpectedRunDuration: TimeInterval = 0.1 + static let createdTables: [(TableRecord & FetchableRecord).Type] = [] + + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { + guard + MigrationHelper.userExists(db), + let userEd25519SecretKey: Data = MigrationHelper.fetchIdentityValue(db, key: "ed25519SecretKey") + else { return MigrationExecution.updateProgress(1) } + + let boolSettings: [Setting.BoolKey] = [ + .areReadReceiptsEnabled, + .typingIndicatorsEnabled, + .isScreenLockEnabled, + .areLinkPreviewsEnabled, + .isGiphyEnabled, + .areCallsEnabled, + .trimOpenGroupMessagesOlderThanSixMonths, + .hasHiddenMessageRequests, + .playNotificationSoundInForeground, + .hasViewedSeed, + .hideRecoveryPasswordPermanently, + .hasSavedThread, + .hasSentAMessage, + .shouldAutoPlayConsecutiveAudioMessages, + .developerModeEnabled, + .lastSeenHasLocalNetworkPermission, + .themeMatchSystemDayNightCycle + ] + let settings: [String: Data] = try Row + .fetchAll(db, sql: "SELECT key, value FROM keyValueStore") + .reduce(into: [:]) { result, next in + guard + let key: String = next["key"] as? String, + let data: Data = next["value"] as? Data + else { return } + + result[key] = data + } + let userSessionId: SessionId = MigrationHelper.userSessionId(db) + let cache: LibSession.Cache = LibSession.Cache(userSessionId: userSessionId, using: dependencies) + cache.setConfig( + for: .local, + sessionId: userSessionId, + to: try cache.loadState( + for: .local, + sessionId: userSessionId, + userEd25519SecretKey: Array(userEd25519SecretKey), + groupEd25519SecretKey: nil, + cachedData: MigrationHelper.configDump(db, for: ConfigDump.Variant.local.rawValue) + ) + ) + + var taskError: Error? + var mutation: LibSession.Mutation? + var keysToDrop: [String] = [ + "isReadyForAppExtensions" /// Removed as we can infer this based on `UserMetadata` existing now + ] + let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + Task { + do { + mutation = try cache.perform(for: .local) { + /// Move bool settings across + for key in boolSettings { + guard let data: Data = settings[key.rawValue] else { continue } + + let boolValue: Bool = data.withUnsafeBytes { $0.loadUnaligned(as: Bool.self) } + cache.set(key, boolValue) + keysToDrop.append(key.rawValue) + } + + /// Move enum settings across explicitly (since they need to be set using their enum values) + if + let data: Data = settings[Setting.EnumKey.preferencesNotificationPreviewType.rawValue], + let enumValue: Preferences.NotificationPreviewType = Preferences.NotificationPreviewType( + rawValue: data.withUnsafeBytes { $0.loadUnaligned(as: Int.self) } + ) + { + cache.set(.preferencesNotificationPreviewType, enumValue) + keysToDrop.append(Setting.EnumKey.preferencesNotificationPreviewType.rawValue) + } + + if + let data: Data = settings[Setting.EnumKey.defaultNotificationSound.rawValue], + let enumValue: Preferences.Sound = Preferences.Sound( + rawValue: data.withUnsafeBytes { $0.loadUnaligned(as: Int.self) } + ) + { + cache.set(.defaultNotificationSound, enumValue) + keysToDrop.append(Setting.EnumKey.defaultNotificationSound.rawValue) + } + + /// Convert the `theme` value from a `String` to an `Int` + if + let data: Data = settings[Setting.EnumKey.theme.rawValue], + let stringValue: String = String(data: data, encoding: .utf8), + let enumValue: Theme = Theme(legacyStringKey: stringValue) + { + cache.set(.theme, enumValue) + keysToDrop.append(Setting.EnumKey.theme.rawValue) + } + + /// Convert the `themePrimaryColor` value from a `String` to an `Int` + if + let data: Data = settings[Setting.EnumKey.themePrimaryColor.rawValue], + let stringValue: String = String(data: data, encoding: .utf8), + let enumValue: Theme.PrimaryColor = Theme.PrimaryColor(legacyStringKey: stringValue) + { + cache.set(.themePrimaryColor, enumValue) + keysToDrop.append(Setting.EnumKey.themePrimaryColor.rawValue) + } + } + } + catch { taskError = error } + + semaphore.signal() + } + semaphore.wait() + + /// If an error occurred then throw it + if let error: Error = taskError { + throw error + } + + /// Save the updated config dump + try mutation?.upsert(db) + + /// Delete the old settings (since they should no longer be accessed via the database) + try db.execute(sql: """ + DELETE FROM keyValueStore + WHERE key IN (\(keysToDrop.map { "'\($0)'" }.joined(separator: ", "))) + """) + + MigrationExecution.updateProgress(1) + } +} + +// MARK: - Converted types + +private extension Theme { + init?(legacyStringKey: String) { + switch legacyStringKey { + case "classic_dark": self = .classicDark + case "classic_light": self = .classicLight + case "ocean_dark": self = .oceanDark + case "ocean_light": self = .oceanLight + default: return nil + } + } +} + +private extension Theme.PrimaryColor { + init?(legacyStringKey: String) { + switch legacyStringKey { + case "green": self = .green + case "blue": self = .blue + case "yellow": self = .yellow + case "pink": self = .pink + case "purple": self = .purple + case "orange": self = .orange + case "red": self = .red + default: return nil + } + } +} diff --git a/SessionMessagingKit/Database/Migrations/_028_RenameAttachments.swift b/SessionMessagingKit/Database/Migrations/_028_RenameAttachments.swift new file mode 100644 index 0000000000..2c92c21d11 --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_028_RenameAttachments.swift @@ -0,0 +1,310 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import UniformTypeIdentifiers +import GRDB +import SessionSnodeKit +import SessionUtilitiesKit + +/// This migration renames all attachments to use a hash of the download url for the filename instead of a random UUID (means we can +/// generate the filename just from the URL and don't need to store the filename) +enum _028_RenameAttachments: Migration { + static let target: TargetMigrations.Identifier = .messagingKit + static let identifier: String = "RenameAttachments" + static let minExpectedRunDuration: TimeInterval = 3 + static let createdTables: [(TableRecord & FetchableRecord).Type] = [] + + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { + /// Define the paths and ensure they exist + let sharedDataProfileAvatarDirPath: String = URL(fileURLWithPath: SessionFileManager.nonInjectedAppSharedDataDirectoryPath) + .appendingPathComponent("ProfileAvatars") + .path + let sharedDataDisplayPicturesDirPath: String = URL(fileURLWithPath: SessionFileManager.nonInjectedAppSharedDataDirectoryPath) + .appendingPathComponent("DisplayPictures") + .path + let sharedDataAttachmentsDirPath: String = URL(fileURLWithPath: SessionFileManager.nonInjectedAppSharedDataDirectoryPath) + .appendingPathComponent("Attachments") + .path + try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: sharedDataProfileAvatarDirPath) + try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: sharedDataDisplayPicturesDirPath) + try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: sharedDataAttachmentsDirPath) + + /// Fetch the data we need from the database + let profileInfo: [Row] = try Row.fetchAll( + db, + sql: """ + SELECT profilePictureUrl, profilePictureFileName + FROM profile + WHERE profilePictureFileName IS NOT NULL + """) + let communityInfo: [Row] = try Row.fetchAll( + db, + sql: """ + SELECT server, roomToken, imageId, displayPictureFilename + FROM openGroup + WHERE displayPictureFilename IS NOT NULL + """) + let groupInfo: [Row] = try Row.fetchAll( + db, + sql: """ + SELECT displayPictureUrl, displayPictureFilename + FROM closedGroup + WHERE displayPictureFilename IS NOT NULL + """) + + /// This change is unrelated to the attachments but since we are dropping deprecated columns we may as well do this one + try db.execute(sql: "ALTER TABLE thread DROP COLUMN isPinned") + + /// Drop the unused columns and rename others for consistency (do this before moving the files in case there is an error as the + /// database query can be rolled back but moving files can't) + /// `profilePictureFileName` - deprecated by this migration + /// `profilePictureUrl` to `displayPictureUrl` for consistency + /// `profileEncryptionKey` to `displayPictureEncryptionKey` for consistency + /// `lastProfilePictureUpdate` to `displayPictureLastUpdated` for consistency + try db.execute(sql: "ALTER TABLE profile DROP COLUMN profilePictureFileName") + try db.execute(sql: "ALTER TABLE profile RENAME COLUMN profilePictureUrl TO displayPictureUrl") + try db.execute(sql: "ALTER TABLE profile RENAME COLUMN profileEncryptionKey TO displayPictureEncryptionKey") + try db.execute(sql: "ALTER TABLE profile RENAME COLUMN lastProfilePictureUpdate TO displayPictureLastUpdated") + + /// Group changes: + /// `displayPictureFilename` - deprecated by this migration + /// `lastDisplayPictureUpdate` - the `GroupInfo` config has a `seqNo` so don't need this for V2 groups + try db.execute(sql: "ALTER TABLE closedGroup DROP COLUMN displayPictureFilename") + try db.execute(sql: "ALTER TABLE closedGroup DROP COLUMN lastDisplayPictureUpdate") + + /// Since the filename is a hash of the URL if the API changes then the hash generated would change so we need to store + /// the `originalDisplayPictureUrl` that was used to generate the hash so we can always generate the same filename + try db.alter(table: "openGroup") { table in + table.add(column: "displayPictureOriginalUrl", .text) + } + let processedCommunityInfo: [(urlString: String, filename: String)] = try communityInfo.compactMap { info in + guard + let server: String = info["server"] as? String, + let roomToken: String = info["roomToken"] as? String, + let imageId: String = info["imageId"] as? String, + let filename: String = info["displayPictureFilename"] as? String, + dependencies[singleton: .fileManager].fileExists( + atPath: sharedDataProfileAvatarDirPath.appending("/\(filename)") + ), + /// At the time of writing this migration this was the structure of the downloadUrl for a community display picture + let urlString: String = "\(server)/room/\(roomToken)/file/\(imageId)".nullIfEmpty + else { return nil } + + try db.execute( + sql: "UPDATE openGroup SET displayPictureOriginalUrl = ? WHERE displayPictureFilename = ?", + arguments: [urlString, filename] + ) + + return (urlString, filename) + } + + /// Now that we've set the `originalDisplayPictureUrl` values we can drop the unused columns: + /// `displayPictureFilename` - deprecated by this migration + /// `imageData` - old deprecated column + /// `lastDisplayPictureUpdate` - `OpenGroup` has an `imageId` so don't need this + try db.execute(sql: "ALTER TABLE openGroup DROP COLUMN displayPictureFilename") + try db.execute(sql: "ALTER TABLE openGroup DROP COLUMN imageData") /// Old deprecated column + try db.execute(sql: "ALTER TABLE openGroup DROP COLUMN lastDisplayPictureUpdate") + + /// Quotes now render using the original message attachment rather than a separate attachment file so we can remove any + /// legacy quote attachments + try db.execute(sql: """ + DELETE FROM attachment + WHERE ( + sourceFilename LIKE 'quoted-thumbnail-%' OR + downloadUrl = 'NON_MEDIA_QUOTE_FILE_ID' + ) + """) + + /// There also seemed to be an issue where the `encryptionKey` and `digest` could incorrectly be 0-byte values instead + /// of `NULL` so we should clean that up as well + try db.execute(sql: """ + UPDATE attachment + SET encryptionKey = NULL, digest = NULL + WHERE LENGTH(encryptionKey) = 0 OR LENGTH(digest) = 0 + """) + + /// Fetch attachment data which we care about and then drop the unneeded columns + let attachmentInfo: [Row] = try Row.fetchAll( + db, + sql: """ + SELECT id, downloadUrl, localRelativeFilePath + FROM attachment + WHERE localRelativeFilePath IS NOT NULL + """) + try db.execute(sql: "ALTER TABLE attachment DROP COLUMN localRelativeFilePath") + try db.execute(sql: "DROP INDEX IF EXISTS quote_on_attachmentId") + try db.execute(sql: "ALTER TABLE quote DROP COLUMN attachmentId") + + /// There was a point where we weren't correctly storing the `downloadUrl` and, since the files path is now derived from it, + /// we will need to set it to some value so we can still generate a unique file path for it - we _might_ be able to "recover" the + /// original url in some cases if we were to join and fetch thread-related data but there isn't much benefit to doing so, so we just + /// generate a unique invalid url instead + /// + /// Since we need to iterate through the `attachmentInfo` to do this we may as well fenerate the final set + /// of `filename` <-> `urlHash` values at the same time + let attachmentsToRename: [(filename: String, urlHash: String)] = [] + .appending( + contentsOf: try attachmentInfo.enumerated().compactMap { index, info in + let urlString: String = { + if let urlString: String = (info["downloadUrl"] as? String)?.nullIfEmpty { + return urlString + } + + return Network.FileServer.downloadUrlString(for: "invalid-legacy-file-\(index)") + }() + + guard + let filename: String = info["localRelativeFilePath"] as? String, + dependencies[singleton: .fileManager].fileExists( + atPath: sharedDataAttachmentsDirPath.appending("/\(filename)") + ), + let urlHash: String = dependencies[singleton: .crypto] + .generate(.hash(message: Array(urlString.utf8)))? + .toHexString() + else { + /// The file somehow doens't exist on the system so we should mark the attachment as invalid + if let id: String = info["id"] as? String { + Log.info(.migration, "Marking attachment \(id) (\(urlString)) as invalid as it's local file is missing") + try db.execute( + sql: """ + UPDATE attachment + SET isValid = false + WHERE id = ? + """, + arguments: [id] + ) + } + + return nil + } + + return (filename, urlHash) + } + ) + + /// Generate a final set of `filename` <-> `urlHash` values + let displayPicturesToRename: [(filename: String, urlHash: String)] = [] + .appending( + contentsOf: profileInfo.compactMap { info in + guard + let urlString: String = info["profilePictureUrl"] as? String, + let filename: String = info["profilePictureFileName"] as? String, + dependencies[singleton: .fileManager].fileExists( + atPath: sharedDataProfileAvatarDirPath.appending("/\(filename)") + ), + let urlHash: String = dependencies[singleton: .crypto] + .generate(.hash(message: Array(urlString.utf8)))? + .toHexString() + else { return nil } + + return (filename, urlHash) + } + ) + .appending( + contentsOf: processedCommunityInfo.compactMap { urlString, filename in + /// Already checked for the file existence so no need to do so here + guard + let urlHash: String = dependencies[singleton: .crypto] + .generate(.hash(message: Array(urlString.utf8)))? + .toHexString() + else { return nil } + + return (filename, urlHash) + } + ) + .appending( + contentsOf: groupInfo.compactMap { info in + guard + let urlString: String = info["displayPictureUrl"] as? String, + let filename: String = info["displayPictureFilename"] as? String, + dependencies[singleton: .fileManager].fileExists( + atPath: sharedDataProfileAvatarDirPath.appending("/\(filename)") + ), + let urlHash: String = dependencies[singleton: .crypto] + .generate(.hash(message: Array(urlString.utf8)))? + .toHexString() + else { return nil } + + return (filename, urlHash) + } + ) + + /// Go through and actually rename the display picture files + /// + /// **Note:** The old file names **all** had a `jpg` extension (even if they weren't `jpg` files) so for the new files we + /// just don't give them extensions + var processedHashes: Set = [] + displayPicturesToRename.forEach { filename, urlHash in + do { + try dependencies[singleton: .fileManager].moveItem( + atPath: sharedDataProfileAvatarDirPath.appending("/\(filename)"), + toPath: sharedDataDisplayPicturesDirPath.appending("/\(urlHash)") + ) + processedHashes.insert(urlHash) + } + catch { + /// It looks like there was an issue previously where multiple profiles could use the same URL but end up with + /// different files (because the generated name was random), now these will end up with the same name so if + /// that occurs we just want to remove the duplicate file + if processedHashes.contains(urlHash) { + try? dependencies[singleton: .fileManager].removeItem( + atPath: sharedDataProfileAvatarDirPath.appending("/\(filename)") + ) + } + else { + Log.warn("Failed to rename display picture due to error: \(error)") + } + } + } + + /// Remove the old `ProfileAvatars` path + try? dependencies[singleton: .fileManager].removeItem(atPath: sharedDataProfileAvatarDirPath) + + /// Go through and actually rename the attachment files + /// + /// **Note:** The old file names included extensions but we already store a `contentType` in the database which is used + /// when handling the file rather than the extension so don't bother adding the extension + var processedAttachmentHashes: Set = [] + attachmentsToRename.forEach { filename, urlHash in + do { + try dependencies[singleton: .fileManager].moveItem( + atPath: sharedDataAttachmentsDirPath.appending("/\(filename)"), + toPath: sharedDataAttachmentsDirPath.appending("/\(urlHash)") + ) + processedAttachmentHashes.insert(urlHash) + + /// Attachments could previously be stored in child directories so if we just moved the only file in an attachments + /// directory then we should remove the directory + if filename.contains("/") { + let directoryPath: String = sharedDataAttachmentsDirPath + .appending("/\(String(filename.split(separator: "/")[0]))") + + if dependencies[singleton: .fileManager].isDirectoryEmpty(atPath: directoryPath) { + try? dependencies[singleton: .fileManager].removeItem(atPath: directoryPath) + } + } + } + catch { + /// There are some rare cases where an attachment could resolve to the same hash as another attachment so we + /// also need to handle that case here - attachments can also be stored in child directories so we need to detect that + /// case as well + if processedAttachmentHashes.contains(urlHash) { + let targetLastPathComponent: String = (filename.contains("/") ? + String(filename.split(separator: "/")[0]) : + filename + ) + + try? dependencies[singleton: .fileManager].removeItem( + atPath: sharedDataAttachmentsDirPath.appending("/\(targetLastPathComponent)") + ) + } + else { + Log.warn("Failed to rename attachment due to error: \(error)") + } + } + } + + MigrationExecution.updateProgress(1) + } +} diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index a62c945c9a..ee84d5b09f 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -12,7 +12,6 @@ import SessionUIKit public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "attachment" } - internal static let quoteForeignKey = ForeignKey([Columns.id], to: [Quote.Columns.attachmentId]) internal static let linkPreviewForeignKey = ForeignKey([Columns.id], to: [LinkPreview.Columns.attachmentId]) public static let interactionAttachments = hasOne(InteractionAttachment.self) public static let interaction = hasOne( @@ -20,7 +19,6 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR through: interactionAttachments, using: InteractionAttachment.interaction ) - fileprivate static let quote = belongsTo(Quote.self, using: quoteForeignKey) fileprivate static let linkPreview = belongsTo(LinkPreview.self, using: linkPreviewForeignKey) public typealias Columns = CodingKeys @@ -34,7 +32,6 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR case creationTimestamp case sourceFilename case downloadUrl - case localRelativeFilePath case width case height case duration @@ -101,11 +98,6 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR /// **Note:** The url is a fully constructed url but the clients just extract the id from the end of the url to perform the actual download public let downloadUrl: String? - /// The file path for the attachment relative to the attachments folder - /// - /// **Note:** We store this path so that file path generation changes don’t break existing attachments - public let localRelativeFilePath: String? - /// The width of the attachment, this will be `null` for non-visual attachment types public let width: UInt? @@ -142,7 +134,6 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR creationTimestamp: TimeInterval? = nil, sourceFilename: String? = nil, downloadUrl: String? = nil, - localRelativeFilePath: String? = nil, width: UInt? = nil, height: UInt? = nil, duration: TimeInterval? = nil, @@ -161,7 +152,6 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR self.creationTimestamp = creationTimestamp self.sourceFilename = sourceFilename self.downloadUrl = downloadUrl - self.localRelativeFilePath = localRelativeFilePath self.width = width self.height = height self.duration = duration @@ -182,21 +172,23 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR caption: String? = nil, using dependencies: Dependencies ) { - guard let originalFilePath: String = Attachment.originalFilePath(id: id, mimeType: contentType, sourceFilename: sourceFilename, using: dependencies) else { - return nil - } - guard case .success = Result(try dataSource.write(to: originalFilePath)) else { return nil } + guard + let uploadInfo: (url: String, path: String) = try? dependencies[singleton: .attachmentManager] + .uploadPathAndUrl(for: id), + case .success = Result(try dataSource.write(to: uploadInfo.path)) + else { return nil } - let imageSize: CGSize? = Attachment.imageSize( - contentType: contentType, - originalFilePath: originalFilePath, + let imageSize: CGSize? = Data.mediaSize( + for: uploadInfo.path, + type: UTType(sessionMimeType: contentType), + mimeType: contentType, + sourceFilename: sourceFilename, using: dependencies ) - let (isValid, duration): (Bool, TimeInterval?) = Attachment.determineValidityAndDuration( + let (isValid, duration): (Bool, TimeInterval?) = dependencies[singleton: .attachmentManager].determineValidityAndDuration( contentType: contentType, - localRelativeFilePath: nil, - originalFilePath: originalFilePath, - using: dependencies + downloadUrl: uploadInfo.url, + sourceFilename: sourceFilename ) self.id = id @@ -207,8 +199,7 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR self.byteCount = UInt(dataSource.dataLength) self.creationTimestamp = nil self.sourceFilename = sourceFilename - self.downloadUrl = nil - self.localRelativeFilePath = Attachment.localRelativeFilePath(from: originalFilePath, using: dependencies) + self.downloadUrl = uploadInfo.url /// This value will be replaced once the upload is successful self.width = imageSize.map { UInt(floor($0.width)) } self.height = imageSize.map { UInt(floor($0.height)) } self.duration = duration @@ -248,6 +239,37 @@ extension Attachment: CustomStringConvertible { self.contentType = contentType self.sourceFilename = sourceFilename } + + public init(id: String, proto: SNProtoAttachmentPointer, sourceFilename: String? = nil) { + self.init( + id: id, + variant: { + let voiceMessageFlag: Int32 = SNProtoAttachmentPointer.SNProtoAttachmentPointerFlags + .voiceMessage + .rawValue + + guard proto.hasFlags && ((proto.flags & UInt32(voiceMessageFlag)) > 0) else { + return .standard + } + + return .voiceMessage + }(), + contentType: ( + proto.contentType ?? + Attachment.inferContentType(from: proto.fileName) + ), + sourceFilename: sourceFilename + ) + } + } + + public var descriptionInfo: DescriptionInfo { + Attachment.DescriptionInfo( + id: id, + variant: variant, + contentType: contentType, + sourceFilename: sourceFilename + ) } public static func description(for descriptionInfo: DescriptionInfo?, count: Int?) -> String? { @@ -325,19 +347,42 @@ extension Attachment { state: State? = nil, creationTimestamp: TimeInterval? = nil, downloadUrl: String? = nil, - localRelativeFilePath: String? = nil, encryptionKey: Data? = nil, digest: Data? = nil, using dependencies: Dependencies ) -> Attachment { + /// If the `downloadUrl` previously had a value and we are updating it then we need to move the file from it's current location + /// to the hash that would be generated for the new location + /// + /// We default `finalDownloadUrl` to the current `downloadUrl` just in case moving the file fails (in which case we don't + /// want to update it or we won't be able to resolve the stored file), but if we don't currently have a `downloadUrl` then we can + /// just use the new one + var finalDownloadUrl: String? = (self.downloadUrl ?? downloadUrl) + + if + let newUrl: String = downloadUrl, + let oldUrl: String = self.downloadUrl, + newUrl != oldUrl + { + if + let oldPath: String = try? dependencies[singleton: .attachmentManager].path(for: oldUrl), + let newPath: String = try? dependencies[singleton: .attachmentManager].path(for: newUrl) + { + do { + try dependencies[singleton: .fileManager].moveItem(atPath: oldPath, toPath: newPath) + finalDownloadUrl = newUrl + } + catch {} + } + } + let (isValid, duration): (Bool, TimeInterval?) = { switch (self.state, state) { case (_, .downloaded): - return Attachment.determineValidityAndDuration( + return dependencies[singleton: .attachmentManager].determineValidityAndDuration( contentType: contentType, - localRelativeFilePath: localRelativeFilePath, - originalFilePath: originalFilePath(using: dependencies), - using: dependencies + downloadUrl: finalDownloadUrl, + sourceFilename: sourceFilename ) // Assume the data is already correct for "uploading" attachments (and don't override it) @@ -354,11 +399,20 @@ extension Attachment { if let width: UInt = self.width, let height: UInt = self.height, width > 0, height > 0 { return CGSize(width: Int(width), height: Int(height)) } - guard isVisualMedia else { return nil } - guard state == .downloaded else { return nil } - guard let originalFilePath: String = originalFilePath(using: dependencies) else { return nil } + guard + isVisualMedia, + state == .downloaded, + let path: String = try? dependencies[singleton: .attachmentManager] + .path(for: finalDownloadUrl) + else { return nil } - return Attachment.imageSize(contentType: contentType, originalFilePath: originalFilePath, using: dependencies) + return Data.mediaSize( + for: path, + type: UTType(sessionMimeType: contentType), + mimeType: contentType, + sourceFilename: sourceFilename, + using: dependencies + ) }() return Attachment( @@ -370,8 +424,7 @@ extension Attachment { byteCount: byteCount, creationTimestamp: (creationTimestamp ?? self.creationTimestamp), sourceFilename: sourceFilename, - downloadUrl: (downloadUrl ?? self.downloadUrl), - localRelativeFilePath: (localRelativeFilePath ?? self.localRelativeFilePath), + downloadUrl: finalDownloadUrl, width: attachmentResolution.map { UInt($0.width) }, height: attachmentResolution.map { UInt($0.height) }, duration: duration, @@ -391,16 +444,16 @@ extension Attachment { // MARK: - Protobuf extension Attachment { - public init(proto: SNProtoAttachmentPointer) { - func inferContentType(from filename: String?) -> String { - guard - let fileName: String = filename, - let fileExtension: String = URL(string: fileName)?.pathExtension - else { return UTType.mimeTypeDefault } - - return (UTType.sessionMimeType(for: fileExtension) ?? UTType.mimeTypeDefault) - } + public static func inferContentType(from filename: String?) -> String { + guard + let fileName: String = filename, + let fileExtension: String = URL(string: fileName)?.pathExtension + else { return UTType.mimeTypeDefault } + return (UTType.sessionMimeType(for: fileExtension) ?? UTType.mimeTypeDefault) + } + + public init(proto: SNProtoAttachmentPointer) { self.id = UUID().uuidString self.serverId = "\(proto.id)" self.variant = { @@ -415,12 +468,11 @@ extension Attachment { return .voiceMessage }() self.state = .pendingDownload - self.contentType = (proto.contentType ?? inferContentType(from: proto.fileName)) + self.contentType = (proto.contentType ?? Attachment.inferContentType(from: proto.fileName)) self.byteCount = UInt(proto.size) self.creationTimestamp = nil self.sourceFilename = proto.fileName self.downloadUrl = proto.url - self.localRelativeFilePath = nil self.width = (proto.hasWidth && proto.width > 0 ? UInt(proto.width) : nil) self.height = (proto.hasHeight && proto.height > 0 ? UInt(proto.height) : nil) self.duration = nil // Needs to be downloaded to be set @@ -496,7 +548,6 @@ extension Attachment { public static func stateInfo(authorId: String, state: State? = nil) -> SQLRequest { let attachment: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() - let quote: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() let linkPreview: TypedTableAlias = TypedTableAlias() @@ -515,7 +566,6 @@ extension Attachment { JOIN \(Interaction.self) ON \(SQL("\(interaction[.authorId]) = \(authorId)")) AND ( - \(interaction[.id]) = \(quote[.interactionId]) OR \(interaction[.id]) = \(interactionAttachment[.interactionId]) OR ( \(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) AND @@ -523,7 +573,6 @@ extension Attachment { ) ) - LEFT JOIN \(Quote.self) ON \(quote[.attachmentId]) = \(attachment[.id]) LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) LEFT JOIN \(LinkPreview.self) ON \(linkPreview[.attachmentId]) = \(attachment[.id]) AND @@ -542,7 +591,6 @@ extension Attachment { public static func stateInfo(interactionId: Int64, state: State? = nil) -> SQLRequest { let attachment: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() - let quote: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() let linkPreview: TypedTableAlias = TypedTableAlias() @@ -561,7 +609,6 @@ extension Attachment { JOIN \(Interaction.self) ON \(SQL("\(interaction[.id]) = \(interactionId)")) AND ( - \(interaction[.id]) = \(quote[.interactionId]) OR \(interaction[.id]) = \(interactionAttachment[.interactionId]) OR ( \(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) AND @@ -569,7 +616,6 @@ extension Attachment { ) ) - LEFT JOIN \(Quote.self) ON \(quote[.attachmentId]) = \(attachment[.id]) LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) LEFT JOIN \(LinkPreview.self) ON \(linkPreview[.attachmentId]) = \(attachment[.id]) AND @@ -584,256 +630,9 @@ extension Attachment { } } -// MARK: - Convenience - Static - -extension Attachment { - private static let thumbnailDimensionSmall: UInt = 200 - private static let thumbnailDimensionMedium: UInt = 450 - - /// This size is large enough to render full screen - private static var thumbnailDimensionLarge: UInt = { - let screenSizePoints: CGSize = UIScreen.main.bounds.size - let minZoomFactor: CGFloat = UIScreen.main.scale - - return UInt(floor(max(screenSizePoints.width, screenSizePoints.height) * minZoomFactor)) - }() - - private static var sharedDataAttachmentsDirPath: String = { - URL(fileURLWithPath: SessionFileManager.nonInjectedAppSharedDataDirectoryPath) - .appendingPathComponent("Attachments") // stringlint:ignore - .path - }() - - public static func attachmentsFolder(using dependencies: Dependencies) -> String { - let attachmentsFolder: String = sharedDataAttachmentsDirPath - try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: attachmentsFolder) - - return attachmentsFolder - } - - public static func resetAttachmentStorage(using dependencies: Dependencies) { - try? dependencies[singleton: .fileManager].removeItem(atPath: Attachment.sharedDataAttachmentsDirPath) - } - - public static func originalFilePath(id: String, mimeType: String, sourceFilename: String?, using dependencies: Dependencies) -> String? { - if let sourceFilename: String = sourceFilename, !sourceFilename.isEmpty { - // Ensure that the filename is a valid filesystem name, - // replacing invalid characters with an underscore. - // stringlint:ignore_start - var normalizedFileName: String = sourceFilename - .trimmingCharacters(in: .whitespacesAndNewlines) - .components(separatedBy: .whitespacesAndNewlines) - .joined(separator: "_") - .components(separatedBy: .illegalCharacters) - .joined(separator: "_") - .components(separatedBy: .controlCharacters) - .joined(separator: "_") - .components(separatedBy: CharacterSet(charactersIn: "<>|\\:()&;?*/~")) - .joined(separator: "_") - // stringlint:ignore_stop - - while normalizedFileName.hasPrefix(".") { // stringlint:ignore - normalizedFileName = String(normalizedFileName.substring(from: 1)) - } - - var targetFileExtension: String = URL(fileURLWithPath: normalizedFileName).pathExtension - let filenameWithoutExtension: String = URL(fileURLWithPath: normalizedFileName) - .deletingPathExtension() - .lastPathComponent - .trimmingCharacters(in: .whitespacesAndNewlines) - - // If the filename has not file extension, deduce one - // from the MIME type. - if targetFileExtension.isEmpty { - targetFileExtension = ( - UTType(sessionMimeType: mimeType)?.sessionFileExtension(sourceFilename: sourceFilename) ?? - UTType.fileExtensionDefault - ) - } - - targetFileExtension = targetFileExtension.lowercased() - - if !targetFileExtension.isEmpty { - // Store the file in a subdirectory whose name is the uniqueId of this attachment, - // to avoid collisions between multiple attachments with the same name - let attachmentFolder: String = Attachment - .attachmentsFolder(using: dependencies) - .appending("/\(id)") // stringlint:ignore - - guard case .success = Result(try dependencies[singleton: .fileManager].ensureDirectoryExists(at: attachmentFolder)) else { - return nil - } - - return attachmentFolder - .appending("/\(filenameWithoutExtension).\(targetFileExtension)") // stringlint:ignore - } - } - - let targetFileExtension: String = ( - UTType(sessionMimeType: mimeType)?.sessionFileExtension(sourceFilename: sourceFilename) ?? - UTType.fileExtensionDefault - ).lowercased() - - return Attachment - .attachmentsFolder(using: dependencies) - .appending("/\(id).\(targetFileExtension)") // stringlint:ignore - } - - public static func localRelativeFilePath(from originalFilePath: String?, using dependencies: Dependencies) -> String? { - guard let originalFilePath: String = originalFilePath else { return nil } - - return originalFilePath - .substring(from: (Attachment.attachmentsFolder(using: dependencies).count + 1)) // Leading forward slash - } - - internal static func imageSize(contentType: String, originalFilePath: String, using dependencies: Dependencies) -> CGSize? { - let type: UTType? = UTType(sessionMimeType: contentType) - - guard type?.isVideo == true || type?.isImage == true || type?.isAnimated == true else { return nil } - - if type?.isVideo == true { - guard MediaUtils.isValidVideo(path: originalFilePath, using: dependencies) else { return nil } - - return Attachment.videoStillImage(filePath: originalFilePath, using: dependencies)?.size - } - - return Data.imageSize(for: originalFilePath, type: type, using: dependencies) - } - - public static func videoStillImage(filePath: String, using dependencies: Dependencies) -> UIImage? { - return try? MediaUtils.thumbnail( - forVideoAtPath: filePath, - maxDimension: CGFloat(Attachment.thumbnailDimensionLarge), - using: dependencies - ) - } - - internal static func determineValidityAndDuration( - contentType: String, - localRelativeFilePath: String?, - originalFilePath: String?, - using dependencies: Dependencies - ) -> (isValid: Bool, duration: TimeInterval?) { - guard let originalFilePath: String = originalFilePath else { return (false, nil) } - - let constructedFilePath: String? = localRelativeFilePath.map { - URL(fileURLWithPath: Attachment.attachmentsFolder(using: dependencies)) - .appendingPathComponent($0) - .path - } - let targetPath: String = (constructedFilePath ?? originalFilePath) - - // Process audio attachments - if UTType.isAudio(contentType) { - do { - let audioPlayer: AVAudioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: targetPath)) - - return ((audioPlayer.duration > 0), audioPlayer.duration) - } - catch { - switch (error as NSError).code { - case Int(kAudioFileInvalidFileError), Int(kAudioFileStreamError_InvalidFile): - // Ignore "invalid audio file" errors - return (false, nil) - - default: return (false, nil) - } - } - } - - // Process image attachments - if UTType.isImage(contentType) || UTType.isAnimated(contentType) { - return ( - Data.isValidImage(at: targetPath, type: UTType(sessionMimeType: contentType), using: dependencies), - nil - ) - } - - // Process video attachments - if UTType.isVideo(contentType) { - let asset: AVURLAsset = AVURLAsset(url: URL(fileURLWithPath: targetPath), options: nil) - let durationSeconds: TimeInterval = ( - // According to the CMTime docs "value/timescale = seconds" - TimeInterval(asset.duration.value) / TimeInterval(asset.duration.timescale) - ) - - return ( - MediaUtils.isValidVideo(path: targetPath, using: dependencies), - durationSeconds - ) - } - - // Any other attachment types are valid and have no duration - return (true, nil) - } -} - // MARK: - Convenience extension Attachment { - public static let nonMediaQuoteFileId: String = "NON_MEDIA_QUOTE_FILE_ID" // stringlint:ignore - - public enum ThumbnailSize { - case small - case medium - case large - - public var dimension: UInt { - switch self { - case .small: return Attachment.thumbnailDimensionSmall - case .medium: return Attachment.thumbnailDimensionMedium - case .large: return Attachment.thumbnailDimensionLarge - } - } - } - - public func originalFilePath(using dependencies: Dependencies) -> String? { - if let localRelativeFilePath: String = self.localRelativeFilePath { - return URL(fileURLWithPath: Attachment.attachmentsFolder(using: dependencies)) - .appendingPathComponent(localRelativeFilePath) - .path - } - - return Attachment.originalFilePath( - id: self.id, - mimeType: self.contentType, - sourceFilename: self.sourceFilename, - using: dependencies - ) - } - - var thumbnailsDirPath: String { - // Thumbnails are written to the caches directory, so that iOS can - // remove them if necessary - return "\(SessionFileManager.cachesDirectoryPath)/\(id)-thumbnails" // stringlint:ignore - } - - func legacyThumbnailPath(using dependencies: Dependencies) -> String? { - guard - let originalFilePath: String = originalFilePath(using: dependencies), - (isImage || isVideo || isAnimated) - else { return nil } - - let fileUrl: URL = URL(fileURLWithPath: originalFilePath) - let filename: String = fileUrl.lastPathComponent.filenameWithoutExtension - let containingDir: String = fileUrl.deletingLastPathComponent().path - - return "\(containingDir)/\(filename)-signal-ios-thumbnail.jpg" // stringlint:ignore - } - - func originalImage(using dependencies: Dependencies) -> UIImage? { - guard let originalFilePath: String = originalFilePath(using: dependencies) else { return nil } - - if isVideo { - return Attachment.videoStillImage(filePath: originalFilePath, using: dependencies) - } - - guard isImage || isAnimated else { return nil } - guard isValid else { return nil } - - return UIImage(contentsOfFile: originalFilePath) - } - public var isImage: Bool { UTType.isImage(contentType) } public var isVideo: Bool { UTType.isVideo(contentType) } public var isAnimated: Bool { UTType.isAnimated(contentType) } @@ -863,213 +662,21 @@ extension Attachment { } public func readDataFromFile(using dependencies: Dependencies) throws -> Data? { - guard let filePath: String = self.originalFilePath(using: dependencies) else { - return nil - } - - return try Data(contentsOf: URL(fileURLWithPath: filePath)) - } - - public func thumbnailPath(for dimensions: UInt) -> String { - return "\(thumbnailsDirPath)/thumbnail-\(dimensions).jpg" // stringlint:ignore - } - - private func loadThumbnail( - with dimensions: UInt, - using dependencies: Dependencies, - success: @escaping (String, () -> UIImage?, () throws -> Data) -> (), - failure: @escaping () -> () - ) { - guard - let targetSize: CGSize = { - guard - let width: UInt = self.width, - let height: UInt = self.height, - width > 1, - height > 1 - else { - guard let filePath: String = self.originalFilePath(using: dependencies) else { - return .zero - } - - let fallbackSize: CGSize = Data.imageSize(for: filePath, type: UTType(sessionMimeType: contentType), using: dependencies) - - guard fallbackSize.width > 1 && fallbackSize.height > 1 else { - return .zero - } - - return fallbackSize - } - - return CGSize(width: Int(width), height: Int(height)) - }(), - targetSize.width > 1 && - targetSize.height > 1 - else { return failure() } - - /// There's no point in generating a thumbnail if the original is smaller than the thumbnail size and it's not a video - if - (Int(targetSize.width) < dimensions || Int(targetSize.height) < dimensions) && - !isVideo - { - guard - isValid, - let originalFilePath: String = originalFilePath(using: dependencies) - else { return failure() } - - success( - originalFilePath, - { originalImage(using: dependencies) }, - { try Data(contentsOf: URL(fileURLWithPath: originalFilePath)) } - ) - return - } - - let thumbnailPath: String = thumbnailPath(for: dimensions) - - /// If we had previously generated the thumbnail then use that - if dependencies[singleton: .fileManager].fileExists(atPath: thumbnailPath) { - success( - thumbnailPath, - { UIImage(contentsOfFile: thumbnailPath) }, - { try Data(contentsOf: URL(fileURLWithPath: thumbnailPath)) } - ) - return - } - - /// Otherwise use the `thumbnailService` to generate a thumbnail - dependencies[singleton: .thumbnailService].ensureThumbnail( - for: self, - dimensions: dimensions, - success: { loadedThumbnail in - success( - thumbnailPath, - loadedThumbnail.imageSourceBlock, - loadedThumbnail.dataSourceBlock - ) - }, - failure: { _ in failure() } - ) - } - - public func thumbnail( - size: ThumbnailSize, - using dependencies: Dependencies, - success: @escaping (String, () -> UIImage?, () throws -> Data) -> (), - failure: @escaping () -> () - ) { - loadThumbnail(with: size.dimension, using: dependencies, success: success, failure: failure) - } - - public func existingThumbnail(size: ThumbnailSize, using dependencies: Dependencies) -> UIImage? { - var existingImage: UIImage? - - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - loadThumbnail( - with: size.dimension, - using: dependencies, - success: { _, imageLoader, _ in - existingImage = imageLoader() - semaphore.signal() - }, - failure: { semaphore.signal() } - ) - - // We don't really want to wait at all so having a tiny timeout here will give the - // 'loadThumbnail' call the change to return a result for an existing thumbnail but - // not a new one - _ = semaphore.wait(timeout: .now() + .milliseconds(10)) - - return existingImage - } - - public func cloneAsQuoteThumbnail(using dependencies: Dependencies) -> Attachment? { - let cloneId: String = UUID().uuidString - let thumbnailName: String = "quoted-thumbnail-\(sourceFilename ?? "null")" // stringlint:ignore - - guard self.isVisualMedia else { return nil } - guard - self.isValid, - let thumbnailPath: String = Attachment.originalFilePath( - id: cloneId, - mimeType: UTType.mimeTypeJpeg, - sourceFilename: thumbnailName, - using: dependencies - ) - else { - // Non-media files cannot have thumbnails but may be sent as quotes, in these cases we want - // to create an attachment in an 'uploaded' state with a hard-coded file id so the messageSend - // job doesn't try to upload the attachment (we include the original `serverId` as it's - // required for generating the protobuf) - return Attachment( - id: cloneId, - serverId: self.serverId, - variant: self.variant, - state: .uploaded, - contentType: self.contentType, - byteCount: 0, - downloadUrl: Attachment.nonMediaQuoteFileId, - isValid: self.isValid - ) - } - - // Try generate the thumbnail - var thumbnailData: Data? - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - - self.thumbnail( - size: .small, - using: dependencies, - success: { _, _, dataSourceBlock in - thumbnailData = try? dataSourceBlock() - semaphore.signal() - }, - failure: { semaphore.signal() } - ) - - // Wait up to 0.5 seconds - _ = semaphore.wait(timeout: .now() + .milliseconds(500)) - - guard let thumbnailData: Data = thumbnailData else { return nil } - - // Write the quoted thumbnail to disk - do { try thumbnailData.write(to: URL(fileURLWithPath: thumbnailPath)) } - catch { return nil } - - // Need to retrieve the size of the thumbnail as it maintains it's aspect ratio - let thumbnailSize: CGSize = Attachment - .imageSize( - contentType: UTType.mimeTypeJpeg, - originalFilePath: thumbnailPath, - using: dependencies - ) - .defaulting( - to: CGSize( - width: Int(ThumbnailSize.small.dimension), - height: Int(ThumbnailSize.small.dimension) - ) - ) + let downloadUrl: String = downloadUrl, + let path: String = try? dependencies[singleton: .attachmentManager].path(for: downloadUrl) + else { return nil } - // Copy the thumbnail to a new attachment - return Attachment( - id: cloneId, - variant: .standard, - state: .downloaded, - contentType: UTType.mimeTypeJpeg, - byteCount: UInt(thumbnailData.count), - sourceFilename: thumbnailName, - localRelativeFilePath: Attachment.localRelativeFilePath(from: thumbnailPath, using: dependencies), - width: UInt(thumbnailSize.width), - height: UInt(thumbnailSize.height), - isValid: true - ) + return try Data(contentsOf: URL(fileURLWithPath: path)) } public func write(data: Data, using dependencies: Dependencies) throws -> Bool { - guard let originalFilePath: String = originalFilePath(using: dependencies) else { return false } + guard + let downloadUrl: String = downloadUrl, + let path: String = try? dependencies[singleton: .attachmentManager].path(for: downloadUrl) + else { return false } - try data.write(to: URL(fileURLWithPath: originalFilePath)) + try data.write(to: URL(fileURLWithPath: path)) return true } @@ -1084,262 +691,3 @@ extension Attachment { } } } - -// MARK: - Upload - -extension Attachment { - private enum Destination { - case fileServer - case community(OpenGroup) - - var shouldEncrypt: Bool { - switch self { - case .fileServer: return true - case .community: return false - } - } - } - - public static func prepare(attachments: [SignalAttachment], using dependencies: Dependencies) -> [Attachment] { - return attachments.compactMap { signalAttachment in - Attachment( - variant: (signalAttachment.isVoiceMessage ? - .voiceMessage : - .standard - ), - contentType: signalAttachment.mimeType, - dataSource: signalAttachment.dataSource, - sourceFilename: signalAttachment.sourceFilename, - caption: signalAttachment.captionText, - using: dependencies - ) - } - } - - public static func process( - _ db: Database, - attachments: [Attachment]?, - for interactionId: Int64? - ) throws { - guard - let attachments: [Attachment] = attachments, - let interactionId: Int64 = interactionId - else { return } - - try attachments - .enumerated() - .forEach { index, attachment in - let interactionAttachment: InteractionAttachment = InteractionAttachment( - albumIndex: index, - interactionId: interactionId, - attachmentId: attachment.id - ) - - try attachment.insert(db) - try interactionAttachment.insert(db) - } - } - - public func preparedUpload( - _ db: Database, - threadId: String, - logCategory cat: Log.Category?, - using dependencies: Dependencies - ) throws -> Network.PreparedRequest { - typealias UploadInfo = ( - attachment: Attachment, - preparedRequest: Network.PreparedRequest, - encryptionKey: Data?, - digest: Data? - ) - - // Retrieve the correct destination for the given thread - let destination: Destination = (try? OpenGroup.fetchOne(db, id: threadId)) - .map { .community($0) } - .defaulting(to: .fileServer) - let uploadInfo: UploadInfo = try { - let endpoint: (any EndpointType) = { - switch destination { - case .fileServer: return Network.FileServer.Endpoint.file - case .community(let openGroup): return OpenGroupAPI.Endpoint.roomFile(openGroup.roomToken) - } - }() - - // This can occur if an AttachmentUploadJob was explicitly created for a message - // dependant on the attachment being uploaded (in this case the attachment has - // already been uploaded so just succeed) - if state == .uploaded, let fileId: String = Attachment.fileId(for: downloadUrl) { - return ( - self, - try Network.PreparedRequest.cached( - FileUploadResponse(id: fileId), - endpoint: endpoint, - using: dependencies - ), - self.encryptionKey, - self.digest - ) - } - - // If the attachment is a downloaded attachment, check if it came from - // the server and if so just succeed immediately (no use re-uploading - // an attachment that is already present on the server) - or if we want - // it to be encrypted and it's not then encrypt it - // - // Note: The most common cases for this will be for LinkPreviews or Quotes - if - state == .downloaded, - serverId != nil, - let fileId: String = Attachment.fileId(for: downloadUrl), - ( - !destination.shouldEncrypt || ( - encryptionKey != nil && - digest != nil - ) - ) - { - return ( - self, - try Network.PreparedRequest.cached( - FileUploadResponse(id: fileId), - endpoint: endpoint, - using: dependencies - ), - self.encryptionKey, - self.digest - ) - } - - // Get the raw attachment data - guard let rawData: Data = try? readDataFromFile(using: dependencies) else { - Log.error([cat].compactMap { $0 }, "Couldn't read attachment from disk.") - throw AttachmentError.noAttachment - } - - // Encrypt the attachment if needed - var finalData: Data = rawData - var encryptionKey: Data? - var digest: Data? - - typealias EncryptionData = (ciphertext: Data, encryptionKey: Data, digest: Data) - if destination.shouldEncrypt { - guard - let result: EncryptionData = dependencies[singleton: .crypto].generate( - .encryptAttachment(plaintext: rawData, using: dependencies) - ) - else { - Log.error([cat].compactMap { $0 }, "Couldn't encrypt attachment.") - throw AttachmentError.encryptionFailed - } - - finalData = result.ciphertext - encryptionKey = result.encryptionKey - digest = result.digest - } - - // Ensure the file size is smaller than our upload limit - Log.info([cat].compactMap { $0 }, "File size: \(finalData.count) bytes.") - guard finalData.count <= Network.maxFileSize else { throw NetworkError.maxFileSizeExceeded } - - // Generate the request - switch destination { - case .fileServer: - return ( - self, - try Network.preparedUpload(data: finalData, using: dependencies), - encryptionKey, - digest - ) - - case .community(let openGroup): - return ( - self, - try OpenGroupAPI.preparedUpload( - db, - data: finalData, - to: openGroup.roomToken, - on: openGroup.server, - using: dependencies - ), - encryptionKey, - digest - ) - } - }() - - return uploadInfo.preparedRequest - .handleEvents( - receiveSubscription: { - // If we have a `cachedResponse` (ie. already uploaded) then don't change - // the attachment state to uploading as it's already been done - guard uploadInfo.preparedRequest.cachedResponse == nil else { return } - - // Update the attachment to the 'uploading' state - dependencies[singleton: .storage].write { db in - _ = try? Attachment - .filter(id: uploadInfo.attachment.id) - .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading)) - } - }, - receiveOutput: { _, response in - /// Save the final upload info - /// - /// **Note:** We **MUST** use the `.with` function here to ensure the `isValid` flag is - /// updated correctly - let updatedAttachment: Attachment = uploadInfo.attachment - .with( - serverId: response.id, - state: .uploaded, - creationTimestamp: ( - uploadInfo.attachment.creationTimestamp ?? - (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) - ), - downloadUrl: { - switch (uploadInfo.attachment.downloadUrl, destination) { - case (.some(let downloadUrl), _): return downloadUrl - case (.none, .fileServer): - return Network.FileServer.downloadUrlString(for: response.id) - - case (.none, .community(let openGroup)): - return OpenGroupAPI.downloadUrlString( - for: response.id, - server: openGroup.server, - roomToken: openGroup.roomToken - ) - } - }(), - encryptionKey: uploadInfo.encryptionKey, - digest: uploadInfo.digest, - using: dependencies - ) - - // Ensure there were changes before triggering a db write to avoid unneeded - // write queue use and UI updates - guard updatedAttachment != uploadInfo.attachment else { return } - - dependencies[singleton: .storage].write { db in - try updatedAttachment.upserted(db) - } - }, - receiveCompletion: { result in - switch result { - case .finished: break - case .failure: - dependencies[singleton: .storage].write { db in - try Attachment - .filter(id: uploadInfo.attachment.id) - .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) - } - } - }, - receiveCancel: { - dependencies[singleton: .storage].write { db in - try Attachment - .filter(id: uploadInfo.attachment.id) - .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) - } - } - ) - .map { _, response in response.id } - } -} diff --git a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift index 8b97d073bb..1d53aff01d 100644 --- a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift +++ b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift @@ -70,7 +70,7 @@ public extension BlindedIdLookup { /// If we can't find a match this method will still store a lookup, just with no standard sessionId value (this gives us a method to /// link back to the open group the blindedId originated from) static func fetchOrCreate( - _ db: Database, + _ db: ObservingDatabase, blindedId: String, sessionId: String? = nil, openGroupServer: String, @@ -141,6 +141,8 @@ public extension BlindedIdLookup { Contact.Columns.isApproved.set(to: true), using: dependencies ) + + db.addContactEvent(id: contact.id, change: .isApproved(true)) } break diff --git a/SessionMessagingKit/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift index 4ac7f6625a..0e846b7f4d 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroup.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -22,9 +22,7 @@ public struct ClosedGroup: Codable, Equatable, Hashable, Identifiable, Fetchable case formationTimestamp case displayPictureUrl - case displayPictureFilename case displayPictureEncryptionKey - case lastDisplayPictureUpdate case shouldPoll case groupIdentityPrivateKey @@ -44,18 +42,14 @@ public struct ClosedGroup: Codable, Equatable, Hashable, Identifiable, Fetchable public let groupDescription: String? public let formationTimestamp: TimeInterval - /// The URL from which to fetch the groups's display picture. + /// The URL from which to fetch the groups's display picture + /// + /// **Note:** This won't be updated until the display picture has actually been downloaded public let displayPictureUrl: String? - /// The file name of the groups's display picture on local storage. - public let displayPictureFilename: String? - /// The key with which the display picture is encrypted. public let displayPictureEncryptionKey: Data? - /// The timestamp (in seconds since epoch) that the display picture was last updated - public let lastDisplayPictureUpdate: TimeInterval? - /// A flag indicating whether we should poll for messages in this group public let shouldPoll: Bool? @@ -111,9 +105,7 @@ public struct ClosedGroup: Codable, Equatable, Hashable, Identifiable, Fetchable groupDescription: String? = nil, formationTimestamp: TimeInterval, displayPictureUrl: String? = nil, - displayPictureFilename: String? = nil, displayPictureEncryptionKey: Data? = nil, - lastDisplayPictureUpdate: TimeInterval? = nil, shouldPoll: Bool?, groupIdentityPrivateKey: Data? = nil, authData: Data? = nil, @@ -125,9 +117,7 @@ public struct ClosedGroup: Codable, Equatable, Hashable, Identifiable, Fetchable self.groupDescription = groupDescription self.formationTimestamp = formationTimestamp self.displayPictureUrl = displayPictureUrl - self.displayPictureFilename = displayPictureFilename self.displayPictureEncryptionKey = displayPictureEncryptionKey - self.lastDisplayPictureUpdate = lastDisplayPictureUpdate self.shouldPoll = shouldPoll self.groupIdentityPrivateKey = groupIdentityPrivateKey self.authData = authData @@ -170,14 +160,10 @@ public extension ClosedGroup { } static func approveGroupIfNeeded( - _ db: Database, + _ db: ObservingDatabase, group: ClosedGroup, using dependencies: Dependencies ) throws { - guard let userED25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { - throw MessageReceiverError.noUserED25519KeyPair - } - /// Update the `USER_GROUPS` config try? LibSession.update( db, @@ -212,6 +198,8 @@ public extension ClosedGroup { ClosedGroup.Columns.shouldPoll.set(to: true), using: dependencies ) + + db.addEvent(group.id, forKey: .messageRequestAccepted) } /// Load the group state into the `LibSession.Cache` if needed @@ -226,13 +214,15 @@ public extension ClosedGroup { _ = try? cache.createAndLoadGroupState( groupSessionId: groupSessionId, - userED25519KeyPair: userED25519KeyPair, + userED25519SecretKey: dependencies[cache: .general].ed25519SecretKey, groupIdentityPrivateKey: group.groupIdentityPrivateKey ) } /// Start the poller - dependencies.mutate(cache: .groupPollers) { $0.getOrCreatePoller(for: group.id).startIfNeeded() } + dependencies.mutate(cache: .groupPollers) { + $0.getOrCreatePoller(for: group.id).startIfNeeded() + } /// Subscribe for group push notifications if let token: String = dependencies[defaults: .standard, key: .deviceToken] { @@ -250,7 +240,7 @@ public extension ClosedGroup { } static func removeData( - _ db: Database, + _ db: ObservingDatabase, threadIds: [String], dataToRemove: [RemovableGroupData], using dependencies: Dependencies @@ -263,7 +253,6 @@ public extension ClosedGroup { } // Remove the group from the database and unsubscribe from PNs - let userSessionId: SessionId = dependencies[cache: .general].sessionId let threadVariants: [ThreadIdVariant] = try { guard dataToRemove.contains(.pushNotifications) || @@ -288,18 +277,22 @@ public extension ClosedGroup { if dataToRemove.contains(.libSessionState) { /// Wait until after the transaction completes before removing the group state (this is needed as it's possible that /// we are already mutating the `libSessionCache` when this function gets called) - db.afterNextTransaction { db in - threadVariants - .filter { $0.variant == .group } - .forEach { threadIdVariant in - let groupSessionId: SessionId = SessionId(.group, hex: threadIdVariant.id) - + db.afterCommit { + let groupVariants: [ThreadIdVariant] = threadVariants.filter { + $0.variant == .group + } + + guard !groupVariants.isEmpty else { return } + + dependencies[singleton: .storage].writeAsync { db in + groupVariants.forEach { threadIdVariant in LibSession.removeGroupStateIfNeeded( db, - groupSessionId: groupSessionId, + groupSessionId: SessionId(.group, hex: threadIdVariant.id), using: dependencies ) } + } } } } @@ -336,19 +329,23 @@ public extension ClosedGroup { } if dataToRemove.contains(.messages) { - try Interaction + struct InteractionThreadInfo: Codable, FetchableRecord, Hashable { + let id: Int64 + let threadId: String + } + + let interactionInfo: Set = try Interaction + .select(.id, .threadId) .filter(threadIds.contains(Interaction.Columns.threadId)) - .deleteAll(db) + .asRequest(of: InteractionThreadInfo.self) + .fetchSet(db) + try Interaction.deleteAll(db, ids: interactionInfo.map { $0.id }) - /// Delete any `ControlMessageProcessRecord` entries that we want to reprocess if the member gets + interactionInfo.forEach { db.addMessageEvent(id: $0.id, threadId: $0.threadId, type: .deleted) } + + /// Delete any `MessageDeduplication` entries that we want to reprocess if the member gets /// re-invited to the group with historic access (these are repeatable records so won't cause issues if we re-run them) - try ControlMessageProcessRecord - .filter(threadIds.contains(ControlMessageProcessRecord.Columns.threadId)) - .filter( - ControlMessageProcessRecord.Variant.variantsToBeReprocessedAfterLeavingAndRejoiningConversation - .contains(ControlMessageProcessRecord.Columns.variant) - ) - .deleteAll(db) + try MessageDeduplication.deleteIfNeeded(db, threadIds: threadIds, using: dependencies) /// Also want to delete the `SnodeReceivedMessageInfo` so if the member gets re-invited to the group with /// historic access they can re-download and process all of the old messages @@ -382,6 +379,10 @@ public extension ClosedGroup { try SessionThread // Intentionally use `deleteAll` here as this gets triggered via `deleteOrLeave` .filter(ids: threadIds) .deleteAll(db) + + threadIds.forEach { id in + db.addConversationEvent(id: id, type: .deleted) + } } // Ignore if called from the config handling diff --git a/SessionMessagingKit/Database/Models/ConfigDump.swift b/SessionMessagingKit/Database/Models/ConfigDump.swift index fa65ec826c..b264051429 100644 --- a/SessionMessagingKit/Database/Models/ConfigDump.swift +++ b/SessionMessagingKit/Database/Models/ConfigDump.swift @@ -26,6 +26,7 @@ public struct ConfigDump: Codable, Equatable, Hashable, FetchableRecord, Persist case contacts case convoInfoVolatile case userGroups + case local case groupInfo case groupMembers @@ -45,7 +46,7 @@ public struct ConfigDump: Codable, Equatable, Hashable, FetchableRecord, Persist /// **Note:** For user config items this will be an empty string public var sessionId: SessionId { switch variant { - case .userProfile, .contacts, .convoInfoVolatile, .userGroups: + case .userProfile, .contacts, .convoInfoVolatile, .userGroups, .local: return SessionId(.standard, hex: publicKey) case .groupInfo, .groupMembers, .groupKeys: @@ -79,7 +80,7 @@ public struct ConfigDump: Codable, Equatable, Hashable, FetchableRecord, Persist public extension ConfigDump.Variant { static let userVariants: Set = [ - .userProfile, .contacts, .convoInfoVolatile, .userGroups + .userProfile, .contacts, .convoInfoVolatile, .userGroups, .local ] static let groupVariants: Set = [ .groupInfo, .groupMembers, .groupKeys @@ -109,6 +110,7 @@ public extension ConfigDump.Variant { case .contacts: return SnodeAPI.Namespace.configContacts case .convoInfoVolatile: return SnodeAPI.Namespace.configConvoInfoVolatile case .userGroups: return SnodeAPI.Namespace.configUserGroups + case .local: return SnodeAPI.Namespace.configLocal case .groupInfo: return SnodeAPI.Namespace.configGroupInfo case .groupMembers: return SnodeAPI.Namespace.configGroupMembers @@ -125,7 +127,7 @@ public extension ConfigDump.Variant { /// the user configs are loaded first var loadOrder: Int { switch self { - case .invalid: return 3 + case .invalid, .local: return 3 case .groupKeys: return 2 case .groupInfo, .groupMembers: return 1 case .userProfile, .contacts, .convoInfoVolatile, .userGroups: return 0 @@ -153,6 +155,7 @@ extension ConfigDump.Variant: CustomStringConvertible { case .contacts: return "contacts" case .convoInfoVolatile: return "convoInfoVolatile" case .userGroups: return "userGroups" + case .local: return "local" case .groupInfo: return "groupInfo" case .groupMembers: return "groupMembers" diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift index 8a72003b03..f1641344a9 100644 --- a/SessionMessagingKit/Database/Models/Contact.swift +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -53,7 +53,6 @@ public struct Contact: Codable, Identifiable, Equatable, FetchableRecord, Persis // MARK: - Initialization public init( - _ db: Database? = nil, id: String, isTrusted: Bool = false, isApproved: Bool = false, @@ -83,8 +82,8 @@ public extension Contact { /// /// **Note:** This method intentionally does **not** save the newly created Contact, /// it will need to be explicitly saved after calling - static func fetchOrCreate(_ db: Database, id: ID, using dependencies: Dependencies) -> Contact { - return ((try? fetchOne(db, id: id)) ?? Contact(db, id: id, using: dependencies)) + static func fetchOrCreate(_ db: ObservingDatabase, id: ID, using dependencies: Dependencies) -> Contact { + return ((try? fetchOne(db, id: id)) ?? Contact(id: id, using: dependencies)) } } diff --git a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift deleted file mode 100644 index 10abba5685..0000000000 --- a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift +++ /dev/null @@ -1,189 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import SessionUtilitiesKit -import SessionSnodeKit - -/// We can rely on the unique constraints within the `Interaction` table to prevent duplicate `VisibleMessage` -/// values from being processed, but some control messages don’t have an associated interaction - this table provides -/// a de-duping mechanism for those messages -/// -/// **Note:** It’s entirely possible for there to be a false-positive with this record where multiple users sent the same -/// type of control message at the same time - this is very unlikely to occur though since unique to the millisecond level -public struct ControlMessageProcessRecord: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { - public static var databaseTableName: String { "controlMessageProcessRecord" } - - /// For notifications and migrated timestamps default to '15' days (which is the timeout for messages on the - /// server at the time of writing) - public static let defaultExpirationSeconds: TimeInterval = (15 * 24 * 60 * 60) - - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression { - case threadId - case timestampMs - case variant - case serverExpirationTimestamp - } - - public enum Variant: Int, Codable, DatabaseValueConvertible { - @available(*, deprecated, message: "Removed along with legacy db migration") case legacyEntry = 0 - - case readReceipt = 1 - case typingIndicator = 2 - @available(*, deprecated) case legacyGroupControlMessage = 3 - case dataExtractionNotification = 4 - case expirationTimerUpdate = 5 - @available(*, deprecated) case configurationMessage = 6 - case unsendRequest = 7 - case messageRequestResponse = 8 - case call = 9 - - /// Since we retrieve messages from all snodes in a swarm there is a fun issue where a user can delete a - /// one-to-one conversation (which removes all associated interactions) and then the poller checks a - /// different service node, if a previously processed message hadn't been processed yet for that specific - /// service node it results in the conversation re-appearing - /// - /// This `Variant` allows us to create a record which survives thread deletion to prevent a duplicate - /// message from being reprocessed - case visibleMessageDedupe = 10 - - case groupUpdateInvite = 11 - case groupUpdatePromote = 12 - case groupUpdateInfoChange = 13 - case groupUpdateMemberChange = 14 - case groupUpdateMemberLeft = 15 - case groupUpdateMemberLeftNotification = 16 - case groupUpdateInviteResponse = 17 - case groupUpdateDeleteMemberContent = 18 - - internal static let variantsToBeReprocessedAfterLeavingAndRejoiningConversation: Set = [ - .dataExtractionNotification, .expirationTimerUpdate, .unsendRequest, - .messageRequestResponse, .call, .visibleMessageDedupe, .groupUpdateInfoChange, .groupUpdateMemberChange, - .groupUpdateMemberLeftNotification, .groupUpdateDeleteMemberContent - ] - } - - /// The id for the thread the control message is associated to - /// - /// **Note:** For user-specific control message (eg. `ConfigurationMessage`) this value will be the - /// users public key - public let threadId: String - - /// The type of control message - /// - /// **Note:** It would be nice to include this in the unique constraint to reduce the likelihood of false positives - /// but this can result in control messages getting re-handled because the variant is unknown in the migration - public let variant: Variant - - /// The timestamp of the control message - public let timestampMs: Int64 - - /// The timestamp for when this message will expire on the server (will be used for garbage collection) - public let serverExpirationTimestamp: TimeInterval? - - // MARK: - Initialization - - public init?( - threadId: String, - message: Message, - serverExpirationTimestamp: TimeInterval? - ) { - // Allow duplicates for UnsendRequest messages, if a user received an UnsendRequest - // as a push notification the it wouldn't include a serverHash and, as a result, - // wouldn't get deleted from the server - since the logic only runs if we find a - // matching message the safest option is to allow duplicate handling to avoid an - // edge-case where a message doesn't get deleted - if message is UnsendRequest { return nil } - - // Allow duplicates for all call messages, the double checking will be done on - // message handling to make sure the messages are for the same ongoing call - if message is CallMessage { return nil } - - // The `LibSessionMessage` doesn't have enough metadata to be able to dedupe via - // the `ControlMessageProcessRecord` so just always process it - if message is LibSessionMessage { return nil } - - /// For all other cases we want to prevent duplicate handling of the message (this can happen in a number of situations, primarily - /// with sync messages though hence why we don't include the 'serverHash' as part of this record - /// - /// **Note:** We should make sure to have a unique `variant` for any message type which could have the same timestamp - /// as another message type as otherwise they might incorrectly be deduped - self.threadId = threadId - self.variant = { - switch message { - case is ReadReceipt: return .readReceipt - case is TypingIndicator: return .typingIndicator - case is DataExtractionNotification: return .dataExtractionNotification - case is ExpirationTimerUpdate: return .expirationTimerUpdate - case is UnsendRequest: return .unsendRequest - case is MessageRequestResponse: return .messageRequestResponse - case is CallMessage: return .call - case is VisibleMessage: return .visibleMessageDedupe - - case is GroupUpdateInviteMessage: return .groupUpdateInvite - case is GroupUpdatePromoteMessage: return .groupUpdatePromote - case is GroupUpdateInfoChangeMessage: return .groupUpdateInfoChange - case is GroupUpdateMemberChangeMessage: return .groupUpdateMemberChange - case is GroupUpdateMemberLeftMessage: return .groupUpdateMemberLeft - case is GroupUpdateMemberLeftNotificationMessage: return .groupUpdateMemberLeftNotification - case is GroupUpdateInviteResponseMessage: return .groupUpdateInviteResponse - case is GroupUpdateDeleteMemberContentMessage: return .groupUpdateDeleteMemberContent - - default: preconditionFailure("[ControlMessageProcessRecord] Unsupported message type") - } - }() - self.timestampMs = Int64(message.sentTimestampMs ?? 0) // Default to `0` if not set - self.serverExpirationTimestamp = serverExpirationTimestamp - } -} - -// MARK: - Migration Extensions - -internal extension ControlMessageProcessRecord { - init?( - threadId: String, - variant: Interaction.Variant, - timestampMs: Int64, - using dependencies: Dependencies - ) { - switch variant { - case .standardOutgoing, .standardIncoming, ._legacyStandardIncomingDeleted, - .standardIncomingDeleted, .standardIncomingDeletedLocally, .standardOutgoingDeleted, - .standardOutgoingDeletedLocally, .infoLegacyGroupCreated, - .infoLegacyGroupUpdated, .infoLegacyGroupCurrentUserLeft: - return nil - - case .infoDisappearingMessagesUpdate: self.variant = .expirationTimerUpdate - case .infoScreenshotNotification, .infoMediaSavedNotification: self.variant = .dataExtractionNotification - case .infoMessageRequestAccepted: self.variant = .messageRequestResponse - case .infoCall: self.variant = .call - case .infoGroupInfoUpdated: self.variant = .groupUpdateInfoChange - case .infoGroupInfoInvited, .infoGroupMembersUpdated: self.variant = .groupUpdateMemberChange - - case .infoGroupCurrentUserLeaving, .infoGroupCurrentUserErrorLeaving: - self.variant = .groupUpdateMemberLeft - } - - self.threadId = threadId - self.timestampMs = timestampMs - self.serverExpirationTimestamp = ( - TimeInterval(dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + - ControlMessageProcessRecord.defaultExpirationSeconds - ) - } - - /// This method should only be called from either the `generateLegacyProcessRecords` method above or - /// within the 'insert' method to maintain the unique constraint - fileprivate init( - threadId: String, - variant: Variant, - timestampMs: Int64, - serverExpirationTimestamp: TimeInterval - ) { - self.threadId = threadId - self.variant = variant - self.timestampMs = timestampMs - self.serverExpirationTimestamp = serverExpirationTimestamp - } -} diff --git a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift index d3126be87e..22c7d9a580 100644 --- a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -243,7 +243,7 @@ public extension DisappearingMessagesConfiguration { public extension DisappearingMessagesConfiguration { func clearUnrelatedControlMessages( - _ db: Database, + _ db: ObservingDatabase, threadVariant: SessionThread.Variant, using dependencies: Dependencies ) throws { @@ -288,14 +288,14 @@ public extension DisappearingMessagesConfiguration { } func insertControlMessage( - _ db: Database, + _ db: ObservingDatabase, threadVariant: SessionThread.Variant, authorId: String, timestampMs: Int64, serverHash: String?, serverExpirationTimestamp: TimeInterval?, using dependencies: Dependencies - ) throws -> Int64? { + ) throws -> MessageReceiver.InsertedInteractionInfo? { switch threadVariant { case .contact: _ = try Interaction @@ -319,8 +319,7 @@ public extension DisappearingMessagesConfiguration { threadId: threadId, threadVariant: threadVariant, timestampMs: timestampMs, - userSessionId: userSessionId, - openGroup: nil + openGroupUrlInfo: nil ) } ) @@ -348,7 +347,7 @@ public extension DisappearingMessagesConfiguration { body: self.messageInfoString( threadVariant: threadVariant, senderName: (authorId != userSessionId.hexString ? - Profile.displayName(db, id: authorId, using: dependencies) : + Profile.displayName(db, id: authorId) : nil ), using: dependencies @@ -372,7 +371,9 @@ public extension DisappearingMessagesConfiguration { ) } - return interaction.id + return interaction.id.map { + (threadId, threadVariant, $0, .infoDisappearingMessagesUpdate, wasRead, 0) + } } } diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 4a4b94f3cc..8223624b41 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -1,12 +1,11 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import UIKit +import Foundation import GRDB import SessionUtilitiesKit import SessionSnodeKit -import SessionUIKit -public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { +public struct Interaction: Codable, Identifiable, Equatable, Hashable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "interaction" } internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) internal static let linkPreviewForeignKey = ForeignKey( @@ -220,12 +219,6 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu /// The reason why the most recent attempt to send this message failed public private(set) var mostRecentFailureText: String? - // MARK: - Internal Values Used During Creation - - /// **Note:** This reference only exist during the initial creation (it should be accessible from within the - /// `{will/around/did}Inset` functions as well) so shouldn't be relied on elsewhere to exist - private let transientDependencies: EquatableIgnoring? - // MARK: - Relationships public var thread: QueryInterfaceRequest { @@ -287,8 +280,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu openGroupWhisperTo: String?, state: State, recipientReadTimestampMs: Int64?, - mostRecentFailureText: String?, - transientDependencies: EquatableIgnoring? + mostRecentFailureText: String? ) { self.id = id self.serverHash = serverHash @@ -311,7 +303,6 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu self.state = (variant.isLocalOnly ? .localOnly : state) self.recipientReadTimestampMs = recipientReadTimestampMs self.mostRecentFailureText = mostRecentFailureText - self.transientDependencies = transientDependencies } public init( @@ -369,7 +360,6 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu self.recipientReadTimestampMs = nil self.mostRecentFailureText = nil - self.transientDependencies = EquatableIgnoring(value: dependencies) } // MARK: - Custom Database Interaction @@ -387,17 +377,21 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu _ = try insert() // Start the disappearing messages timer if needed - switch (self.transientDependencies?.value, self.expiresStartedAtMs) { - case (_, .none): break - case (.none, .some): - Log.error("[Interaction] Could not update disappearing messages job due to missing transientDependencies.") + switch ObservationContext.observingDb { + case .none: Log.error("[Interaction] Could not process 'aroundInsert' due to missing observingDb.") + case .some(let observingDb): + observingDb.addMessageEvent(id: id, threadId: threadId, type: .created) - case (.some(let dependencies), .some): - dependencies[singleton: .jobRunner].upsert( - db, - job: DisappearingMessagesJob.updateNextRunIfNeeded(db, using: dependencies), - canStartJob: true - ) + if self.expiresStartedAtMs != nil { + observingDb.dependencies[singleton: .jobRunner].upsert( + observingDb, + job: DisappearingMessagesJob.updateNextRunIfNeeded( + observingDb, + using: observingDb.dependencies + ), + canStartJob: true + ) + } } } @@ -433,8 +427,7 @@ public extension Interaction { openGroupWhisperTo: try? container.decode(String?.self, forKey: .openGroupWhisperTo), state: try container.decode(State.self, forKey: .state), recipientReadTimestampMs: try? container.decode(Int64?.self, forKey: .recipientReadTimestampMs), - mostRecentFailureText: try? container.decode(String?.self, forKey: .mostRecentFailureText), - transientDependencies: decoder.dependencies.map { EquatableIgnoring(value: $0) } + mostRecentFailureText: try? container.decode(String?.self, forKey: .mostRecentFailureText) ) } } @@ -477,12 +470,11 @@ public extension Interaction { openGroupWhisperTo: self.openGroupWhisperTo, state: (state ?? self.state), recipientReadTimestampMs: (recipientReadTimestampMs ?? self.recipientReadTimestampMs), - mostRecentFailureText: (mostRecentFailureText ?? self.mostRecentFailureText), - transientDependencies: self.transientDependencies + mostRecentFailureText: (mostRecentFailureText ?? self.mostRecentFailureText) ) } - func withDisappearingMessagesConfiguration(_ db: Database, threadVariant: SessionThread.Variant) -> Interaction { + func withDisappearingMessagesConfiguration(_ db: ObservingDatabase, threadVariant: SessionThread.Variant) -> Interaction { guard threadVariant != .community else { return self } if let config = try? DisappearingMessagesConfiguration.fetchOne(db, id: self.threadId) { @@ -501,13 +493,14 @@ public extension Interaction { public extension Interaction { struct ReadInfo: Decodable, FetchableRecord { let id: Int64 + let serverHash: String? let variant: Interaction.Variant let timestampMs: Int64 let wasRead: Bool } static func fetchAppBadgeUnreadCount( - _ db: Database, + _ db: ObservingDatabase, using dependencies: Dependencies ) throws -> Int { let userSessionId: SessionId = dependencies[cache: .general].sessionId @@ -548,7 +541,7 @@ public extension Interaction { /// - includingOlder: Setting this to `true` will updated the `wasRead` flag for all older interactions as well /// - trySendReadReceipt: Setting this to `true` will schedule a `ReadReceiptJob` static func markAsRead( - _ db: Database, + _ db: ObservingDatabase, interactionId: Int64?, threadId: String, threadVariant: SessionThread.Variant, @@ -561,7 +554,7 @@ public extension Interaction { // Since there is no guarantee on the order messages are inserted into the database // fetch the timestamp for the interaction and set everything before that as read let maybeInteractionInfo: Interaction.ReadInfo? = try Interaction - .select(.id, .variant, .timestampMs, .wasRead) + .select(.id, .serverHash, .variant, .timestampMs, .wasRead) .filter(id: interactionId) .asRequest(of: Interaction.ReadInfo.self) .fetchOne(db) @@ -591,6 +584,7 @@ public extension Interaction { interactionInfo: [ Interaction.ReadInfo( id: interactionId, + serverHash: nil, variant: variant, timestampMs: 0, wasRead: false @@ -609,7 +603,7 @@ public extension Interaction { .filter(Interaction.Columns.timestampMs <= interactionInfo.timestampMs) .filter(Interaction.Columns.wasRead == false) let interactionInfoToMarkAsRead: [Interaction.ReadInfo] = try interactionQuery - .select(.id, .variant, .timestampMs, .wasRead) + .select(.id, .serverHash, .variant, .timestampMs, .wasRead) .asRequest(of: Interaction.ReadInfo.self) .fetchAll(db) @@ -632,6 +626,9 @@ public extension Interaction { // Update the `wasRead` flag to true try interactionQuery.updateAll(db, Columns.wasRead.set(to: true)) + interactionInfoToMarkAsRead.forEach { info in + db.addMessageEvent(id: info.id, threadId: threadId, type: .updated(.wasRead(true))) + } // Retrieve the interaction ids we want to update try Interaction.scheduleReadJobs( @@ -650,27 +647,28 @@ public extension Interaction { /// /// **Note:** This method won't update the 'wasRead' flag (it will be updated via the above method) @discardableResult static func markAsRecipientRead( - _ db: Database, + _ db: ObservingDatabase, threadId: String, timestampMsValues: [Int64], - readTimestampMs: Int64 + readTimestampMs: Int64, + using dependencies: Dependencies ) throws -> Set { - guard db[.areReadReceiptsEnabled] == true else { return [] } + guard dependencies.mutate(cache: .libSession, { $0.get(.areReadReceiptsEnabled) }) else { return [] } struct InterationRowState: Codable, FetchableRecord { public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey { - case rowId + case id case state } - var rowId: Int64 + var id: Int64 var state: Interaction.State } // Get the row ids for the interactions which should be updated let interactionInfo: [InterationRowState] = try Interaction - .select(Column.rowID.forKey(InterationRowState.Columns.rowId), Interaction.Columns.state) + .select(.id, .state) .filter(Interaction.Columns.threadId == threadId) .filter(timestampMsValues.contains(Columns.timestampMs)) .filter(Variant.variantsWhichSupportReadReceipts.contains(Columns.variant)) @@ -681,20 +679,20 @@ public extension Interaction { // timestamps are for pending read receipts guard !interactionInfo.isEmpty else { return timestampMsValues.asSet() } - let allRowIds: Set = Set(interactionInfo.map { $0.rowId }) + let allIds: Set = Set(interactionInfo.map { $0.id }) let sentInteractionIds: Set = interactionInfo .filter { $0.state != .sending } - .map { $0.rowId } + .map { $0.id } .asSet() - let sendingInteractionInfo: Set = interactionInfo + let sendingInteractionIds: Set = interactionInfo .filter { $0.state == .sending } - .map { $0.rowId } + .map { $0.id } .asSet() // Update the 'recipientReadTimestampMs' if it doesn't match (need to do this to prevent // the UI update from being triggered for a redundant update) try Interaction - .filter(sentInteractionIds.contains(Column.rowID)) + .filter(sentInteractionIds.contains(Interaction.Columns.id)) .filter(Interaction.Columns.recipientReadTimestampMs == nil) .updateAll( db, @@ -704,7 +702,7 @@ public extension Interaction { // If the message still appeared to be sending then mark it as sent (can also remove the // failure text as it's redundant if the message is in the sent state) try Interaction - .filter(sendingInteractionInfo.contains(Column.rowID)) + .filter(sendingInteractionIds.contains(Interaction.Columns.id)) .filter(Interaction.Columns.state == Interaction.State.sending) .updateAll( db, @@ -712,10 +710,19 @@ public extension Interaction { Interaction.Columns.mostRecentFailureText.set(to: nil) ) + // Send events for the read receipt + sentInteractionIds.forEach { id in + db.addMessageEvent(id: id, threadId: threadId, type: .updated(.recipientReadTimestampMs(readTimestampMs))) + } + sendingInteractionIds.forEach { id in + db.addMessageEvent(id: id, threadId: threadId, type: .updated(.state(.sent))) + db.addMessageEvent(id: id, threadId: threadId, type: .updated(.recipientReadTimestampMs(readTimestampMs))) + } + // Retrieve the set of timestamps which were updated let timestampsUpdated: Set = try Interaction .select(Columns.timestampMs) - .filter(allRowIds.contains(Column.rowID)) + .filter(allIds.contains(Interaction.Columns.id)) .filter(timestampMsValues.contains(Columns.timestampMs)) .filter(Variant.variantsWhichSupportReadReceipts.contains(Columns.variant)) .asRequest(of: Int64.self) @@ -728,7 +735,7 @@ public extension Interaction { } static func scheduleReadJobs( - _ db: Database, + _ db: ObservingDatabase, threadId: String, threadVariant: SessionThread.Variant, interactionInfo: [Interaction.ReadInfo], @@ -776,15 +783,15 @@ public extension Interaction { // Clear out any notifications for the interactions we mark as read dependencies[singleton: .notificationsManager].cancelNotifications( identifiers: interactionInfo - .map { interactionInfo in + .map { info in Interaction.notificationIdentifier( - for: interactionInfo.id, + for: (info.serverHash ?? "\(info.id)"), threadId: threadId, shouldGroupMessagesForThread: false ) } .appending(Interaction.notificationIdentifier( - for: 0, + for: "0", threadId: threadId, shouldGroupMessagesForThread: true )) @@ -894,43 +901,52 @@ public extension Interaction { func notificationIdentifier(shouldGroupMessagesForThread: Bool) -> String { // When the app is in the background we want the notifications to be grouped to prevent spam return Interaction.notificationIdentifier( - for: (id ?? 0), + for: (serverHash ?? "\(id ?? 0)"), threadId: threadId, shouldGroupMessagesForThread: shouldGroupMessagesForThread ) } - static func notificationIdentifier(for id: Int64, threadId: String, shouldGroupMessagesForThread: Bool) -> String { + static func notificationIdentifier( + for interactionIdentifier: String, + threadId: String, + shouldGroupMessagesForThread: Bool + ) -> String { // When the app is in the background we want the notifications to be grouped to prevent spam guard !shouldGroupMessagesForThread else { return threadId } - return "\(threadId)-\(id)" + return "\(threadId)-\(interactionIdentifier)" } static func isUserMentioned( - _ db: Database, + _ db: ObservingDatabase, threadId: String, body: String?, quoteAuthorId: String? = nil, using dependencies: Dependencies ) -> Bool { - var publicKeysToCheck: [String] = [ + var publicKeysToCheck: Set = [ dependencies[cache: .general].sessionId.hexString ] // If the thread is an open group then add the blinded id as a key to check if let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: threadId) { if - let userEd25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), let blinded15KeyPair: KeyPair = dependencies[singleton: .crypto].generate( - .blinded15KeyPair(serverPublicKey: openGroup.publicKey, ed25519SecretKey: userEd25519KeyPair.secretKey) + .blinded15KeyPair( + serverPublicKey: openGroup.publicKey, + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey + ) ), let blinded25KeyPair: KeyPair = dependencies[singleton: .crypto].generate( - .blinded25KeyPair(serverPublicKey: openGroup.publicKey, ed25519SecretKey: userEd25519KeyPair.secretKey) + .blinded25KeyPair( + serverPublicKey: openGroup.publicKey, + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey + ) ) { - publicKeysToCheck.append(SessionId(.blinded15, publicKey: blinded15KeyPair.publicKey).hexString) - publicKeysToCheck.append(SessionId(.blinded25, publicKey: blinded25KeyPair.publicKey).hexString) + publicKeysToCheck.insert(SessionId(.blinded15, publicKey: blinded15KeyPair.publicKey).hexString) + publicKeysToCheck.insert(SessionId(.blinded25, publicKey: blinded25KeyPair.publicKey).hexString) } } @@ -943,7 +959,7 @@ public extension Interaction { // stringlint:ignore_contents static func isUserMentioned( - publicKeysToCheck: [String], + publicKeysToCheck: Set, body: String?, quoteAuthorId: String? = nil ) -> Bool { @@ -961,7 +977,7 @@ public extension Interaction { /// Use the `Interaction.previewText` method directly where possible rather than this one to avoid database queries static func notificationPreviewText( - _ db: Database, + _ db: ObservingDatabase, interaction: Interaction, using dependencies: Dependencies ) -> String { @@ -987,7 +1003,7 @@ public extension Interaction { return Interaction.previewText( variant: interaction.variant, body: interaction.body, - authorDisplayName: Profile.displayName(db, id: interaction.threadId, using: dependencies), + authorDisplayName: Profile.displayName(db, id: interaction.threadId), using: dependencies ) @@ -1203,7 +1219,7 @@ public extension Interaction.Variant { /// This flag controls whether the `wasRead` flag is automatically set to true based on the message variant (as a result they will /// or won't affect the unread count) - fileprivate var canBeUnread: Bool { + var canBeUnread: Bool { switch self { case .standardIncoming: return true case .infoCall: return true @@ -1227,75 +1243,6 @@ public extension Interaction.Variant { } } -// MARK: - Interaction.State Convenience - -public extension Interaction.State { - func statusIconInfo( - variant: Interaction.Variant, - hasBeenReadByRecipient: Bool, - hasAttachments: Bool - ) -> (image: UIImage?, text: String?, themeTintColor: ThemeValue) { - guard variant == .standardOutgoing else { - return (nil, nil, .messageBubble_deliveryStatus) - } - - switch (self, hasBeenReadByRecipient, hasAttachments) { - case (.deleted, _, _), (.localOnly, _, _): - return (nil, nil, .messageBubble_deliveryStatus) - - case (.sending, _, true): - return ( - UIImage(systemName: "ellipsis.circle"), - "uploading".localized(), - .messageBubble_deliveryStatus - ) - - case (.sending, _, _): - return ( - UIImage(systemName: "ellipsis.circle"), - "sending".localized(), - .messageBubble_deliveryStatus - ) - - case (.sent, false, _): - return ( - UIImage(systemName: "checkmark.circle"), - "disappearingMessagesSent".localized(), - .messageBubble_deliveryStatus - ) - - case (.sent, true, _): - return ( - UIImage(systemName: "eye.fill"), - "read".localized(), - .messageBubble_deliveryStatus - ) - - case (.failed, _, _): - return ( - UIImage(systemName: "exclamationmark.triangle"), - "messageStatusFailedToSend".localized(), - .danger - ) - - case (.failedToSync, _, _): - return ( - UIImage(systemName: "exclamationmark.triangle"), - "messageStatusFailedToSync".localized(), - .warning - ) - - case (.syncing, _, _): - return ( - UIImage(systemName: "ellipsis.circle"), - "messageStatusSyncing".localized(), - .warning - ) - - } - } -} - // MARK: - Deletion public extension Interaction { @@ -1326,7 +1273,7 @@ public extension Interaction { /// When deleting a message we should also delete any reactions which were on the message, so fetch and /// return those hashes as well static func serverHashesForDeletion( - _ db: Database, + _ db: ObservingDatabase, interactionIds: Set, additionalServerHashesToRemove: [String] = [] ) throws -> Set { @@ -1347,7 +1294,7 @@ public extension Interaction { } static func markAllAsDeleted( - _ db: Database, + _ db: ObservingDatabase, threadId: String, threadVariant: SessionThread.Variant, options: DeletionOption, @@ -1373,7 +1320,7 @@ public extension Interaction { } static func markAsDeleted( - _ db: Database, + _ db: ObservingDatabase, threadId: String, threadVariant: SessionThread.Variant, interactionIds: Set, @@ -1401,85 +1348,34 @@ public extension Interaction { /// Remove any notifications for the messages dependencies[singleton: .notificationsManager].cancelNotifications( - identifiers: interactionIds.reduce(into: []) { result, id in - result.append(Interaction.notificationIdentifier(for: id, threadId: threadId, shouldGroupMessagesForThread: true)) - result.append(Interaction.notificationIdentifier(for: id, threadId: threadId, shouldGroupMessagesForThread: false)) + identifiers: interactionInfo.reduce(into: []) { result, info in + result.append(Interaction.notificationIdentifier( + for: (info.serverHash ?? "\(info.id)"), + threadId: threadId, + shouldGroupMessagesForThread: true) + ) + result.append(Interaction.notificationIdentifier( + for: (info.serverHash ?? "\(info.id)"), + threadId: threadId, + shouldGroupMessagesForThread: false) + ) } ) - /// Retrieve any attachments for the messages + /// Retrieve any attachments for the messages and delete them from the database + let interactionAttachments: [InteractionAttachment] = try InteractionAttachment + .filter(interactionIds.contains(InteractionAttachment.Columns.interactionId)) + .fetchAll(db) let attachments: [Attachment] = try Attachment .joining(required: Attachment.interaction.filter(interactionIds.contains(Interaction.Columns.id))) .fetchAll(db) + try attachments.forEach { try $0.delete(db) } - /// If attachments were removed then we also need to tetrieve any quotes of the interactions which had attachments and - /// remove their thumbnails - /// - /// **Note:** This needs to happen before the attachments are deleted otherwise the joins in the query will fail - if !attachments.isEmpty { - let quote: TypedTableAlias = TypedTableAlias() - let interaction: TypedTableAlias = TypedTableAlias() - let interactionAttachment: TypedTableAlias = TypedTableAlias() - var blinded15SessionIdHexString: String = "" - var blinded25SessionIdHexString: String = "" - - /// If it's a `community` conversation then we need to get the blinded ids - if threadVariant == .community { - blinded15SessionIdHexString = (SessionThread.getCurrentUserBlindedSessionId( - db, - threadId: threadId, - threadVariant: threadVariant, - blindingPrefix: .blinded15, - using: dependencies - )?.hexString).defaulting(to: "") - blinded25SessionIdHexString = (SessionThread.getCurrentUserBlindedSessionId( - db, - threadId: threadId, - threadVariant: threadVariant, - blindingPrefix: .blinded25, - using: dependencies - )?.hexString).defaulting(to: "") - } - - /// Construct a request which gets the `quote.attachmentId` for any `Quote` entries related - /// to the removed `interactionIds` - let request: SQLRequest = """ - SELECT \(quote[.attachmentId]) - FROM \(Quote.self) - JOIN \(Interaction.self) ON ( - \(interaction[.timestampMs]) = \(quote[.timestampMs]) AND ( - \(interaction[.authorId]) = \(quote[.authorId]) OR ( - -- A users outgoing message is stored in some cases using their standard id - -- but the quote will use their blinded id so handle that case - \(interaction[.authorId]) = \(dependencies[cache: .general].sessionId.hexString) AND - ( - \(quote[.authorId]) = \(blinded15SessionIdHexString) OR - \(quote[.authorId]) = \(blinded25SessionIdHexString) - ) - ) - ) - ) - JOIN \(InteractionAttachment.self) ON ( - \(interactionAttachment[.interactionId]) = \(interaction[.id]) AND - \(interactionAttachment[.attachmentId]) IN \(attachments.map { $0.id }) - ) - - WHERE ( - \(quote[.attachmentId]) IS NOT NULL AND - \(interaction[.id]) IN \(interactionIds) - ) - """ - - let quoteAttachmentIds: [String] = try request.fetchAll(db) - - _ = try Attachment - .filter(ids: quoteAttachmentIds) - .deleteAll(db) + /// Notify about the attachment deletion + interactionAttachments.forEach { info in + db.addAttachmentEvent(id: info.attachmentId, messageId: info.interactionId, type: .deleted) } - /// Delete any attachments from the database - try attachments.forEach { try $0.delete(db) } - /// Delete the reactions from the database _ = try Reaction .filter(interactionIds.contains(Reaction.Columns.interactionId)) @@ -1543,11 +1439,18 @@ public extension Interaction { } } + /// Notify about the deletion + 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 { $0.originalFilePath(using: dependencies) } + 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) } diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index a3f05af304..682504f955 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -80,7 +80,7 @@ public struct LinkPreview: Codable, Equatable, Hashable, FetchableRecord, Persis // MARK: - Protobuf public extension LinkPreview { - init?(_ db: Database, proto: SNProtoDataMessage, sentTimestampMs: TimeInterval) throws { + init?(_ db: ObservingDatabase, proto: SNProtoDataMessage, sentTimestampMs: TimeInterval) throws { guard let previewProto = proto.preview.first else { throw LinkPreviewError.noPreview } guard URL(string: previewProto.url) != nil else { throw LinkPreviewError.invalidInput } guard LinkPreview.isValidLinkUrl(previewProto.url) else { throw LinkPreviewError.invalidInput } @@ -220,7 +220,7 @@ public extension LinkPreview { selectedRange: NSRange? = nil, using dependencies: Dependencies ) -> String? { - guard dependencies[singleton: .storage, key: .areLinkPreviewsEnabled] else { return nil } + guard dependencies.mutate(cache: .libSession, { $0.get(.areLinkPreviewsEnabled) }) else { return nil } guard let body: String = body else { return nil } if let cachedUrl = previewUrlCache.get(key: body) { @@ -303,7 +303,7 @@ public extension LinkPreview { // Exit early if link previews are not enabled in order to avoid // tainting the cache. - guard dependencies[singleton: .storage, key: .areLinkPreviewsEnabled] else { return } + guard dependencies.mutate(cache: .libSession, { $0.get(.areLinkPreviewsEnabled) }) else { return } serialQueue.sync { linkPreviewDraftCache = linkPreviewDraft @@ -314,7 +314,7 @@ public extension LinkPreview { previewUrl: String?, using dependencies: Dependencies ) -> AnyPublisher { - guard dependencies[singleton: .storage, key: .areLinkPreviewsEnabled] else { + guard dependencies.mutate(cache: .libSession, { $0.get(.areLinkPreviewsEnabled) }) else { return Fail(error: LinkPreviewError.featureDisabled) .eraseToAnyPublisher() } @@ -510,8 +510,14 @@ public extension LinkPreview { ) .tryMap { asset, _ -> Data in let type: UTType? = UTType(sessionMimeType: imageMimeType) - let imageSize = Data.imageSize(for: asset.filePath, type: type, using: dependencies) - + let imageSize = Data.mediaSize( + for: asset.filePath, + type: type, + mimeType: imageMimeType, + sourceFilename: nil, + using: dependencies + ) + guard imageSize.width > 0, imageSize.height > 0 else { throw LinkPreviewError.invalidContent } diff --git a/SessionMessagingKit/Database/Models/MessageDeduplication.swift b/SessionMessagingKit/Database/Models/MessageDeduplication.swift new file mode 100644 index 0000000000..476eb0e493 --- /dev/null +++ b/SessionMessagingKit/Database/Models/MessageDeduplication.swift @@ -0,0 +1,478 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit +import SessionSnodeKit + +// MARK: - Log.Category + +private extension Log.Category { + static let cat: Log.Category = .create("MessageDeduplication", defaultLevel: .info) +} + +// MARK: - MessageDeduplication + +public struct MessageDeduplication: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "messageDeduplication" } + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case threadId + case uniqueIdentifier + case expirationTimestampSeconds + case shouldDeleteWhenDeletingThread + } + + public let threadId: String + public let uniqueIdentifier: String + public let expirationTimestampSeconds: Int64? + public let shouldDeleteWhenDeletingThread: Bool +} + +// MARK: - Convenience + +public extension MessageDeduplication { + static func insert( + _ db: ObservingDatabase, + threadId: String, + threadVariant: SessionThread.Variant?, + uniqueIdentifier: String?, + legacyIdentifier: String? = nil, + message: Message?, + serverExpirationTimestamp: TimeInterval?, + ignoreDedupeFiles: Bool, + using dependencies: Dependencies + ) throws { + /// If we don't have a `uniqueIdentifier` then we can't dedupe the message + guard let uniqueIdentifier: String = uniqueIdentifier else { return } + + /// If we aren't ignoring dedupe files then check to ensure they don't already exist (ie. a message was received as a push + /// notification but doesn't yet exist in the database) + if !ignoreDedupeFiles { + /// Ensure this isn't a duplicate message received as a PN first + try ensureMessageIsNotADuplicate( + threadId: threadId, + uniqueIdentifier: uniqueIdentifier, + legacyIdentifier: legacyIdentifier, + using: dependencies + ) + + /// We need additional dedupe logic if the message is a `CallMessage` as multiple messages can related to the same call + try ensureCallMessageIsNotADuplicate( + threadId: threadId, + callMessage: message as? CallMessage, + using: dependencies + ) + } + + /// Add `(SnodeReceivedMessage.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) } + + /// 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) + /// + /// For `community` conversations we only poll while the conversation exists and have a `seqNo` to poll from in order + /// to prevent retrieving old messages + /// + /// Updated `group` conversations are a bit special because we want to delete _most_ records, but there are a few that + /// can cause issues if we process them again so we hold on to those just in case + let shouldDeleteWhenDeletingThread: Bool = { + switch (threadVariant, message.map { Message.Variant(from: $0) }) { + case (.contact, _): return false + case (.community, _), (.legacyGroup, _): return true + case (.group, .groupUpdateInvite), (.group, .groupUpdatePromote), + (.group, .groupUpdateMemberLeft), (.group, .groupUpdateInviteResponse): + return false + case (.group, _): return true + case (.none, .none), (.none, _): return false + } + }() + + /// Insert the `MessageDeduplication` record + _ = try MessageDeduplication( + threadId: threadId, + uniqueIdentifier: uniqueIdentifier, + expirationTimestampSeconds: finalExpiryTimestampSeconds, + shouldDeleteWhenDeletingThread: shouldDeleteWhenDeletingThread + ).insert(db) + + /// Create the replicated file in the 'AppGroup' so that the PN extension is able to dedupe messages + try createDedupeFile( + threadId: threadId, + uniqueIdentifier: uniqueIdentifier, + legacyIdentifier: legacyIdentifier, + using: dependencies + ) + + /// Insert & create special call-specific dedupe records + try insertCallDedupeRecordsIfNeeded( + db, + threadId: threadId, + callMessage: message as? CallMessage, + expirationTimestampSeconds: finalExpiryTimestampSeconds, + shouldDeleteWhenDeletingThread: shouldDeleteWhenDeletingThread, + using: dependencies + ) + + /// Create a legacy dedupe record + try createLegacyDeduplicationRecord( + db, + threadId: threadId, + legacyIdentifier: legacyIdentifier, + legacyVariant: getLegacyVariant(for: message.map { Message.Variant(from: $0) }), + timestampMs: message?.sentTimestampMs.map { Int64($0) }, + serverExpirationTimestamp: serverExpirationTimestamp, + using: dependencies + ) + } + + static func deleteIfNeeded( + _ db: ObservingDatabase, + threadIds: [String], + using dependencies: Dependencies + ) throws { + /// First update the rows to be considered expired (so they are garbage collected in case the file deletion fails for some reason) + try MessageDeduplication + .filter(threadIds.contains(MessageDeduplication.Columns.threadId)) + .filter(MessageDeduplication.Columns.shouldDeleteWhenDeletingThread == true) + .updateAll( + db, + MessageDeduplication.Columns.expirationTimestampSeconds.set(to: 0) + ) + + /// Then fetch the records and try to individually delete each one + let records: [MessageDeduplication] = try MessageDeduplication + .filter(threadIds.contains(MessageDeduplication.Columns.threadId)) + .filter(MessageDeduplication.Columns.shouldDeleteWhenDeletingThread == true) + .fetchAll(db) + records.forEach { record in + do { + try dependencies[singleton: .extensionHelper].removeDedupeRecord( + threadId: record.threadId, + uniqueIdentifier: record.uniqueIdentifier + ) + try record.delete(db) + } + catch { Log.warn(.cat, "Failed to delete dedupe record (will rely on garbage collection).") } + } + } + + static func createDedupeFile( + threadId: String, + uniqueIdentifier: String, + legacyIdentifier: String? = nil, + using dependencies: Dependencies + ) throws { + try dependencies[singleton: .extensionHelper].createDedupeRecord( + threadId: threadId, + uniqueIdentifier: uniqueIdentifier + ) + + /// Also create a dedupe file for the legacy identifier if provided + guard let legacyIdentifier: String = legacyIdentifier else { return } + + try dependencies[singleton: .extensionHelper].createDedupeRecord( + threadId: threadId, + uniqueIdentifier: legacyIdentifier + ) + } + + static func ensureMessageIsNotADuplicate( + _ processedMessage: ProcessedMessage, + using dependencies: Dependencies + ) throws { + typealias Variant = _026_MessageDeduplicationTable.ControlMessageProcessRecordVariant + try ensureMessageIsNotADuplicate( + threadId: processedMessage.threadId, + uniqueIdentifier: processedMessage.uniqueIdentifier, + legacyIdentifier: getLegacyIdentifier(for: processedMessage), + using: dependencies + ) + } + + static func ensureMessageIsNotADuplicate( + threadId: String, + uniqueIdentifier: String, + legacyIdentifier: String? = nil, + using dependencies: Dependencies + ) throws { + if dependencies[singleton: .extensionHelper].dedupeRecordExists( + threadId: threadId, + uniqueIdentifier: uniqueIdentifier + ) { + throw MessageReceiverError.duplicateMessage + } + + /// Also check for a dedupe file using the legacy identifier + guard let legacyIdentifier: String = legacyIdentifier else { return } + + if dependencies[singleton: .extensionHelper].dedupeRecordExists( + threadId: threadId, + uniqueIdentifier: legacyIdentifier + ) { + throw MessageReceiverError.duplicateMessage + } + } +} + +// MARK: - CallMessage Convenience + +public extension MessageDeduplication { + static func insertCallDedupeRecordsIfNeeded( + _ db: ObservingDatabase, + threadId: String, + callMessage: CallMessage?, + expirationTimestampSeconds: Int64?, + shouldDeleteWhenDeletingThread: Bool, + using dependencies: Dependencies + ) throws { + guard let callMessage: CallMessage = callMessage else { return } + + switch (callMessage.kind, callMessage.state) { + /// If the call was ended, was missed or had a permission issue then reject all subsequent messages associated with the call + case (.endCall, _), (_, .missed), (_, .permissionDenied), (_, .permissionDeniedMicrophone): + _ = try MessageDeduplication( + threadId: threadId, + uniqueIdentifier: callMessage.uuid, + expirationTimestampSeconds: expirationTimestampSeconds, + shouldDeleteWhenDeletingThread: shouldDeleteWhenDeletingThread + ).insert(db) + + /// We only want to handle a single `preOffer` so add a custom record for that + case (.preOffer, _): + _ = try MessageDeduplication( + threadId: threadId, + uniqueIdentifier: callMessage.preOfferDedupeIdentifier, + expirationTimestampSeconds: expirationTimestampSeconds, + shouldDeleteWhenDeletingThread: shouldDeleteWhenDeletingThread + ).insert(db) + + /// For any other combinations we don't want to deduplicate messages (as they are needed to keep the call going) + default: break + } + + /// Create the replicated file in the 'AppGroup' so that the PN extension is able to dedupe call messages + try createCallDedupeFilesIfNeeded( + threadId: threadId, + callMessage: callMessage, + using: dependencies + ) + } + + static func createCallDedupeFilesIfNeeded( + threadId: String, + callMessage: CallMessage?, + using dependencies: Dependencies + ) throws { + guard let callMessage: CallMessage = callMessage else { return } + + switch (callMessage.kind, callMessage.state) { + /// If the call was ended, was missed or had a permission issue then reject all subsequent messages associated with the call + case (.endCall, _), (_, .missed), (_, .permissionDenied), (_, .permissionDeniedMicrophone): + try dependencies[singleton: .extensionHelper].createDedupeRecord( + threadId: threadId, + uniqueIdentifier: callMessage.uuid + ) + + /// We only want to handle a single `preOffer` so add a custom record for that + case (.preOffer, _): + try dependencies[singleton: .extensionHelper].createDedupeRecord( + threadId: threadId, + uniqueIdentifier: callMessage.preOfferDedupeIdentifier + ) + + /// For any other combinations we don't want to deduplicate messages (as they are needed to keep the call going) + default: break + } + } + + static func ensureCallMessageIsNotADuplicate( + threadId: String, + callMessage: CallMessage?, + using dependencies: Dependencies + ) throws { + guard let callMessage: CallMessage = callMessage else { return } + + do { + /// We only want to handle the `preOffer` message once + if callMessage.kind == .preOffer { + try MessageDeduplication.ensureMessageIsNotADuplicate( + threadId: threadId, + uniqueIdentifier: callMessage.preOfferDedupeIdentifier, + using: dependencies + ) + } + + /// If a call has officially "ended" then we don't want to handle _any_ further messages related to it + try MessageDeduplication.ensureMessageIsNotADuplicate( + threadId: threadId, + uniqueIdentifier: callMessage.uuid, + using: dependencies + ) + } + catch { throw MessageReceiverError.duplicatedCall } + } +} + +// MARK: - ProcessedMessage Convenience + +public extension MessageDeduplication { + static func insert( + _ db: ObservingDatabase, + processedMessage: ProcessedMessage, + ignoreDedupeFiles: Bool, + using dependencies: Dependencies + ) throws { + /// We don't actually want to dedupe config messages as `libSession` will take care of that logic and if we do anything + /// special then it could result in unexpected behaviours where config messages don't get merged correctly + switch processedMessage { + case .config, .invalid: return + case .standard(_, let threadVariant, _, let messageInfo, _): + try insert( + db, + threadId: processedMessage.threadId, + threadVariant: threadVariant, + uniqueIdentifier: processedMessage.uniqueIdentifier, + legacyIdentifier: getLegacyIdentifier(for: processedMessage), + message: messageInfo.message, + serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, + ignoreDedupeFiles: ignoreDedupeFiles, + using: dependencies + ) + } + } + + static func createDedupeFile( + _ processedMessage: ProcessedMessage, + using dependencies: Dependencies + ) throws { + /// We don't actually want to dedupe config messages as `libSession` will take care of that logic and if we do anything + /// special then it could result in unexpected behaviours where config messages don't get merged correctly + switch processedMessage { + case .config, .invalid: return + case .standard: + try createDedupeFile( + threadId: processedMessage.threadId, + uniqueIdentifier: processedMessage.uniqueIdentifier, + legacyIdentifier: getLegacyIdentifier(for: processedMessage), + using: dependencies + ) + } + } +} + +// MARK: - Legacy Dedupe Records + +private extension MessageDeduplication { + @available(*, deprecated, message: "⚠️ Remove this code once once enough time has passed since it's release (at least 1 month)") + private static func createLegacyDeduplicationRecord( + _ db: ObservingDatabase, + threadId: String, + legacyIdentifier: String?, + legacyVariant: _026_MessageDeduplicationTable.ControlMessageProcessRecordVariant?, + timestampMs: Int64?, + serverExpirationTimestamp: TimeInterval?, + using dependencies: Dependencies + ) throws { + typealias Variant = _026_MessageDeduplicationTable.ControlMessageProcessRecordVariant + guard + let legacyIdentifier: String = legacyIdentifier, + let legacyVariant: Variant = legacyVariant, + let timestampMs: Int64 = timestampMs + else { return } + + let expirationTimestampSeconds: Int64? = { + /// If we have a server expiration for the hash then we should use that value as the priority + if let serverExpirationTimestamp: TimeInterval = serverExpirationTimestamp { + return Int64(serverExpirationTimestamp) + } + + /// 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` + /// + /// 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) + }() + + /// Add `(SnodeReceivedMessage.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) } + + /// 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) + /// + /// For `community` conversations we only poll while the conversation exists and have a `seqNo` to poll from in order + /// to prevent retrieving old messages + /// + /// Updated `group` conversations are a bit special because we want to delete _most_ records, but there are a few that + /// can cause issues if we process them again so we hold on to those just in case + let shouldDeleteWhenDeletingThread: Bool = { + switch legacyVariant { + case .groupUpdateInvite, .groupUpdatePromote, .groupUpdateMemberLeft, + .groupUpdateInviteResponse: + return false + default: return true + } + }() + + /// Add the record + _ = try MessageDeduplication( + threadId: threadId, + uniqueIdentifier: legacyIdentifier, + expirationTimestampSeconds: finalExpiryTimestampSeconds, + shouldDeleteWhenDeletingThread: shouldDeleteWhenDeletingThread + ).insert(db) + } + + @available(*, deprecated, message: "⚠️ Remove this code once once enough time has passed since it's release (at least 1 month)") + static func getLegacyVariant(for variant: Message.Variant?) -> _026_MessageDeduplicationTable.ControlMessageProcessRecordVariant? { + guard let variant: Message.Variant = variant else { return nil } + + switch variant { + case .visibleMessage: return .visibleMessageDedupe + case .readReceipt: return .readReceipt + case .typingIndicator: return .typingIndicator + case .unsendRequest: return .unsendRequest + case .dataExtractionNotification: return .dataExtractionNotification + case .expirationTimerUpdate: return .expirationTimerUpdate + case .messageRequestResponse: return .messageRequestResponse + case .callMessage: return .call + case .groupUpdateInvite, .groupUpdateMemberChange, .groupUpdatePromote: + return .groupUpdateMemberChange + case .groupUpdateInfoChange: return .groupUpdateInfoChange + case .groupUpdateMemberLeft: return .groupUpdateMemberLeft + case .groupUpdateMemberLeftNotification: return .groupUpdateMemberLeftNotification + case .groupUpdateInviteResponse: return .groupUpdateInviteResponse + case .groupUpdateDeleteMemberContent: return .groupUpdateDeleteMemberContent + + case .libSessionMessage: return nil + } + } + + @available(*, deprecated, message: "⚠️ Remove this code once once enough time has passed since it's release (at least 1 month)") + static func getLegacyIdentifier(for processedMessage: ProcessedMessage) -> String? { + switch processedMessage { + case .config, .invalid: return nil + case .standard(_, _, _, let messageInfo, _): + guard + let timestampMs: UInt64 = messageInfo.message.sentTimestampMs, + let variant: _026_MessageDeduplicationTable.ControlMessageProcessRecordVariant = getLegacyVariant(for: Message.Variant(from: messageInfo.message)) + else { return nil } + + return "LegacyRecord-\(variant.rawValue)-\(timestampMs)" // stringlint:ignore + } + } +} + +public extension CallMessage { + var preOfferDedupeIdentifier: String { "\(uuid)-preOffer" } +} + diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index 077527c6a1..f6ac676fea 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -22,7 +22,6 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe case isActive case roomDescription = "description" case imageId - @available(*, deprecated, message: "use 'DisplayPictureManager' instead") case imageData case userCount case infoUpdates case sequenceNumber @@ -31,8 +30,7 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe case pollFailureCount case permissions - case displayPictureFilename - case lastDisplayPictureUpdate + case displayPictureOriginalUrl } public struct Permissions: OptionSet, Codable, DatabaseValueConvertible, Hashable { @@ -92,7 +90,7 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe /// The public key for the group public let publicKey: String - /// Flag indicating whether this is an OpenGroup the user has actively joined (we store inactive + /// Flag indicating whether this is an `OpenGroup` the user has actively joined (we store inactive /// open groups so we can display them in the UI but they won't be polled for) public let isActive: Bool @@ -105,10 +103,6 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe /// The ID with which the image can be retrieved from the server public let imageId: String? - /// The image for the group - @available(*, deprecated, message: "use 'DisplayPictureManager' instead") - public let imageData: Data? = nil - /// The number of users in the group public let userCount: Int64 @@ -136,11 +130,12 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe /// The permissions this room has for current user public let permissions: Permissions? - /// The file name of the groups's display picture on local storage. - public let displayPictureFilename: String? - - /// The timestamp (in seconds since epoch) that the display picture was last updated - public let lastDisplayPictureUpdate: TimeInterval? + /// The url that the the open groups's display picture was at the time it was downloaded + /// + /// **Note:** Since the filename is a hash of the download url we need to store this to ensure any API changes wouldn't result in + /// a different hash being generated for existing files - this value also won't be updated until the display picture has actually + /// been downloaded + public let displayPictureOriginalUrl: String? // MARK: - Relationships @@ -175,8 +170,7 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe outboxLatestMessageId: Int64 = 0, pollFailureCount: Int64 = 0, permissions: Permissions? = nil, - displayPictureFilename: String? = nil, - lastDisplayPictureUpdate: TimeInterval? = nil + displayPictureOriginalUrl: String? = nil ) { self.threadId = OpenGroup.idFor(roomToken: roomToken, server: server) self.server = server.lowercased() @@ -193,8 +187,7 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe self.outboxLatestMessageId = outboxLatestMessageId self.pollFailureCount = pollFailureCount self.permissions = permissions - self.displayPictureFilename = displayPictureFilename - self.lastDisplayPictureUpdate = lastDisplayPictureUpdate + self.displayPictureOriginalUrl = displayPictureOriginalUrl } } @@ -202,7 +195,7 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe public extension OpenGroup { static func fetchOrCreate( - _ db: Database, + _ db: ObservingDatabase, server: String, roomToken: String, publicKey: String @@ -267,8 +260,7 @@ extension OpenGroup: CustomStringConvertible, CustomDebugStringConvertible { outboxLatestMessageId: \(outboxLatestMessageId), pollFailureCount: \(pollFailureCount), permissions: \(permissions?.toString() ?? "---"), - displayPictureFilename: \(displayPictureFilename.map { "\"\($0)\"" } ?? "null"), - lastDisplayPictureUpdate: \(lastDisplayPictureUpdate ?? 0) + displayPictureOriginalUrl: \(displayPictureOriginalUrl.map { "\"\($0)\"" } ?? "null") ) """ } diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 8a66d774c1..cd06bb1f5d 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -8,12 +8,12 @@ import SessionUtilitiesKit /// This type is duplicate in both the database and within the LibSession config so should only ever have it's data changes via the /// `updateAllAndConfig` function. Updating it elsewhere could result in issues with syncing data between devices -public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, Differentiable { +public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, Differentiable { public static var databaseTableName: String { "profile" } internal static let interactionForeignKey = ForeignKey([Columns.id], to: [Interaction.Columns.authorId]) internal static let contactForeignKey = ForeignKey([Columns.id], to: [Contact.Columns.id]) internal static let groupMemberForeignKey = ForeignKey([GroupMember.Columns.profileId], to: [Columns.id]) - internal static let contact = hasOne(Contact.self, using: contactForeignKey) + public static let contact = hasOne(Contact.self, using: contactForeignKey) public static let groupMembers = hasMany(GroupMember.self, using: groupMemberForeignKey) public typealias Columns = CodingKeys @@ -24,10 +24,9 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco case lastNameUpdate case nickname - case profilePictureUrl - case profilePictureFileName - case profileEncryptionKey - case lastProfilePictureUpdate + case displayPictureUrl + case displayPictureEncryptionKey + case displayPictureLastUpdated case blocksCommunityMessageRequests case lastBlocksCommunityMessageRequests @@ -45,17 +44,16 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco /// A custom name for the profile set by the current user public let nickname: String? - /// The URL from which to fetch the contact's profile picture. - public let profilePictureUrl: String? - - /// The file name of the contact's profile picture on local storage. - public let profilePictureFileName: String? + /// The URL from which to fetch the contact's profile picture + /// + /// **Note:** This won't be updated until the display picture has actually been downloaded + public let displayPictureUrl: String? /// The key with which the profile is encrypted. - public let profileEncryptionKey: Data? + public let displayPictureEncryptionKey: Data? /// The timestamp (in seconds since epoch) that the profile picture was last updated - public let lastProfilePictureUpdate: TimeInterval? + public let displayPictureLastUpdated: TimeInterval? /// A flag indicating whether this profile has reported that it blocks community message requests public let blocksCommunityMessageRequests: Bool? @@ -70,10 +68,9 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco name: String, lastNameUpdate: TimeInterval? = nil, nickname: String? = nil, - profilePictureUrl: String? = nil, - profilePictureFileName: String? = nil, - profileEncryptionKey: Data? = nil, - lastProfilePictureUpdate: TimeInterval? = nil, + displayPictureUrl: String? = nil, + displayPictureEncryptionKey: Data? = nil, + displayPictureLastUpdated: TimeInterval? = nil, blocksCommunityMessageRequests: Bool? = nil, lastBlocksCommunityMessageRequests: TimeInterval? = nil ) { @@ -81,10 +78,9 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco self.name = name self.lastNameUpdate = lastNameUpdate self.nickname = nickname - self.profilePictureUrl = profilePictureUrl - self.profilePictureFileName = profilePictureFileName - self.profileEncryptionKey = profileEncryptionKey - self.lastProfilePictureUpdate = lastProfilePictureUpdate + self.displayPictureUrl = displayPictureUrl + self.displayPictureEncryptionKey = displayPictureEncryptionKey + self.displayPictureLastUpdated = displayPictureLastUpdated self.blocksCommunityMessageRequests = blocksCommunityMessageRequests self.lastBlocksCommunityMessageRequests = lastBlocksCommunityMessageRequests } @@ -97,8 +93,8 @@ extension Profile: CustomStringConvertible, CustomDebugStringConvertible { """ Profile( name: \(name), - profileKey: \(profileEncryptionKey?.description ?? "null"), - profilePictureUrl: \(profilePictureUrl ?? "null") + profileKey: \(displayPictureEncryptionKey?.description ?? "null"), + profilePictureUrl: \(displayPictureUrl ?? "null") ) """ } @@ -110,10 +106,9 @@ extension Profile: CustomStringConvertible, CustomDebugStringConvertible { name: \(name), lastNameUpdate: \(lastNameUpdate.map { "\($0)" } ?? "null"), nickname: \(nickname.map { "\($0)" } ?? "null"), - profilePictureUrl: \(profilePictureUrl.map { "\"\($0)\"" } ?? "null"), - profilePictureFileName: \(profilePictureFileName.map { "\"\($0)\"" } ?? "null"), - profileEncryptionKey: \(profileEncryptionKey?.toHexString() ?? "null"), - lastProfilePictureUpdate: \(lastProfilePictureUpdate.map { "\($0)" } ?? "null"), + displayPictureUrl: \(displayPictureUrl.map { "\"\($0)\"" } ?? "null"), + displayPictureEncryptionKey: \(displayPictureEncryptionKey?.toHexString() ?? "null"), + displayPictureLastUpdated: \(displayPictureLastUpdated.map { "\($0)" } ?? "null"), blocksCommunityMessageRequests: \(blocksCommunityMessageRequests.map { "\($0)" } ?? "null"), lastBlocksCommunityMessageRequests: \(lastBlocksCommunityMessageRequests.map { "\($0)" } ?? "null") ) @@ -127,16 +122,16 @@ public extension Profile { init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - var profileKey: Data? - var profilePictureUrl: String? + var displayPictureKey: Data? + var displayPictureUrl: String? // If we have both a `profileKey` and a `profilePicture` then the key MUST be valid if - let profileKeyData: Data = try? container.decode(Data?.self, forKey: .profileEncryptionKey), - let profilePictureUrlValue: String = try? container.decode(String?.self, forKey: .profilePictureUrl) + let displayPictureKeyData: Data = try? container.decode(Data?.self, forKey: .displayPictureEncryptionKey), + let displayPictureUrlValue: String = try? container.decode(String?.self, forKey: .displayPictureUrl) { - profileKey = profileKeyData - profilePictureUrl = profilePictureUrlValue + displayPictureKey = displayPictureKeyData + displayPictureUrl = displayPictureUrlValue } self = Profile( @@ -144,10 +139,9 @@ public extension Profile { name: try container.decode(String.self, forKey: .name), lastNameUpdate: try? container.decode(TimeInterval?.self, forKey: .lastNameUpdate), nickname: try? container.decode(String?.self, forKey: .nickname), - profilePictureUrl: profilePictureUrl, - profilePictureFileName: try? container.decode(String?.self, forKey: .profilePictureFileName), - profileEncryptionKey: profileKey, - lastProfilePictureUpdate: try? container.decode(TimeInterval?.self, forKey: .lastProfilePictureUpdate), + displayPictureUrl: displayPictureUrl, + displayPictureEncryptionKey: displayPictureKey, + displayPictureLastUpdated: try? container.decode(TimeInterval?.self, forKey: .displayPictureLastUpdated), blocksCommunityMessageRequests: try? container.decode(Bool?.self, forKey: .blocksCommunityMessageRequests), lastBlocksCommunityMessageRequests: try? container.decode(TimeInterval?.self, forKey: .lastBlocksCommunityMessageRequests) ) @@ -160,10 +154,9 @@ public extension Profile { try container.encode(name, forKey: .name) try container.encodeIfPresent(lastNameUpdate, forKey: .lastNameUpdate) try container.encodeIfPresent(nickname, forKey: .nickname) - try container.encodeIfPresent(profilePictureUrl, forKey: .profilePictureUrl) - try container.encodeIfPresent(profilePictureFileName, forKey: .profilePictureFileName) - try container.encodeIfPresent(profileEncryptionKey, forKey: .profileEncryptionKey) - try container.encodeIfPresent(lastProfilePictureUpdate, forKey: .lastProfilePictureUpdate) + try container.encodeIfPresent(displayPictureUrl, forKey: .displayPictureUrl) + try container.encodeIfPresent(displayPictureEncryptionKey, forKey: .displayPictureEncryptionKey) + try container.encodeIfPresent(displayPictureLastUpdated, forKey: .displayPictureLastUpdated) try container.encodeIfPresent(blocksCommunityMessageRequests, forKey: .blocksCommunityMessageRequests) try container.encodeIfPresent(lastBlocksCommunityMessageRequests, forKey: .lastBlocksCommunityMessageRequests) } @@ -177,9 +170,11 @@ public extension Profile { let profileProto = SNProtoLokiProfile.builder() profileProto.setDisplayName(name) - if let profileKey: Data = profileEncryptionKey, let profilePictureUrl: String = profilePictureUrl { - dataMessageProto.setProfileKey(profileKey) - profileProto.setProfilePicture(profilePictureUrl) + if + let displayPictureEncryptionKey: Data = displayPictureEncryptionKey, + let displayPictureUrl: String = displayPictureUrl { + dataMessageProto.setProfileKey(displayPictureEncryptionKey) + profileProto.setProfilePicture(displayPictureUrl) } do { @@ -226,26 +221,11 @@ public extension Profile { } static func displayName( - _ db: Database? = nil, + _ db: ObservingDatabase, id: ID, threadVariant: SessionThread.Variant = .contact, - customFallback: String? = nil, - using dependencies: Dependencies + customFallback: String? = nil ) -> String { - guard let db: Database = db else { - return dependencies[singleton: .storage] - .read { db in - displayName( - db, - id: id, - threadVariant: threadVariant, - customFallback: customFallback, - using: dependencies - ) - } - .defaulting(to: (customFallback ?? id)) - } - let existingDisplayName: String? = (try? Profile.fetchOne(db, id: id))? .displayName(for: threadVariant) @@ -253,17 +233,10 @@ public extension Profile { } static func displayNameNoFallback( - _ db: Database? = nil, + _ db: ObservingDatabase, id: ID, - threadVariant: SessionThread.Variant = .contact, - using dependencies: Dependencies + threadVariant: SessionThread.Variant = .contact ) -> String? { - guard let db: Database = db else { - return dependencies[singleton: .storage].read { db in - displayNameNoFallback(db, id: id, threadVariant: threadVariant, using: dependencies) - } - } - return (try? Profile.fetchOne(db, id: id))? .displayName(for: threadVariant) } @@ -276,45 +249,19 @@ public extension Profile { name: "", lastNameUpdate: nil, nickname: nil, - profilePictureUrl: nil, - profilePictureFileName: nil, - profileEncryptionKey: nil, - lastProfilePictureUpdate: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + displayPictureLastUpdated: nil, blocksCommunityMessageRequests: nil, lastBlocksCommunityMessageRequests: nil ) } - /// Fetches or creates a Profile for the current user - /// - /// **Note:** This method intentionally does **not** save the newly created Profile, - /// it will need to be explicitly saved after calling - static func fetchOrCreateCurrentUser(using dependencies: Dependencies) -> Profile { - let userSessionId: SessionId = dependencies[cache: .general].sessionId - - return dependencies[singleton: .storage] - .read { db in fetchOrCreateCurrentUser(db, using: dependencies) } - .defaulting(to: defaultFor(userSessionId.hexString)) - } - - /// Fetches or creates a Profile for the current user - /// - /// **Note:** This method intentionally does **not** save the newly created Profile, - /// it will need to be explicitly saved after calling - static func fetchOrCreateCurrentUser(_ db: Database, using dependencies: Dependencies) -> Profile { - let userSessionId: SessionId = dependencies[cache: .general].sessionId - - return ( - (try? Profile.fetchOne(db, id: userSessionId.hexString)) ?? - defaultFor(userSessionId.hexString) - ) - } - /// Fetches or creates a Profile for the specified user /// /// **Note:** This method intentionally does **not** save the newly created Profile, /// it will need to be explicitly saved after calling - static func fetchOrCreate(_ db: Database, id: String) -> Profile { + static func fetchOrCreate(_ db: ObservingDatabase, id: String) -> Profile { return ( (try? Profile.fetchOne(db, id: id)) ?? defaultFor(id) @@ -322,6 +269,56 @@ public extension Profile { } } +// MARK: - Deprecated GRDB Interactions + +public extension Profile { + @available(*, deprecated, message: "This function should be avoided as it uses a blocking database query to retrieve the result. Use an async method instead.") + static func displayName( + id: ID, + threadVariant: SessionThread.Variant = .contact, + customFallback: String? = nil, + using dependencies: Dependencies + ) -> String { + let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + var displayName: String? + dependencies[singleton: .storage].readAsync( + retrieve: { db in Profile.displayName(db, id: id, threadVariant: threadVariant) }, + completion: { result in + switch result { + case .failure: break + case .success(let name): displayName = name + } + semaphore.signal() + } + ) + semaphore.wait() + return (displayName ?? (customFallback ?? id)) + } + + @available(*, deprecated, message: "This function should be avoided as it uses a blocking database query to retrieve the result. Use an async method instead.") + static func displayNameNoFallback( + id: ID, + threadVariant: SessionThread.Variant = .contact, + using dependencies: Dependencies + ) -> String? { + let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + var displayName: String? + dependencies[singleton: .storage].readAsync( + retrieve: { db in Profile.displayNameNoFallback(db, id: id, threadVariant: threadVariant) }, + completion: { result in + switch result { + case .failure: break + case .success(let name): displayName = name + } + semaphore.signal() + } + ) + semaphore.wait() + return displayName + } +} + + // MARK: - Search Queries public extension Profile { @@ -369,12 +366,13 @@ public extension Profile { /// The name to display in the UI for a given thread variant func displayName( for threadVariant: SessionThread.Variant = .contact, + messageProfile: VisibleMessage.VMProfile? = nil, ignoringNickname: Bool = false ) -> String { return Profile.displayName( for: threadVariant, id: id, - name: name, + name: (messageProfile?.displayName?.nullIfEmpty ?? name), nickname: (ignoringNickname ? nil : nickname), suppressId: false ) @@ -444,7 +442,7 @@ public extension ProfileAssociated { } public extension FetchRequest where RowDecoder: FetchableRecord & ProfileAssociated { - func fetchAllWithProfiles(_ db: Database, using dependencies: Dependencies) throws -> [WithProfile] { + func fetchAllWithProfiles(_ db: ObservingDatabase, using dependencies: Dependencies) throws -> [WithProfile] { let originalResult: [RowDecoder] = try self.fetchAll(db) let profiles: [String: Profile]? = try? Profile .fetchAll(db, ids: originalResult.map { $0.profileId }.asSet()) @@ -459,3 +457,25 @@ public extension FetchRequest where RowDecoder: FetchableRecord & ProfileAssocia } } } + +// MARK: - Convenience + +public extension Profile { + func with( + name: String? = nil, + nickname: String?? = nil, + displayPictureUrl: String?? = nil + ) -> Profile { + return Profile( + id: id, + name: (name ?? self.name), + lastNameUpdate: lastNameUpdate, + nickname: (nickname ?? self.nickname), + displayPictureUrl: (displayPictureUrl ?? self.displayPictureUrl), + displayPictureEncryptionKey: displayPictureEncryptionKey, + displayPictureLastUpdated: displayPictureLastUpdated, + blocksCommunityMessageRequests: blocksCommunityMessageRequests, + lastBlocksCommunityMessageRequests: lastBlocksCommunityMessageRequests + ) + } +} diff --git a/SessionMessagingKit/Database/Models/Quote.swift b/SessionMessagingKit/Database/Models/Quote.swift index 61914c340f..04d43134ef 100644 --- a/SessionMessagingKit/Database/Models/Quote.swift +++ b/SessionMessagingKit/Database/Models/Quote.swift @@ -15,7 +15,6 @@ public struct Quote: Codable, Equatable, Hashable, FetchableRecord, PersistableR internal static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) private static let profile = hasOne(Profile.self, using: profileForeignKey) private static let quotedInteraction = hasOne(Interaction.self, using: originalInteractionForeignKey) - public static let attachment = hasOne(Attachment.self, using: Attachment.quoteForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -23,7 +22,6 @@ public struct Quote: Codable, Equatable, Hashable, FetchableRecord, PersistableR case authorId case timestampMs case body - case attachmentId } /// The id for the interaction this Quote belongs to @@ -38,9 +36,6 @@ public struct Quote: Codable, Equatable, Hashable, FetchableRecord, PersistableR /// The body of the quoted message if the user is quoting a text message or an attachment with a caption public let body: String? - /// The id for the attachment this Quote is associated with - public let attachmentId: String? - // MARK: - Relationships public var interaction: QueryInterfaceRequest { @@ -51,10 +46,6 @@ public struct Quote: Codable, Equatable, Hashable, FetchableRecord, PersistableR request(for: Quote.profile) } - public var attachment: QueryInterfaceRequest { - request(for: Quote.attachment) - } - public var originalInteraction: QueryInterfaceRequest { request(for: Quote.quotedInteraction) } @@ -65,14 +56,12 @@ public struct Quote: Codable, Equatable, Hashable, FetchableRecord, PersistableR interactionId: Int64, authorId: String, timestampMs: Int64, - body: String?, - attachmentId: String? + body: String? ) { self.interactionId = interactionId self.authorId = authorId self.timestampMs = timestampMs self.body = body - self.attachmentId = attachmentId } } @@ -83,15 +72,13 @@ public extension Quote { interactionId: Int64? = nil, authorId: String? = nil, timestampMs: Int64? = nil, - body: String? = nil, - attachmentId: String? = nil + body: String? = nil ) -> Quote { return Quote( interactionId: interactionId ?? self.interactionId, authorId: authorId ?? self.authorId, timestampMs: timestampMs ?? self.timestampMs, - body: body ?? self.body, - attachmentId: attachmentId ?? self.attachmentId + body: body ?? self.body ) } @@ -100,8 +87,7 @@ public extension Quote { interactionId: self.interactionId, authorId: self.authorId, timestampMs: self.timestampMs, - body: nil, - attachmentId: nil + body: nil ) } } @@ -109,7 +95,7 @@ public extension Quote { // MARK: - Protobuf public extension Quote { - init?(_ db: Database, proto: SNProtoDataMessage, interactionId: Int64, thread: SessionThread) throws { + init?(proto: SNProtoDataMessage, interactionId: Int64, thread: SessionThread) throws { guard let quoteProto = proto.quote, quoteProto.id != 0, @@ -120,6 +106,5 @@ public extension Quote { self.timestampMs = Int64(quoteProto.id) self.authorId = quoteProto.author self.body = nil - self.attachmentId = nil } } diff --git a/SessionMessagingKit/Database/Models/Reaction.swift b/SessionMessagingKit/Database/Models/Reaction.swift index df10caad48..4c5557a5d8 100644 --- a/SessionMessagingKit/Database/Models/Reaction.swift +++ b/SessionMessagingKit/Database/Models/Reaction.swift @@ -106,7 +106,7 @@ public extension Reaction { public extension Reaction { static func getSortId( - _ db: Database, + _ db: ObservingDatabase, interactionId: Int64, emoji: String ) -> Int64 { diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 96ed8fab1f..e849bdff67 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -5,8 +5,10 @@ import GRDB import SessionUtilitiesKit import SessionSnodeKit -public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct SessionThread: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, IdentifiableTableRecord { public static var databaseTableName: String { "thread" } + public static let idColumn: ColumnExpression = Columns.id + public static let contact = hasOne(Contact.self, using: Contact.threadForeignKey) public static let closedGroup = hasOne(ClosedGroup.self, using: ClosedGroup.threadForeignKey) public static let openGroup = hasOne(OpenGroup.self, using: OpenGroup.threadForeignKey) @@ -26,7 +28,6 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, case variant case creationDateTimestamp case shouldBeVisible - @available(*, deprecated, message: "use 'pinnedPriority > 0' instead") case isPinned case messageDraft case notificationSound case mutedUntilTimestamp @@ -60,10 +61,6 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, /// A flag indicating whether the thread should be visible public let shouldBeVisible: Bool - /// A flag indicating whether the thread is pinned - @available(*, deprecated, message: "use 'pinnedPriority > 0' instead") - private let isPinned: Bool = false - /// The value the user started entering into the input field before they left the conversation screen public let messageDraft: String? @@ -127,8 +124,7 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, onlyNotifyForMentions: Bool = false, markedAsUnread: Bool? = false, pinnedPriority: Int32? = nil, - isDraft: Bool? = nil, - using dependencies: Dependencies + isDraft: Bool? = nil ) { self.id = id self.variant = variant @@ -147,8 +143,39 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, // MARK: - Custom Database Interaction - public func willInsert(_ db: Database) throws { - db[.hasSavedThread] = true + public func aroundInsert(_ db: Database, insert: () throws -> InsertionSuccess) throws { + _ = try insert() + + 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) + observingDb.addConversationEvent(id: id, type: .created) + } + } +} + +// MARK: - Codable + +public extension SessionThread { + init(from decoder: any Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + let pinnedPriority: Int32? = try container.decodeIfPresent(Int32.self, forKey: .pinnedPriority) + + self = SessionThread( + id: try container.decode(String.self, forKey: .id), + variant: try container.decode(Variant.self, forKey: .variant), + creationDateTimestamp: try container.decode(TimeInterval.self, forKey: .creationDateTimestamp), + shouldBeVisible: try container.decode(Bool.self, forKey: .shouldBeVisible), + isPinned: ((pinnedPriority ?? 0) > 0), + messageDraft: try container.decodeIfPresent(String.self, forKey: .messageDraft), + notificationSound: try container.decodeIfPresent(Preferences.Sound.self, forKey: .notificationSound), + mutedUntilTimestamp: try container.decodeIfPresent(TimeInterval.self, forKey: .mutedUntilTimestamp), + onlyNotifyForMentions: try container.decode(Bool.self, forKey: .onlyNotifyForMentions), + markedAsUnread: try container.decodeIfPresent(Bool.self, forKey: .markedAsUnread), + pinnedPriority: pinnedPriority, + isDraft: try container.decodeIfPresent(Bool.self, forKey: .isDraft) + ) } } @@ -176,6 +203,13 @@ public extension SessionThread { default: return nil } } + + var shouldUseLibSession: Bool { + switch self { + case .useLibSession: return true + default: return false + } + } } let creationDateTimestamp: Value @@ -183,6 +217,8 @@ public extension SessionThread { let pinnedPriority: Value let isDraft: Value let disappearingMessagesConfig: Value + let mutedUntilTimestamp: Value + let onlyNotifyForMentions: Value // MARK: - Convenience @@ -197,13 +233,86 @@ public extension SessionThread { shouldBeVisible: Value, pinnedPriority: Value = .useLibSession, isDraft: Value = .useExisting, - disappearingMessagesConfig: Value = .useLibSession + disappearingMessagesConfig: Value = .useLibSession, + mutedUntilTimestamp: Value = .useExisting, + onlyNotifyForMentions: Value = .useExisting ) { self.creationDateTimestamp = creationDateTimestamp self.shouldBeVisible = shouldBeVisible self.pinnedPriority = pinnedPriority self.isDraft = isDraft self.disappearingMessagesConfig = disappearingMessagesConfig + self.mutedUntilTimestamp = mutedUntilTimestamp + self.onlyNotifyForMentions = onlyNotifyForMentions + } + + // MARK: - Functions + + func resolveLibSessionValues( + _ db: ObservingDatabase, + id: ID, + variant: Variant, + using dependencies: Dependencies + ) -> TargetValues { + guard + creationDateTimestamp.shouldUseLibSession || + shouldBeVisible.shouldUseLibSession || + pinnedPriority.shouldUseLibSession || + isDraft.shouldUseLibSession || + disappearingMessagesConfig.shouldUseLibSession || + mutedUntilTimestamp.shouldUseLibSession || + onlyNotifyForMentions.shouldUseLibSession + else { return self } + + let openGroupUrlInfo: LibSession.OpenGroupUrlInfo? = (variant != .community ? nil : + try? LibSession.OpenGroupUrlInfo.fetchOne(db, id: id) + ) + + return dependencies.mutate(cache: .libSession) { cache in + var shouldBeVisible: Value = self.shouldBeVisible + var pinnedPriority: Value = self.pinnedPriority + + /// The `shouldBeVisible` flag is based on `pinnedPriority` so we need to check these two together if they + /// should both be sourced from `libSession` + switch (self.pinnedPriority, self.shouldBeVisible) { + case (.useLibSession, .useLibSession): + let targetPriority: Int32 = cache.pinnedPriority( + threadId: id, + threadVariant: variant, + openGroupUrlInfo: openGroupUrlInfo + ) + let libSessionShouldBeVisible: Bool = LibSession.shouldBeVisible(priority: targetPriority) + + shouldBeVisible = .setTo(LibSession.shouldBeVisible(priority: targetPriority)) + pinnedPriority = .setTo(targetPriority) + + default: break + } + + /// Sort out the disappearing message conifg setting + var disappearingMessagesConfig: Value = self.disappearingMessagesConfig + + if + variant != .community, + disappearingMessagesConfig.shouldUseLibSession, + let config: DisappearingMessagesConfiguration = cache.disappearingMessagesConfig( + threadId: id, + threadVariant: variant + ) + { + disappearingMessagesConfig = .setTo(config) + } + + return TargetValues( + creationDateTimestamp: self.creationDateTimestamp, + shouldBeVisible: shouldBeVisible, + pinnedPriority: pinnedPriority, + isDraft: self.isDraft, + disappearingMessagesConfig: disappearingMessagesConfig, + mutedUntilTimestamp: self.mutedUntilTimestamp, + onlyNotifyForMentions: self.onlyNotifyForMentions + ) + } } } @@ -211,7 +320,7 @@ public extension SessionThread { /// /// **Note:** This method **will** save the newly created/updated `SessionThread` to the database @discardableResult static func upsert( - _ db: Database, + _ db: ObservingDatabase, id: ID, variant: Variant, values: TargetValues, @@ -223,9 +332,17 @@ public extension SessionThread { switch try? fetchOne(db, id: id) { case .some(let existingThread): result = existingThread case .none: - let targetPriority: Int32 = dependencies - .mutate(cache: .libSession) { $0.pinnedPriority(db, threadId: id, threadVariant: variant) } - .defaulting(to: LibSession.defaultNewThreadPriority) + let targetPriority: Int32 = dependencies.mutate(cache: .libSession) { cache in + let openGroupUrlInfo: LibSession.OpenGroupUrlInfo? = (variant != .community ? nil : + try? LibSession.OpenGroupUrlInfo.fetchOne(db, id: id) + ) + + return cache.pinnedPriority( + threadId: id, + threadVariant: variant, + openGroupUrlInfo: openGroupUrlInfo + ) + } result = try SessionThread( id: id, @@ -235,14 +352,32 @@ public extension SessionThread { (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) ), shouldBeVisible: LibSession.shouldBeVisible(priority: targetPriority), + mutedUntilTimestamp: nil, + onlyNotifyForMentions: false, pinnedPriority: targetPriority, - isDraft: (values.isDraft.valueOrNull == true), - using: dependencies + isDraft: (values.isDraft.valueOrNull == true) ).upserted(db) } + /// Apply any changes if the provided `values` don't match the current or default settings + var requiredChanges: [ConfigColumnAssignment] = [] + var finalCreationDateTimestamp: TimeInterval = result.creationDateTimestamp + var finalShouldBeVisible: Bool = result.shouldBeVisible + var finalPinnedPriority: Int32? = result.pinnedPriority + var finalIsDraft: Bool? = result.isDraft + var finalMutedUntilTimestamp: TimeInterval? = result.mutedUntilTimestamp + var finalOnlyNotifyForMentions: Bool = result.onlyNotifyForMentions + + /// Resolve any settings which should be sourced from `libSession` + let resolvedValues: TargetValues = values.resolveLibSessionValues( + db, + id: id, + variant: variant, + using: dependencies + ) + /// Setup the `DisappearingMessagesConfiguration` as specified - switch (variant, values.disappearingMessagesConfig) { + switch (variant, resolvedValues.disappearingMessagesConfig) { case (.community, _), (_, .useExisting): break // No need to do anything case (_, .setTo(let config)): // Save the explicit config try config @@ -264,63 +399,25 @@ public extension SessionThread { using: dependencies ) - case (_, .useLibSession): // Create and save the config from libSession - let disappearingConfig: DisappearingMessagesConfiguration? = dependencies.mutate(cache: .libSession) { cache in - cache.disappearingMessagesConfig(threadId: id, threadVariant: variant) - } - - try disappearingConfig? - .upserted(db) - .clearUnrelatedControlMessages( - db, - threadVariant: variant, - using: dependencies - ) - } - - /// Apply any changes if the provided `values` don't match the current or default settings - var requiredChanges: [ConfigColumnAssignment] = [] - var finalCreationDateTimestamp: TimeInterval = result.creationDateTimestamp - var finalShouldBeVisible: Bool = result.shouldBeVisible - var finalPinnedPriority: Int32? = result.pinnedPriority - var finalIsDraft: Bool? = result.isDraft - - /// The `shouldBeVisible` flag is based on `pinnedPriority` so we need to check these two together if they - /// should both be sourced from `libSession` - switch (values.pinnedPriority, values.shouldBeVisible) { - case (.useLibSession, .useLibSession): - let targetPriority: Int32 = dependencies - .mutate(cache: .libSession) { $0.pinnedPriority(db, threadId: id, threadVariant: variant) } - .defaulting(to: LibSession.defaultNewThreadPriority) - let libSessionShouldBeVisible: Bool = LibSession.shouldBeVisible(priority: targetPriority) - - if targetPriority != result.pinnedPriority { - requiredChanges.append(SessionThread.Columns.pinnedPriority.set(to: targetPriority)) - finalPinnedPriority = targetPriority - } - - if libSessionShouldBeVisible != result.shouldBeVisible { - requiredChanges.append(SessionThread.Columns.shouldBeVisible.set(to: libSessionShouldBeVisible)) - finalShouldBeVisible = libSessionShouldBeVisible - } - - default: break + case (_, .useLibSession): break // Shouldn't happen } - /// Otherwise we can just handle the explicit `setTo` cases for these + /// And update any explicit `setTo` cases if case .setTo(let value) = values.creationDateTimestamp, value != result.creationDateTimestamp { requiredChanges.append(SessionThread.Columns.creationDateTimestamp.set(to: value)) finalCreationDateTimestamp = value } - if case .setTo(let value) = values.pinnedPriority, value != result.pinnedPriority { - requiredChanges.append(SessionThread.Columns.pinnedPriority.set(to: value)) - finalPinnedPriority = value - } - if case .setTo(let value) = values.shouldBeVisible, value != result.shouldBeVisible { requiredChanges.append(SessionThread.Columns.shouldBeVisible.set(to: value)) finalShouldBeVisible = value + db.addConversationEvent(id: id, type: .updated(.shouldBeVisible(value))) + } + + if case .setTo(let value) = values.pinnedPriority, value != result.pinnedPriority { + requiredChanges.append(SessionThread.Columns.pinnedPriority.set(to: value)) + finalPinnedPriority = value + db.addConversationEvent(id: id, type: .updated(.pinnedPriority(value))) } if case .setTo(let value) = values.isDraft, value != result.isDraft { @@ -328,6 +425,18 @@ public extension SessionThread { finalIsDraft = value } + if case .setTo(let value) = values.mutedUntilTimestamp, value != result.mutedUntilTimestamp { + requiredChanges.append(SessionThread.Columns.mutedUntilTimestamp.set(to: value)) + finalMutedUntilTimestamp = value + db.addConversationEvent(id: id, type: .updated(.mutedUntilTimestamp(value))) + } + + if case .setTo(let value) = values.onlyNotifyForMentions, value != result.onlyNotifyForMentions { + requiredChanges.append(SessionThread.Columns.onlyNotifyForMentions.set(to: value)) + finalOnlyNotifyForMentions = value + db.addConversationEvent(id: id, type: .updated(.onlyNotifyForMentions(value))) + } + /// If no changes were needed we can just return the existing/default thread guard !requiredChanges.isEmpty else { return result } @@ -352,15 +461,16 @@ public extension SessionThread { variant: variant, creationDateTimestamp: finalCreationDateTimestamp, shouldBeVisible: finalShouldBeVisible, + mutedUntilTimestamp: finalMutedUntilTimestamp, + onlyNotifyForMentions: finalOnlyNotifyForMentions, pinnedPriority: finalPinnedPriority, - isDraft: finalIsDraft, - using: dependencies + isDraft: finalIsDraft ).upserted(db) ) } static func canSendReadReceipt( - _ db: Database, + _ db: ObservingDatabase, threadId: String, threadVariant maybeThreadVariant: SessionThread.Variant? = nil, isBlocked maybeIsBlocked: Bool? = nil, @@ -403,7 +513,7 @@ public extension SessionThread { } @available(*, unavailable, message: "should not be used until pin re-ordering is built") - static func refreshPinnedPriorities(_ db: Database, adding threadId: String) throws { + static func refreshPinnedPriorities(_ db: ObservingDatabase, adding threadId: String) throws { struct PinnedPriority: TableRecord, ColumnExpressible { public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -455,7 +565,7 @@ public extension SessionThread { } static func deleteOrLeave( - _ db: Database, + _ db: ObservingDatabase, type: SessionThread.DeletionType, threadId: String, threadVariant: Variant, @@ -471,7 +581,7 @@ public extension SessionThread { } static func deleteOrLeave( - _ db: Database, + _ db: ObservingDatabase, type: SessionThread.DeletionType, threadIds: [String], threadVariant: Variant, @@ -482,14 +592,12 @@ public extension SessionThread { switch type { case .hideContactConversation: - _ = try SessionThread - .filter(ids: threadIds) - .updateAllAndConfig( - db, - SessionThread.Columns.pinnedPriority.set(to: LibSession.hiddenPriority), - SessionThread.Columns.shouldBeVisible.set(to: false), - using: dependencies - ) + try SessionThread.updateVisibility( + db, + threadIds: threadIds, + isVisible: false, + using: dependencies + ) case .hideContactConversationAndDeleteContentDirectly: // Clear any interactions for the deleted thread @@ -498,20 +606,25 @@ public extension SessionThread { .deleteAll(db) // Hide the threads - _ = try SessionThread - .filter(ids: threadIds) - .updateAllAndConfig( - db, - SessionThread.Columns.pinnedPriority.set(to: LibSession.hiddenPriority), - SessionThread.Columns.shouldBeVisible.set(to: false), - using: dependencies - ) + try SessionThread.updateVisibility( + db, + threadIds: threadIds, + isVisible: false, + using: dependencies + ) + + // Remove desired deduplication records + try MessageDeduplication.deleteIfNeeded(db, threadIds: threadIds, using: dependencies) case .deleteContactConversationAndMarkHidden: _ = try SessionThread .filter(ids: remainingThreadIds) .deleteAll(db) + remainingThreadIds.forEach { id in + db.addConversationEvent(id: id, type: .deleted) + } + // We need to custom handle the 'Note to Self' conversation (it should just be // hidden locally rather than deleted) if threadIds.contains(userSessionId.hexString) { @@ -520,16 +633,17 @@ public extension SessionThread { .filter(Interaction.Columns.threadId == userSessionId.hexString) .deleteAll(db) - _ = try SessionThread - .filter(id: userSessionId.hexString) - .updateAllAndConfig( - db, - SessionThread.Columns.pinnedPriority.set(to: LibSession.hiddenPriority), - SessionThread.Columns.shouldBeVisible.set(to: false), - using: dependencies - ) + try SessionThread.updateVisibility( + db, + threadIds: threadIds, + isVisible: false, + using: dependencies + ) } + // Remove desired deduplication records + try MessageDeduplication.deleteIfNeeded(db, threadIds: threadIds, using: dependencies) + // Update any other threads to be hidden try LibSession.hide(db, contactIds: Array(remainingThreadIds), using: dependencies) @@ -550,6 +664,13 @@ public extension SessionThread { .filter(ids: remainingThreadIds) .deleteAll(db) + remainingThreadIds.forEach { id in + db.addConversationEvent(id: id, type: .deleted) + } + + // Remove desired deduplication records + try MessageDeduplication.deleteIfNeeded(db, threadIds: threadIds, using: dependencies) + case .leaveGroupAsync: try threadIds.forEach { threadId in try MessageSender.leave(db, threadId: threadId, threadVariant: threadVariant, using: dependencies) @@ -578,27 +699,76 @@ public extension SessionThread { // MARK: - Convenience public extension SessionThread { - static func isMessageRequest( - _ db: Database, + static func updateVisibility( + _ db: ObservingDatabase, threadId: String, - userSessionId: SessionId, - includeNonVisible: Bool = false - ) -> Bool { - let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let closedGroup: TypedTableAlias = TypedTableAlias() - let request: SQLRequest = """ - SELECT \(thread[.id]) - FROM \(SessionThread.self) - LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) - LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - WHERE ( - \(thread[.id]) = \(threadId) AND - \(SessionThread.isMessageRequest(userSessionId: userSessionId, includeNonVisible: includeNonVisible)) + isVisible: Bool, + customPriority: Int32? = nil, + additionalChanges: [ConfigColumnAssignment] = [], + using dependencies: Dependencies + ) throws { + try updateVisibility( + db, + threadIds: [threadId], + isVisible: isVisible, + customPriority: customPriority, + additionalChanges: additionalChanges, + using: dependencies + ) + } + + static func updateVisibility( + _ db: ObservingDatabase, + threadIds: [String], + isVisible: Bool, + customPriority: Int32? = nil, + additionalChanges: [ConfigColumnAssignment] = [], + using dependencies: Dependencies + ) throws { + struct ThreadInfo: Decodable, FetchableRecord { + var id: String + var shouldBeVisible: Bool + var pinnedPriority: Int32 + } + + let targetPriority: Int32 + + switch (customPriority, isVisible) { + case (.some(let priority), _): targetPriority = priority + case (.none, true): targetPriority = LibSession.visiblePriority + case (.none, false): targetPriority = LibSession.hiddenPriority + } + + let currentInfo: [String: ThreadInfo] = try SessionThread + .select(.id, .shouldBeVisible, .pinnedPriority) + .filter(ids: threadIds) + .asRequest(of: ThreadInfo.self) + .fetchAll(db) + .reduce(into: [:]) { result, next in + result[next.id] = next + } + + _ = try SessionThread + .filter(ids: threadIds) + .updateAllAndConfig( + db, + [ + SessionThread.Columns.pinnedPriority.set(to: targetPriority), + SessionThread.Columns.shouldBeVisible.set(to: isVisible) + ].appending(contentsOf: additionalChanges), + using: dependencies ) - """ - return ((try? request.fetchOne(db)) != nil) + /// Emit events for any changes + threadIds.forEach { id in + if currentInfo[id]?.shouldBeVisible != isVisible { + db.addConversationEvent(id: id, type: .updated(.shouldBeVisible(isVisible))) + } + + if currentInfo[id]?.pinnedPriority != targetPriority { + db.addConversationEvent(id: id, type: .updated(.pinnedPriority(targetPriority))) + } + } } static func unreadMessageRequestsCountQuery(userSessionId: SessionId, includeNonVisible: Bool = false) -> SQLRequest { @@ -653,69 +823,21 @@ public extension SessionThread { ).sqlExpression } - func isNoteToSelf(_ db: Database? = nil, using dependencies: Dependencies) -> Bool { + func isNoteToSelf(using dependencies: Dependencies) -> Bool { return ( variant == .contact && id == dependencies[cache: .general].sessionId.hexString ) } - func shouldShowNotification( - _ db: Database, - for interaction: Interaction, - isMessageRequest: Bool, - using dependencies: Dependencies - ) -> Bool { - // Ensure that the thread isn't muted and either the thread isn't only notifying for mentions - // or the user was actually mentioned - guard - Date().timeIntervalSince1970 > (self.mutedUntilTimestamp ?? 0) && - (!self.onlyNotifyForMentions || interaction.hasMention) - else { return false } - - let userSessionId: SessionId = dependencies[cache: .general].sessionId - - // No need to notify the user for self-send messages - guard interaction.authorId != userSessionId.hexString else { return false } - - // If the thread is a message request then we only want to notify for the first message - if (self.variant == .contact || self.variant == .group) && isMessageRequest { - let numInteractions: Int = { - switch interaction.serverHash { - case .some(let serverHash): - return (try? self.interactions - .filter(Interaction.Columns.serverHash != serverHash) - .fetchCount(db)) - .defaulting(to: 0) - - case .none: - return (try? self.interactions - .filter(Interaction.Columns.timestampMs != interaction.timestampMs) - .fetchCount(db)) - .defaulting(to: 0) - } - }() - - // We only want to show a notification for the first interaction in the thread - guard numInteractions == 0 else { return false } - - // Need to re-show the message requests section if it had been hidden - if db[.hasHiddenMessageRequests] { - db[.hasHiddenMessageRequests] = false - } - } - - return true - } - static func displayName( threadId: String, variant: Variant, - closedGroupName: String? = nil, - openGroupName: String? = nil, - isNoteToSelf: Bool = false, - ignoringNickname: Bool = false, - profile: Profile? = nil + closedGroupName: String?, + openGroupName: String?, + isNoteToSelf: Bool, + ignoringNickname: Bool, + profile: Profile? ) -> String { switch variant { case .legacyGroup, .group: return (closedGroupName ?? "groupUnknown".localized()) @@ -731,57 +853,30 @@ public extension SessionThread { } static func getCurrentUserBlindedSessionId( - _ db: Database? = nil, threadId: String, threadVariant: Variant, blindingPrefix: SessionId.Prefix, + openGroupCapabilityInfo: LibSession.OpenGroupCapabilityInfo?, using dependencies: Dependencies ) -> SessionId? { - guard threadVariant == .community else { return nil } - guard let db: Database = db else { - return dependencies[singleton: .storage].read { db in - getCurrentUserBlindedSessionId( - db, - threadId: threadId, - threadVariant: threadVariant, - blindingPrefix: blindingPrefix, - using: dependencies - ) - } - } - - // Retrieve the relevant open group info - struct OpenGroupInfo: Decodable, FetchableRecord { - let publicKey: String - let server: String - } - guard - let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), - let openGroupInfo: OpenGroupInfo = try? OpenGroup - .filter(id: threadId) - .select(.publicKey, .server) - .asRequest(of: OpenGroupInfo.self) - .fetchOne(db) + threadVariant == .community, + let openGroupCapabilityInfo: LibSession.OpenGroupCapabilityInfo = openGroupCapabilityInfo else { return nil } // Check the capabilities to ensure the SOGS is blinded (or whether we have no capabilities) - let capabilities: Set = (try? Capability - .select(.variant) - .filter(Capability.Columns.openGroupServer == openGroupInfo.server.lowercased()) - .asRequest(of: Capability.Variant.self) - .fetchSet(db)) - .defaulting(to: []) - - guard capabilities.isEmpty || capabilities.contains(.blind) else { return nil } + guard + openGroupCapabilityInfo.capabilities.isEmpty || + openGroupCapabilityInfo.capabilities.contains(.blind) + else { return nil } switch blindingPrefix { case .blinded15: return dependencies[singleton: .crypto] .generate( .blinded15KeyPair( - serverPublicKey: openGroupInfo.publicKey, - ed25519SecretKey: userEdKeyPair.secretKey + serverPublicKey: openGroupCapabilityInfo.publicKey, + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey ) ) .map { SessionId(.blinded15, publicKey: $0.publicKey) } @@ -790,8 +885,8 @@ public extension SessionThread { return dependencies[singleton: .crypto] .generate( .blinded25KeyPair( - serverPublicKey: openGroupInfo.publicKey, - ed25519SecretKey: userEdKeyPair.secretKey + serverPublicKey: openGroupCapabilityInfo.publicKey, + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey ) ) .map { SessionId(.blinded25, publicKey: $0.publicKey) } diff --git a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift index 994ee1f86c..0127ebc746 100644 --- a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift @@ -22,98 +22,127 @@ public enum AttachmentDownloadJob: JobExecutor { dependencies[singleton: .appContext].isValid, let threadId: String = job.threadId, let detailsData: Data = job.details, - let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData), - let attachment: Attachment = dependencies[singleton: .storage] - .read({ db in try Attachment.fetchOne(db, id: details.attachmentId) }) + let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData) else { return failure(job, JobRunnerError.missingRequiredDetails, true) } - // Due to the complex nature of jobs and how attachments can be reused it's possible for - // an AttachmentDownloadJob to get created for an attachment which has already been - // downloaded/uploaded so in those cases just succeed immediately - guard attachment.state != .downloaded && attachment.state != .uploaded else { - return success(job, false) - } - - // If we ever make attachment downloads concurrent this will prevent us from downloading - // the same attachment multiple times at the same time (it also adds a "clean up" mechanism - // if an attachment ends up stuck in a "downloading" state incorrectly - guard attachment.state != .downloading else { - let otherCurrentJobAttachmentIds: Set = dependencies[singleton: .jobRunner] - .jobInfoFor(state: .running, variant: .attachmentDownload) - .filter { key, _ in key != job.id } - .values - .compactMap { info -> String? in - guard let data: Data = info.detailsData else { return nil } - - return (try? JSONDecoder(using: dependencies).decode(Details.self, from: data))? - .attachmentId + dependencies[singleton: .storage] + .writePublisher { db -> Attachment in + guard let attachment: Attachment = try? Attachment.fetchOne(db, id: details.attachmentId) else { + throw JobRunnerError.missingRequiredDetails } - .asSet() - - // If there isn't another currently running attachmentDownload job downloading this attachment - // then we should update the state of the attachment to be failed to avoid having attachments - // appear in an endlessly downloading state - if !otherCurrentJobAttachmentIds.contains(attachment.id) { - dependencies[singleton: .storage].write { db in - _ = try Attachment - .filter(id: attachment.id) - .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedDownload)) + + // Due to the complex nature of jobs and how attachments can be reused it's possible for + // an AttachmentDownloadJob to get created for an attachment which has already been + // downloaded/uploaded so in those cases just succeed immediately + guard attachment.state != .downloaded && attachment.state != .uploaded else { + throw AttachmentDownloadError.alreadyDownloaded } + + // If we ever make attachment downloads concurrent this will prevent us from downloading + // the same attachment multiple times at the same time (it also adds a "clean up" mechanism + // if an attachment ends up stuck in a "downloading" state incorrectly + guard attachment.state != .downloading else { + let otherCurrentJobAttachmentIds: Set = dependencies[singleton: .jobRunner] + .jobInfoFor(state: .running, variant: .attachmentDownload) + .filter { key, _ in key != job.id } + .values + .compactMap { info -> String? in + guard let data: Data = info.detailsData else { return nil } + + return (try? JSONDecoder(using: dependencies).decode(Details.self, from: data))? + .attachmentId + } + .asSet() + + // If there isn't another currently running attachmentDownload job downloading this + // attachment then we should update the state of the attachment to be failed to + // avoid having attachments appear in an endlessly downloading state + if !otherCurrentJobAttachmentIds.contains(attachment.id) { + _ = try Attachment + .filter(id: attachment.id) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedDownload)) + db.addAttachmentEvent( + id: attachment.id, + messageId: job.interactionId, + type: .updated(.state(.failedDownload)) + ) + } + + // Note: The only ways we should be able to get into this state are if we enable + // concurrent downloads or if the app was closed/crashed while an attachmentDownload + // job was in progress + // + // If there is another current job then just fail this one permanently, otherwise + // let it retry (if there are more retry attempts available) and in the next retry + // it's state should be 'failedDownload' so we won't get stuck in a loop + throw JobRunnerError.possibleDuplicateJob( + permanentFailure: otherCurrentJobAttachmentIds.contains(attachment.id) + ) + } + + // Update to the 'downloading' state (no need to update the 'attachment' instance) + try Attachment + .filter(id: attachment.id) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.downloading)) + db.addAttachmentEvent( + id: attachment.id, + messageId: job.interactionId, + type: .updated(.state(.downloading)) + ) + + return attachment } - - // Note: The only ways we should be able to get into this state are if we enable concurrent - // downloads or if the app was closed/crashed while an attachmentDownload job was in progress - // - // If there is another current job then just fail this one permanently, otherwise let it - // retry (if there are more retry attempts available) and in the next retry it's state should - // be 'failedDownload' so we won't get stuck in a loop - return failure(job, JobRunnerError.possibleDuplicateJob, otherCurrentJobAttachmentIds.contains(attachment.id)) - } - - // Update to the 'downloading' state (no need to update the 'attachment' instance) - dependencies[singleton: .storage].write { db in - try Attachment - .filter(id: attachment.id) - .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.downloading)) - } - - let temporaryFileUrl: URL = URL( - fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectoryAccessibleAfterFirstAuth + UUID().uuidString - ) - - Just(attachment.downloadUrl) - .setFailureType(to: Error.self) - .tryFlatMap { maybeDownloadUrl -> AnyPublisher in - guard let downloadUrl: URL = maybeDownloadUrl.map({ URL(string: $0) }) else { + .tryMap { attachment -> (attachment: Attachment, temporaryFileUrl: URL, downloadUrl: URL) in + guard let downloadUrl: URL = attachment.downloadUrl.map({ URL(string: $0) }) else { throw AttachmentDownloadError.invalidUrl - } + } + + let temporaryFileUrl: URL = URL( + fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectoryAccessibleAfterFirstAuth + UUID().uuidString + ) + + return (attachment, temporaryFileUrl, downloadUrl) + } + .flatMapStorageReadPublisher(using: dependencies, value: { db, info -> Network.PreparedRequest<(data: Data, attachment: Attachment, temporaryFileUrl: URL)> in + let maybeRoomToken: String? = try OpenGroup + .select(.roomToken) + .filter(id: threadId) + .asRequest(of: String.self) + .fetchOne(db) - return dependencies[singleton: .storage] - .readPublisher { db -> Network.PreparedRequest in - switch try OpenGroup.fetchOne(db, id: threadId) { - case .some(let openGroup): - return try OpenGroupAPI.preparedDownload( + switch maybeRoomToken { + case .some(let roomToken): + return try OpenGroupAPI + .preparedDownload( + url: info.downloadUrl, + roomToken: roomToken, + authMethod: try Authentication.with( db, - url: downloadUrl, - from: openGroup.roomToken, - on: openGroup.server, - using: dependencies - ) - - case .none: - return try Network.preparedDownload( - url: downloadUrl, + threadId: threadId, + threadVariant: .community, using: dependencies - ) - } - } - .flatMap { $0.send(using: dependencies) } - .map { _, data in data } - .eraseToAnyPublisher() + ), + using: dependencies + ) + .map { _, data in (data, info.attachment, info.temporaryFileUrl) } + + case .none: + return try Network + .preparedDownload( + url: info.downloadUrl, + using: dependencies + ) + .map { _, data in (data, info.attachment, info.temporaryFileUrl) } + } + }) + .flatMap { downloadRequest in + downloadRequest.send(using: dependencies).map { _, response in + (response.attachment, response.temporaryFileUrl, response.data) + } } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) - .tryMap { data -> Void in + .tryMap { attachment, temporaryFileUrl, data -> Attachment in // Store the encrypted data temporarily try data.write(to: temporaryFileUrl, options: .atomic) @@ -141,39 +170,45 @@ public enum AttachmentDownloadJob: JobExecutor { throw AttachmentDownloadError.failedToSaveFile } - return () + // Remove the temporary file + try? dependencies[singleton: .fileManager].removeItem(atPath: temporaryFileUrl.path) + + return attachment + } + .flatMapStorageWritePublisher(using: dependencies) { db, attachment in + /// Update the attachment state + /// + /// **Note:** We **MUST** use the `'with()` function here as it will update the + /// `isValid` and `duration` values based on the downloaded data and the state + let updatedAttachment: Attachment = try attachment + .with( + state: .downloaded, + creationTimestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000), + using: dependencies + ) + .upserted(db) + db.addAttachmentEvent( + id: attachment.id, + messageId: job.interactionId, + type: .updated(.state(.downloaded)) + ) + + return updatedAttachment } .sinkUntilComplete( receiveCompletion: { result in - // Remove the temporary file - try? dependencies[singleton: .fileManager].removeItem(atPath: temporaryFileUrl.path) - - switch result { - case .finished: - /// Update the attachment state - /// - /// **Note:** We **MUST** use the `'with()` function here as it will update the - /// `isValid` and `duration` values based on the downloaded data and the state - dependencies[singleton: .storage].write { db in - try attachment - .with( - state: .downloaded, - creationTimestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000), - localRelativeFilePath: ( - attachment.localRelativeFilePath ?? - Attachment.localRelativeFilePath( - from: attachment.originalFilePath(using: dependencies), - using: dependencies - ) - ), - using: dependencies - ) - .upserted(db) - } - + switch (result, result.errorOrNull, result.errorOrNull as? JobRunnerError) { + case (.finished, _, _): success(job, false) + case (_, let error as AttachmentDownloadError, _) where error == .alreadyDownloaded: success(job, false) - case .failure(let error): + case (_, _, .missingRequiredDetails): + failure(job, JobRunnerError.missingRequiredDetails, true) + + case (_, _, .possibleDuplicateJob(let permanentFailure)): + failure(job, JobRunnerError.possibleDuplicateJob(permanentFailure: permanentFailure), permanentFailure) + + case (.failure(let error), _, _): let targetState: Attachment.State let permanentFailure: Bool @@ -204,14 +239,22 @@ public enum AttachmentDownloadJob: JobExecutor { /// /// **Note:** We **MUST** use the `'with()` function here as it will update the /// `isValid` and `duration` values based on the downloaded data and the state - dependencies[singleton: .storage].write { db in - _ = try Attachment - .filter(id: attachment.id) - .updateAll(db, Attachment.Columns.state.set(to: targetState)) - } - - /// Trigger the failure and provide the `permanentFailure` value defined above - failure(job, error, permanentFailure) + dependencies[singleton: .storage].writeAsync( + updates: { db in + _ = try Attachment + .filter(id: details.attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: targetState)) + db.addAttachmentEvent( + id: details.attachmentId, + messageId: job.interactionId, + type: .updated(.state(targetState)) + ) + }, + completion: { _ in + /// Trigger the failure and provide the `permanentFailure` value defined above + failure(job, error, permanentFailure) + } + ) } } ) @@ -232,12 +275,14 @@ extension AttachmentDownloadJob { public enum AttachmentDownloadError: LocalizedError { case failedToSaveFile case invalidUrl + case alreadyDownloaded // stringlint:ignore_contents public var errorDescription: String? { switch self { case .failedToSaveFile: return "Failed to save file" case .invalidUrl: return "Invalid file URL" + case .alreadyDownloaded: return "Attachment already downloaded." } } } diff --git a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift index 7b9b80f9b3..d1dc85ddea 100644 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift @@ -31,86 +31,175 @@ public enum AttachmentUploadJob: JobExecutor { let threadId: String = job.threadId, let interactionId: Int64 = job.interactionId, let detailsData: Data = job.details, - let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData), - let attachment: Attachment = dependencies[singleton: .storage] - .read({ db in try Attachment.fetchOne(db, id: details.attachmentId) }) + let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData) else { return failure(job, JobRunnerError.missingRequiredDetails, true) } - // If the original interaction no longer exists then don't bother uploading the attachment (ie. the - // message was deleted before it even got sent) - guard dependencies[singleton: .storage].read({ db in try Interaction.exists(db, id: interactionId) }) == true else { - Log.info(.cat, "Failed due to missing interaction") - return failure(job, StorageError.objectNotFound, true) - } - - // If the attachment is still pending download the hold off on running this job - guard attachment.state != .pendingDownload && attachment.state != .downloading else { - Log.info(.cat, "Deferred as attachment is still being downloaded") - return deferred(job) - } - - // If this upload is related to sending a message then trigger the 'handleMessageWillSend' logic - // as if this is a retry the logic wouldn't run until after the upload has completed resulting in - // a potentially incorrect delivery status - dependencies[singleton: .storage].write { db in - guard - let sendJob: Job = try Job.fetchOne(db, id: details.messageSendJobId), - let sendJobDetails: Data = sendJob.details, - let details: MessageSendJob.Details = try? JSONDecoder(using: dependencies) - .decode(MessageSendJob.Details.self, from: sendJobDetails) - else { return } - - MessageSender.handleMessageWillSend( - db, - message: details.message, - destination: details.destination, - interactionId: interactionId - ) - } - - // Note: In the AttachmentUploadJob we intentionally don't provide our own db instance to prevent - // reentrancy issues when the success/failure closures get called before the upload as the JobRunner - // will attempt to update the state of the job immediately dependencies[singleton: .storage] - .writePublisher { db -> Network.PreparedRequest in - try attachment.preparedUpload(db, threadId: threadId, logCategory: .cat, using: dependencies) + .readPublisher { db -> Attachment in + guard let attachment: Attachment = try? Attachment.fetchOne(db, id: details.attachmentId) else { + throw JobRunnerError.missingRequiredDetails + } + + /// If the original interaction no longer exists then don't bother uploading the attachment (ie. the message was + /// deleted before it even got sent) + guard (try? Interaction.exists(db, id: interactionId)) == true else { + throw StorageError.objectNotFound + } + + /// If the attachment is still pending download the hold off on running this job + guard attachment.state != .pendingDownload && attachment.state != .downloading else { + throw AttachmentError.uploadIsStillPendingDownload + } + + return attachment + } + .flatMapStorageWritePublisher(using: dependencies) { db, attachment -> (Attachment, AuthenticationMethod) in + /// If this upload is related to sending a message then trigger the `handleMessageWillSend` logic as if this is a retry the + /// logic wouldn't run until after the upload has completed resulting in a potentially incorrect delivery status + let threadVariant: SessionThread.Variant = try SessionThread + .select(.variant) + .filter(id: threadId) + .asRequest(of: SessionThread.Variant.self) + .fetchOne(db, orThrow: StorageError.objectNotFound) + let authMethod: AuthenticationMethod = try Authentication.with( + db, + threadId: threadId, + threadVariant: threadVariant, + using: dependencies + ) + + guard + let sendJob: Job = try Job.fetchOne(db, id: details.messageSendJobId), + let sendJobDetails: Data = sendJob.details, + let details: MessageSendJob.Details = try? JSONDecoder(using: dependencies) + .decode(MessageSendJob.Details.self, from: sendJobDetails) + else { return (attachment, authMethod) } + + MessageSender.handleMessageWillSend( + db, + threadId: threadId, + message: details.message, + destination: details.destination, + interactionId: interactionId, + using: dependencies + ) + + return (attachment, authMethod) } - .flatMap { $0.send(using: dependencies) } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) + .tryMap { attachment, authMethod -> Network.PreparedRequest<(attachment: Attachment, fileId: String)> in + try AttachmentUploader.preparedUpload( + attachment: attachment, + logCategory: .cat, + authMethod: authMethod, + using: dependencies + ) + } + .flatMapStorageWritePublisher(using: dependencies) { db, uploadRequest -> Network.PreparedRequest<(attachment: Attachment, fileId: String)> in + /// If we have a `cachedResponse` (ie. already uploaded) then don't change the attachment state to uploading + /// as it's already been done + guard uploadRequest.cachedResponse == nil else { return uploadRequest } + + /// Update the attachment to the `uploading` state + _ = try? Attachment + .filter(id: details.attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading)) + db.addAttachmentEvent( + id: details.attachmentId, + messageId: job.interactionId, + type: .updated(.state(.uploading)) + ) + + return uploadRequest + } + .flatMap { $0.send(using: dependencies) } + .map { _, value in value.attachment } + .handleEvents( + receiveCancel: { + /// If the stream gets cancelled then `receiveCompletion` won't get called, so we need to handle that + /// case and flag the upload as cancelled + dependencies[singleton: .storage].writeAsync { db in + try Attachment + .filter(id: details.attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) + db.addAttachmentEvent( + id: details.attachmentId, + messageId: job.interactionId, + type: .updated(.state(.failedUpload)) + ) + } + } + ) + .flatMapStorageWritePublisher(using: dependencies) { db, updatedAttachment in + let updatedAttachment: Attachment = try updatedAttachment.upserted(db) + db.addAttachmentEvent( + id: updatedAttachment.id, + messageId: job.interactionId, + type: .updated(.state(updatedAttachment.state)) + ) + + return updatedAttachment + } .sinkUntilComplete( receiveCompletion: { result in - switch result { - case .failure(let error): - // If this upload is related to sending a message then trigger the - // 'handleFailedMessageSend' logic as we want to ensure the message - // has the correct delivery status - var didLogError: Bool = false - - dependencies[singleton: .storage].read { db in - guard - let sendJob: Job = try Job.fetchOne(db, id: details.messageSendJobId), - let sendJobDetails: Data = sendJob.details, - let details: MessageSendJob.Details = try? JSONDecoder(using: dependencies) - .decode(MessageSendJob.Details.self, from: sendJobDetails) - else { return } - - MessageSender.handleFailedMessageSend( - db, - message: details.message, - destination: nil, - error: .other(.cat, "Failed", error), - interactionId: interactionId, - using: dependencies - ) - didLogError = true - } + switch (result, result.errorOrNull) { + case (.finished, _): success(job, false) - // If we didn't log an error above then log it now - if !didLogError { Log.error(.cat, "Failed due to error: \(error)") } - failure(job, error, false) + case (_, let error as JobRunnerError) where error == .missingRequiredDetails: + failure(job, error, true) - case .finished: success(job, false) + case (_, let error as StorageError) where error == .objectNotFound: + Log.info(.cat, "Failed due to missing interaction") + failure(job, error, true) + + case (_, let error as AttachmentError) where error == .uploadIsStillPendingDownload: + Log.info(.cat, "Deferred as attachment is still being downloaded") + return deferred(job) + + case (.failure(let error), _): + dependencies[singleton: .storage].writeAsync( + updates: { db in + /// Update the attachment state + try Attachment + .filter(id: details.attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) + db.addAttachmentEvent( + id: details.attachmentId, + messageId: job.interactionId, + type: .updated(.state(.failedUpload)) + ) + + /// If this upload is related to sending a message then trigger the `handleFailedMessageSend` logic + /// as we want to ensure the message has the correct delivery status + guard + let sendJob: Job = try Job.fetchOne(db, id: details.messageSendJobId), + let sendJobDetails: Data = sendJob.details, + let details: MessageSendJob.Details = try? JSONDecoder(using: dependencies) + .decode(MessageSendJob.Details.self, from: sendJobDetails) + else { return false } + + MessageSender.handleFailedMessageSend( + db, + threadId: threadId, + message: details.message, + destination: nil, + error: .other(.cat, "Failed", error), + interactionId: interactionId, + using: dependencies + ) + return true + }, + completion: { result in + /// If we didn't log an error above then log it now + switch result { + case .failure, .success(true): break + case .success(false): Log.error(.cat, "Failed due to error: \(error)") + } + + failure(job, error, false) + } + ) } } ) diff --git a/SessionMessagingKit/Jobs/CheckForAppUpdatesJob.swift b/SessionMessagingKit/Jobs/CheckForAppUpdatesJob.swift index 96be65f0f4..aba703204e 100644 --- a/SessionMessagingKit/Jobs/CheckForAppUpdatesJob.swift +++ b/SessionMessagingKit/Jobs/CheckForAppUpdatesJob.swift @@ -47,16 +47,10 @@ public enum CheckForAppUpdatesJob: JobExecutor { return deferred(updatedJob) } - dependencies[singleton: .storage] - .readPublisher { db -> [UInt8]? in Identity.fetchUserEd25519KeyPair(db)?.secretKey } + dependencies[singleton: .network] + .checkClientVersion(ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey) .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) - .tryFlatMap { maybeEd25519SecretKey -> AnyPublisher<(ResponseInfoType, AppVersionResponse), Error> in - guard let ed25519SecretKey: [UInt8] = maybeEd25519SecretKey else { throw StorageError.objectNotFound } - - return dependencies[singleton: .network] - .checkClientVersion(ed25519SecretKey: ed25519SecretKey) - } .sinkUntilComplete( receiveCompletion: { _ in var updatedJob: Job = job.with( diff --git a/SessionMessagingKit/Jobs/ConfigMessageReceiveJob.swift b/SessionMessagingKit/Jobs/ConfigMessageReceiveJob.swift index db74dfa733..5720b9acfb 100644 --- a/SessionMessagingKit/Jobs/ConfigMessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/ConfigMessageReceiveJob.swift @@ -116,8 +116,8 @@ extension ConfigMessageReceiveJob { self.messages = messages .compactMap { processedMessage -> MessageInfo? in switch processedMessage { - case .standard: return nil - case .config(_, let namespace, let serverHash, let serverTimestampMs, let data): + case .standard, .invalid: return nil + case .config(_, let namespace, let serverHash, let serverTimestampMs, let data, _): return MessageInfo( namespace: namespace, serverHash: serverHash, diff --git a/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift b/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift index 6cb435ae0a..dc37ac0c02 100644 --- a/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift +++ b/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift @@ -67,21 +67,18 @@ public enum ConfigurationSyncJob: JobExecutor { // fresh install due to the migrations getting run) guard let swarmPublicKey: String = job.threadId, - let pendingChanges: LibSession.PendingChanges = dependencies[singleton: .storage].read({ db in - try dependencies.mutate(cache: .libSession) { - try $0.pendingChanges(db, swarmPublicKey: swarmPublicKey) - } + let pendingPushes: LibSession.PendingPushes = try? dependencies.mutate(cache: .libSession, { + try $0.pendingPushes(swarmPublicKey: swarmPublicKey) }) else { Log.info(.cat, "For \(job.threadId ?? "UnknownId") failed due to invalid data") return failure(job, StorageError.generic, false) } - /// If there is no `pushData`, `obsoleteHashes` or additional sequence requests then the job can just complete (next time - /// something is updated we want to try and run immediately so don't scuedule another run in this case) + /// If there is no `pushData` or additional sequence requests then the job can just complete (next time something is updated + /// we want to try and run immediately so don't scuedule another run in this case) guard - !pendingChanges.pushData.isEmpty || - !pendingChanges.obsoleteHashes.isEmpty || + !pendingPushes.pushData.isEmpty || job.transientData != nil else { Log.info(.cat, "For \(swarmPublicKey) completed with no pending changes") @@ -92,47 +89,42 @@ public enum ConfigurationSyncJob: JobExecutor { let jobStartTimestamp: TimeInterval = dependencies.dateNow.timeIntervalSince1970 let messageSendTimestamp: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() let additionalTransientData: AdditionalTransientData? = (job.transientData as? AdditionalTransientData) - Log.info(.cat, "For \(swarmPublicKey) started with changes: \(pendingChanges.pushData.count), old hashes: \(pendingChanges.obsoleteHashes.count)") + Log.info(.cat, "For \(swarmPublicKey) started with changes: \(pendingPushes.pushData.count), old hashes: \(pendingPushes.obsoleteHashes.count)") dependencies[singleton: .storage] - .readPublisher { db -> Network.PreparedRequest in + .readPublisher { db -> AuthenticationMethod in + try Authentication.with(db, swarmPublicKey: swarmPublicKey, using: dependencies) + } + .tryFlatMap { authMethod -> AnyPublisher<(ResponseInfoType, Network.BatchResponse), Error> in try SnodeAPI.preparedSequence( requests: [] .appending(contentsOf: additionalTransientData?.beforeSequenceRequests) .appending( - contentsOf: try pendingChanges.pushData + contentsOf: try pendingPushes.pushData .flatMap { pushData -> [ErasedPreparedRequest] in try pushData.data.map { data -> ErasedPreparedRequest in try SnodeAPI .preparedSendMessage( message: SnodeMessage( recipient: swarmPublicKey, - data: data.base64EncodedString(), + data: data, ttl: pushData.variant.ttl, timestampMs: UInt64(messageSendTimestamp) ), in: pushData.variant.namespace, - authMethod: try Authentication.with( - db, - swarmPublicKey: swarmPublicKey, - using: dependencies - ), + authMethod: authMethod, using: dependencies ) } } ) .appending(try { - guard !pendingChanges.obsoleteHashes.isEmpty else { return nil } + guard !pendingPushes.obsoleteHashes.isEmpty else { return nil } return try SnodeAPI.preparedDeleteMessages( - serverHashes: Array(pendingChanges.obsoleteHashes), + serverHashes: Array(pendingPushes.obsoleteHashes), requireSuccessfulDeletion: false, - authMethod: try Authentication.with( - db, - swarmPublicKey: swarmPublicKey, - using: dependencies - ), + authMethod: authMethod, using: dependencies ) }()) @@ -142,9 +134,8 @@ public enum ConfigurationSyncJob: JobExecutor { snodeRetrievalRetryCount: 0, // This job has it's own retry mechanism requestAndPathBuildTimeout: Network.defaultTimeout, using: dependencies - ) + ).send(using: dependencies) } - .flatMap { $0.send(using: dependencies) } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) .tryMap { (_: ResponseInfoType, response: Network.BatchResponse) -> [ConfigDump] in @@ -169,8 +160,8 @@ public enum ConfigurationSyncJob: JobExecutor { }) else { throw NetworkError.invalidResponse } - let results: [(pushData: LibSession.PendingChanges.PushData, hash: String?)] = zip(responseWithoutBeforeRequests, pendingChanges.pushData) - .map { (subResponse: Any, pushData: LibSession.PendingChanges.PushData) in + let results: [(pushData: LibSession.PendingPushes.PushData, hash: String?)] = zip(responseWithoutBeforeRequests, pendingPushes.pushData) + .map { (subResponse: Any, pushData: LibSession.PendingPushes.PushData) in /// 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 @@ -242,7 +233,12 @@ public enum ConfigurationSyncJob: JobExecutor { // Lastly we need to save the updated dumps to the database let updatedJob: Job? = dependencies[singleton: .storage].write { db in // Save the updated dumps to the database - try configDumps.forEach { try $0.upsert(db) } + try configDumps.forEach { dump in + try dump.upsert(db) + Task.detached(priority: .medium) { [extensionHelper = dependencies[singleton: .extensionHelper]] in + extensionHelper.replicate(dump: dump) + } + } // When we complete the 'ConfigurationSync' job we want to immediately schedule // another one with a 'nextRunTimestamp' set to the 'maxRunFrequency' value to @@ -364,7 +360,7 @@ extension ConfigurationSyncJob { public extension ConfigurationSyncJob { static func enqueue( - _ db: Database, + _ db: ObservingDatabase, swarmPublicKey: String, using dependencies: Dependencies ) { @@ -377,7 +373,7 @@ public extension ConfigurationSyncJob { } @discardableResult static func createIfNeeded( - _ db: Database, + _ db: ObservingDatabase, swarmPublicKey: String, using dependencies: Dependencies ) -> Job? { diff --git a/SessionMessagingKit/Jobs/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/DisappearingMessagesJob.swift index ad4f5df8f8..0f7e16f812 100644 --- a/SessionMessagingKit/Jobs/DisappearingMessagesJob.swift +++ b/SessionMessagingKit/Jobs/DisappearingMessagesJob.swift @@ -27,7 +27,7 @@ public enum DisappearingMessagesJob: JobExecutor { deferred: @escaping (Job) -> Void, using dependencies: Dependencies ) { - guard Identity.userExists(using: dependencies) else { return success(job, false) } + 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() @@ -35,10 +35,19 @@ public enum DisappearingMessagesJob: JobExecutor { var numDeleted: Int = -1 let updatedJob: Job? = dependencies[singleton: .storage].write { db in - numDeleted = try Interaction + let interactionInfo: Set = try Interaction + .select(.id, .threadId) .filter(Interaction.Columns.expiresStartedAtMs != nil) .filter((Interaction.Columns.expiresStartedAtMs + (Interaction.Columns.expiresInSeconds * 1000)) <= timestampNowMs) - .deleteAll(db) + .asRequest(of: InteractionThreadInfo.self) + .fetchSet(db) + try Interaction.filter(interactionInfo.map { $0.id }.contains(Interaction.Columns.id)).deleteAll(db) + numDeleted = interactionInfo.count + + // Notify of the deletion + interactionInfo.forEach { info in + db.addMessageEvent(id: info.id, threadId: info.threadId, type: .deleted) + } // Update the next run timestamp for the DisappearingMessagesJob (if the call // to 'updateNextRunIfNeeded' returns 'nil' then it doesn't need to re-run so @@ -56,11 +65,16 @@ public enum DisappearingMessagesJob: JobExecutor { } } +private struct InteractionThreadInfo: Codable, FetchableRecord, Hashable { + let id: Int64 + let threadId: String +} + // MARK: - Clean expired messages on app launch public extension DisappearingMessagesJob { static func cleanExpiredMessagesOnLaunch(using dependencies: Dependencies) { - guard Identity.userExists(using: dependencies) else { return } + guard dependencies[cache: .general].userExists else { return } let timestampNowMs: Double = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() var numDeleted: Int = -1 @@ -80,7 +94,7 @@ public extension DisappearingMessagesJob { public extension DisappearingMessagesJob { @discardableResult static func updateNextRunIfNeeded( - _ db: Database, + _ db: ObservingDatabase, using dependencies: Dependencies ) -> Job? { // If there is another expiring message then update the job to run 1 second after it's meant to expire @@ -112,7 +126,7 @@ public extension DisappearingMessagesJob { } static func updateNextRunIfNeeded( - _ db: Database, + _ db: ObservingDatabase, lastReadTimestampMs: Int64, threadId: String, using dependencies: Dependencies @@ -156,7 +170,7 @@ public extension DisappearingMessagesJob { } @discardableResult static func updateNextRunIfNeeded( - _ db: Database, + _ db: ObservingDatabase, interactionIds: [Int64], startedAtMs: Double, threadId: String, @@ -220,7 +234,7 @@ public extension DisappearingMessagesJob { } @discardableResult static func updateNextRunIfNeeded( - _ db: Database, + _ db: ObservingDatabase, interaction: Interaction, startedAtMs: Double, using dependencies: Dependencies diff --git a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift index 6ec37d036a..ec9ee87bc3 100644 --- a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift +++ b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift @@ -31,8 +31,11 @@ public enum DisplayPictureDownloadJob: JobExecutor { ) { guard let detailsData: Data = job.details, - let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData), - let preparedDownload: Network.PreparedRequest = try? { + let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData) + else { return failure(job, JobRunnerError.missingRequiredDetails, true) } + + dependencies[singleton: .storage] + .readPublisher { db -> Network.PreparedRequest in switch details.target { case .profile(_, let url, _), .group(_, let url, _): guard @@ -46,136 +49,177 @@ public enum DisplayPictureDownloadJob: JobExecutor { ) case .community(let fileId, let roomToken, let server): - return dependencies[singleton: .storage].read { db in - try OpenGroupAPI.preparedDownload( - db, - fileId: fileId, - from: roomToken, - on: server, - using: dependencies - ) - } + guard + let info: LibSession.OpenGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo + .fetchOne(db, id: OpenGroup.idFor(roomToken: roomToken, server: server)) + else { throw JobRunnerError.missingRequiredDetails } + + return try OpenGroupAPI.preparedDownload( + fileId: fileId, + roomToken: roomToken, + authMethod: Authentication.community(info: info), + using: dependencies + ) } - }() - else { return failure(job, JobRunnerError.missingRequiredDetails, true) } - - let fileName: String = dependencies[singleton: .displayPictureManager].generateFilenameWithoutExtension( - for: (preparedDownload.destination.url?.absoluteString) - .defaulting(to: preparedDownload.destination.urlPathAndParamsString) - ) - - guard let filePathNoExtension: String = try? dependencies[singleton: .displayPictureManager].filepath(for: fileName) else { - Log.error(.cat, "Failed to generate display picture file path for \(details.target)") - failure(job, DisplayPictureError.invalidFilename, true) - return - } - - preparedDownload - .send(using: dependencies) + } + .tryMap { (preparedDownload: Network.PreparedRequest) -> Network.PreparedRequest<(Data, String, URL?)> in + guard + let filePath: String = try? dependencies[singleton: .displayPictureManager].path( + for: (preparedDownload.destination.url?.absoluteString) + .defaulting(to: preparedDownload.destination.urlPathAndParamsString) + ) + else { throw DisplayPictureError.invalidPath } + + guard !dependencies[singleton: .fileManager].fileExists(atPath: filePath) else { + throw DisplayPictureError.alreadyDownloaded(preparedDownload.destination.url) + } + + return preparedDownload.map { _, data in + (data, filePath, preparedDownload.destination.url) + } + } + .flatMap { $0.send(using: dependencies) } + .map { _, result in result } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) + .flatMapStorageReadPublisher(using: dependencies) { (db: ObservingDatabase, result: (Data, String, URL?)) -> (Data, String, URL?) in + /// Check to make sure this download is still a valid update + guard details.isValidUpdate(db, using: dependencies) else { + throw DisplayPictureError.updateNoLongerValid + } + + return result + } + .tryMap { (data: Data, filePath: String, downloadUrl: URL?) -> URL? in + guard + let decryptedData: Data = { + switch details.target { + case .community: return data // Community data is unencrypted + case .profile(_, _, let encryptionKey), .group(_, _, let encryptionKey): + return dependencies[singleton: .crypto].generate( + .decryptedDataDisplayPicture(data: data, key: encryptionKey) + ) + } + }() + else { throw DisplayPictureError.writeFailed } + + guard + UIImage(data: decryptedData) != nil, + dependencies[singleton: .fileManager].createFile( + atPath: filePath, + contents: decryptedData + ) + else { throw DisplayPictureError.loadFailed } + + /// Kick off a task to load the image into the cache (assuming we want to render it soon) + Task(priority: .userInitiated) { + await dependencies[singleton: .imageDataManager].load( + .url(URL(fileURLWithPath: filePath)) + ) + } + + return downloadUrl + } + .flatMapStorageWritePublisher(using: dependencies) { (db: ObservingDatabase, downloadUrl: URL?) in + /// Store the updated information in the database (this will generally result in the UI refreshing as it'll observe + /// the `downloadUrl` changing) + try writeChanges( + db, + details: details, + downloadUrl: downloadUrl, + using: dependencies + ) + } .sinkUntilComplete( receiveCompletion: { result in - switch result { - case .finished: success(job, false) - case .failure(let error): failure(job, error, true) - } - }, - receiveValue: { _, data in - // Check to make sure this download is still a valid update - guard dependencies[singleton: .storage].read({ db in details.isValidUpdate(db) }) == true else { - return - } - - guard - let decryptedData: Data = { - switch details.target { - case .community: return data // Community data is unencrypted - case .profile(_, _, let encryptionKey), .group(_, _, let encryptionKey): - return dependencies[singleton: .crypto].generate( - .decryptedDataDisplayPicture(data: data, key: encryptionKey, using: dependencies) - ) - } - }() - else { - Log.error(.cat, "Failed to decrypt display picture for \(details.target)") - failure(job, DisplayPictureError.writeFailed, true) - return - } - - // Ensure the data is actually image data and then save it to disk - let fileExtension: String = (preparedDownload.destination.url?.pathExtension - .nullIfEmpty) - .defaulting(to: decryptedData.guessedImageFormat.fileExtension) - let finalFileUrl: URL = URL(fileURLWithPath: filePathNoExtension) - .appendingPathExtension(fileExtension) - let finalFileName: String = finalFileUrl.lastPathComponent - - guard - UIImage(data: decryptedData) != nil, - dependencies[singleton: .fileManager].createFile( - atPath: finalFileUrl.path, - contents: decryptedData - ) - else { - Log.error(.cat, "Failed to load display picture for \(details.target)") - failure(job, DisplayPictureError.writeFailed, true) - return - } - - // Update the cache first (in case the DBWrite thread is blocked, this way other threads - // can retrieve from the cache and avoid triggering a download) - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - Task { - await dependencies[singleton: .imageDataManager].loadImageData( - identifier: finalFileName, - source: .data(decryptedData) - ) - semaphore.signal() - } - semaphore.wait() - - // Store the updated information in the database - dependencies[singleton: .storage].write { db in - switch details.target { - case .profile(let id, let url, let encryptionKey): - _ = try? Profile - .filter(id: id) - .updateAllAndConfig( - db, - Profile.Columns.profilePictureUrl.set(to: url), - Profile.Columns.profileEncryptionKey.set(to: encryptionKey), - Profile.Columns.profilePictureFileName.set(to: finalFileName), - Profile.Columns.lastProfilePictureUpdate.set(to: details.timestamp), - using: dependencies - ) - - case .group(let id, let url, let encryptionKey): - _ = try? ClosedGroup - .filter(id: id) - .updateAllAndConfig( - db, - ClosedGroup.Columns.displayPictureUrl.set(to: url), - ClosedGroup.Columns.displayPictureEncryptionKey.set(to: encryptionKey), - ClosedGroup.Columns.displayPictureFilename.set(to: finalFileName), - ClosedGroup.Columns.lastDisplayPictureUpdate.set(to: details.timestamp), - using: dependencies - ) - - case .community(_, let roomToken, let server): - _ = try? OpenGroup - .filter(id: OpenGroup.idFor(roomToken: roomToken, server: server)) - .updateAllAndConfig( + switch (result, result.errorOrNull, result.errorOrNull as? DisplayPictureError) { + case (.finished, _, _): success(job, false) + case (_, _, .updateNoLongerValid): success(job, false) + case (_, _, .alreadyDownloaded(let downloadUrl)): + /// If the file already exists then write the changes to the database + dependencies[singleton: .storage].writeAsync( + updates: { db in + try writeChanges( db, - OpenGroup.Columns.displayPictureFilename.set(to: finalFileName), - OpenGroup.Columns.lastDisplayPictureUpdate.set(to: details.timestamp), + details: details, + downloadUrl: downloadUrl, using: dependencies ) - } + }, + completion: { result in + switch result { + case .success: success(job, false) + case .failure(let error): failure(job, error, true) + } + } + ) + + case (_, let error as JobRunnerError, _) where error == .missingRequiredDetails: + failure(job, error, true) + + case (_, _, .invalidPath): + Log.error(.cat, "Failed to generate display picture file path for \(details.target)") + failure(job, DisplayPictureError.invalidPath, true) + + case (_, _, .writeFailed): + Log.error(.cat, "Failed to decrypt display picture for \(details.target)") + failure(job, DisplayPictureError.writeFailed, true) + + case (_, _, .loadFailed): + Log.error(.cat, "Failed to load display picture for \(details.target)") + failure(job, DisplayPictureError.loadFailed, true) + + case (.failure(let error), _, _): failure(job, error, true) } } ) } + + private static func writeChanges( + _ db: ObservingDatabase, + details: Details, + downloadUrl: URL?, + using dependencies: Dependencies + ) throws { + switch details.target { + case .profile(let id, let url, let encryptionKey): + _ = try? Profile + .filter(id: id) + .updateAllAndConfig( + db, + Profile.Columns.displayPictureUrl.set(to: url), + Profile.Columns.displayPictureEncryptionKey.set(to: encryptionKey), + Profile.Columns.displayPictureLastUpdated.set(to: details.timestamp), + using: dependencies + ) + db.addProfileEvent(id: id, change: .displayPictureUrl(url)) + db.addConversationEvent(id: id, type: .updated(.displayPictureUrl(url))) + + case .group(let id, let url, let encryptionKey): + _ = try? ClosedGroup + .filter(id: id) + .updateAllAndConfig( + db, + ClosedGroup.Columns.displayPictureUrl.set(to: url), + ClosedGroup.Columns.displayPictureEncryptionKey.set(to: encryptionKey), + using: dependencies + ) + db.addConversationEvent(id: id, type: .updated(.displayPictureUrl(url))) + + case .community(_, let roomToken, let server): + _ = try? OpenGroup + .filter(id: OpenGroup.idFor(roomToken: roomToken, server: server)) + .updateAllAndConfig( + db, + OpenGroup.Columns.displayPictureOriginalUrl.set(to: downloadUrl), + using: dependencies + ) + db.addConversationEvent( + id: OpenGroup.idFor(roomToken: roomToken, server: server), + type: .updated(.displayPictureUrl(downloadUrl?.absoluteString)) + ) + } + } } // MARK: - DisplayPictureDownloadJob.Details @@ -247,11 +291,11 @@ extension DisplayPictureDownloadJob { switch owner { case .user(let profile): guard - let url: String = profile.profilePictureUrl, - let key: Data = profile.profileEncryptionKey, + let url: String = profile.displayPictureUrl, + let key: Data = profile.displayPictureEncryptionKey, let details: Details = Details( target: .profile(id: profile.id, url: url, encryptionKey: key), - timestamp: (profile.lastProfilePictureUpdate ?? 0) + timestamp: (profile.displayPictureLastUpdated ?? 0) ) else { return nil } @@ -263,7 +307,7 @@ extension DisplayPictureDownloadJob { let key: Data = group.displayPictureEncryptionKey, let details: Details = Details( target: .group(id: group.id, url: url, encryptionKey: key), - timestamp: (group.lastDisplayPictureUpdate ?? 0) + timestamp: 0 ) else { return nil } @@ -278,7 +322,7 @@ extension DisplayPictureDownloadJob { roomToken: openGroup.roomToken, server: openGroup.server ), - timestamp: (openGroup.lastDisplayPictureUpdate ?? 0) + timestamp: 0 ) else { return nil } @@ -290,40 +334,40 @@ extension DisplayPictureDownloadJob { // MARK: - Functions - fileprivate func isValidUpdate(_ db: Database) -> Bool { + fileprivate func isValidUpdate(_ db: ObservingDatabase, using dependencies: Dependencies) -> Bool { switch self.target { case .profile(let id, let url, let encryptionKey): guard let latestProfile: Profile = try? Profile.fetchOne(db, id: id) else { return false } return ( - timestamp >= (latestProfile.lastProfilePictureUpdate ?? 0) || ( - encryptionKey == latestProfile.profileEncryptionKey && - url == latestProfile.profilePictureUrl + timestamp >= (latestProfile.displayPictureLastUpdated ?? 0) || ( + encryptionKey == latestProfile.displayPictureEncryptionKey && + url == latestProfile.displayPictureUrl ) ) - case .group(let id, let url, let encryptionKey): - guard let latestGroup: ClosedGroup = try? ClosedGroup.fetchOne(db, id: id) else { return false } + case .group(let id, let url,_): + /// Groups now rely on a `GroupInfo` config message which has a proper `seqNo` so we don't need any + /// `displayPictureLastUpdated` hacks to ensure we have the last one (the `displayPictureUrl` + /// will always be correct) + guard + let latestDisplayPictureUrl: String = dependencies.mutate(cache: .libSession, { cache in + cache.displayPictureUrl(threadId: id, threadVariant: .group) + }) + else { return false } - return ( - timestamp >= (latestGroup.lastDisplayPictureUpdate ?? 0) || ( - encryptionKey == latestGroup.displayPictureEncryptionKey && - url == latestGroup.displayPictureUrl - ) - ) + return (url == latestDisplayPictureUrl) case .community(let imageId, let roomToken, let server): guard - let latestGroup: OpenGroup = try? OpenGroup.fetchOne( - db, - id: OpenGroup.idFor(roomToken: roomToken, server: server) - ) + let latestImageId: String = try? OpenGroup + .select(.imageId) + .filter(id: OpenGroup.idFor(roomToken: roomToken, server: server)) + .asRequest(of: String.self) + .fetchOne(db) else { return false } - return ( - timestamp >= (latestGroup.lastDisplayPictureUpdate ?? 0) || - imageId == latestGroup.imageId - ) + return (imageId == latestImageId) } } } diff --git a/SessionMessagingKit/Jobs/ExpirationUpdateJob.swift b/SessionMessagingKit/Jobs/ExpirationUpdateJob.swift index 1016654a72..0ffd051f5a 100644 --- a/SessionMessagingKit/Jobs/ExpirationUpdateJob.swift +++ b/SessionMessagingKit/Jobs/ExpirationUpdateJob.swift @@ -46,11 +46,11 @@ public enum ExpirationUpdateJob: JobExecutor { guard let results: [UpdateExpiryResponseResult] = response .compactMap({ _, value in value.didError ? nil : value }) - .nullIfEmpty(), + .nullIfEmpty, let unchangedMessages: [UInt64: [String]] = results .reduce([:], { result, next in result.updated(with: next.unchanged) }) .groupedByValue() - .nullIfEmpty() + .nullIfEmpty else { return [:] } return unchangedMessages diff --git a/SessionMessagingKit/Jobs/FailedAttachmentDownloadsJob.swift b/SessionMessagingKit/Jobs/FailedAttachmentDownloadsJob.swift index de52e91663..68ab51fc2e 100644 --- a/SessionMessagingKit/Jobs/FailedAttachmentDownloadsJob.swift +++ b/SessionMessagingKit/Jobs/FailedAttachmentDownloadsJob.swift @@ -26,18 +26,52 @@ public enum FailedAttachmentDownloadsJob: JobExecutor { deferred: @escaping (Job) -> Void, using dependencies: Dependencies ) { - guard Identity.userExists(using: dependencies) else { return success(job, false) } + guard dependencies[cache: .general].userExists else { return success(job, false) } var changeCount: Int = -1 // Update all 'sending' message states to 'failed' - dependencies[singleton: .storage].write { db in - changeCount = try Attachment - .filter(Attachment.Columns.state == Attachment.State.downloading) - .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedDownload)) - } - - Log.info(.cat, "Marked \(changeCount) attachments as failed") - success(job, false) + dependencies[singleton: .storage] + .writePublisher { db in + let attachmentIds: Set = try Attachment + .select(.id) + .filter(Attachment.Columns.state == Attachment.State.downloading) + .asRequest(of: String.self) + .fetchSet(db) + let interactionAttachment: [InteractionAttachment] = try InteractionAttachment + .filter(attachmentIds.contains(InteractionAttachment.Columns.attachmentId)) + .fetchAll(db) + + try Attachment + .filter(attachmentIds.contains(Attachment.Columns.id)) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedDownload)) + changeCount = attachmentIds.count + + interactionAttachment.forEach { val in + db.addAttachmentEvent( + id: val.attachmentId, + messageId: val.interactionId, + type: .updated(.state(.failedDownload)) + ) + } + + /// Shouldn't be possible but just in case + if attachmentIds.count != interactionAttachment.count { + let remainingIds: Set = attachmentIds + .removing(contentsOf: Set(interactionAttachment.map { $0.attachmentId })) + + remainingIds.forEach { id in + db.addAttachmentEvent(id: id, messageId: nil, type: .updated(.state(.failedDownload))) + } + } + } + .subscribe(on: scheduler, using: dependencies) + .receive(on: scheduler, using: dependencies) + .sinkUntilComplete( + receiveCompletion: { _ in + Log.info(.cat, "Marked \(changeCount) attachments as failed") + success(job, false) + } + ) } } diff --git a/SessionMessagingKit/Jobs/FailedGroupInvitesAndPromotionsJob.swift b/SessionMessagingKit/Jobs/FailedGroupInvitesAndPromotionsJob.swift index fa4206cb26..8b66f2ed0e 100644 --- a/SessionMessagingKit/Jobs/FailedGroupInvitesAndPromotionsJob.swift +++ b/SessionMessagingKit/Jobs/FailedGroupInvitesAndPromotionsJob.swift @@ -26,7 +26,7 @@ public enum FailedGroupInvitesAndPromotionsJob: JobExecutor { deferred: @escaping (Job) -> Void, using dependencies: Dependencies ) { - guard Identity.userExists(using: dependencies) else { return success(job, false) } + guard dependencies[cache: .general].userExists else { return success(job, false) } guard !dependencies[cache: .libSession].isEmpty else { return failure(job, JobRunnerError.missingRequiredDetails, false) } diff --git a/SessionMessagingKit/Jobs/FailedMessageSendsJob.swift b/SessionMessagingKit/Jobs/FailedMessageSendsJob.swift index 999d891177..5e5ccc5cad 100644 --- a/SessionMessagingKit/Jobs/FailedMessageSendsJob.swift +++ b/SessionMessagingKit/Jobs/FailedMessageSendsJob.swift @@ -26,7 +26,7 @@ public enum FailedMessageSendsJob: JobExecutor { deferred: @escaping (Job) -> Void, using dependencies: Dependencies ) { - guard Identity.userExists(using: dependencies) else { return success(job, false) } + guard dependencies[cache: .general].userExists else { return success(job, false) } var changeCount: Int = -1 var attachmentChangeCount: Int = -1 @@ -34,16 +34,50 @@ public enum FailedMessageSendsJob: JobExecutor { // Update all 'sending' message states to 'failed' dependencies[singleton: .storage] .writePublisher { db in - let sendChangeCount: Int = try Interaction + let sendInteractionInfo: Set = try Interaction + .select(.id, .threadId) .filter(Interaction.Columns.state == Interaction.State.sending) + .asRequest(of: InteractionIdThreadId.self) + .fetchSet(db) + let syncInteractionInfo: Set = try Interaction + .select(.id, .threadId) + .filter(Interaction.Columns.state == Interaction.State.syncing) + .asRequest(of: InteractionIdThreadId.self) + .fetchSet(db) + let attachmentIds: Set = try Attachment + .select(.id) + .filter(Attachment.Columns.state == Attachment.State.uploading) + .asRequest(of: String.self) + .fetchSet(db) + let interactionAttachment: [InteractionAttachment] = try InteractionAttachment + .filter(attachmentIds.contains(InteractionAttachment.Columns.attachmentId)) + .fetchAll(db) + + let sendChangeCount: Int = try Interaction + .filter(sendInteractionInfo.map { $0.id }.contains(Interaction.Columns.id)) .updateAll(db, Interaction.Columns.state.set(to: Interaction.State.failed)) let syncChangeCount: Int = try Interaction - .filter(Interaction.Columns.state == Interaction.State.syncing) + .filter(syncInteractionInfo.map { $0.id }.contains(Interaction.Columns.id)) .updateAll(db, Interaction.Columns.state.set(to: Interaction.State.failedToSync)) attachmentChangeCount = try Attachment - .filter(Attachment.Columns.state == Attachment.State.uploading) + .filter(attachmentIds.contains(Attachment.Columns.id)) .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) changeCount = (sendChangeCount + syncChangeCount) + + /// Send the database events + sendInteractionInfo.forEach { info in + db.addMessageEvent(id: info.id, threadId: info.threadId, type: .updated(.state(.failed))) + } + syncInteractionInfo.forEach { info in + db.addMessageEvent(id: info.id, threadId: info.threadId, type: .updated(.state(.failedToSync))) + } + interactionAttachment.forEach { val in + db.addAttachmentEvent( + id: val.attachmentId, + messageId: val.interactionId, + type: .updated(.state(.failedUpload)) + ) + } } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) @@ -55,3 +89,8 @@ public enum FailedMessageSendsJob: JobExecutor { ) } } + +private struct InteractionIdThreadId: Codable, Hashable, FetchableRecord { + let id: Int64 + let threadId: String +} diff --git a/SessionMessagingKit/Jobs/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/GarbageCollectionJob.swift index 99744d96e0..b799931f85 100644 --- a/SessionMessagingKit/Jobs/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/GarbageCollectionJob.swift @@ -27,8 +27,9 @@ public enum GarbageCollectionJob: JobExecutor { private static let minInteractionsToTrim: Int = 2000 private struct FileInfo { - let attachmentLocalRelativePaths: Set - let displayPictureFilenames: Set + let attachmentDownloadUrls: Set + let displayPictureFilePaths: Set + let messageDedupeRecords: [MessageDeduplication] } public static func run( @@ -72,24 +73,25 @@ public enum GarbageCollectionJob: JobExecutor { }() dependencies[singleton: .storage].writeAsync( - updates: { db in + updates: { db -> FileInfo in let userSessionId: SessionId = dependencies[cache: .general].sessionId /// Remove any typing indicators if finalTypesToCollect.contains(.threadTypingIndicators) { - _ = try ThreadTypingIndicator - .deleteAll(db) - } - - /// Remove any expired controlMessageProcessRecords - if finalTypesToCollect.contains(.expiredControlMessageProcessRecords) { - _ = try ControlMessageProcessRecord - .filter(ControlMessageProcessRecord.Columns.serverExpirationTimestamp <= timestampNow) - .deleteAll(db) + let threadIds: Set = try ThreadTypingIndicator + .select(.threadId) + .asRequest(of: String.self) + .fetchSet(db) + _ = try ThreadTypingIndicator.deleteAll(db) + + /// Just in case we should emit events for each typing indicator to indicate that it should have stopped typing + threadIds.forEach { id in + db.addTypingIndicatorEvent(threadId: id, change: .stopped) + } } /// Remove any old open group messages - open group messages which are older than six months - if finalTypesToCollect.contains(.oldOpenGroupMessages) && db[.trimOpenGroupMessagesOlderThanSixMonths] { + if finalTypesToCollect.contains(.oldOpenGroupMessages) && dependencies.mutate(cache: .libSession, { $0.get(.trimOpenGroupMessagesOlderThanSixMonths) }) { let interaction: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() let threadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name) @@ -252,7 +254,6 @@ public enum GarbageCollectionJob: JobExecutor { /// Orphaned attachments - attachments which have no related interactions, quotes or link previews if finalTypesToCollect.contains(.orphanedAttachments) { let attachment: TypedTableAlias = TypedTableAlias() - let quote: TypedTableAlias = TypedTableAlias() let linkPreview: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() @@ -261,11 +262,9 @@ public enum GarbageCollectionJob: JobExecutor { WHERE \(Column.rowID) IN ( SELECT \(attachment[.rowId]) FROM \(Attachment.self) - LEFT JOIN \(Quote.self) ON \(quote[.attachmentId]) = \(attachment[.id]) LEFT JOIN \(LinkPreview.self) ON \(linkPreview[.attachmentId]) = \(attachment[.id]) LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) WHERE ( - \(quote[.attachmentId]) IS NULL AND \(linkPreview[.url]) IS NULL AND \(interactionAttachment[.attachmentId]) IS NULL ) @@ -358,150 +357,181 @@ public enum GarbageCollectionJob: JobExecutor { .filter(SnodeReceivedMessageInfo.Columns.expirationDateMs <= (timestampNow * 1000)) .deleteAll(db) } + + /// Retrieve any files which need to be deleted + var attachmentDownloadUrls: Set = [] + var displayPictureFilePaths: Set = [] + var messageDedupeRecords: [MessageDeduplication] = [] + + /// Orphaned attachment files - attachment files which don't have an associated record in the database + if finalTypesToCollect.contains(.orphanedAttachmentFiles) { + /// **Note:** Thumbnails are stored in the `NSCachesDirectory` directory which should be automatically manage + /// it's own garbage collection so we can just ignore it according to the various comments in the following stack overflow + /// post, the directory will be cleared during app updates as well as if the system is running low on memory (if the app isn't running) + /// https://stackoverflow.com/questions/6879860/when-are-files-from-nscachesdirectory-removed + attachmentDownloadUrls = try Attachment + .select(.downloadUrl) + .filter(Attachment.Columns.downloadUrl != nil) + .asRequest(of: String.self) + .fetchSet(db) + } + + /// Orphaned display picture files - profile avatar files which don't have an associated record in the database + if finalTypesToCollect.contains(.orphanedDisplayPictures) { + displayPictureFilePaths.insert( + contentsOf: Set(try Profile + .select(.displayPictureUrl) + .filter(Profile.Columns.displayPictureUrl != nil) + .asRequest(of: String.self) + .fetchSet(db) + .compactMap { try? dependencies[singleton: .displayPictureManager].path(for: $0) }) + ) + displayPictureFilePaths.insert( + contentsOf: Set(try ClosedGroup + .select(.displayPictureUrl) + .filter(ClosedGroup.Columns.displayPictureUrl != nil) + .asRequest(of: String.self) + .fetchSet(db) + .compactMap { try? dependencies[singleton: .displayPictureManager].path(for: $0) }) + ) + displayPictureFilePaths.insert( + contentsOf: Set(try OpenGroup + .select(.displayPictureOriginalUrl) + .filter(OpenGroup.Columns.displayPictureOriginalUrl != nil) + .asRequest(of: String.self) + .fetchSet(db) + .compactMap { try? dependencies[singleton: .displayPictureManager].path(for: $0) }) + ) + } + + if finalTypesToCollect.contains(.pruneExpiredDeduplicationRecords) { + messageDedupeRecords = try MessageDeduplication + .filter( + MessageDeduplication.Columns.expirationTimestampSeconds != nil && + MessageDeduplication.Columns.expirationTimestampSeconds < timestampNow + ) + .fetchAll(db) + } + + return FileInfo( + attachmentDownloadUrls: attachmentDownloadUrls, + displayPictureFilePaths: displayPictureFilePaths, + messageDedupeRecords: messageDedupeRecords + ) }, - completion: { _ in - // Dispatch async so we can swap from the write queue to a read one (we are done - // writing) + completion: { result in + guard case .success(let fileInfo) = result else { + return failure(job, StorageError.generic, false) + } + + /// Dispatch async so we don't block the database threads while doing File I/O scheduler.schedule { - // Retrieve a list of all valid attachmnet and avatar file paths - let maybeFileInfo: FileInfo? = dependencies[singleton: .storage].read { db -> FileInfo in - var attachmentLocalRelativePaths: Set = [] - var displayPictureFilenames: Set = [] - - /// Orphaned attachment files - attachment files which don't have an associated record in the database - if finalTypesToCollect.contains(.orphanedAttachmentFiles) { - /// **Note:** Thumbnails are stored in the `NSCachesDirectory` directory which should be automatically manage - /// it's own garbage collection so we can just ignore it according to the various comments in the following stack overflow - /// post, the directory will be cleared during app updates as well as if the system is running low on memory (if the app isn't running) - /// https://stackoverflow.com/questions/6879860/when-are-files-from-nscachesdirectory-removed - attachmentLocalRelativePaths = try Attachment - .select(.localRelativeFilePath) - .filter(Attachment.Columns.localRelativeFilePath != nil) - .asRequest(of: String.self) - .fetchSet(db) - } - - /// Orphaned display picture files - profile avatar files which don't have an associated record in the database - if finalTypesToCollect.contains(.orphanedDisplayPictures) { - displayPictureFilenames.insert( - contentsOf: try Profile - .select(.profilePictureFileName) - .filter(Profile.Columns.profilePictureFileName != nil) - .asRequest(of: String.self) - .fetchSet(db) - ) - displayPictureFilenames.insert( - contentsOf: try ClosedGroup - .select(.displayPictureFilename) - .filter(ClosedGroup.Columns.displayPictureFilename != nil) - .asRequest(of: String.self) - .fetchSet(db) - ) - displayPictureFilenames.insert( - contentsOf: try OpenGroup - .select(.displayPictureFilename) - .filter(OpenGroup.Columns.displayPictureFilename != nil) - .asRequest(of: String.self) - .fetchSet(db) - ) - } - - return FileInfo( - attachmentLocalRelativePaths: attachmentLocalRelativePaths, - displayPictureFilenames: displayPictureFilenames - ) - } - - // If we couldn't get the file lists then fail (invalid state and don't want to delete all attachment/profile files) - guard let fileInfo: FileInfo = maybeFileInfo else { - failure(job, StorageError.generic, false) - return - } - var deletionErrors: [Error] = [] - // Orphaned attachment files (actual deletion) + /// Orphaned attachment files (actual deletion) if finalTypesToCollect.contains(.orphanedAttachmentFiles) { - // Note: Looks like in order to recursively look through files we need to use the - // enumerator method - let fileEnumerator = dependencies[singleton: .fileManager].enumerator( - at: URL(fileURLWithPath: Attachment.attachmentsFolder(using: dependencies)), - includingPropertiesForKeys: nil, - options: .skipsHiddenFiles // Ignore the `.DS_Store` for the simulator - ) - - let allAttachmentFilePaths: Set = (fileEnumerator? - .allObjects - .compactMap { Attachment.localRelativeFilePath(from: ($0 as? URL)?.path, using: dependencies) }) - .defaulting(to: []) - .asSet() - - // Note: Directories will have their own entries in the list, if there is a folder with content - // the file will include the directory in it's path with a forward slash so we can use this to - // distinguish empty directories from ones with content so we don't unintentionally delete a - // directory which contains content to keep as well as delete (directories which end up empty after - // this clean up will be removed during the next run) - // stringlint:ignore_start - let directoryNamesContainingContent: [String] = allAttachmentFilePaths - .filter { path -> Bool in path.contains("/") } - .compactMap { path -> String? in path.components(separatedBy: "/").first } + let attachmentDirPath: String = dependencies[singleton: .attachmentManager] + .sharedDataAttachmentsDirPath() + let allAttachmentFilePaths: Set = (Set((try? dependencies[singleton: .fileManager] + .contentsOfDirectory(atPath: attachmentDirPath))? + .map { filename in + URL(fileURLWithPath: attachmentDirPath) + .appendingPathComponent(filename) + .path + } ?? [])) + let databaseAttachmentFilePaths: Set = Set(fileInfo.attachmentDownloadUrls + .compactMap { try? dependencies[singleton: .attachmentManager].path(for: $0) }) let orphanedAttachmentFiles: Set = allAttachmentFilePaths - .subtracting(fileInfo.attachmentLocalRelativePaths) - .subtracting(directoryNamesContainingContent) - // stringlint:ignore_stop + .subtracting(databaseAttachmentFilePaths) orphanedAttachmentFiles.forEach { filepath in - // We don't want a single deletion failure to block deletion of the other files so try - // each one and store the error to be used to determine success/failure of the job - do { - try dependencies[singleton: .fileManager].removeItem( - atPath: URL(fileURLWithPath: Attachment.attachmentsFolder(using: dependencies)) - .appendingPathComponent(filepath) - .path - ) - } + /// We don't want a single deletion failure to block deletion of the other files so try each one and store + /// the error to be used to determine success/failure of the job + do { try dependencies[singleton: .fileManager].removeItem(atPath: filepath) } + catch CocoaError.fileNoSuchFile {} /// No need to do anything if the file doesn't eixst catch { deletionErrors.append(error) } } Log.info(.cat, "Orphaned attachments removed: \(orphanedAttachmentFiles.count)") } - // Orphaned display picture files (actual deletion) + /// Orphaned display picture files (actual deletion) if finalTypesToCollect.contains(.orphanedDisplayPictures) { - let allDisplayPictureFilenames: Set = (try? dependencies[singleton: .fileManager] + let allDisplayPictureFilePaths: Set = (try? dependencies[singleton: .fileManager] .contentsOfDirectory(atPath: dependencies[singleton: .displayPictureManager].sharedDataDisplayPictureDirPath())) .defaulting(to: []) + .map { filename in + URL(fileURLWithPath: dependencies[singleton: .displayPictureManager].sharedDataDisplayPictureDirPath()) + .appendingPathComponent(filename) + .path + } .asSet() - let orphanedFiles: Set = allDisplayPictureFilenames - .subtracting(fileInfo.displayPictureFilenames) + let orphanedFilePaths: Set = allDisplayPictureFilePaths + .subtracting(fileInfo.displayPictureFilePaths) + + orphanedFilePaths.forEach { path in + /// We don't want a single deletion failure to block deletion of the other files so try each one and store + /// the error to be used to determine success/failure of the job + do { try dependencies[singleton: .fileManager].removeItem(atPath: path) } + catch CocoaError.fileNoSuchFile {} /// No need to do anything if the file doesn't eixst + catch { deletionErrors.append(error) } + } - orphanedFiles.forEach { filename in - // We don't want a single deletion failure to block deletion of the other files so try - // each one and store the error to be used to determine success/failure of the job + Log.info(.cat, "Orphaned display pictures removed: \(orphanedFilePaths.count)") + } + + /// Explicit deduplication records that we want to delete + if finalTypesToCollect.contains(.pruneExpiredDeduplicationRecords) { + fileInfo.messageDedupeRecords.forEach { record in + /// We don't want a single deletion failure to block deletion of the other files so try each one and store + /// the error to be used to determine success/failure of the job do { - try dependencies[singleton: .fileManager].removeItem( - atPath: dependencies[singleton: .displayPictureManager].filepath(for: filename) + try dependencies[singleton: .extensionHelper].removeDedupeRecord( + threadId: record.threadId, + uniqueIdentifier: record.uniqueIdentifier ) } + catch CocoaError.fileNoSuchFile {} /// No need to do anything if the file doesn't eixst catch { deletionErrors.append(error) } } - Log.info(.cat, "Orphaned display pictures removed: \(orphanedFiles.count)") + Log.info(.cat, "Dedupe records removed: \(fileInfo.messageDedupeRecords.count)") } - // Report a single file deletion as a job failure (even if other content was successfully removed) + /// Report a single file deletion as a job failure (even if other content was successfully removed) guard deletionErrors.isEmpty else { failure(job, (deletionErrors.first ?? StorageError.generic), false) return } - // If we did a full collection then update the 'lastGarbageCollection' date to - // prevent a full collection from running again in the next 23 hours - if job.behaviour == .recurringOnActive && dependencies.dateNow.timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60) { - dependencies[defaults: .standard, key: .lastGarbageCollection] = dependencies.dateNow + /// Define a `successClosure` to avoid duplication + let successClosure: () -> Void = { + /// If we did a full collection then update the `lastGarbageCollection` date to prevent a full collection + /// from running again in the next 23 hours + if job.behaviour == .recurringOnActive && dependencies.dateNow.timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60) { + dependencies[defaults: .standard, key: .lastGarbageCollection] = dependencies.dateNow + } + + success(job, false) } - success(job, false) + /// Since the explicit file deletion was successful we can now _actually_ delete the `MessageDeduplication` + /// entries from the database (we don't do this until after the files have been removed to ensure we don't orphan + /// files by doing so) + guard !fileInfo.messageDedupeRecords.isEmpty else { return successClosure() } + + dependencies[singleton: .storage] + .writeAsync( + updates: { db in + try fileInfo.messageDedupeRecords.forEach { try $0.delete(db) } + }, + completion: { result in + switch result { + case .failure: failure(job, StorageError.generic, false) + case .success: successClosure() + } + } + ) } } ) @@ -512,7 +542,6 @@ public enum GarbageCollectionJob: JobExecutor { extension GarbageCollectionJob { public enum Types: Codable, CaseIterable { - case expiredControlMessageProcessRecords case threadTypingIndicators case oldOpenGroupMessages case orphanedJobs @@ -529,6 +558,7 @@ extension GarbageCollectionJob { case expiredPendingReadReceipts case shadowThreads case pruneExpiredLastHashRecords + case pruneExpiredDeduplicationRecords } public struct Details: Codable { diff --git a/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift b/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift index 14e68c9155..92f5606d4f 100644 --- a/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift +++ b/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift @@ -31,25 +31,22 @@ public enum GroupInviteMemberJob: JobExecutor { guard let threadId: String = job.threadId, let detailsData: Data = job.details, - let currentInfo: (groupName: String, adminProfile: Profile) = dependencies[singleton: .storage].read({ db in - let maybeGroupName: String? = try ClosedGroup + let groupName: String = dependencies[singleton: .storage].read({ db in + try ClosedGroup .filter(id: threadId) .select(.name) .asRequest(of: String.self) .fetchOne(db) - - guard let groupName: String = maybeGroupName else { throw StorageError.objectNotFound } - - return (groupName, Profile.fetchOrCreateCurrentUser(db, using: dependencies)) }), 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 adminProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } /// Perform the actual message sending dependencies[singleton: .storage] - .writePublisher { db -> Network.PreparedRequest in + .writePublisher { db -> (AuthenticationMethod, AuthenticationMethod) in _ = try? GroupMember .filter(GroupMember.Columns.groupId == threadId) .filter(GroupMember.Columns.profileId == details.memberSessionIdHexString) @@ -60,33 +57,40 @@ public enum GroupInviteMemberJob: JobExecutor { using: dependencies ) - return try MessageSender.preparedSend( - db, + 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( message: try GroupUpdateInviteMessage( inviteeSessionIdHexString: details.memberSessionIdHexString, groupSessionId: SessionId(.group, hex: threadId), - groupName: currentInfo.groupName, + groupName: groupName, memberAuthData: details.memberAuthData, - profile: VisibleMessage.VMProfile.init( - profile: currentInfo.adminProfile, - blocksCommunityMessageRequests: nil + profile: VisibleMessage.VMProfile( + displayName: adminProfile.name, + profileKey: adminProfile.displayPictureEncryptionKey, + profilePictureUrl: adminProfile.displayPictureUrl ), sentTimestampMs: UInt64(sentTimestampMs), - authMethod: try Authentication.with( - db, - swarmPublicKey: threadId, - using: dependencies - ), + authMethod: groupAuthMethod, using: dependencies ), to: .contact(publicKey: details.memberSessionIdHexString), namespace: .default, interactionId: nil, - fileIds: [], + attachments: nil, + authMethod: memberAuthMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies - ) + ).send(using: dependencies) } - .flatMap { $0.send(using: dependencies) } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) .sinkUntilComplete( diff --git a/SessionMessagingKit/Jobs/GroupLeavingJob.swift b/SessionMessagingKit/Jobs/GroupLeavingJob.swift index c45bbdc145..475a5ea13d 100644 --- a/SessionMessagingKit/Jobs/GroupLeavingJob.swift +++ b/SessionMessagingKit/Jobs/GroupLeavingJob.swift @@ -37,7 +37,7 @@ public enum GroupLeavingJob: JobExecutor { let destination: Message.Destination = .closedGroup(groupPublicKey: threadId) dependencies[singleton: .storage] - .writePublisher { db -> LeaveType in + .writePublisher(updates: { db -> RequestType in guard (try? ClosedGroup.exists(db, id: threadId)) == true else { Log.error(.cat, "Failed due to non-existent group") throw MessageSenderError.invalidClosedGroupUpdate @@ -55,57 +55,23 @@ public enum GroupLeavingJob: JobExecutor { .distinct() .fetchCount(db)) .defaulting(to: 0) - let finalBehaviour: GroupLeavingJob.Details.Behaviour = { + let finalBehaviour: Details.Behaviour = { guard - LibSession.wasKickedFromGroup( - groupSessionId: SessionId(.group, hex: threadId), - using: dependencies - ) || - LibSession.groupIsDestroyed( - groupSessionId: SessionId(.group, hex: threadId), - using: dependencies - ) - else { return details.behaviour } + dependencies.mutate(cache: .libSession, { cache in + !cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadId)) || + !cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadId)) + }) + else { return .delete } - return .delete + return details.behaviour }() 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 .leave( - try SnodeAPI - .preparedBatch( - requests: [ - /// Don't expire the `GroupUpdateMemberLeftMessage` as that's not a UI-based - /// message (it's an instruction for admin devices) - try MessageSender.preparedSend( - db, - message: GroupUpdateMemberLeftMessage(), - to: destination, - namespace: destination.defaultNamespace, - interactionId: job.interactionId, - fileIds: [], - using: dependencies - ), - try MessageSender.preparedSend( - db, - message: GroupUpdateMemberLeftNotificationMessage() - .with(disappearingConfig), - to: destination, - namespace: destination.defaultNamespace, - interactionId: nil, - fileIds: [], - using: dependencies - ) - ], - requireAllBatchResponses: false, - swarmPublicKey: threadId, - using: dependencies - ) - .map { _, _ in () } - ) + return .sendLeaveMessage(authMethod, disappearingConfig) case (.delete, true, _), (.leave, true, true): let groupSessionId: SessionId = SessionId(.group, hex: threadId) @@ -117,22 +83,51 @@ public enum GroupLeavingJob: JobExecutor { } } - return .delete + return .configSync - case (.delete, false, _): return .delete - + case (.delete, false, _): return .configSync default: throw MessageSenderError.invalidClosedGroupUpdate } - } - .flatMap { leaveType -> AnyPublisher in - switch leaveType { - case .leave(let leaveMessage): - return leaveMessage + }) + .tryFlatMap { requestType -> AnyPublisher in + switch requestType { + case .sendLeaveMessage(let authMethod, let disappearingConfig): + return try SnodeAPI + .preparedBatch( + requests: [ + /// Don't expire the `GroupUpdateMemberLeftMessage` as that's not a UI-based + /// message (it's an instruction for admin devices) + try MessageSender.preparedSend( + message: GroupUpdateMemberLeftMessage(), + to: destination, + namespace: destination.defaultNamespace, + interactionId: job.interactionId, + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), + using: dependencies + ), + try MessageSender.preparedSend( + message: GroupUpdateMemberLeftNotificationMessage() + .with(disappearingConfig), + to: destination, + namespace: destination.defaultNamespace, + interactionId: nil, + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), + using: dependencies + ) + ], + requireAllBatchResponses: false, + swarmPublicKey: threadId, + using: dependencies + ) .send(using: dependencies) .map { _ in () } .eraseToAnyPublisher() - case .delete: + case .configSync: return ConfigurationSyncJob .run(swarmPublicKey: threadId, using: dependencies) .map { _ in () } @@ -143,9 +138,9 @@ 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) { - case (.invalidClosedGroupUpdate, _), (.noKeyPair, _), (.encryptionFailed, _), - (_, .unauthorised), (_, .invalidAuthentication): + switch (error as? MessageSenderError, error as? SnodeAPIError, error as? CryptoError) { + case (.invalidClosedGroupUpdate, _, _), (.noKeyPair, _, _), (.encryptionFailed, _, _), + (_, .unauthorised, _), (_, _, .invalidAuthentication): return Just(()).setFailureType(to: Error.self).eraseToAnyPublisher() default: throw error @@ -224,8 +219,8 @@ extension GroupLeavingJob { // MARK: - Convenience private extension GroupLeavingJob { - enum LeaveType { - case leave(Network.PreparedRequest) - case delete + enum RequestType { + case sendLeaveMessage(AuthenticationMethod, DisappearingMessagesConfiguration?) + case configSync } } diff --git a/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift b/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift index 1e5f17334e..432d7e75be 100644 --- a/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift +++ b/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift @@ -61,7 +61,7 @@ public enum GroupPromoteMemberJob: JobExecutor { /// Perform the actual message sending dependencies[singleton: .storage] - .writePublisher { db -> Network.PreparedRequest in + .writePublisher { db -> AuthenticationMethod in _ = try? GroupMember .filter(GroupMember.Columns.groupId == threadId) .filter(GroupMember.Columns.profileId == details.memberSessionIdHexString) @@ -72,17 +72,20 @@ public enum GroupPromoteMemberJob: JobExecutor { using: dependencies ) - return try MessageSender.preparedSend( - db, + return try Authentication.with(db, swarmPublicKey: details.memberSessionIdHexString, using: dependencies) + } + .tryFlatMap { authMethod -> AnyPublisher<(ResponseInfoType, Message), Error> in + try MessageSender.preparedSend( message: message, to: .contact(publicKey: details.memberSessionIdHexString), namespace: .default, interactionId: nil, - fileIds: [], + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies - ) + ).send(using: dependencies) } - .flatMap { $0.send(using: dependencies) } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) .sinkUntilComplete( diff --git a/SessionMessagingKit/Jobs/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/MessageReceiveJob.swift index df2980c8a2..d1b779bd17 100644 --- a/SessionMessagingKit/Jobs/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/MessageReceiveJob.swift @@ -55,13 +55,24 @@ public enum MessageReceiveJob: JobExecutor { updates: { db -> Error? in for (messageInfo, protoContent) in messageData { do { - try MessageReceiver.handle( + let info: MessageReceiver.InsertedInteractionInfo? = try MessageReceiver.handle( db, threadId: threadId, threadVariant: messageInfo.threadVariant, message: messageInfo.message, serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, associatedWithProto: protoContent, + suppressNotifications: false, + using: dependencies + ) + + /// Notify about the received message + MessageReceiver.prepareNotificationsForInsertedInteractions( + db, + insertedInteractionInfo: info, + isMessageRequest: dependencies.mutate(cache: .libSession) { cache in + cache.isMessageRequest(threadId: threadId, threadVariant: messageInfo.threadVariant) + }, using: dependencies ) } @@ -76,7 +87,6 @@ public enum MessageReceiveJob: JobExecutor { case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, DatabaseError.SQLITE_CONSTRAINT, // Sometimes thrown for UNIQUE MessageReceiverError.duplicateMessage, - MessageReceiverError.duplicateControlMessage, MessageReceiverError.selfSend: break @@ -110,11 +120,17 @@ public enum MessageReceiveJob: JobExecutor { // Handle the result switch result { case .failure(let error): failure(updatedJob, error, false) - case .success(.some(let error as MessageReceiverError)) where !error.isRetryable: - failure(updatedJob, error, true) + case .success(let lastError): + /// Report the result of the job + switch lastError { + case let error as MessageReceiverError where !error.isRetryable: + failure(updatedJob, error, true) + + case .some(let error): failure(updatedJob, error, false) + case .none: success(updatedJob, false) + } - case .success(.some(let error)): failure(updatedJob, error, false) - case .success: success(updatedJob, false) + success(updatedJob, false) } } ) @@ -208,8 +224,8 @@ extension MessageReceiveJob { public init(messages: [ProcessedMessage]) { self.messages = messages.compactMap { processedMessage in switch processedMessage { - case .config: return nil - case .standard(_, _, _, let messageInfo): return messageInfo + case .config, .invalid: return nil + case .standard(_, _, _, let messageInfo, _): return messageInfo } } } diff --git a/SessionMessagingKit/Jobs/MessageSendJob.swift b/SessionMessagingKit/Jobs/MessageSendJob.swift index 65d3edb022..590a660a46 100644 --- a/SessionMessagingKit/Jobs/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/MessageSendJob.swift @@ -28,13 +28,14 @@ public enum MessageSendJob: JobExecutor { using dependencies: Dependencies ) { guard + let threadId: String = job.threadId, let detailsData: Data = job.details, let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData) else { return failure(job, JobRunnerError.missingRequiredDetails, true) } /// We need to include `fileIds` when sending messages with attachments to Open Groups so extract them from any /// associated attachments - var messageFileIds: [String] = [] + var messageAttachments: [(attachment: Attachment, fileId: String)] = [] let messageType: String = { switch details.destination { case .syncMessage: return "\(type(of: details.message)) (SyncMessage)" @@ -46,7 +47,7 @@ public enum MessageSendJob: JobExecutor { switch job.behaviour { case .runOnceAfterConfigSyncIgnoringPermanentFailure: guard - let sessionId: SessionId = try? SessionId(from: job.threadId), + let sessionId: SessionId = try? SessionId(from: threadId), let variant: ConfigDump.Variant = details.requiredConfigSyncVariant else { return failure(job, JobRunnerError.missingRequiredDetails, true) } @@ -82,7 +83,7 @@ public enum MessageSendJob: JobExecutor { let interactionId: Int64 = job.interactionId else { return failure(job, JobRunnerError.missingRequiredDetails, true) } - // Retrieve the current attachment state + /// 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)) @@ -150,8 +151,8 @@ public enum MessageSendJob: JobExecutor { return deferred(job) } - // Store the fileIds so they can be sent with the open group message content - messageFileIds = attachmentState.preparedFileIds + /// Store the fileIds so they can be sent with the open group message content + messageAttachments = attachmentState.preparedAttachments } /// If this message is being sent to an updated group then we should first make sure that we have a encryption keys @@ -202,18 +203,31 @@ public enum MessageSendJob: JobExecutor { /// **Note:** No need to upload attachments as part of this process as the above logic splits that out into it's own job /// so we shouldn't get here until attachments have already been uploaded dependencies[singleton: .storage] - .writePublisher { db -> Network.PreparedRequest in - try MessageSender.preparedSend( + .readPublisher(value: { [dependencies] db -> AuthenticationMethod in + try Authentication.with( db, + threadId: { + switch details.destination { + case .syncMessage: return dependencies[cache: .general].sessionId.hexString + default: return threadId + } + }(), + threadVariant: details.destination.threadVariant, + using: dependencies + ) + }) + .tryFlatMap { authMethod in + try MessageSender.preparedSend( message: details.message, to: details.destination, namespace: details.destination.defaultNamespace, interactionId: job.interactionId, - fileIds: messageFileIds, + attachments: messageAttachments, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies - ) + ).send(using: dependencies) } - .flatMap { $0.send(using: dependencies) } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) .sinkUntilComplete( @@ -221,6 +235,7 @@ public enum MessageSendJob: JobExecutor { switch result { case .finished: Log.info(.cat, "Completed sending \(messageType) (\(job.id ?? -1)) after \(.seconds(dependencies.dateNow.timeIntervalSince1970 - startTime), unit: .s)\(previousDeferralsMessage).") + dependencies.setAsync(.hasSentAMessage, true) success(job, false) case .failure(let error): @@ -268,24 +283,24 @@ public extension MessageSendJob { struct AttachmentState { public let error: Error? public let pendingUploadAttachmentIds: [String] - public let preparedFileIds: [String] public let allAttachmentIds: [String] + public let preparedAttachments: [(attachment: Attachment, fileId: String)] init( error: Error? = nil, pendingUploadAttachmentIds: [String] = [], - preparedFileIds: [String] = [], - allAttachmentIds: [String] = [] + allAttachmentIds: [String] = [], + preparedAttachments: [(Attachment, String)] = [] ) { self.error = error self.pendingUploadAttachmentIds = pendingUploadAttachmentIds - self.preparedFileIds = preparedFileIds self.allAttachmentIds = allAttachmentIds + self.preparedAttachments = preparedAttachments } } static func fetchAttachmentState( - _ db: Database, + _ db: ObservingDatabase, interactionId: Int64 ) throws -> AttachmentState { // If the original interaction no longer exists then don't bother sending the message (ie. the @@ -298,18 +313,14 @@ public extension MessageSendJob { let allAttachmentStateInfo: [Attachment.StateInfo] = try Attachment .stateInfo(interactionId: interactionId) .fetchAll(db) - let maybeFileIds: [String?] = allAttachmentStateInfo - .sorted { lhs, rhs in lhs.albumIndex < rhs.albumIndex } - .map { Attachment.fileId(for: $0.downloadUrl) } - let fileIds: [String] = maybeFileIds.compactMap { $0 } + let allAttachmentIds: [String] = allAttachmentStateInfo.map(\.attachmentId) // If there were failed attachments then this job should fail (can't send a // message which has associated attachments if the attachments fail to upload) guard !allAttachmentStateInfo.contains(where: { $0.state == .failedDownload }) else { return AttachmentState( error: AttachmentError.notUploaded, - preparedFileIds: fileIds, - allAttachmentIds: allAttachmentStateInfo.map(\.attachmentId) + allAttachmentIds: allAttachmentIds ) } @@ -320,9 +331,6 @@ public extension MessageSendJob { /// device - both `LinkPreview` and `Quote` can have this case) let pendingUploadAttachmentIds: [String] = allAttachmentStateInfo .filter { attachment -> Bool in - // Non-media quotes won't have thumbnails so so don't try to upload them - guard attachment.downloadUrl != Attachment.nonMediaQuoteFileId else { return false } - switch attachment.state { case .uploading, .pendingDownload, .downloading, .failedUpload, .downloaded: return true @@ -335,11 +343,25 @@ public extension MessageSendJob { } } .map { $0.attachmentId } + let preparedAttachmentIds: [String] = allAttachmentIds.filter { !pendingUploadAttachmentIds.contains($0) } + let attachments: [String: Attachment] = try Attachment + .fetchAll(db, ids: preparedAttachmentIds) + .reduce(into: [:]) { result, next in result[next.id] = next } + let preparedAttachments: [(Attachment, String)] = allAttachmentStateInfo + .sorted { lhs, rhs in lhs.albumIndex < rhs.albumIndex } + .compactMap { info in + guard + let attachment: Attachment = attachments[info.attachmentId], + let fileId: String = Attachment.fileId(for: info.downloadUrl) + else { return nil } + + return (attachment, fileId) + } return AttachmentState( pendingUploadAttachmentIds: pendingUploadAttachmentIds, - preparedFileIds: fileIds, - allAttachmentIds: allAttachmentStateInfo.map(\.attachmentId) + allAttachmentIds: allAttachmentIds, + preparedAttachments: preparedAttachments ) } } @@ -351,7 +373,6 @@ extension MessageSendJob { private enum CodingKeys: String, CodingKey { case destination case message - @available(*, deprecated, message: "replaced by 'Message.Destination.syncMessage'") case isSyncMessage case variant case requiredConfigSyncVariant } diff --git a/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift b/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift index b872071385..3decb55cf9 100644 --- a/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift +++ b/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift @@ -135,7 +135,7 @@ public enum ProcessPendingGroupMemberRemovalsJob: JobExecutor { .preparedSendMessage( message: SnodeMessage( recipient: groupSessionId.hexString, - data: encryptedDeleteMessageData.base64EncodedString(), + data: encryptedDeleteMessageData, ttl: Message().ttl, timestampMs: UInt64(messageSendTimestamp) ), @@ -153,26 +153,29 @@ public enum ProcessPendingGroupMemberRemovalsJob: JobExecutor { let preparedMemberContentRemovalMessage: Network.PreparedRequest? = { () -> Network.PreparedRequest? in guard !memberIdsToRemoveContent.isEmpty else { return nil } - return dependencies[singleton: .storage].write { db in - try MessageSender.preparedSend( - db, - message: GroupUpdateDeleteMemberContentMessage( - memberSessionIds: Array(memberIdsToRemoveContent), - messageHashes: [], - sentTimestampMs: UInt64(targetChangeTimestampMs), - authMethod: Authentication.groupAdmin( - groupSessionId: groupSessionId, - ed25519SecretKey: Array(groupIdentityPrivateKey) - ), - using: dependencies + return try? MessageSender.preparedSend( + message: GroupUpdateDeleteMemberContentMessage( + memberSessionIds: Array(memberIdsToRemoveContent), + messageHashes: [], + sentTimestampMs: UInt64(targetChangeTimestampMs), + authMethod: Authentication.groupAdmin( + groupSessionId: groupSessionId, + ed25519SecretKey: Array(groupIdentityPrivateKey) ), - to: .closedGroup(groupPublicKey: groupSessionId.hexString), - namespace: .groupMessages, - interactionId: nil, - fileIds: [], using: dependencies - ) - } + ), + to: .closedGroup(groupPublicKey: groupSessionId.hexString), + namespace: .groupMessages, + interactionId: nil, + attachments: nil, + authMethod: Authentication.groupAdmin( + groupSessionId: groupSessionId, + ed25519SecretKey: Array(groupIdentityPrivateKey) + ), + onEvent: MessageSender.standardEventHandling(using: dependencies), + using: dependencies + ) + .map { _, _ in () } }() /// Combine the two requests to be sent at the same time diff --git a/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift b/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift index b6e9f32ff2..a864b6c2fe 100644 --- a/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift +++ b/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift @@ -61,14 +61,20 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { /// Try to retrieve the default rooms 8 times dependencies[singleton: .storage] - .readPublisher { [dependencies] db -> Network.PreparedRequest in - try OpenGroupAPI.preparedCapabilitiesAndRooms( + .readPublisher { [dependencies] db -> AuthenticationMethod in + try Authentication.with( db, - on: OpenGroupAPI.defaultServer, + server: OpenGroupAPI.defaultServer, + activeOnly: false, /// The record for the default rooms is inactive using: dependencies ) } - .flatMap { [dependencies] request in request.send(using: dependencies) } + .tryFlatMap { [dependencies] authMethod -> AnyPublisher<(ResponseInfoType, OpenGroupAPI.CapabilitiesAndRoomsResponse), Error> in + try OpenGroupAPI.preparedCapabilitiesAndRooms( + authMethod: authMethod, + using: dependencies + ).send(using: dependencies) + } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) .retry(8, using: dependencies) @@ -139,7 +145,7 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { guard let imageId: String = room.imageId, imageId != existingImageIds[openGroupId] || - openGroup.displayPictureFilename == nil + openGroup.displayPictureOriginalUrl == nil else { return } dependencies[singleton: .jobRunner].add( diff --git a/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift index 62d24d07bc..1da0c5a95c 100644 --- a/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift @@ -34,20 +34,21 @@ public enum SendReadReceiptsJob: JobExecutor { } dependencies[singleton: .storage] - .writePublisher { db -> Network.PreparedRequest in + .readPublisher { db in try Authentication.with(db, swarmPublicKey: threadId, using: dependencies) } + .tryFlatMap { authMethod -> AnyPublisher<(ResponseInfoType, Message), Error> in try MessageSender.preparedSend( - db, message: ReadReceipt( timestamps: details.timestampMsValues.map { UInt64($0) } ), to: details.destination, namespace: details.destination.defaultNamespace, interactionId: nil, - fileIds: [], + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies - ) + ).send(using: dependencies) } - .flatMap { $0.send(using: dependencies) } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) .sinkUntilComplete( @@ -111,12 +112,12 @@ public extension SendReadReceiptsJob { /// **Note:** This method assumes that the provided `interactionIds` are valid and won't filter out any invalid ids so /// ensure that is done correctly beforehand @discardableResult static func createOrUpdateIfNeeded( - _ db: Database, + _ db: ObservingDatabase, threadId: String, interactionIds: [Int64], using dependencies: Dependencies ) -> Job? { - guard db[.areReadReceiptsEnabled] == true else { return nil } + guard dependencies.mutate(cache: .libSession, { $0.get(.areReadReceiptsEnabled) }) else { return nil } guard !interactionIds.isEmpty else { return nil } // Retrieve the timestampMs values for the specified interactions diff --git a/SessionMessagingKit/Jobs/UpdateProfilePictureJob.swift b/SessionMessagingKit/Jobs/UpdateProfilePictureJob.swift index 6ccf4baa78..cf1f402590 100644 --- a/SessionMessagingKit/Jobs/UpdateProfilePictureJob.swift +++ b/SessionMessagingKit/Jobs/UpdateProfilePictureJob.swift @@ -50,10 +50,11 @@ public enum UpdateProfilePictureJob: JobExecutor { return deferred(job) } - // Note: The user defaults flag is updated in DisplayPictureManager - let profile: Profile = Profile.fetchOrCreateCurrentUser(using: dependencies) - let displayPictureUpdate: DisplayPictureManager.Update = profile.profilePictureFileName - .map { dependencies[singleton: .displayPictureManager].loadDisplayPictureFromDisk(for: $0) } + /// **Note:** The `lastProfilePictureUpload` value is updated in `DisplayPictureManager` + let profile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } + let displayPictureUpdate: DisplayPictureManager.Update = profile.displayPictureUrl + .map { try? dependencies[singleton: .displayPictureManager].path(for: $0) } + .map { dependencies[singleton: .fileManager].contents(atPath: $0) } .map { .currentUserUploadImageData($0) } .defaulting(to: .none) diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift index f00fe98af4..0c2df44f23 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift @@ -22,8 +22,8 @@ internal extension LibSession { Contact.Columns.didApproveMe, Profile.Columns.name, Profile.Columns.nickname, - Profile.Columns.profilePictureUrl, - Profile.Columns.profileEncryptionKey, + Profile.Columns.displayPictureUrl, + Profile.Columns.displayPictureEncryptionKey, DisappearingMessagesConfiguration.Columns.isEnabled, DisappearingMessagesConfiguration.Columns.type, DisappearingMessagesConfiguration.Columns.durationSeconds @@ -34,16 +34,18 @@ internal extension LibSession { internal extension LibSessionCacheType { func handleContactsUpdate( - _ db: Database, + _ db: ObservingDatabase, in config: LibSession.Config?, + oldState: [ObservableKey: Any], serverTimestampMs: Int64 ) throws { guard configNeedsDump(config) else { return } - guard case .contacts(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .contacts(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .contacts, got: config) + } // The current users contact data is handled separately so exclude it if it's present (as that's // actually a bug) - let userSessionId: SessionId = dependencies[cache: .general].sessionId let targetContactData: [String: ContactData] = try LibSession.extractContacts( from: conf, serverTimestampMs: serverTimestampMs, @@ -67,10 +69,10 @@ internal extension LibSessionCacheType { ) let profilePictureShouldBeUpdated: Bool = ( ( - profile.profilePictureUrl != data.profile.profilePictureUrl || - profile.profileEncryptionKey != data.profile.profileEncryptionKey + profile.displayPictureUrl != data.profile.displayPictureUrl || + profile.displayPictureEncryptionKey != data.profile.displayPictureEncryptionKey ) && - (profile.lastProfilePictureUpdate ?? 0) < (data.profile.lastProfilePictureUpdate ?? 0) + (profile.displayPictureLastUpdated ?? 0) < (data.profile.displayPictureLastUpdated ?? 0) ) if @@ -78,6 +80,7 @@ internal extension LibSessionCacheType { profile.nickname != data.profile.nickname || profilePictureShouldBeUpdated { + db.addEvent(profile, forKey: .profile(profile.id)) try profile.upsert(db) try Profile .filter(id: sessionId) @@ -93,18 +96,34 @@ internal extension LibSessionCacheType { (profile.nickname == data.profile.nickname ? nil : Profile.Columns.nickname.set(to: data.profile.nickname) ), - (profile.profilePictureUrl != data.profile.profilePictureUrl ? nil : - Profile.Columns.profilePictureUrl.set(to: data.profile.profilePictureUrl) + (profile.displayPictureUrl != data.profile.displayPictureUrl ? nil : + Profile.Columns.displayPictureUrl.set(to: data.profile.displayPictureUrl) ), - (profile.profileEncryptionKey != data.profile.profileEncryptionKey ? nil : - Profile.Columns.profileEncryptionKey.set(to: data.profile.profileEncryptionKey) + (profile.displayPictureEncryptionKey != data.profile.displayPictureEncryptionKey ? nil : + Profile.Columns.displayPictureEncryptionKey.set(to: data.profile.displayPictureEncryptionKey) ), (!profilePictureShouldBeUpdated ? nil : - Profile.Columns.lastProfilePictureUpdate.set(to: data.profile.lastProfilePictureUpdate) + Profile.Columns.displayPictureLastUpdated.set(to: data.profile.displayPictureLastUpdated) ) ].compactMap { $0 }, using: dependencies ) + + if profileNameShouldBeUpdated { + db.addProfileEvent(id: sessionId, change: .name(data.profile.name)) + + if data.profile.nickname == nil { + db.addConversationEvent(id: sessionId, type: .updated(.displayName(data.profile.name))) + } + } + + if profile.nickname != data.profile.nickname { + db.addProfileEvent(id: sessionId, change: .nickname(data.profile.nickname)) + db.addConversationEvent( + id: sessionId, + type: .updated(.displayName(data.profile.nickname ?? data.profile.name)) + ) + } } /// Since message requests have no reverse, we should only handle setting `isApproved` @@ -115,6 +134,7 @@ internal extension LibSessionCacheType { (contact.isBlocked != data.contact.isBlocked) || (contact.didApproveMe != data.contact.didApproveMe) { + db.addEvent(contact, forKey: .contact(contact.id)) try contact.upsert(db) try Contact .filter(id: sessionId) @@ -133,14 +153,27 @@ internal extension LibSessionCacheType { ].compactMap { $0 }, using: dependencies ) + + if contact.isApproved != data.contact.isApproved { + db.addContactEvent(id: contact.id, change: .isApproved(data.contact.isApproved)) + db.addEvent(contact.id, forKey: .messageRequestAccepted) + } + + if contact.didApproveMe != data.contact.didApproveMe { + db.addContactEvent(id: contact.id, change: .didApproveMe(data.contact.didApproveMe)) + } + + if contact.isBlocked != data.contact.isBlocked { + db.addContactEvent(id: contact.id, change: .isBlocked(data.contact.isBlocked)) + } } /// If the contact's `hidden` flag doesn't match the visibility of their conversation then create/delete the /// associated contact conversation accordingly - let threadInfo: LibSession.PriorityVisibilityInfo? = try? SessionThread + let threadInfo: LibSession.ThreadUpdateInfo? = try? SessionThread .filter(id: sessionId) - .select(.id, .variant, .pinnedPriority, .shouldBeVisible) - .asRequest(of: LibSession.PriorityVisibilityInfo.self) + .select(LibSession.ThreadUpdateInfo.threadColumns) + .asRequest(of: LibSession.ThreadUpdateInfo.self) .fetchOne(db) let threadExists: Bool = (threadInfo != nil) let updatedShouldBeVisible: Bool = LibSession.shouldBeVisible(priority: data.priority) @@ -259,7 +292,11 @@ internal extension LibSessionCacheType { try LibSession.remove( db, - volatileContactIds: combinedIds, + volatileContactIds: combinedIds + .filter { + (try? SessionId.Prefix(from: $0)) != .blinded15 && + (try? SessionId.Prefix(from: $0)) != .blinded25 + }, using: dependencies ) } @@ -270,16 +307,18 @@ internal extension LibSessionCacheType { public extension LibSession { static func upsert( - contactData: [SyncedContactInfo], + contactData: [ContactUpdateInfo], in config: Config?, using dependencies: Dependencies ) throws { - guard case .contacts(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .contacts(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .contacts, got: config) + } // The current users contact data doesn't need to sync so exclude it, we also don't want to sync // blinded message requests so exclude those as well let userSessionId: SessionId = dependencies[cache: .general].sessionId - let targetContacts: [SyncedContactInfo] = contactData + let targetContacts: [ContactUpdateInfo] = contactData .filter { $0.id != userSessionId.hexString && (try? SessionId(from: $0.id))?.prefix == .standard @@ -305,37 +344,16 @@ public extension LibSession { ) } - // Assign all properties to match the updated contact (if there is one) - if - let isApproved: Bool = info.isApproved, - let didApproveMe: Bool = info.didApproveMe, - let isBlocked: Bool = info.isBlocked - { - contact.approved = isApproved - contact.approved_me = didApproveMe - contact.blocked = isBlocked - - // If we were given a `created` timestamp then set it to the min between the current - // setting and the value (as long as the current setting isn't `0`) - if let created: Int64 = info.created.map({ Int64(floor($0)) }) { - contact.created = (contact.created > 0 ? min(contact.created, created) : created) - } - - // Store the updated contact (needs to happen before variables go out of scope) - contacts_set(conf, &contact) - try LibSessionError.throwIfNeeded(conf) - } - - // Update the profile data (if there is one - users we have sent a message request to may - // not have profile info in certain situations) + /// Update the profile data (if there is one - users we have sent a message request to may not have profile info + /// in certain situations) if let updatedName: String = info.name { let oldAvatarUrl: String? = contact.get(\.profile_pic.url) let oldAvatarKey: Data? = contact.get(\.profile_pic.key) contact.set(\.name, to: updatedName) contact.set(\.nickname, to: info.nickname) - contact.set(\.profile_pic.url, to: info.profilePictureUrl) - contact.set(\.profile_pic.key, to: info.profileEncryptionKey) + contact.set(\.profile_pic.url, to: info.displayPictureUrl) + contact.set(\.profile_pic.key, to: info.displayPictureEncryptionKey) // Attempts retrieval of the profile picture (will schedule a download if // needed via a throttled subscription on another thread to prevent blocking) @@ -345,13 +363,12 @@ public extension LibSession { if let updatedProfile: Profile = info.profile, dependencies[singleton: .appContext].isMainApp && ( - oldAvatarUrl != (info.profilePictureUrl ?? "") || - oldAvatarKey != (info.profileEncryptionKey ?? Data(repeating: 0, count: DisplayPictureManager.aes256KeyByteLength)) + oldAvatarUrl != (info.displayPictureUrl ?? "") || + oldAvatarKey != (info.displayPictureEncryptionKey ?? Data(repeating: 0, count: DisplayPictureManager.aes256KeyByteLength)) ) { dependencies[singleton: .displayPictureManager].scheduleDownload( - for: .user(updatedProfile), - currentFileInvalid: false + for: .user(updatedProfile) ) } @@ -360,7 +377,7 @@ public extension LibSession { try LibSessionError.throwIfNeeded(conf) } - // Assign all properties to match the updated disappearing messages configuration (if there is one) + /// Assign all properties to match the updated disappearing messages configuration (if there is one) if let disappearingInfo: LibSession.DisappearingMessageInfo = info.disappearingMessagesInfo, let exp_mode: CONVO_EXPIRATION_MODE = disappearingInfo.type?.toLibSession() @@ -369,7 +386,24 @@ public extension LibSession { contact.exp_seconds = Int32(disappearingInfo.durationSeconds) } - // Store the updated contact (can't be sure if we made any changes above) + /// If we were given a `created` timestamp then set it to the min between the current setting and the value (as + /// long as the current setting isn't `0`) + if let created: Int64 = info.created.map({ Int64(floor($0)) }) { + contact.created = (contact.created > 0 ? min(contact.created, created) : created) + } + + /// Only support approving (not un-approving) a contact + contact.approved = (!contact.approved ? + (info.isApproved ?? contact.approved) : + contact.approved + ) + contact.approved_me = (!contact.approved_me ? + (info.didApproveMe ?? contact.approved_me) : + contact.approved_me + ) + + /// Store the updated contact (can't be sure if we made any changes above) + contact.blocked = (info.isBlocked ?? contact.blocked) contact.priority = (info.priority ?? contact.priority) contacts_set(conf, &contact) try LibSessionError.throwIfNeeded(conf) @@ -386,7 +420,7 @@ internal extension LibSession { } static func updatingContacts( - _ db: Database, + _ db: ObservingDatabase, _ updated: [T], using dependencies: Dependencies ) throws -> [T] { @@ -406,7 +440,9 @@ internal extension LibSession { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .contacts, sessionId: userSessionId) { config in - guard case .contacts(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .contacts(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .contacts, got: config) + } // When inserting new contacts (or contacts with invalid profile data) we want // to add any valid profile information we have so identify if any of the updated @@ -441,7 +477,7 @@ internal extension LibSession { .upsert( contactData: targetContacts .map { contact in - SyncedContactInfo( + ContactUpdateInfo( id: contact.id, contact: contact, profile: newProfiles[contact.id], @@ -458,7 +494,7 @@ internal extension LibSession { } static func updatingProfiles( - _ db: Database, + _ db: ObservingDatabase, _ updated: [T], using dependencies: Dependencies ) throws -> [T] { @@ -490,10 +526,14 @@ internal extension LibSession { // Update the user profile first (if needed) if let updatedUserProfile: Profile = updatedProfiles.first(where: { $0.id == userSessionId.hexString }) { try dependencies.mutate(cache: .libSession) { cache in - try cache.performAndPushChange(db, for: .userProfile, sessionId: userSessionId) { config in - try LibSession.update( - profile: updatedUserProfile, - in: config + try cache.performAndPushChange(db, for: .userProfile, sessionId: userSessionId) { _ in + db.addEventIfNotNull( + try cache.updateProfile( + displayName: updatedUserProfile.name, + displayPictureUrl: updatedUserProfile.displayPictureUrl, + displayPictureEncryptionKey: updatedUserProfile.displayPictureEncryptionKey + ), + forKey: .profile(userSessionId.hexString) ) } } @@ -504,7 +544,7 @@ internal extension LibSession { try LibSession .upsert( contactData: targetProfiles - .map { SyncedContactInfo(id: $0.id, profile: $0) }, + .map { ContactUpdateInfo(id: $0.id, profile: $0) }, in: config, using: dependencies ) @@ -515,7 +555,7 @@ internal extension LibSession { } @discardableResult static func updatingDisappearingConfigsOneToOne( - _ db: Database, + _ db: ObservingDatabase, _ updated: [T], using dependencies: Dependencies ) throws -> [T] { @@ -565,7 +605,7 @@ internal extension LibSession { try LibSession .upsert( contactData: targetDisappearingConfigs - .map { SyncedContactInfo(id: $0.id, disappearingMessagesConfig: $0) }, + .map { ContactUpdateInfo(id: $0.id, disappearingMessagesConfig: $0) }, in: config, using: dependencies ) @@ -580,7 +620,7 @@ internal extension LibSession { public extension LibSession { static func hide( - _ db: Database, + _ db: ObservingDatabase, contactIds: [String], using dependencies: Dependencies ) throws { @@ -590,7 +630,7 @@ public extension LibSession { try LibSession.upsert( contactData: contactIds .map { - SyncedContactInfo( + ContactUpdateInfo( id: $0, priority: LibSession.hiddenPriority ) @@ -603,7 +643,7 @@ public extension LibSession { } static func remove( - _ db: Database, + _ db: ObservingDatabase, contactIds: [String], using dependencies: Dependencies ) throws { @@ -611,7 +651,9 @@ public extension LibSession { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .contacts, sessionId: dependencies[cache: .general].sessionId) { config in - guard case .contacts(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .contacts(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .contacts, got: config) + } contactIds.forEach { sessionId in guard var cSessionId: [CChar] = sessionId.cString(using: .utf8) else { return } @@ -624,7 +666,7 @@ public extension LibSession { } static func update( - _ db: Database, + _ db: ObservingDatabase, sessionId: String, disappearingMessagesConfig: DisappearingMessagesConfiguration, using dependencies: Dependencies @@ -648,7 +690,7 @@ public extension LibSession { try LibSession .upsert( contactData: [ - SyncedContactInfo( + ContactUpdateInfo( id: sessionId, disappearingMessagesConfig: disappearingMessagesConfig ) @@ -662,10 +704,30 @@ public extension LibSession { } } -// MARK: - SyncedContactInfo +// MARK: - State Access + +public extension LibSession.Cache { + func isContactBlocked(contactId: String) -> Bool { + guard + case .contacts(let conf) = config(for: .contacts, sessionId: userSessionId), + var cContactId: [CChar] = contactId.cString(using: .utf8) + else { return false } + + var contact: contacts_contact = contacts_contact() + + guard contacts_get(conf, &contact, &cContactId) else { + LibSessionError.clear(conf) + return false + } + + return contact.blocked + } +} + +// MARK: - ContactUpdateInfo extension LibSession { - public struct SyncedContactInfo { + public struct ContactUpdateInfo { let id: String let isTrusted: Bool? let isApproved: Bool? @@ -674,8 +736,8 @@ extension LibSession { let name: String? let nickname: String? - let profilePictureUrl: String? - let profileEncryptionKey: Data? + let displayPictureUrl: String? + let displayPictureEncryptionKey: Data? let disappearingMessagesInfo: DisappearingMessageInfo? let priority: Int32? @@ -688,8 +750,8 @@ extension LibSession { id: id, name: name, nickname: nickname, - profilePictureUrl: profilePictureUrl, - profileEncryptionKey: profileEncryptionKey + displayPictureUrl: displayPictureUrl, + displayPictureEncryptionKey: displayPictureEncryptionKey ) } @@ -709,8 +771,8 @@ extension LibSession { didApproveMe: contact?.didApproveMe, name: profile?.name, nickname: profile?.nickname, - profilePictureUrl: profile?.profilePictureUrl, - profileEncryptionKey: profile?.profileEncryptionKey, + displayPictureUrl: profile?.displayPictureUrl, + displayPictureEncryptionKey: profile?.displayPictureEncryptionKey, disappearingMessagesInfo: disappearingMessagesConfig.map { DisappearingMessageInfo( isEnabled: $0.isEnabled, @@ -731,8 +793,8 @@ extension LibSession { didApproveMe: Bool? = nil, name: String? = nil, nickname: String? = nil, - profilePictureUrl: String? = nil, - profileEncryptionKey: Data? = nil, + displayPictureUrl: String? = nil, + displayPictureEncryptionKey: Data? = nil, disappearingMessagesInfo: DisappearingMessageInfo? = nil, priority: Int32? = nil, created: TimeInterval? = nil @@ -744,8 +806,8 @@ extension LibSession { self.didApproveMe = didApproveMe self.name = name self.nickname = nickname - self.profilePictureUrl = profilePictureUrl - self.profileEncryptionKey = profileEncryptionKey + self.displayPictureUrl = displayPictureUrl + self.displayPictureEncryptionKey = displayPictureEncryptionKey self.disappearingMessagesInfo = disappearingMessagesInfo self.priority = priority self.created = created @@ -774,17 +836,17 @@ extension LibSession { // MARK: - ContactData -private struct ContactData { - let contact: Contact - let profile: Profile - let config: DisappearingMessagesConfiguration - let priority: Int32 - let created: TimeInterval +internal struct ContactData { + internal let contact: Contact + internal let profile: Profile + internal let config: DisappearingMessagesConfiguration + internal let priority: Int32 + internal let created: TimeInterval } // MARK: - Convenience -private extension LibSession { +internal extension LibSession { static func extractContacts( from conf: UnsafeMutablePointer?, serverTimestampMs: Int64, @@ -806,15 +868,15 @@ private extension LibSession { didApproveMe: contact.approved_me, using: dependencies ) - let profilePictureUrl: String? = contact.get(\.profile_pic.url, nullIfEmpty: true) + let displayPictureUrl: String? = contact.get(\.profile_pic.url, nullIfEmpty: true) let profileResult: Profile = Profile( id: contactId, name: contact.get(\.name), lastNameUpdate: (TimeInterval(serverTimestampMs) / 1000), nickname: contact.get(\.nickname, nullIfEmpty: true), - profilePictureUrl: profilePictureUrl, - profileEncryptionKey: (profilePictureUrl == nil ? nil : contact.get(\.profile_pic.key)), - lastProfilePictureUpdate: (TimeInterval(serverTimestampMs) / 1000) + displayPictureUrl: displayPictureUrl, + displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : contact.get(\.profile_pic.key)), + displayPictureLastUpdated: (TimeInterval(serverTimestampMs) / 1000) ) let configResult: DisappearingMessagesConfiguration = DisappearingMessagesConfiguration( threadId: contactId, diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift index 22f299e260..b45838b404 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift @@ -19,11 +19,13 @@ internal extension LibSession { internal extension LibSessionCacheType { func handleConvoInfoVolatileUpdate( - _ db: Database, + _ db: ObservingDatabase, in config: LibSession.Config? ) throws { guard configNeedsDump(config) else { return } - guard case .convoInfoVolatile(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .convoInfoVolatile(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .convoInfoVolatile, got: config) + } // Get the volatile thread info from the conf and local conversations let volatileThreadInfo: [LibSession.VolatileThreadInfo] = try LibSession.extractConvoVolatileInfo(from: conf) @@ -61,6 +63,7 @@ internal extension LibSessionCacheType { SessionThread.Columns.markedAsUnread.set(to: markedAsUnread), using: dependencies ) + db.addConversationEvent(id: threadId, type: .updated(.markedAsUnread(markedAsUnread))) } // If the device has a more recent read interaction then return the info so we can @@ -86,7 +89,7 @@ internal extension LibSessionCacheType { .filter(Interaction.Columns.timestampMs <= lastReadTimestampMs) .filter(Interaction.Columns.wasRead == false) let interactionInfoToMarkAsRead: [Interaction.ReadInfo] = try interactionQuery - .select(.id, .variant, .timestampMs, .wasRead) + .select(.id, .serverHash, .variant, .timestampMs, .wasRead) .asRequest(of: Interaction.ReadInfo.self) .fetchAll(db) try interactionQuery @@ -123,7 +126,9 @@ internal extension LibSession { convoInfoVolatileChanges: [VolatileThreadInfo], in config: Config? ) throws { - guard case .convoInfoVolatile(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .convoInfoVolatile(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .convoInfoVolatile, got: config) + } // Exclude any invalid thread info let validChanges: [VolatileThreadInfo] = convoInfoVolatileChanges @@ -255,7 +260,7 @@ internal extension LibSession { } static func updateMarkedAsUnreadState( - _ db: Database, + _ db: ObservingDatabase, threads: [SessionThread], using dependencies: Dependencies ) throws { @@ -294,13 +299,15 @@ internal extension LibSession { } static func remove( - _ db: Database, + _ db: ObservingDatabase, volatileContactIds: [String], using dependencies: Dependencies ) throws { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .convoInfoVolatile, sessionId: dependencies[cache: .general].sessionId) { config in - guard case .convoInfoVolatile(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .convoInfoVolatile(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .convoInfoVolatile, got: config) + } try volatileContactIds.forEach { contactId in var cSessionId: [CChar] = try contactId.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() @@ -313,13 +320,15 @@ internal extension LibSession { } static func remove( - _ db: Database, + _ db: ObservingDatabase, volatileLegacyGroupIds: [String], using dependencies: Dependencies ) throws { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .convoInfoVolatile, sessionId: dependencies[cache: .general].sessionId) { config in - guard case .convoInfoVolatile(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .convoInfoVolatile(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .convoInfoVolatile, got: config) + } try volatileLegacyGroupIds.forEach { legacyGroupId in var cLegacyGroupId: [CChar] = try legacyGroupId.cString(using: .utf8) ?? { @@ -334,13 +343,15 @@ internal extension LibSession { } static func remove( - _ db: Database, + _ db: ObservingDatabase, volatileGroupSessionIds: [SessionId], using dependencies: Dependencies ) throws { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .convoInfoVolatile, sessionId: dependencies[cache: .general].sessionId) { config in - guard case .convoInfoVolatile(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .convoInfoVolatile(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .convoInfoVolatile, got: config) + } try volatileGroupSessionIds.forEach { groupSessionId in var cGroupId: [CChar] = try groupSessionId.hexString.cString(using: .utf8) ?? { @@ -355,13 +366,15 @@ internal extension LibSession { } static func remove( - _ db: Database, + _ db: ObservingDatabase, volatileCommunityInfo: [OpenGroupUrlInfo], using dependencies: Dependencies ) throws { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .convoInfoVolatile, sessionId: dependencies[cache: .general].sessionId) { config in - guard case .convoInfoVolatile(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .convoInfoVolatile(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .convoInfoVolatile, got: config) + } try volatileCommunityInfo.forEach { urlInfo in var cBaseUrl: [CChar] = try urlInfo.server.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() @@ -379,7 +392,7 @@ internal extension LibSession { public extension LibSession { static func syncThreadLastReadIfNeeded( - _ db: Database, + _ db: ObservingDatabase, threadId: String, threadVariant: SessionThread.Variant, lastReadTimestampMs: Int64, @@ -405,11 +418,13 @@ public extension LibSession { } } -public extension LibSessionCacheType { +// MARK: State Access + +public extension LibSession.Cache { func conversationLastRead( threadId: String, threadVariant: SessionThread.Variant, - openGroup: OpenGroup? + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? ) -> Int64? { // If we don't have a config then just assume it's unread guard case .convoInfoVolatile(let conf) = config(for: .convoInfoVolatile, sessionId: userSessionId) else { @@ -443,13 +458,11 @@ public extension LibSessionCacheType { return legacyGroup.last_read case .community: - guard let openGroup: OpenGroup = openGroup else { return nil } - var convoCommunity: convo_info_volatile_community = convo_info_volatile_community() guard - var cBaseUrl: [CChar] = openGroup.server.cString(using: .utf8), - var cRoomToken: [CChar] = openGroup.roomToken.cString(using: .utf8), + var cBaseUrl: [CChar] = openGroupUrlInfo?.server.cString(using: .utf8), + var cRoomToken: [CChar] = openGroupUrlInfo?.roomToken.cString(using: .utf8), convo_info_volatile_get_community(conf, &convoCommunity, &cBaseUrl, &cRoomToken) else { LibSessionError.clear(conf) @@ -469,100 +482,30 @@ public extension LibSessionCacheType { return group.last_read } } - +} + +// MARK: State Access + +public extension LibSessionCacheType { func timestampAlreadyRead( threadId: String, threadVariant: SessionThread.Variant, timestampMs: Int64, - userSessionId: SessionId, - openGroup: OpenGroup? + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? ) -> Bool { - // If we don't have a config then just assume it's unread - guard case .convoInfoVolatile(let conf) = config(for: .convoInfoVolatile, sessionId: userSessionId) else { - return false - } - - switch threadVariant { - case .contact: - var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1() - guard - var cThreadId: [CChar] = threadId.cString(using: .utf8), - convo_info_volatile_get_1to1(conf, &oneToOne, &cThreadId) - else { - LibSessionError.clear(conf) - return false - } - - return (oneToOne.last_read >= timestampMs) - - case .legacyGroup: - var legacyGroup: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group() - - guard - var cThreadId: [CChar] = threadId.cString(using: .utf8), - convo_info_volatile_get_legacy_group(conf, &legacyGroup, &cThreadId) - else { - LibSessionError.clear(conf) - return false - } - - return (legacyGroup.last_read >= timestampMs) - - case .community: - guard let openGroup: OpenGroup = openGroup else { return false } - - var convoCommunity: convo_info_volatile_community = convo_info_volatile_community() - - guard - var cBaseUrl: [CChar] = openGroup.server.cString(using: .utf8), - var cRoomToken: [CChar] = openGroup.roomToken.cString(using: .utf8), - convo_info_volatile_get_community(conf, &convoCommunity, &cBaseUrl, &cRoomToken) - else { - LibSessionError.clear(conf) - return false - } - - return (convoCommunity.last_read >= timestampMs) - - case .group: - var group: convo_info_volatile_group = convo_info_volatile_group() - - guard - var cThreadId: [CChar] = threadId.cString(using: .utf8), - convo_info_volatile_get_group(conf, &group, &cThreadId) - else { return false } - - return (group.last_read >= timestampMs) - } + let lastReadTimestampMs = conversationLastRead( + threadId: threadId, + threadVariant: threadVariant, + openGroupUrlInfo: openGroupUrlInfo + ) + + return ((lastReadTimestampMs ?? 0) >= timestampMs) } } // MARK: - VolatileThreadInfo public extension LibSession { - struct OpenGroupUrlInfo: FetchableRecord, Codable, Hashable { - let threadId: String - let server: String - let roomToken: String - let publicKey: String - - static func fetchOne(_ db: Database, id: String) throws -> OpenGroupUrlInfo? { - return try OpenGroup - .filter(id: id) - .select(.threadId, .server, .roomToken, .publicKey) - .asRequest(of: OpenGroupUrlInfo.self) - .fetchOne(db) - } - - static func fetchAll(_ db: Database, ids: [String]) throws -> [OpenGroupUrlInfo] { - return try OpenGroup - .filter(ids: ids) - .select(.threadId, .server, .roomToken, .publicKey) - .asRequest(of: OpenGroupUrlInfo.self) - .fetchAll(db) - } - } - struct VolatileThreadInfo { enum Change { case markedAsUnread(Bool) @@ -597,7 +540,7 @@ public extension LibSession { ) } - static func fetchAll(_ db: Database, ids: [String]? = nil) -> [VolatileThreadInfo] { + static func fetchAll(_ db: ObservingDatabase, ids: [String]? = nil) -> [VolatileThreadInfo] { struct FetchedInfo: FetchableRecord, Codable, Hashable { let id: String let variant: SessionThread.Variant diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift index f7026fe81d..ee77869c1e 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift @@ -49,13 +49,15 @@ private struct InteractionInfo: Codable, FetchableRecord { internal extension LibSessionCacheType { func handleGroupInfoUpdate( - _ db: Database, + _ db: ObservingDatabase, in config: LibSession.Config?, groupSessionId: SessionId, serverTimestampMs: Int64 ) throws { guard configNeedsDump(config) else { return } - guard case .groupInfo(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .groupInfo(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupInfo, got: config) + } // If the group is destroyed then mark the group as kicked in the USER_GROUPS config and remove // the group data (want to keep the group itself around because the UX of conversations randomly @@ -114,14 +116,8 @@ internal extension LibSessionCacheType { (!needsDisplayPictureUpdate || displayPictureUrl != nil ? nil : ClosedGroup.Columns.displayPictureUrl.set(to: nil) ), - (!needsDisplayPictureUpdate || displayPictureUrl != nil ? nil : - ClosedGroup.Columns.displayPictureFilename.set(to: nil) - ), (!needsDisplayPictureUpdate || displayPictureUrl != nil ? nil : ClosedGroup.Columns.displayPictureEncryptionKey.set(to: nil) - ), - (!needsDisplayPictureUpdate || displayPictureUrl != nil ? nil : - ClosedGroup.Columns.lastDisplayPictureUpdate.set(to: (serverTimestampMs / 1000)) ) ].compactMap { $0 } @@ -134,6 +130,18 @@ internal extension LibSessionCacheType { using: dependencies ) } + + // Emit events + if existingGroup?.name != groupName { + db.addConversationEvent(id: groupSessionId.hexString, type: .updated(.displayName(groupName))) + } + + if existingGroup?.groupDescription == groupDesc { + db.addConversationEvent( + id: groupSessionId.hexString, + type: .updated(.description(groupDesc)) + ) + } // If we have a display picture then start downloading it if needsDisplayPictureUpdate, let url: String = displayPictureUrl, let key: Data = displayPictureKey { @@ -304,7 +312,7 @@ internal extension LibSessionCacheType { internal extension LibSession { static func updatingGroupInfo( - _ db: Database, + _ db: ObservingDatabase, _ updated: [T], using dependencies: Dependencies ) throws -> [T] { @@ -314,7 +322,11 @@ internal extension LibSession { // admin (non-admins can't update `GroupInfo` anyway) let targetGroups: [ClosedGroup] = updatedGroups .filter { (try? SessionId(from: $0.id))?.prefix == .group } - .filter { isAdmin(groupSessionId: SessionId(.group, hex: $0.id), using: dependencies) } + .filter { group in + dependencies.mutate(cache: .libSession, { cache in + cache.isAdmin(groupSessionId: SessionId(.group, hex: group.id)) + }) + } // If we only updated the current user contact then no need to continue guard !targetGroups.isEmpty else { return updated } @@ -329,7 +341,9 @@ internal extension LibSession { guard cache.isAdmin(groupSessionId: groupSessionId) else { return } try cache.performAndPushChange(db, for: .groupInfo, sessionId: groupSessionId) { config in - guard case .groupInfo(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .groupInfo(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupInfo, got: config) + } guard var cGroupName: [CChar] = group.name.cString(using: .utf8), var cGroupDesc: [CChar] = (group.groupDescription ?? "").cString(using: .utf8) @@ -339,9 +353,24 @@ internal extension LibSession { /// /// **Note:** We indentionally only update the `GROUP_INFO` and not the `USER_GROUPS` as once the /// group is synced between devices we want to rely on the proper group config to get display info + let currentGroupName: String? = groups_info_get_name(conf) + .map { String(cString: $0) } + let currentGroupDesc: String? = groups_info_get_description(conf) + .map { String(cString: $0) } groups_info_set_name(conf, &cGroupName) groups_info_set_description(conf, &cGroupDesc) + if currentGroupName != group.name { + db.addConversationEvent(id: group.threadId, type: .updated(.displayName(group.name))) + } + + if currentGroupDesc != group.groupDescription { + db.addConversationEvent( + id: group.threadId, + type: .updated(.description(group.groupDescription)) + ) + } + // Either assign the updated display pic, or sent a blank pic (to remove the current one) var displayPic: user_profile_pic = user_profile_pic() displayPic.set(\.url, to: group.displayPictureUrl) @@ -355,7 +384,7 @@ internal extension LibSession { } static func updatingDisappearingConfigsGroups( - _ db: Database, + _ db: ObservingDatabase, _ updated: [T], using dependencies: Dependencies ) throws -> [T] { @@ -365,7 +394,11 @@ internal extension LibSession { // the current user isn't an admin (non-admins can't update `GroupInfo` anyway) let targetUpdatedConfigs: [DisappearingMessagesConfiguration] = updatedDisappearingConfigs .filter { (try? SessionId.Prefix(from: $0.id)) == .group } - .filter { isAdmin(groupSessionId: SessionId(.group, hex: $0.id), using: dependencies) } + .filter { group in + dependencies.mutate(cache: .libSession, { cache in + cache.isAdmin(groupSessionId: SessionId(.group, hex: group.id)) + }) + } guard !targetUpdatedConfigs.isEmpty else { return updated } @@ -387,7 +420,9 @@ internal extension LibSession { .forEach { groupId, updatedConfig in try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .groupInfo, sessionId: SessionId(.group, hex: groupId)) { config in - guard case .groupInfo(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .groupInfo(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupInfo, got: config) + } groups_info_set_expiry_timer(conf, Int32(updatedConfig.durationSeconds)) } @@ -402,14 +437,16 @@ internal extension LibSession { public extension LibSession { static func update( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: SessionId, disappearingConfig: DisappearingMessagesConfiguration?, using dependencies: Dependencies ) throws { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .groupInfo, sessionId: groupSessionId) { config in - guard case .groupInfo(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .groupInfo(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupInfo, got: config) + } if let config: DisappearingMessagesConfiguration = disappearingConfig { groups_info_set_expiry_timer(conf, Int32(config.durationSeconds)) @@ -419,14 +456,16 @@ public extension LibSession { } static func deleteMessagesBefore( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: SessionId, timestamp: TimeInterval, using dependencies: Dependencies ) throws { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .groupInfo, sessionId: groupSessionId) { config in - guard case .groupInfo(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .groupInfo(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupInfo, got: config) + } // Do nothing if the timestamp isn't newer than the current value guard Int64(timestamp) > groups_info_get_delete_before(conf) else { return } @@ -437,14 +476,16 @@ public extension LibSession { } static func deleteAttachmentsBefore( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: SessionId, timestamp: TimeInterval, using dependencies: Dependencies ) throws { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .groupInfo, sessionId: groupSessionId) { config in - guard case .groupInfo(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .groupInfo(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupInfo, got: config) + } // Do nothing if the timestamp isn't newer than the current value guard Int64(timestamp) > groups_info_get_attach_delete_before(conf) else { return } @@ -456,35 +497,28 @@ public extension LibSession { } public extension LibSessionCacheType { - func deleteGroupForEveryone(_ db: Database, groupSessionId: SessionId) throws { + func deleteGroupForEveryone(_ db: ObservingDatabase, groupSessionId: SessionId) throws { try performAndPushChange(db, for: .groupInfo, sessionId: groupSessionId) { config in - guard case .groupInfo(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .groupInfo(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupInfo, got: config) + } groups_info_destroy_group(conf) } } } -// MARK: - Direct Values +// MARK: - State Access -extension LibSession { - static func groupName(in config: Config?) throws -> String { - guard - case .groupInfo(let conf) = config, - let groupNamePtr: UnsafePointer = groups_info_get_name(conf) - else { throw LibSessionError.invalidConfigObject } - - return String(cString: groupNamePtr) - } - - static func groupDeleteBefore(in config: Config?) throws -> TimeInterval { - guard case .groupInfo(let conf) = config else { throw LibSessionError.invalidConfigObject } +public extension LibSession.Cache { + func groupDeleteBefore(groupSessionId: SessionId) -> TimeInterval? { + guard case .groupInfo(let conf) = config(for: .groupInfo, sessionId: groupSessionId) else { return nil } return TimeInterval(groups_info_get_delete_before(conf)) } - static func groupAttachmentDeleteBefore(in config: Config?) throws -> TimeInterval { - guard case .groupInfo(let conf) = config else { throw LibSessionError.invalidConfigObject } + func groupDeleteAttachmentsBefore(groupSessionId: SessionId) -> TimeInterval? { + guard case .groupInfo(let conf) = config(for: .groupInfo, sessionId: groupSessionId) else { return nil } return TimeInterval(groups_info_get_attach_delete_before(conf)) } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupKeys.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupKeys.swift index 1ceceebd39..dfd4c4c62e 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupKeys.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupKeys.swift @@ -25,12 +25,12 @@ internal extension LibSession { internal extension LibSessionCacheType { func handleGroupKeysUpdate( - _ db: Database, + _ db: ObservingDatabase, in config: LibSession.Config?, groupSessionId: SessionId ) throws { guard case .groupKeys(let conf, let infoConf, let membersConf) = config else { - throw LibSessionError.invalidConfigObject + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) } /// If the group had been flagged as "expired" (because it got no config messages when initially polling) then receiving a config @@ -72,16 +72,34 @@ internal extension LibSessionCacheType { // MARK: - Outgoing Changes +public extension LibSession.Cache { + func loadAdminKey( + groupIdentitySeed: Data, + groupSessionId: SessionId + ) throws { + guard let config: LibSession.Config = config(for: .groupKeys, sessionId: groupSessionId) else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: nil) + } + guard case .groupKeys(let conf, let infoConf, let membersConf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) + } + + var identitySeed: [UInt8] = Array(groupIdentitySeed) + groups_keys_load_admin_key(conf, &identitySeed, infoConf, membersConf) + try LibSessionError.throwIfNeeded(conf) + } +} + internal extension LibSession { static func rekey( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: SessionId, using dependencies: Dependencies ) throws { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .groupKeys, sessionId: groupSessionId) { config in guard case .groupKeys(let conf, let infoConf, let membersConf) = config else { - throw LibSessionError.invalidConfigObject + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) } // Performing a `rekey` returns the updated key data which we don't use directly, this updated @@ -97,14 +115,17 @@ internal extension LibSession { } static func keySupplement( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: SessionId, memberIds: Set, using dependencies: Dependencies ) throws -> Data { return try dependencies.mutate(cache: .libSession) { cache in - guard case .groupKeys(let conf, _, _) = cache.config(for: .groupKeys, sessionId: groupSessionId) else { - throw LibSessionError.invalidConfigObject + guard let config: LibSession.Config = cache.config(for: .groupKeys, sessionId: groupSessionId) else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: nil) + } + guard case .groupKeys(let conf, _, _) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) } return try memberIds.withUnsafeCStrArray { cMemberIds in @@ -132,7 +153,7 @@ internal extension LibSession { } static func loadAdminKey( - _ db: Database, + _ db: ObservingDatabase, groupIdentitySeed: Data, groupSessionId: SessionId, using dependencies: Dependencies @@ -140,14 +161,8 @@ internal extension LibSession { try dependencies.mutate(cache: .libSession) { cache in /// Disable the admin check because we are about to convert the user to being an admin and it's guaranteed to fail try cache.withCustomBehaviour(.skipGroupAdminCheck, for: groupSessionId) { - try cache.performAndPushChange(db, for: .groupKeys, sessionId: groupSessionId) { config in - guard case .groupKeys(let conf, let infoConf, let membersConf) = config else { - throw LibSessionError.invalidConfigObject - } - - var identitySeed: [UInt8] = Array(groupIdentitySeed) - groups_keys_load_admin_key(conf, &identitySeed, infoConf, membersConf) - try LibSessionError.throwIfNeeded(conf) + try cache.performAndPushChange(db, for: .groupKeys, sessionId: groupSessionId) { _ in + try cache.loadAdminKey(groupIdentitySeed: groupIdentitySeed, groupSessionId: groupSessionId) } } } @@ -158,8 +173,11 @@ internal extension LibSession { using dependencies: Dependencies ) throws -> Int { return try dependencies.mutate(cache: .libSession) { cache in - guard case .groupKeys(let conf, _, _) = cache.config(for: .groupKeys, sessionId: groupSessionId) else { - throw LibSessionError.invalidConfigObject + guard let config: LibSession.Config = cache.config(for: .groupKeys, sessionId: groupSessionId) else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: nil) + } + guard case .groupKeys(let conf, _, _) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) } return Int(groups_keys_size(conf)) @@ -171,11 +189,26 @@ internal extension LibSession { using dependencies: Dependencies ) throws -> Int { return try dependencies.mutate(cache: .libSession) { cache in - guard case .groupKeys(let conf, _, _) = cache.config(for: .groupKeys, sessionId: groupSessionId) else { - throw LibSessionError.invalidConfigObject + guard let config: LibSession.Config = cache.config(for: .groupKeys, sessionId: groupSessionId) else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: nil) + } + guard case .groupKeys(let conf, _, _) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) } return Int(groups_keys_current_generation(conf)) } } } + +// MARK: - State Accses + +public extension LibSession.Cache { + func isAdmin(groupSessionId: SessionId) -> Bool { + guard case .groupKeys(let conf, _, _) = config(for: .groupKeys, sessionId: groupSessionId) else { + return false + } + + return groups_keys_is_admin(conf) + } +} diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift index 60bb37583b..51215e9344 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift @@ -25,16 +25,17 @@ internal extension LibSession { internal extension LibSessionCacheType { func handleGroupMembersUpdate( - _ db: Database, + _ db: ObservingDatabase, in config: LibSession.Config?, groupSessionId: SessionId, serverTimestampMs: Int64 ) throws { guard configNeedsDump(config) else { return } - guard case .groupMembers(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .groupMembers(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupMembers, got: config) + } // Get the two member sets - let userSessionId: SessionId = dependencies[cache: .general].sessionId let updatedMembers: Set = try LibSession.extractMembers(from: conf, groupSessionId: groupSessionId) let existingMembers: Set = (try? GroupMember .filter(GroupMember.Columns.groupId == groupSessionId.hexString) @@ -132,18 +133,7 @@ internal extension LibSessionCacheType { db, publicKey: profile.id, displayNameUpdate: .contactUpdate(profile.name), - displayPictureUpdate: { - guard - let profilePictureUrl: String = profile.profilePictureUrl, - let profileKey: Data = profile.profileEncryptionKey - else { return .none } - - return .contactUpdateTo( - url: profilePictureUrl, - key: profileKey, - fileName: nil - ) - }(), + displayPictureUpdate: .from(profile, fallback: .none, using: dependencies), sentTimestamp: TimeInterval(Double(serverTimestampMs) * 1000), using: dependencies ) @@ -159,8 +149,11 @@ internal extension LibSession { using dependencies: Dependencies ) throws -> Set { return try dependencies.mutate(cache: .libSession) { cache in - guard case .groupMembers(let conf) = cache.config(for: .groupMembers, sessionId: groupSessionId) else { - throw LibSessionError.invalidConfigObject + guard let config: LibSession.Config = cache.config(for: .groupMembers, sessionId: groupSessionId) else { + throw LibSessionError.invalidConfigObject(wanted: .groupMembers, got: nil) + } + guard case .groupMembers(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupMembers, got: config) } return try extractMembers( @@ -175,8 +168,11 @@ internal extension LibSession { using dependencies: Dependencies ) throws -> [String: GROUP_MEMBER_STATUS] { return try dependencies.mutate(cache: .libSession) { cache in - guard case .groupMembers(let conf) = cache.config(for: .groupMembers, sessionId: groupSessionId) else { - throw LibSessionError.invalidConfigObject + guard let config: LibSession.Config = cache.config(for: .groupMembers, sessionId: groupSessionId) else { + throw LibSessionError.invalidConfigObject(wanted: .groupMembers, got: nil) + } + guard case .groupMembers(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupMembers, got: config) } return try extractPendingRemovals( @@ -187,7 +183,7 @@ internal extension LibSession { } static func addMembers( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: SessionId, members: [(id: String, profile: Profile?)], allowAccessToHistoricMessages: Bool, @@ -195,7 +191,9 @@ internal extension LibSession { ) throws { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .groupMembers, sessionId: groupSessionId) { config in - guard case .groupMembers(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .groupMembers(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupMembers, got: config) + } try members.forEach { memberId, profile in var cMemberId: [CChar] = try memberId.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() @@ -215,8 +213,8 @@ internal extension LibSession { } if - let picUrl: String = profile?.profilePictureUrl, - let picKey: Data = profile?.profileEncryptionKey, + let picUrl: String = profile?.displayPictureUrl, + let picKey: Data = profile?.displayPictureEncryptionKey, !picUrl.isEmpty, picKey.count == DisplayPictureManager.aes256KeyByteLength { @@ -233,7 +231,7 @@ internal extension LibSession { } static func updateMemberStatus( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: SessionId, memberId: String, role: GroupMember.Role, @@ -255,7 +253,9 @@ internal extension LibSession { status: GroupMember.RoleStatus, in config: Config? ) throws { - guard case .groupMembers(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .groupMembers(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupMembers, got: config) + } // Only update members if they already exist in the group var cMemberId: [CChar] = try memberId.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() @@ -278,7 +278,7 @@ internal extension LibSession { } static func updateMemberProfile( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: SessionId, memberId: String, profile: Profile?, @@ -297,7 +297,9 @@ internal extension LibSession { in config: Config? ) throws { guard let profile: Profile = profile else { return } - guard case .groupMembers(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .groupMembers(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupMembers, got: config) + } // Only update members if they already exist in the group var cMemberId: [CChar] = try memberId.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() @@ -308,9 +310,9 @@ internal extension LibSession { groupMember.set(\.name, to: profile.name) - if profile.profilePictureUrl != nil && profile.profileEncryptionKey != nil { - groupMember.set(\.profile_pic.url, to: profile.profilePictureUrl) - groupMember.set(\.profile_pic.key, to: profile.profileEncryptionKey) + if profile.displayPictureUrl != nil && profile.displayPictureEncryptionKey != nil { + groupMember.set(\.profile_pic.url, to: profile.displayPictureUrl) + groupMember.set(\.profile_pic.key, to: profile.displayPictureEncryptionKey) } groups_members_set(conf, &groupMember) @@ -318,7 +320,7 @@ internal extension LibSession { } static func flagMembersForRemoval( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: SessionId, memberIds: Set, removeMessages: Bool, @@ -326,7 +328,9 @@ internal extension LibSession { ) throws { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .groupMembers, sessionId: groupSessionId) { config in - guard case .groupMembers(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .groupMembers(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupMembers, got: config) + } try memberIds.forEach { memberId in // Only update members if they already exist in the group @@ -339,14 +343,16 @@ internal extension LibSession { } static func removeMembers( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: SessionId, memberIds: Set, using dependencies: Dependencies ) throws { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .groupMembers, sessionId: groupSessionId) { config in - guard case .groupMembers(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .groupMembers(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupMembers, got: config) + } try memberIds.forEach { memberId in var cMemberId: [CChar] = try memberId.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() @@ -358,7 +364,7 @@ internal extension LibSession { } static func updatingGroupMembers( - _ db: Database, + _ db: ObservingDatabase, _ updated: [T], using dependencies: Dependencies ) throws -> [T] { @@ -368,7 +374,11 @@ internal extension LibSession { // isn't an admin (non-admins can't update `GroupMembers` anyway) let targetMembers: [GroupMember] = updatedMembers .filter { (try? SessionId(from: $0.groupId))?.prefix == .group } - .filter { isAdmin(groupSessionId: SessionId(.group, hex: $0.groupId), using: dependencies) } + .filter { member in + dependencies.mutate(cache: .libSession, { cache in + cache.isAdmin(groupSessionId: SessionId(.group, hex: member.groupId)) + }) + } // If we only updated the current user contact then no need to continue guard @@ -514,11 +524,11 @@ internal extension LibSession { name: member.get(\.name), lastNameUpdate: TimeInterval(Double(serverTimestampMs) / 1000), nickname: nil, - profilePictureUrl: member.get(\.profile_pic.url, nullIfEmpty: true), - profileEncryptionKey: (member.get(\.profile_pic.url, nullIfEmpty: true) == nil ? nil : + displayPictureUrl: member.get(\.profile_pic.url, nullIfEmpty: true), + displayPictureEncryptionKey: (member.get(\.profile_pic.url, nullIfEmpty: true) == nil ? nil : member.get(\.profile_pic.key) ), - lastProfilePictureUpdate: TimeInterval(Double(serverTimestampMs) / 1000), + displayPictureLastUpdated: TimeInterval(Double(serverTimestampMs) / 1000), lastBlocksCommunityMessageRequests: nil ) ) diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Local.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Local.swift new file mode 100644 index 0000000000..8ec2b5225a --- /dev/null +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Local.swift @@ -0,0 +1,266 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUIKit +import SessionUtilitiesKit + +// MARK: - State Access + +public extension LibSession.Cache { + func has(_ key: Setting.BoolKey) -> Bool { + /// If a `bool` value doesn't exist then we return a negative value + switch key { + case .checkForCommunityMessageRequests: + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { + return false + } + + return (user_profile_get_blinded_msgreqs(conf) >= 0) + + default: + guard case .local(let conf) = config(for: .local, sessionId: userSessionId) else { + return false + } + + return (local_get_setting(conf, key.rawValue) >= 0) + } + } + + func has(_ key: Setting.EnumKey) -> Bool { + guard case .local(let conf) = config(for: .local, sessionId: userSessionId) else { + return false + } + + switch key { + case .preferencesNotificationPreviewType: + return (local_get_notification_content(conf) != Preferences.NotificationPreviewType.defaultLibSessionValue) + + case .defaultNotificationSound: + return (local_get_ios_notification_sound(conf) != Preferences.Sound.defaultLibSessionValue) + + case .theme: return (local_get_theme(conf) != Theme.defaultLibSessionValue) + case .themePrimaryColor: + return (local_get_theme_primary_color(conf) != Theme.PrimaryColor.defaultLibSessionValue) + + default: + Log.critical(.libSession, "Failed to check existence of unknown '\(key)' setting due to missing libSesison function") + return false + } + } + + func get(_ key: Setting.BoolKey) -> Bool { + switch key { + case .checkForCommunityMessageRequests: + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { + return false + } + + return (user_profile_get_blinded_msgreqs(conf) > 0) + + default: + guard case .local(let conf) = config(for: .local, sessionId: userSessionId) else { + return false + } + + return (local_get_setting(conf, key.rawValue) > 0) + } + } + + func get(_ key: Setting.EnumKey) -> T? { + guard case .local(let conf) = config(for: .local, sessionId: userSessionId) else { + return nil + } + + let retriever: (UnsafePointer) -> Any? = { + switch key { + case .preferencesNotificationPreviewType: return local_get_notification_content + case .defaultNotificationSound: return local_get_ios_notification_sound + case .theme: return local_get_theme + case .themePrimaryColor: return local_get_theme_primary_color + default: return { _ in nil } + } + }() + + switch retriever(conf) { + case let libSessionValue as T.LibSessionType: return T(libSessionValue) + case .some: + Log.critical(.libSession, "Failed to get \(key) because we couldn't cast to the C type") + return nil + + case .none: + Log.critical(.libSession, "Failed to get unknown '\(key)' setting due to missing libSesison function") + return nil + } + } + + func set(_ key: Setting.BoolKey, _ value: Bool?) { + let valueAsInt: Int32 = { + switch value { + case .none: return -1 + case .some(false): return 0 + case .some(true): return 1 + } + }() + + switch key { + case .checkForCommunityMessageRequests: + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { + return Log.critical(.libSession, "Failed to set \(key) because there is no Local config") + } + + user_profile_set_blinded_msgreqs(conf, valueAsInt) + + default: + guard case .local(let conf) = config(for: .local, sessionId: userSessionId) else { + return Log.critical(.libSession, "Failed to set \(key) because there is no Local config") + } + + local_set_setting(conf, key.rawValue, valueAsInt) + } + + /// Add an event to notify any observers of the change once it's committed + addEvent(key: key, value: value) + } + + func set(_ key: Setting.EnumKey, _ value: T?) { + guard case .local(let conf) = config(for: .local, sessionId: userSessionId) else { + return Log.critical(.libSession, "Failed to set \(key) because there is no Local config") + } + + let libSessionValue: T.LibSessionType = (value?.libSessionValue ?? T.defaultLibSessionValue) + + switch key { + case .defaultNotificationSound: + guard let value: Int64 = libSessionValue as? Int64 else { + return Log.critical(.libSession, "Failed to set \(key) because we couldn't cast to the C type") + } + + local_set_ios_notification_sound(conf, value) + + case .preferencesNotificationPreviewType: + guard let value: CLIENT_NOTIFY_CONTENT = libSessionValue as? CLIENT_NOTIFY_CONTENT else { + return Log.critical(.libSession, "Failed to set \(key) because we couldn't cast to the C type") + } + + local_set_notification_content(conf, value) + + case .theme: + guard let value: CLIENT_THEME = libSessionValue as? CLIENT_THEME else { + return Log.critical(.libSession, "Failed to set \(key) because we couldn't cast to the C type") + } + + local_set_theme(conf, value) + + case .themePrimaryColor: + guard let value: CLIENT_THEME_PRIMARY_COLOR = libSessionValue as? CLIENT_THEME_PRIMARY_COLOR else { + return Log.critical(.libSession, "Failed to set \(key) because we couldn't cast to the C type") + } + + local_set_theme_primary_color(conf, value) + + default: Log.critical(.libSession, "Failed to set unknown \(key) due to missing libSesison function") + } + + /// Add an event to notify any observers of the change once it's committed + addEvent(key: key, value: value) + } +} + +// MARK: - LibSessionConvertibleEnum + +public protocol LibSessionConvertibleEnum: Hashable { + associatedtype LibSessionType + + static var defaultLibSessionValue: LibSessionType { get } + var libSessionValue: LibSessionType { get } + + init(_ libSessionValue: LibSessionType) +} + +extension Preferences.NotificationPreviewType: LibSessionConvertibleEnum { + public typealias LibSessionType = CLIENT_NOTIFY_CONTENT + + public static var defaultLibSessionValue: LibSessionType { CLIENT_NOTIFY_CONTENT_DEFAULT } + public var libSessionValue: LibSessionType { + switch self { + case .nameAndPreview: return CLIENT_NOTIFY_CONTENT_NAME_AND_PREVIEW + case .nameNoPreview: return CLIENT_NOTIFY_CONTENT_NAME_NO_PREVIEW + case .noNameNoPreview: return CLIENT_NOTIFY_CONTENT_NO_NAME_NO_PREVIEW + } + } + + public init(_ libSessionValue: LibSessionType) { + switch libSessionValue { + case CLIENT_NOTIFY_CONTENT_NAME_AND_PREVIEW: self = .nameAndPreview + case CLIENT_NOTIFY_CONTENT_NAME_NO_PREVIEW: self = .nameNoPreview + case CLIENT_NOTIFY_CONTENT_NO_NAME_NO_PREVIEW: self = .noNameNoPreview + default: self = Preferences.NotificationPreviewType.defaultPreviewType + } + } +} + +extension Preferences.Sound: LibSessionConvertibleEnum { + public typealias LibSessionType = Int64 + + public static var defaultLibSessionValue: LibSessionType { 0 } + public var libSessionValue: LibSessionType { Int64(rawValue) } + + public init(_ libSessionValue: LibSessionType) { + self = (Preferences.Sound(rawValue: Int(libSessionValue)) ?? Preferences.Sound.default) + } +} + +extension Theme: LibSessionConvertibleEnum { + public typealias LibSessionType = CLIENT_THEME + + public static var defaultLibSessionValue: LibSessionType { CLIENT_THEME_DEFAULT } + public var libSessionValue: LibSessionType { + switch self { + case .classicDark: return CLIENT_THEME_CLASSIC_DARK + case .classicLight: return CLIENT_THEME_CLASSIC_LIGHT + case .oceanDark: return CLIENT_THEME_OCEAN_DARK + case .oceanLight: return CLIENT_THEME_OCEAN_LIGHT + } + } + + public init(_ libSessionValue: LibSessionType) { + switch libSessionValue { + case CLIENT_THEME_CLASSIC_DARK: self = .classicDark + case CLIENT_THEME_CLASSIC_LIGHT: self = .classicLight + case CLIENT_THEME_OCEAN_DARK: self = .oceanDark + case CLIENT_THEME_OCEAN_LIGHT: self = .oceanLight + default: self = Theme.defaultTheme + } + } +} + +extension Theme.PrimaryColor: LibSessionConvertibleEnum { + public typealias LibSessionType = CLIENT_THEME_PRIMARY_COLOR + + public static var defaultLibSessionValue: LibSessionType { CLIENT_THEME_PRIMARY_COLOR_DEFAULT } + public var libSessionValue: LibSessionType { + switch self { + case .green: return CLIENT_THEME_PRIMARY_COLOR_GREEN + case .blue: return CLIENT_THEME_PRIMARY_COLOR_BLUE + case .yellow: return CLIENT_THEME_PRIMARY_COLOR_YELLOW + case .pink: return CLIENT_THEME_PRIMARY_COLOR_PINK + case .purple: return CLIENT_THEME_PRIMARY_COLOR_PURPLE + case .orange: return CLIENT_THEME_PRIMARY_COLOR_ORANGE + case .red: return CLIENT_THEME_PRIMARY_COLOR_RED + } + } + + public init(_ libSessionValue: LibSessionType) { + switch libSessionValue { + case CLIENT_THEME_PRIMARY_COLOR_GREEN: self = .green + case CLIENT_THEME_PRIMARY_COLOR_BLUE: self = .blue + case CLIENT_THEME_PRIMARY_COLOR_YELLOW: self = .yellow + case CLIENT_THEME_PRIMARY_COLOR_PINK: self = .pink + case CLIENT_THEME_PRIMARY_COLOR_PURPLE: self = .purple + case CLIENT_THEME_PRIMARY_COLOR_ORANGE: self = .orange + case CLIENT_THEME_PRIMARY_COLOR_RED: self = .red + default: self = Theme.PrimaryColor.defaultPrimaryColor + } + } +} diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift index 9188e5e8a4..99559185e4 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift @@ -27,13 +27,15 @@ public extension LibSession { internal extension LibSession { /// This is a buffer period within which we will process messages which would result in a config change, any message which would normally - /// result in a config change which was sent before `lastConfigMessage.timestamp - configChangeBufferPeriod` will not + /// result in a config change which was sent before `lastConfigMessage.timestamp - configChangeBufferPeriodMs` will not /// actually have it's changes applied (info messages would still be inserted though) - static let configChangeBufferPeriod: TimeInterval = (2 * 60) + static let configChangeBufferPeriodMs: Int64 = ((2 * 60) * 1000) static let columnsRelatedToThreads: [ColumnExpression] = [ SessionThread.Columns.pinnedPriority, - SessionThread.Columns.shouldBeVisible + SessionThread.Columns.shouldBeVisible, + SessionThread.Columns.onlyNotifyForMentions, + SessionThread.Columns.mutedUntilTimestamp ] static func assignmentsRequireConfigUpdate(_ assignments: [ConfigColumnAssignment]) -> Bool { @@ -58,7 +60,7 @@ internal extension LibSession { } @discardableResult static func updatingThreads( - _ db: Database, + _ db: ObservingDatabase, _ updated: [T], using dependencies: Dependencies ) throws -> [T] { @@ -118,7 +120,7 @@ internal extension LibSession { try LibSession.upsert( contactData: remainingThreads .map { thread in - SyncedContactInfo( + ContactUpdateInfo( id: thread.id, priority: { guard thread.shouldBeVisible else { return LibSession.hiddenPriority } @@ -132,6 +134,20 @@ internal extension LibSession { in: config, using: dependencies ) + + remainingThreads.forEach { thread in + db.addEvent( + ConversationEvent( + id: thread.id, + change: .pinnedPriority( + thread.pinnedPriority + .map { Int32($0 == 0 ? LibSession.visiblePriority : max($0, 1)) } + .defaulting(to: LibSession.visiblePriority) + ) + ), + forKey: .conversationUpdated(thread.id) + ) + } } } @@ -140,9 +156,9 @@ internal extension LibSession { try cache.performAndPushChange(db, for: .userGroups, sessionId: userSessionId) { config in try LibSession.upsert( communities: threads - .compactMap { thread -> CommunityInfo? in + .compactMap { thread -> CommunityUpdateInfo? in urlInfo[thread.id].map { urlInfo in - CommunityInfo( + CommunityUpdateInfo( urlInfo: urlInfo, priority: thread.pinnedPriority .map { Int32($0 == 0 ? LibSession.visiblePriority : max($0, 1)) } @@ -197,52 +213,6 @@ internal extension LibSession { return updated } - static func hasSetting( - _ db: Database, - forKey key: String, - using dependencies: Dependencies - ) throws -> Bool { - let userSessionId: SessionId = dependencies[cache: .general].sessionId - - // Currently the only synced setting is 'checkForCommunityMessageRequests' - switch key { - case Setting.BoolKey.checkForCommunityMessageRequests.rawValue: - return dependencies.mutate(cache: .libSession) { cache in - let config: LibSession.Config? = cache.config(for: .userProfile, sessionId: userSessionId) - - return (((try? LibSession.rawBlindedMessageRequestValue(in: config)) ?? 0) >= 0) - } - - default: return false - } - } - - static func updatingSetting( - _ db: Database, - _ updated: Setting?, - using dependencies: Dependencies - ) throws { - // Don't current support any nullable settings - guard let updatedSetting: Setting = updated else { return } - - let userSessionId: SessionId = dependencies[cache: .general].sessionId - - // Currently the only synced setting is 'checkForCommunityMessageRequests' - switch updatedSetting.id { - case Setting.BoolKey.checkForCommunityMessageRequests.rawValue: - try dependencies.mutate(cache: .libSession) { cache in - try cache.performAndPushChange(db, for: .userProfile, sessionId: userSessionId) { config in - try LibSession.updateSettings( - checkForCommunityMessageRequests: updatedSetting.unsafeValue(as: Bool.self), - in: config - ) - } - } - - default: break - } - } - static func kickFromConversationUIIfNeeded(removedThreadIds: [String], using dependencies: Dependencies) { guard !removedThreadIds.isEmpty else { return } @@ -331,37 +301,6 @@ internal extension LibSession { } } - static func canPerformChange( - _ db: Database, - threadId: String, - targetConfig: ConfigDump.Variant, - changeTimestampMs: Int64, - using dependencies: Dependencies - ) -> Bool { - let targetSessionId: String = { - switch targetConfig { - case .userProfile, .contacts, .convoInfoVolatile, .userGroups: - return dependencies[cache: .general].sessionId.hexString - - case .groupInfo, .groupMembers, .groupKeys: return threadId - case .invalid: return "" - } - }() - - let configDumpTimestampMs: Int64 = (try? ConfigDump - .filter( - ConfigDump.Columns.variant == targetConfig && - ConfigDump.Columns.sessionId == targetSessionId - ) - .select(.timestampMs) - .asRequest(of: Int64.self) - .fetchOne(db)) - .defaulting(to: 0) - - // Ensure the change occurred after the last config message was handled (minus the buffer period) - return (changeTimestampMs >= (configDumpTimestampMs - Int64(LibSession.configChangeBufferPeriod * 1000))) - } - static func checkLoopLimitReached(_ loopCounter: inout Int, for variant: ConfigDump.Variant, maxLoopCount: Int = 50000) throws { loopCounter += 1 @@ -374,22 +313,268 @@ internal extension LibSession { // MARK: - State Access -extension LibSession.Config { - public func pinnedPriority( - _ db: Database, +public extension LibSession.Cache { + func canPerformChange( + threadId: String, + threadVariant: SessionThread.Variant, + changeTimestampMs: Int64 + ) -> Bool { + let variant: ConfigDump.Variant = { + switch threadVariant { + case .contact: return (threadId == userSessionId.hexString ? .userProfile : .contacts) + case .legacyGroup, .group, .community: return .userGroups + } + }() + + let configDumpTimestamp: TimeInterval = dependencies[singleton: .extensionHelper] + .lastUpdatedTimestamp(for: userSessionId, variant: variant) + let configDumpTimestampMs: Int64 = Int64(configDumpTimestamp * 1000) + + /// Ensure the change occurred after the last config message was handled (minus the buffer period) + return (changeTimestampMs >= (configDumpTimestampMs - LibSession.configChangeBufferPeriodMs)) + } + + func conversationInConfig( + threadId: String, + threadVariant: SessionThread.Variant, + visibleOnly: Bool, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Bool { + // Currently blinded conversations cannot be contained in the config, so there is no + // point checking (it'll always be false) + guard + threadVariant == .community || ( + (try? SessionId(from: threadId))?.prefix != .blinded15 && + (try? SessionId(from: threadId))?.prefix != .blinded25 + ), + var cThreadId: [CChar] = threadId.cString(using: .utf8) + else { return false } + + switch threadVariant { + case .contact where threadId == userSessionId.hexString: + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { + return false + } + + return ( + !visibleOnly || + LibSession.shouldBeVisible(priority: user_profile_get_nts_priority(conf)) + ) + + case .contact: + var contact: contacts_contact = contacts_contact() + + guard case .contacts(let conf) = config(for: .contacts, sessionId: userSessionId) else { + return false + } + guard contacts_get(conf, &contact, &cThreadId) else { + LibSessionError.clear(conf) + return false + } + + /// If the user opens a conversation with an existing contact but doesn't send them a message + /// then the one-to-one conversation should remain hidden so we want to delete the `SessionThread` + /// when leaving the conversation + return (!visibleOnly || LibSession.shouldBeVisible(priority: contact.priority)) + + case .community: + guard + let urlInfo: LibSession.OpenGroupUrlInfo = openGroupUrlInfo, + var cBaseUrl: [CChar] = urlInfo.server.cString(using: .utf8), + var cRoom: [CChar] = urlInfo.roomToken.cString(using: .utf8), + case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) + else { return false } + + var community: ugroups_community_info = ugroups_community_info() + + /// Not handling the `hidden` behaviour for communities so just indicate the existence + let result: Bool = user_groups_get_community(conf, &community, &cBaseUrl, &cRoom) + LibSessionError.clear(conf) + + return result + + case .group: + guard case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) else { + return false + } + + var group: ugroups_group_info = ugroups_group_info() + + /// Not handling the `hidden` behaviour for legacy groups so just indicate the existence + return user_groups_get_group(conf, &group, &cThreadId) + + case .legacyGroup: + guard case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) else { + return false + } + + let groupInfo: UnsafeMutablePointer? = user_groups_get_legacy_group(conf, &cThreadId) + LibSessionError.clear(conf) + + /// Not handling the `hidden` behaviour for legacy groups so just indicate the existence + if groupInfo != nil { + ugroups_legacy_group_free(groupInfo) + return true + } + + return false + } + } + + func conversationDisplayName( + threadId: String, + threadVariant: SessionThread.Variant, + contactProfile: Profile?, + visibleMessage: VisibleMessage?, + openGroupName: String?, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> String { + var finalProfile: Profile? = contactProfile + var finalOpenGroupName: String? = openGroupName + var finalClosedGroupName: String? + + switch threadVariant { + case .contact where threadId == userSessionId.hexString: break + case .contact: + guard contactProfile == nil else { break } + + finalProfile = profile( + contactId: threadId, + threadId: threadId, + threadVariant: threadVariant, + visibleMessage: visibleMessage + ) + + case .community: + guard + openGroupName == nil, + let urlInfo: LibSession.OpenGroupUrlInfo = openGroupUrlInfo, + var cBaseUrl: [CChar] = urlInfo.server.cString(using: .utf8), + var cRoom: [CChar] = urlInfo.roomToken.cString(using: .utf8), + case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) + else { break } + + var community: ugroups_community_info = ugroups_community_info() + + guard user_groups_get_community(conf, &community, &cBaseUrl, &cRoom) else { + LibSessionError.clear(conf) + break + } + + finalOpenGroupName = community.get(\.room).nullIfEmpty + + case .group: + guard var cThreadId: [CChar] = threadId.cString(using: .utf8) else { break } + + /// For a group try to extract the name from a `GroupInfo` config first, falling back to the `UserGroups` config + guard + case .groupInfo(let conf) = config(for: .groupInfo, sessionId: SessionId(.group, hex: threadId)), + let groupNamePtr: UnsafePointer = groups_info_get_name(conf) + else { + guard case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) else { + break + } + + var group: ugroups_group_info = ugroups_group_info() + + guard user_groups_get_group(conf, &group, &cThreadId) else { + LibSessionError.clear(conf) + break + } + + finalClosedGroupName = group.get(\.name).nullIfEmpty + break + } + + finalClosedGroupName = String(cString: groupNamePtr) + + case .legacyGroup: + guard + case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId), + var cThreadId: [CChar] = threadId.cString(using: .utf8) + else { break } + + let groupInfo: UnsafeMutablePointer? = user_groups_get_legacy_group(conf, &cThreadId) + LibSessionError.clear(conf) + + defer { + if groupInfo != nil { + ugroups_legacy_group_free(groupInfo) + } + } + + finalClosedGroupName = groupInfo?.get(\.name).nullIfEmpty + } + + return SessionThread.displayName( + threadId: threadId, + variant: threadVariant, + closedGroupName: finalClosedGroupName, + openGroupName: finalOpenGroupName, + isNoteToSelf: (threadId == userSessionId.hexString), + ignoringNickname: false, + profile: finalProfile + ) + } + + func isMessageRequest( threadId: String, threadVariant: SessionThread.Variant - ) -> Int32? { + ) -> Bool { + guard var cThreadId: [CChar] = threadId.cString(using: .utf8) else { return true } + + switch threadVariant { + case .community, .legacyGroup: return false + case .contact where threadId == userSessionId.hexString: return false + case .contact: + var contact: contacts_contact = contacts_contact() + + guard case .contacts(let conf) = config(for: .contacts, sessionId: userSessionId) else { + return true + } + guard contacts_get(conf, &contact, &cThreadId) else { + LibSessionError.clear(conf) + return true + } + + return !contact.approved + + case .group: + guard case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) else { + return true + } + + var group: ugroups_group_info = ugroups_group_info() + _ = user_groups_get_group(conf, &group, &cThreadId) + LibSessionError.clear(conf) + + return group.invited + } + } + + func pinnedPriority( + threadId: String, + threadVariant: SessionThread.Variant, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Int32 { guard var cThreadId: [CChar] = threadId.cString(using: .utf8) else { return LibSession.defaultNewThreadPriority } - switch (threadVariant, self) { - case (_, .userProfile(let conf)): return user_profile_get_nts_priority(conf) + switch threadVariant { + case .contact where threadId == userSessionId.hexString: + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { + return LibSession.defaultNewThreadPriority + } + + return user_profile_get_nts_priority(conf) - case (_, .contacts(let conf)): + case .contact: var contact: contacts_contact = contacts_contact() + guard case .contacts(let conf) = config(for: .contacts, sessionId: userSessionId) else { + return LibSession.defaultNewThreadPriority + } guard contacts_get(conf, &contact, &cThreadId) else { LibSessionError.clear(conf) return LibSession.defaultNewThreadPriority @@ -397,11 +582,12 @@ extension LibSession.Config { return contact.priority - case (.community, .userGroups(let conf)): + case .community: guard - let urlInfo: LibSession.OpenGroupUrlInfo = try? LibSession.OpenGroupUrlInfo.fetchOne(db, id: threadId), + let urlInfo: LibSession.OpenGroupUrlInfo = openGroupUrlInfo, var cBaseUrl: [CChar] = urlInfo.server.cString(using: .utf8), - var cRoom: [CChar] = urlInfo.roomToken.cString(using: .utf8) + var cRoom: [CChar] = urlInfo.roomToken.cString(using: .utf8), + case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) else { return LibSession.defaultNewThreadPriority } var community: ugroups_community_info = ugroups_community_info() @@ -410,7 +596,11 @@ extension LibSession.Config { return community.priority - case (.legacyGroup, .userGroups(let conf)): + case .legacyGroup: + guard case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) else { + return LibSession.defaultNewThreadPriority + } + let groupInfo: UnsafeMutablePointer? = user_groups_get_legacy_group(conf, &cThreadId) LibSessionError.clear(conf) @@ -422,28 +612,32 @@ extension LibSession.Config { return (groupInfo?.pointee.priority ?? LibSession.defaultNewThreadPriority) - case (.group, .userGroups(let conf)): + case .group: + guard case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) else { + return LibSession.defaultNewThreadPriority + } + var group: ugroups_group_info = ugroups_group_info() _ = user_groups_get_group(conf, &group, &cThreadId) LibSessionError.clear(conf) return group.priority - - default: - Log.warn(.libSession, "Attempted to retrieve priority for invalid combination of threadVariant: \(threadVariant) and config variant: \(variant)") - return LibSession.defaultNewThreadPriority } } - public func disappearingMessagesConfig( + func disappearingMessagesConfig( threadId: String, threadVariant: SessionThread.Variant ) -> DisappearingMessagesConfiguration? { guard var cThreadId: [CChar] = threadId.cString(using: .utf8) else { return nil } - switch (threadVariant, self) { - case (.community, _): return nil - case (_, .userProfile(let conf)): + switch threadVariant { + case .community: return nil + case .contact where threadId == userSessionId.hexString: + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { + return nil + } + let targetExpiry: Int32 = user_profile_get_nts_expiry(conf) let targetIsEnabled: Bool = (targetExpiry > 0) @@ -454,9 +648,12 @@ extension LibSession.Config { type: targetIsEnabled ? .disappearAfterSend : .unknown ) - case (_, .contacts(let conf)): + case .contact: var contact: contacts_contact = contacts_contact() + guard case .contacts(let conf) = config(for: .contacts, sessionId: userSessionId) else { + return nil + } guard contacts_get(conf, &contact, &cThreadId) else { LibSessionError.clear(conf) return nil @@ -471,7 +668,11 @@ extension LibSession.Config { ) ) - case (.legacyGroup, .userGroups(let conf)): + case .legacyGroup: + guard case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) else { + return nil + } + let groupInfo: UnsafeMutablePointer? = user_groups_get_legacy_group(conf, &cThreadId) LibSessionError.clear(conf) @@ -490,7 +691,13 @@ extension LibSession.Config { ) } - case (.group, .groupInfo(let conf)): + case .group: + guard + let groupSessionId: SessionId = try? SessionId(from: threadId), + groupSessionId.prefix == .group, + case .groupInfo(let conf) = config(for: .groupInfo, sessionId: groupSessionId) + else { return nil } + let durationSeconds: Int32 = groups_info_get_expiry_timer(conf) return DisappearingMessagesConfiguration( @@ -499,107 +706,209 @@ extension LibSession.Config { durationSeconds: TimeInterval(durationSeconds), type: .disappearAfterSend ) - - default: - Log.warn(.libSession, "Attempted to retrieve disappearing messages config for invalid combination of threadVariant: \(threadVariant) and config variant: \(variant)") - return nil } } - public func isAdmin() -> Bool { - guard case .groupKeys(let conf, _, _) = self else { return false } + func displayPictureUrl(threadId: String, threadVariant: SessionThread.Variant) -> String? { + switch threadVariant { + case .contact where threadId == userSessionId.hexString: + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { + return nil + } + + let profilePic: user_profile_pic = user_profile_get_pic(conf) + return profilePic.get(\.url, nullIfEmpty: true) + + case .contact: + var contact: contacts_contact = contacts_contact() + + guard case .contacts(let conf) = config(for: .contacts, sessionId: userSessionId) else { + return nil + } + guard + var cThreadId: [CChar] = threadId.cString(using: .utf8), + contacts_get(conf, &contact, &cThreadId) + else { + LibSessionError.clear(conf) + return nil + } + + return contact.get(\.profile_pic.url, nullIfEmpty: true) + + case .group: + guard case .groupInfo(let conf) = config(for: .groupInfo, sessionId: SessionId(.group, hex: threadId)) else { + return nil + } + + let profilePic: user_profile_pic = groups_info_get_pic(conf) + return profilePic.get(\.url, nullIfEmpty: true) + + case .legacyGroup, .community: return nil + } + } + + func profile( + contactId: String, + threadId: String?, + threadVariant: SessionThread.Variant?, + visibleMessage: VisibleMessage? + ) -> Profile? { + // FIXME: Once `libSession` manages unsynced "Profile" data we should source this from there + /// Extract the `displayName` directly from the `VisibleMessage` if available and it was sent by the desired contact + let displayNameInMessage: String? = (visibleMessage?.sender != contactId ? nil : + visibleMessage?.profile?.displayName?.nullIfEmpty + ) + let fallbackProfile: Profile? = displayNameInMessage.map { Profile(id: contactId, name: $0) } + + guard var cContactId: [CChar] = contactId.cString(using: .utf8) else { + return fallbackProfile + } + + /// If we are trying to retrive the profile for the current user then we need to extract it from the `UserProfile` config + guard contactId != userSessionId.hexString else { + guard + case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId), + let profileNamePtr: UnsafePointer = user_profile_get_name(conf) + else { + return nil + } + + let displayPic: user_profile_pic = user_profile_get_pic(conf) + let displayPictureUrl: String? = displayPic.get(\.url, nullIfEmpty: true) + + return Profile( + id: contactId, + name: String(cString: profileNamePtr), + lastNameUpdate: nil, + nickname: nil, + displayPictureUrl: displayPictureUrl, + displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : displayPic.get(\.key)), + displayPictureLastUpdated: nil + ) + } + + /// Define a function to extract a profile from the `GroupMembers` config, if we can't get a direct name for the contact and it's + /// a group conversation then be might be able to source it from there + func extractGroupMembersProfile() -> Profile? { + guard + threadVariant == .group, + let threadId: String = threadId, + case .groupMembers(let conf) = config(for: .groupMembers, sessionId: SessionId(.group, hex: threadId)) + else { return nil } + + var member: config_group_member = config_group_member() + + guard groups_members_get(conf, &member, &cContactId) else { + LibSessionError.clear(conf) + return fallbackProfile + } + + let displayPictureUrl: String? = member.get(\.profile_pic.url, nullIfEmpty: true) + + /// The `displayNameInMessage` value is likely newer than the `name` value in the config so use that if available + return Profile( + id: contactId, + name: (displayNameInMessage ?? member.get(\.name)), + lastNameUpdate: nil, + nickname: nil, + displayPictureUrl: displayPictureUrl, + displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : member.get(\.profile_pic.key)), + displayPictureLastUpdated: nil + ) + } + + /// Try to extract profile information from the `Contacts` config + guard case .contacts(let conf) = config(for: .contacts, sessionId: userSessionId) else { + return extractGroupMembersProfile() + } + + var contact: contacts_contact = contacts_contact() - return groups_keys_is_admin(conf) + guard contacts_get(conf, &contact, &cContactId) else { + LibSessionError.clear(conf) + return extractGroupMembersProfile() + } + + let displayPictureUrl: String? = contact.get(\.profile_pic.url, nullIfEmpty: true) + + /// The `displayNameInMessage` value is likely newer than the `name` value in the config so use that if available + return Profile( + id: contactId, + name: (displayNameInMessage ?? contact.get(\.name)), + lastNameUpdate: nil, + nickname: contact.get(\.nickname, nullIfEmpty: true), + displayPictureUrl: displayPictureUrl, + displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : contact.get(\.profile_pic.key)), + displayPictureLastUpdated: nil + ) } -} - -public extension LibSession { - static func conversationInConfig( - _ db: Database, - threadId: String, - threadVariant: SessionThread.Variant, - visibleOnly: Bool, - using dependencies: Dependencies - ) -> Bool { - // Currently blinded conversations cannot be contained in the config, so there is no - // point checking (it'll always be false) + + func groupName(groupSessionId: SessionId) -> String? { guard - threadVariant == .community || ( - (try? SessionId(from: threadId))?.prefix != .blinded15 && - (try? SessionId(from: threadId))?.prefix != .blinded25 - ) - else { return false } + case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId), + var cGroupId: [CChar] = groupSessionId.hexString.cString(using: .utf8) + else { return nil } + + var group: ugroups_group_info = ugroups_group_info() - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let configVariant: ConfigDump.Variant = { - switch threadVariant { - case .contact: return (threadId == userSessionId.hexString ? .userProfile : .contacts) - case .legacyGroup, .group, .community: return .userGroups + guard user_groups_get_group(conf, &group, &cGroupId) else { + LibSessionError.clear(conf) + + guard let legacyGroup: UnsafeMutablePointer = user_groups_get_legacy_group(conf, &cGroupId) else { + LibSessionError.clear(conf) + return nil } - }() + + defer { ugroups_legacy_group_free(legacyGroup) } + return legacyGroup.get(\.name) + } - return dependencies.mutate(cache: .libSession) { cache in - guard var cThreadId: [CChar] = threadId.cString(using: .utf8) else { return false } + return group.get(\.name) + } +} + +// MARK: - Convenience + +public extension Dependencies { + func setAsync(_ key: Setting.BoolKey, _ value: Bool?, onComplete: (() -> Void)? = nil) { + Task(priority: .userInitiated) { [dependencies = self] in + let targetVariant: ConfigDump.Variant - switch (threadVariant, cache.config(for: configVariant, sessionId: userSessionId)) { - case (_, .userProfile(let conf)): - return ( - !visibleOnly || - LibSession.shouldBeVisible(priority: user_profile_get_nts_priority(conf)) - ) - - case (_, .contacts(let conf)): - var contact: contacts_contact = contacts_contact() - - guard contacts_get(conf, &contact, &cThreadId) else { - LibSessionError.clear(conf) - return false - } - - /// If the user opens a conversation with an existing contact but doesn't send them a message - /// then the one-to-one conversation should remain hidden so we want to delete the `SessionThread` - /// when leaving the conversation - return (!visibleOnly || LibSession.shouldBeVisible(priority: contact.priority)) - - case (.community, .userGroups(let conf)): - let maybeUrlInfo: OpenGroupUrlInfo? = (try? OpenGroupUrlInfo - .fetchAll(db, ids: [threadId]))? - .first - - guard - let urlInfo: OpenGroupUrlInfo = maybeUrlInfo, - var cBaseUrl: [CChar] = urlInfo.server.cString(using: .utf8), - var cRoom: [CChar] = urlInfo.roomToken.cString(using: .utf8) - else { return false } - - var community: ugroups_community_info = ugroups_community_info() - - /// Not handling the `hidden` behaviour for communities so just indicate the existence - let result: Bool = user_groups_get_community(conf, &community, &cBaseUrl, &cRoom) - LibSessionError.clear(conf) - - return result - - case (.legacyGroup, .userGroups(let conf)): - let groupInfo: UnsafeMutablePointer? = user_groups_get_legacy_group(conf, &cThreadId) - LibSessionError.clear(conf) - - /// Not handling the `hidden` behaviour for legacy groups so just indicate the existence - if groupInfo != nil { - ugroups_legacy_group_free(groupInfo) - return true - } - - return false - - case (.group, .userGroups(let conf)): - var group: ugroups_group_info = ugroups_group_info() - - /// Not handling the `hidden` behaviour for legacy groups so just indicate the existence - return user_groups_get_group(conf, &group, &cThreadId) - - default: return false + switch key { + case .checkForCommunityMessageRequests: targetVariant = .userProfile + default: targetVariant = .local + } + + let mutation: LibSession.Mutation? = try? dependencies.mutate(cache: .libSession) { cache in + try cache.perform(for: targetVariant) { + cache.set(key, value) + } } + + dependencies[singleton: .storage].writeAsync( + updates: { db in + try mutation?.upsert(db) + db.addEvent(value, forKey: .setting(key)) + }, + completion: { _ in onComplete?() } + ) + } + } + + func setAsync(_ key: Setting.EnumKey, _ value: T?, onComplete: (() -> Void)? = nil) { + Task(priority: .userInitiated) { [dependencies = self] in + let mutation: LibSession.Mutation? = try? dependencies.mutate(cache: .libSession) { cache in + try cache.perform(for: .local) { + cache.set(key, value) + } + } + + try? await dependencies[singleton: .storage].writeAsync { db in + try mutation?.upsert(db) + db.addEvent(value, forKey: .setting(key)) + } + + onComplete?() } } } @@ -630,17 +939,6 @@ internal extension LibSession { } } -// MARK: - PriorityVisibilityInfo - -extension LibSession { - struct PriorityVisibilityInfo: Codable, FetchableRecord, Identifiable { - let id: String - let variant: SessionThread.Variant - let pinnedPriority: Int32? - let shouldBeVisible: Bool - } -} - // MARK: - LibSessionRespondingViewController public protocol LibSessionRespondingViewController { diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift index 254b88007c..54908b4470 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift @@ -18,12 +18,12 @@ public extension LibSession.Crypto.Domain { internal extension LibSessionCacheType { @discardableResult func createAndLoadGroupState( groupSessionId: SessionId, - userED25519KeyPair: KeyPair, + userED25519SecretKey: [UInt8], groupIdentityPrivateKey: Data? ) throws -> [ConfigDump.Variant: LibSession.Config] { let groupState: [ConfigDump.Variant: LibSession.Config] = try LibSession.createGroupState( groupSessionId: groupSessionId, - userED25519KeyPair: userED25519KeyPair, + userED25519SecretKey: userED25519SecretKey, groupIdentityPrivateKey: groupIdentityPrivateKey ) @@ -50,30 +50,29 @@ internal extension LibSession { ) static func createGroup( - _ db: Database, + _ db: ObservingDatabase, name: String, description: String?, displayPictureUrl: String?, - displayPictureFilename: String?, displayPictureEncryptionKey: Data?, members: [(id: String, profile: Profile?)], using dependencies: Dependencies ) throws -> CreatedGroupInfo { guard let groupIdentityKeyPair: KeyPair = dependencies[singleton: .crypto].generate(.ed25519KeyPair()), - let userED25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) + !dependencies[cache: .general].ed25519SecretKey.isEmpty else { throw MessageSenderError.noKeyPair } // 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 userSessionId: SessionId = dependencies[cache: .general].sessionId - let currentUserProfile: Profile? = Profile.fetchOrCreateCurrentUser(db, using: dependencies) + let currentUserProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } // Create the new config objects let groupState: [ConfigDump.Variant: Config] = try createGroupState( groupSessionId: groupSessionId, - userED25519KeyPair: userED25519KeyPair, + userED25519SecretKey: dependencies[cache: .general].ed25519SecretKey, groupIdentityPrivateKey: Data(groupIdentityKeyPair.secretKey) ) @@ -126,8 +125,8 @@ internal extension LibSession { member.set(\.invited, to: (memberInfo.isAdmin ? 0 : 1)) // Admins can't be in the invited state if - let picUrl: String = memberInfo.profile?.profilePictureUrl, - let picKey: Data = memberInfo.profile?.profileEncryptionKey, + let picUrl: String = memberInfo.profile?.displayPictureUrl, + let picKey: Data = memberInfo.profile?.displayPictureEncryptionKey, !picUrl.isEmpty, picKey.count == DisplayPictureManager.aes256KeyByteLength { @@ -162,9 +161,7 @@ internal extension LibSession { name: name, formationTimestamp: creationTimestamp, displayPictureUrl: displayPictureUrl, - displayPictureFilename: displayPictureFilename, displayPictureEncryptionKey: displayPictureEncryptionKey, - lastDisplayPictureUpdate: creationTimestamp, shouldPoll: true, groupIdentityPrivateKey: Data(groupIdentityKeyPair.secretKey), invited: false @@ -194,10 +191,12 @@ internal extension LibSession { static func createGroupState( groupSessionId: SessionId, - userED25519KeyPair: KeyPair, + userED25519SecretKey: [UInt8], groupIdentityPrivateKey: Data? ) throws -> [ConfigDump.Variant: LibSession.Config] { - var secretKey: [UInt8] = userED25519KeyPair.secretKey + guard userED25519SecretKey.count >= 32 else { throw CryptoError.missingUserSecretKey } + + var secretKey: [UInt8] = userED25519SecretKey var groupIdentityPublicKey: [UInt8] = groupSessionId.publicKey // Create the new config objects @@ -292,7 +291,7 @@ internal extension LibSession { } static func removeGroupStateIfNeeded( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: SessionId, using dependencies: Dependencies ) { @@ -306,7 +305,7 @@ internal extension LibSession { } static func saveCreatedGroup( - _ db: Database, + _ db: ObservingDatabase, group: ClosedGroup, groupState: [ConfigDump.Variant: Config], using dependencies: Dependencies @@ -314,12 +313,17 @@ internal extension LibSession { // Create and save dumps for the configs try dependencies.mutate(cache: .libSession) { cache in try groupState.forEach { variant, config in - try cache.createDump( + let dump: ConfigDump? = try cache.createDump( config: config, for: variant, sessionId: SessionId(.group, hex: group.id), timestampMs: Int64(floor(group.formationTimestamp * 1000)) - )?.upsert(db) + ) + + try dump?.upsert(db) + Task.detached(priority: .medium) { [extensionHelper = dependencies[singleton: .extensionHelper]] in + extensionHelper.replicate(dump: dump) + } } } @@ -335,20 +339,11 @@ internal extension LibSession { using: dependencies ) } - - static func isAdmin( - groupSessionId: SessionId, - using dependencies: Dependencies - ) -> Bool { - return dependencies.mutate(cache: .libSession) { cache in - return cache.isAdmin(groupSessionId: groupSessionId) - } - } } internal extension LibSessionCacheType { func removeGroupStateIfNeeded( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: SessionId ) { removeConfigs(for: groupSessionId) diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift index e2d3aade1e..ceb28cd05d 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift @@ -38,12 +38,14 @@ internal extension LibSession { internal extension LibSessionCacheType { func handleUserGroupsUpdate( - _ db: Database, + _ db: ObservingDatabase, in config: LibSession.Config?, serverTimestampMs: Int64 ) throws { guard configNeedsDump(config) else { return } - guard case .userGroups(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .userGroups(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .userGroups, got: config) + } // Extract all of the user group info let extractedUserGroups: LibSession.ExtractedUserGroups = try LibSession.extractUserGroups( @@ -52,8 +54,8 @@ internal extension LibSessionCacheType { ) // Extract all community/legacyGroup/group thread priorities - let existingThreadInfo: [String: LibSession.PriorityVisibilityInfo] = (try? SessionThread - .select(.id, .variant, .pinnedPriority, .shouldBeVisible) + let existingThreadInfo: [String: LibSession.ThreadUpdateInfo] = (try? SessionThread + .select(LibSession.ThreadUpdateInfo.threadColumns) .filter( [ SessionThread.Variant.community, @@ -61,7 +63,7 @@ internal extension LibSessionCacheType { SessionThread.Variant.group ].contains(SessionThread.Columns.variant) ) - .asRequest(of: LibSession.PriorityVisibilityInfo.self) + .asRequest(of: LibSession.ThreadUpdateInfo.self) .fetchAll(db)) .defaulting(to: []) .reduce(into: [:]) { result, next in result[next.id] = next } @@ -72,36 +74,47 @@ internal extension LibSessionCacheType { extractedUserGroups.communities.forEach { community in let successfullyAddedGroup: Bool = dependencies[singleton: .openGroupManager].add( db, - roomToken: community.data.roomToken, - server: community.data.server, - publicKey: community.data.publicKey, + roomToken: community.roomToken, + server: community.server, + publicKey: community.publicKey, forceVisible: true ) if successfullyAddedGroup { - db.afterNextTransactionNested(using: dependencies) { [dependencies] _ in + db.afterCommit { [dependencies] in dependencies[singleton: .openGroupManager].performInitialRequestsAfterAdd( queue: DispatchQueue.global(qos: .userInitiated), successfullyAddedGroup: successfullyAddedGroup, - roomToken: community.data.roomToken, - server: community.data.server, - publicKey: community.data.publicKey + roomToken: community.roomToken, + server: community.server, + publicKey: community.publicKey ) .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .sinkUntilComplete() } } - // Set the priority if it's changed (new communities will have already been inserted at - // this stage) - if existingThreadInfo[community.data.threadId]?.pinnedPriority != community.priority { + // Update any thread settings which have changed (new communities will have already been + // inserted at this stage) + if let existingInfo: LibSession.ThreadUpdateInfo = existingThreadInfo[community.threadId] { _ = try? SessionThread - .filter(id: community.data.threadId) + .filter(id: community.threadId) .updateAllAndConfig( db, - SessionThread.Columns.pinnedPriority.set(to: community.priority), + [ + (existingInfo.pinnedPriority == community.priority ? nil : + SessionThread.Columns.pinnedPriority.set(to: community.priority) + ) + ].compactMap { $0 }, using: dependencies ) + + if existingInfo.pinnedPriority != community.priority { + db.addConversationEvent( + id: community.threadId, + type: .updated(.pinnedPriority(community.priority)) + ) + } } } @@ -109,7 +122,7 @@ internal extension LibSessionCacheType { let communityIdsToRemove: Set = Set(existingThreadInfo .filter { $0.value.variant == .community } .keys) - .subtracting(extractedUserGroups.communities.map { $0.data.threadId }) + .subtracting(extractedUserGroups.communities.map { $0.threadId }) if !communityIdsToRemove.isEmpty { LibSession.kickFromConversationUIIfNeeded(removedThreadIds: Array(communityIdsToRemove), using: dependencies) @@ -198,6 +211,10 @@ internal extension LibSessionCacheType { ) } + if existingLegacyGroups[group.id]?.name != name { + db.addConversationEvent(id: group.id, type: .updated(.displayName(name))) + } + // Update the members let updatedMembers: Set = members .map { member in @@ -287,6 +304,11 @@ internal extension LibSessionCacheType { SessionThread.Columns.pinnedPriority.set(to: group.priority), using: dependencies ) + + db.addConversationEvent( + id: group.id, + type: .updated(.pinnedPriority(group.priority ?? LibSession.hiddenPriority)) + ) } } @@ -377,16 +399,27 @@ internal extension LibSessionCacheType { } } } - - // Make any thread-specific changes if needed - if existingThreadInfo[group.groupSessionId]?.pinnedPriority != group.priority { + + // Update any thread settings which have changed + if let existingInfo: LibSession.ThreadUpdateInfo = existingThreadInfo[group.groupSessionId] { _ = try? SessionThread .filter(id: group.groupSessionId) .updateAllAndConfig( db, - SessionThread.Columns.pinnedPriority.set(to: group.priority), + [ + (existingInfo.pinnedPriority == group.priority ? nil : + SessionThread.Columns.pinnedPriority.set(to: group.priority) + ) + ].compactMap { $0 }, using: dependencies ) + + if existingInfo.pinnedPriority != group.priority { + db.addConversationEvent( + id: group.groupSessionId, + type: .updated(.pinnedPriority(group.priority)) + ) + } } } @@ -427,53 +460,15 @@ internal extension LibSessionCacheType { return ugroups_group_is_kicked(&userGroup) } - func markAsInvited( - _ db: Database, - groupSessionIds: [String], - using dependencies: Dependencies - ) throws { - try performAndPushChange(db, for: .userGroups, sessionId: dependencies[cache: .general].sessionId) { config in - guard case .userGroups(let conf) = config else { throw LibSessionError.invalidConfigObject } - - try groupSessionIds.forEach { groupId in - var cGroupId: [CChar] = try groupId.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() - var userGroup: ugroups_group_info = ugroups_group_info() - - guard user_groups_get_group(conf, &userGroup, &cGroupId) else { return } - - ugroups_group_set_invited(&userGroup) - user_groups_set_group(conf, &userGroup) - } - } - } - - func markAsKicked( - _ db: Database, - groupSessionIds: [String], - using dependencies: Dependencies - ) throws { - try performAndPushChange(db, for: .userGroups, sessionId: dependencies[cache: .general].sessionId) { config in - guard case .userGroups(let conf) = config else { throw LibSessionError.invalidConfigObject } - - try groupSessionIds.forEach { groupId in - var cGroupId: [CChar] = try groupId.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() - var userGroup: ugroups_group_info = ugroups_group_info() - - guard user_groups_get_group(conf, &userGroup, &cGroupId) else { return } - - ugroups_group_set_kicked(&userGroup) - user_groups_set_group(conf, &userGroup) - } - } - } - func markAsDestroyed( - _ db: Database, + _ db: ObservingDatabase, groupSessionIds: [String], using dependencies: Dependencies ) throws { - try performAndPushChange(db, for: .userGroups, sessionId: dependencies[cache: .general].sessionId) { config in - guard case .userGroups(let conf) = config else { throw LibSessionError.invalidConfigObject } + try performAndPushChange(db, for: .userGroups, sessionId: userSessionId) { config in + guard case .userGroups(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .userGroups, got: config) + } try groupSessionIds.forEach { groupId in var cGroupId: [CChar] = try groupId.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() @@ -488,31 +483,16 @@ internal extension LibSessionCacheType { } } -internal extension LibSession { - fileprivate static func memberInfo(in legacyGroup: UnsafeMutablePointer) -> [String: Bool] { - let membersIt: OpaquePointer = ugroups_legacy_members_begin(legacyGroup) - var members: [String: Bool] = [:] - var maybeMemberSessionId: UnsafePointer? = nil - var memberAdmin: Bool = false +// MARK: - Outgoing Changes - while ugroups_legacy_members_next(membersIt, &maybeMemberSessionId, &memberAdmin) { - guard let memberSessionId: UnsafePointer = maybeMemberSessionId else { - continue - } - - members[String(cString: memberSessionId)] = memberAdmin - } - - return members - } - - // MARK: - Outgoing Changes - +public extension LibSession { static func upsert( legacyGroups: [LegacyGroupInfo], in config: Config? ) throws { - guard case .userGroups(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .userGroups(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .userGroups, got: config) + } guard !legacyGroups.isEmpty else { return } try legacyGroups @@ -606,7 +586,9 @@ internal extension LibSession { in config: Config?, using dependencies: Dependencies ) throws { - guard case .userGroups(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .userGroups(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .userGroups, got: config) + } guard !groups.isEmpty else { return } try groups @@ -653,10 +635,12 @@ internal extension LibSession { } static func upsert( - communities: [CommunityInfo], + communities: [CommunityUpdateInfo], in config: Config? ) throws { - guard case .userGroups(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .userGroups(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .userGroups, got: config) + } guard !communities.isEmpty else { return } try communities @@ -686,9 +670,28 @@ internal extension LibSession { user_groups_set_community(conf, &userCommunity) } } +} + +internal extension LibSession { + fileprivate static func memberInfo(in legacyGroup: UnsafeMutablePointer) -> [String: Bool] { + let membersIt: OpaquePointer = ugroups_legacy_members_begin(legacyGroup) + var members: [String: Bool] = [:] + var maybeMemberSessionId: UnsafePointer? = nil + var memberAdmin: Bool = false + + while ugroups_legacy_members_next(membersIt, &maybeMemberSessionId, &memberAdmin) { + guard let memberSessionId: UnsafePointer = maybeMemberSessionId else { + continue + } + + members[String(cString: memberSessionId)] = memberAdmin + } + + return members + } @discardableResult static func updatingGroups( - _ db: Database, + _ db: ObservingDatabase, _ updated: [T], using dependencies: Dependencies ) throws -> [T] { @@ -731,7 +734,7 @@ public extension LibSession { // MARK: -- Communities static func add( - _ db: Database, + _ db: ObservingDatabase, server: String, rootToken: String, publicKey: String, @@ -741,7 +744,7 @@ public extension LibSession { try cache.performAndPushChange(db, for: .userGroups, sessionId: dependencies[cache: .general].sessionId) { config in try LibSession.upsert( communities: [ - CommunityInfo( + CommunityUpdateInfo( urlInfo: OpenGroupUrlInfo( threadId: OpenGroup.idFor(roomToken: rootToken, server: server), server: server, @@ -757,14 +760,16 @@ public extension LibSession { } static func remove( - _ db: Database, + _ db: ObservingDatabase, server: String, roomToken: String, using dependencies: Dependencies ) throws { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .userGroups, sessionId: dependencies[cache: .general].sessionId) { config in - guard case .userGroups(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .userGroups(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .userGroups, got: config) + } var cBaseUrl: [CChar] = try server.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() var cRoom: [CChar] = try roomToken.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() @@ -792,7 +797,7 @@ public extension LibSession { // MARK: -- Legacy Group Changes static func remove( - _ db: Database, + _ db: ObservingDatabase, legacyGroupIds: [String], using dependencies: Dependencies ) throws { @@ -800,7 +805,9 @@ public extension LibSession { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .userGroups, sessionId: dependencies[cache: .general].sessionId) { config in - guard case .userGroups(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .userGroups(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .userGroups, got: config) + } legacyGroupIds.forEach { legacyGroupId in guard var cGroupId: [CChar] = legacyGroupId.cString(using: .utf8) else { return } @@ -818,7 +825,7 @@ public extension LibSession { // MARK: -- Group Changes static func add( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: String, groupIdentityPrivateKey: Data?, name: String?, @@ -848,7 +855,7 @@ public extension LibSession { } static func update( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: String, groupIdentityPrivateKey: Data? = nil, name: String? = nil, @@ -875,58 +882,36 @@ public extension LibSession { } } - static func markAsKicked( - _ db: Database, + func markAsInvited( + _ db: ObservingDatabase, groupSessionIds: [String], using dependencies: Dependencies ) throws { guard !groupSessionIds.isEmpty else { return } try dependencies.mutate(cache: .libSession) { cache in - try cache.markAsKicked(db, groupSessionIds: groupSessionIds, using: dependencies) - } - } - - static func wasKickedFromGroup( - groupSessionId: SessionId, - using dependencies: Dependencies - ) -> Bool { - return dependencies.mutate(cache: .libSession) { cache in - guard - case .userGroups(let conf) = cache.config(for: .userGroups, sessionId: dependencies[cache: .general].sessionId), - var cGroupId: [CChar] = groupSessionId.hexString.cString(using: .utf8) - else { return false } - - var userGroup: ugroups_group_info = ugroups_group_info() - - // If the group doesn't exist then assume the user hasn't been kicked - guard user_groups_get_group(conf, &userGroup, &cGroupId) else { return false } - - return ugroups_group_is_kicked(&userGroup) + try cache.performAndPushChange(db, for: .userGroups, sessionId: dependencies[cache: .general].sessionId) { _ in + try cache.markAsInvited(groupSessionIds: groupSessionIds) + } } } - static func groupIsDestroyed( - groupSessionId: SessionId, + static func markAsKicked( + _ db: ObservingDatabase, + groupSessionIds: [String], using dependencies: Dependencies - ) -> Bool { - return dependencies.mutate(cache: .libSession) { cache in - guard - case .userGroups(let conf) = cache.config(for: .userGroups, sessionId: dependencies[cache: .general].sessionId), - var cGroupId: [CChar] = groupSessionId.hexString.cString(using: .utf8) - else { return false } - - var userGroup: ugroups_group_info = ugroups_group_info() - - // If the group doesn't exist then assume the user hasn't been kicked - guard user_groups_get_group(conf, &userGroup, &cGroupId) else { return false } - - return ugroups_group_is_destroyed(&userGroup) + ) throws { + guard !groupSessionIds.isEmpty else { return } + + try dependencies.mutate(cache: .libSession) { cache in + try cache.performAndPushChange(db, for: .userGroups, sessionId: dependencies[cache: .general].sessionId) { _ in + try cache.markAsKicked(groupSessionIds: groupSessionIds) + } } } static func remove( - _ db: Database, + _ db: ObservingDatabase, groupSessionIds: [SessionId], using dependencies: Dependencies ) throws { @@ -934,7 +919,9 @@ public extension LibSession { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .userGroups, sessionId: dependencies[cache: .general].sessionId) { config in - guard case .userGroups(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .userGroups(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .userGroups, got: config) + } try groupSessionIds.forEach { groupSessionId in var cGroupId: [CChar] = try groupSessionId.hexString.cString(using: .utf8) ?? { @@ -952,11 +939,110 @@ public extension LibSession { } } +// MARK: - State Changes + +public extension LibSession.Cache { + func markAsInvited(groupSessionIds: [String]) throws { + guard let config: LibSession.Config = config(for: .userGroups, sessionId: userSessionId) else { + throw LibSessionError.invalidConfigObject(wanted: .userGroups, got: nil) + } + guard case .userGroups(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .userGroups, got: config) + } + + try groupSessionIds.forEach { groupId in + var cGroupId: [CChar] = try groupId.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() + var userGroup: ugroups_group_info = ugroups_group_info() + + guard user_groups_get_group(conf, &userGroup, &cGroupId) else { return } + + ugroups_group_set_invited(&userGroup) + user_groups_set_group(conf, &userGroup) + } + } + + func markAsKicked(groupSessionIds: [String]) throws { + guard let config: LibSession.Config = config(for: .userGroups, sessionId: userSessionId) else { + throw LibSessionError.invalidConfigObject(wanted: .userGroups, got: nil) + } + guard case .userGroups(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .userGroups, got: config) + } + + try groupSessionIds.forEach { groupId in + var cGroupId: [CChar] = try groupId.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() + var userGroup: ugroups_group_info = ugroups_group_info() + + guard user_groups_get_group(conf, &userGroup, &cGroupId) else { return } + + ugroups_group_set_kicked(&userGroup) + user_groups_set_group(conf, &userGroup) + } + } +} + +// MARK: - State Access + +public extension LibSession.Cache { + func hasCredentials(groupSessionId: SessionId) -> Bool { + var userGroup: ugroups_group_info = ugroups_group_info() + + /// If the group doesn't exist or a conversion fails then assume the user hasn't been kicked + guard + case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId), + var cGroupId: [CChar] = groupSessionId.hexString.cString(using: .utf8), + user_groups_get_group(conf, &userGroup, &cGroupId) + else { return false } + + return (userGroup.have_auth_data || userGroup.have_secretkey) + } + + func secretKey(groupSessionId: SessionId) -> [UInt8]? { + var userGroup: ugroups_group_info = ugroups_group_info() + + /// If the group doesn't exist or a conversion fails then assume the user hasn't been kicked + guard + case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId), + var cGroupId: [CChar] = groupSessionId.hexString.cString(using: .utf8), + user_groups_get_group(conf, &userGroup, &cGroupId), + userGroup.have_secretkey + else { return nil } + + return userGroup.get(\.secretkey, nullIfEmpty: true) + } + + func wasKickedFromGroup(groupSessionId: SessionId) -> Bool { + var userGroup: ugroups_group_info = ugroups_group_info() + + /// If the group doesn't exist or a conversion fails then assume the user hasn't been kicked + guard + case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId), + var cGroupId: [CChar] = groupSessionId.hexString.cString(using: .utf8), + user_groups_get_group(conf, &userGroup, &cGroupId) + else { return false } + + return ugroups_group_is_kicked(&userGroup) + } + + func groupIsDestroyed(groupSessionId: SessionId) -> Bool { + var userGroup: ugroups_group_info = ugroups_group_info() + + /// If the group doesn't exist or a conversion fails then assume the group hasn't been destroyed + guard + case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId), + var cGroupId: [CChar] = groupSessionId.hexString.cString(using: .utf8), + user_groups_get_group(conf, &userGroup, &cGroupId) + else { return false } + + return ugroups_group_is_destroyed(&userGroup) + } +} + // MARK: - Convenience public extension LibSession { typealias ExtractedUserGroups = ( - communities: [PrioritisedData], + communities: [CommunityInfo], legacyGroups: [LibSession.LegacyGroupInfo], groups: [LibSession.GroupInfo] ) @@ -966,7 +1052,7 @@ public extension LibSession { using dependencies: Dependencies ) throws -> ExtractedUserGroups { var infiniteLoopGuard: Int = 0 - var communities: [PrioritisedData] = [] + var communities: [CommunityInfo] = [] var legacyGroups: [LibSession.LegacyGroupInfo] = [] var groups: [LibSession.GroupInfo] = [] var community: ugroups_community_info = ugroups_community_info() @@ -982,13 +1068,11 @@ public extension LibSession { let roomToken: String = community.get(\.room) communities.append( - PrioritisedData( - data: LibSession.OpenGroupUrlInfo( - threadId: OpenGroup.idFor(roomToken: roomToken, server: server), - server: server, - roomToken: roomToken, - publicKey: community.getHex(\.pubkey) - ), + CommunityInfo( + threadId: OpenGroup.idFor(roomToken: roomToken, server: server), + server: server, + roomToken: roomToken, + publicKey: community.getHex(\.pubkey), priority: community.priority ) ) @@ -1049,6 +1133,18 @@ public extension LibSession { } } +// MARK: - CommunityInfo + +public extension LibSession { + struct CommunityInfo { + let threadId: String + let server: String + let roomToken: String + let publicKey: String + let priority: Int32 + } +} + // MARK: - LegacyGroupInfo public extension LibSession { @@ -1109,7 +1205,7 @@ public extension LibSession { let wasKickedFromGroup: Bool? let wasGroupDestroyed: Bool? - init( + public init( groupSessionId: String, groupIdentityPrivateKey: Data? = nil, name: String? = nil, @@ -1135,8 +1231,8 @@ public extension LibSession { // MARK: - CommunityInfo -extension LibSession { - struct CommunityInfo { +public extension LibSession { + struct CommunityUpdateInfo { let urlInfo: OpenGroupUrlInfo let priority: Int32? @@ -1150,13 +1246,6 @@ extension LibSession { } } -// MARK: - PrioritisedData - -public struct PrioritisedData { - let data: T - let priority: Int32 -} - // MARK: - C Conformance extension ugroups_community_info: CAccessible & CMutable {} diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index 723139a75a..5427623ede 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -10,8 +10,8 @@ import SessionUtilitiesKit internal extension LibSession { static let columnsRelatedToUserProfile: [Profile.Columns] = [ Profile.Columns.name, - Profile.Columns.profilePictureUrl, - Profile.Columns.profileEncryptionKey + Profile.Columns.displayPictureUrl, + Profile.Columns.displayPictureEncryptionKey ] static let syncedSettings: [String] = [ @@ -19,37 +19,38 @@ internal extension LibSession { ] } -// MARK: - LibSessionCacheType - -public extension LibSessionCacheType { - var userProfileDisplayName: String { - guard - case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId), - let profileNamePtr: UnsafePointer = user_profile_get_name(conf) - else { return "" } - - return String(cString: profileNamePtr) - } -} - // MARK: - Incoming Changes internal extension LibSessionCacheType { func handleUserProfileUpdate( - _ db: Database, + _ db: ObservingDatabase, in config: LibSession.Config?, + oldState: [ObservableKey: Any], serverTimestampMs: Int64 ) throws { guard configNeedsDump(config) else { return } - guard case .userProfile(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .userProfile(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .userProfile, got: config) + } // A profile must have a name so if this is null then it's invalid and can be ignored guard let profileNamePtr: UnsafePointer = user_profile_get_name(conf) else { return } - let userSessionId: SessionId = dependencies[cache: .general].sessionId let profileName: String = String(cString: profileNamePtr) - let profilePic: user_profile_pic = user_profile_get_pic(conf) - let profilePictureUrl: String? = profilePic.get(\.url, nullIfEmpty: true) + let displayPic: user_profile_pic = user_profile_get_pic(conf) + let displayPictureUrl: String? = displayPic.get(\.url, nullIfEmpty: true) + let updatedProfile: Profile = Profile( + id: userSessionId.hexString, + name: profileName, + displayPictureUrl: (oldState[.profile(userSessionId.hexString)] as? Profile)?.displayPictureUrl + ) + + if + let profile: Profile = oldState[.profile(userSessionId.hexString)] as? Profile, + profile != updatedProfile + { + db.addEvent(updatedProfile, forKey: .profile(updatedProfile.id)) + } // Handle user profile changes try Profile.updateIfNeeded( @@ -57,12 +58,16 @@ internal extension LibSessionCacheType { publicKey: userSessionId.hexString, displayNameUpdate: .currentUserUpdate(profileName), displayPictureUpdate: { - guard let profilePictureUrl: String = profilePictureUrl else { return .currentUserRemove } + guard + let displayPictureUrl: String = displayPictureUrl, + let filePath: String = try? dependencies[singleton: .displayPictureManager] + .path(for: displayPictureUrl) + else { return .currentUserRemove } return .currentUserUpdateTo( - url: profilePictureUrl, - key: profilePic.get(\.key), - fileName: nil + url: displayPictureUrl, + key: displayPic.get(\.key), + filePath: filePath ) }(), sentTimestamp: TimeInterval(Double(serverTimestampMs) / 1000), @@ -70,15 +75,15 @@ internal extension LibSessionCacheType { ) // Update the 'Note to Self' visibility and priority - let threadInfo: LibSession.PriorityVisibilityInfo? = try? SessionThread + let threadInfo: LibSession.ThreadUpdateInfo? = try? SessionThread .filter(id: userSessionId.hexString) - .select(.id, .variant, .pinnedPriority, .shouldBeVisible) - .asRequest(of: LibSession.PriorityVisibilityInfo.self) + .select(LibSession.ThreadUpdateInfo.threadColumns) + .asRequest(of: LibSession.ThreadUpdateInfo.self) .fetchOne(db) let targetPriority: Int32 = user_profile_get_nts_priority(conf) // Create the 'Note to Self' thread if it doesn't exist - if let threadInfo: LibSession.PriorityVisibilityInfo = threadInfo { + if let threadInfo: LibSession.ThreadUpdateInfo = threadInfo { let threadChanges: [ConfigColumnAssignment] = [ ((threadInfo.shouldBeVisible == LibSession.shouldBeVisible(priority: targetPriority)) ? nil : SessionThread.Columns.shouldBeVisible.set(to: LibSession.shouldBeVisible(priority: targetPriority)) @@ -97,6 +102,20 @@ internal extension LibSessionCacheType { using: dependencies ) } + + if threadInfo.pinnedPriority != targetPriority { + db.addConversationEvent( + id: userSessionId.hexString, + type: .updated(.pinnedPriority(targetPriority)) + ) + } + + if threadInfo.shouldBeVisible != LibSession.shouldBeVisible(priority: targetPriority) { + db.addConversationEvent( + id: userSessionId.hexString, + type: .updated(.shouldBeVisible(LibSession.shouldBeVisible(priority: targetPriority))) + ) + } } else { // If the 'Note to Self' conversation is hidden then we should trigger the proper @@ -146,16 +165,17 @@ internal extension LibSessionCacheType { using: dependencies ) } - - // Update settings if needed - let updatedAllowBlindedMessageRequests: Int32 = user_profile_get_blinded_msgreqs(conf) - let updatedAllowBlindedMessageRequestsBoolValue: Bool = (updatedAllowBlindedMessageRequests >= 1) + + // Notify of settings change if needed + let checkForCommunityMessageRequestsKey: ObservableKey = .setting(Setting.BoolKey.checkForCommunityMessageRequests) + let oldCheckForCommunityMessageRequests: Bool? = oldState[checkForCommunityMessageRequestsKey] as? Bool + let newCheckForCommunityMessageRequests: Bool = get(.checkForCommunityMessageRequests) if - updatedAllowBlindedMessageRequests >= 0 && - updatedAllowBlindedMessageRequestsBoolValue != db[.checkForCommunityMessageRequests] + oldCheckForCommunityMessageRequests != nil && + oldCheckForCommunityMessageRequests != newCheckForCommunityMessageRequests { - db[.checkForCommunityMessageRequests] = updatedAllowBlindedMessageRequestsBoolValue + db.addEvent(newCheckForCommunityMessageRequests, forKey: checkForCommunityMessageRequestsKey) } // Create a contact for the current user if needed (also force-approve the current user @@ -173,6 +193,10 @@ internal extension LibSessionCacheType { Contact.Columns.didApproveMe.set(to: true), using: dependencies ) + + db.addContactEvent(id: userSessionId.hexString, change: .isTrusted(true)) + db.addContactEvent(id: userSessionId.hexString, change: .isApproved(true)) + db.addContactEvent(id: userSessionId.hexString, change: .didApproveMe(true)) } } } @@ -180,45 +204,14 @@ internal extension LibSessionCacheType { // MARK: - Outgoing Changes internal extension LibSession { - static func update( - profile: Profile, - in config: Config? - ) throws { - try update( - profileInfo: ProfileInfo( - name: profile.name, - profilePictureUrl: profile.profilePictureUrl, - profileEncryptionKey: profile.profileEncryptionKey - ), - in: config - ) - } - - static func update( - profileInfo: ProfileInfo, - in config: Config? - ) throws { - guard case .userProfile(let conf) = config else { throw LibSessionError.invalidConfigObject } - - // Update the name - var cUpdatedName: [CChar] = try profileInfo.name.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() - user_profile_set_name(conf, &cUpdatedName) - try LibSessionError.throwIfNeeded(conf) - - // Either assign the updated profile pic, or sent a blank profile pic (to remove the current one) - var profilePic: user_profile_pic = user_profile_pic() - profilePic.set(\.url, to: profileInfo.profilePictureUrl) - profilePic.set(\.key, to: profileInfo.profileEncryptionKey) - user_profile_set_pic(conf, profilePic) - try LibSessionError.throwIfNeeded(conf) - } - static func updateNoteToSelf( priority: Int32? = nil, disappearingMessagesConfig: DisappearingMessagesConfiguration? = nil, in config: Config? ) throws { - guard case .userProfile(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .userProfile(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .userProfile, got: config) + } if let priority: Int32 = priority { user_profile_set_nts_priority(conf, priority) @@ -233,7 +226,9 @@ internal extension LibSession { checkForCommunityMessageRequests: Bool? = nil, in config: Config? ) throws { - guard case .userProfile(let conf) = config else { throw LibSessionError.invalidConfigObject } + guard case .userProfile(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .userProfile, got: config) + } if let blindedMessageRequests: Bool = checkForCommunityMessageRequests { user_profile_set_blinded_msgreqs(conf, (blindedMessageRequests ? 1 : 0)) @@ -245,7 +240,7 @@ internal extension LibSession { public extension LibSession { static func updateNoteToSelf( - _ db: Database, + _ db: ObservingDatabase, priority: Int32? = nil, disappearingMessagesConfig: DisappearingMessagesConfiguration? = nil, using dependencies: Dependencies @@ -262,13 +257,59 @@ public extension LibSession { } } -// MARK: - Direct Values +// MARK: - State Access -extension LibSession { - static func rawBlindedMessageRequestValue(in config: Config?) throws -> Int32 { - guard case .userProfile(let conf) = config else { throw LibSessionError.invalidConfigObject } +public extension LibSession.Cache { + var displayName: String? { + guard + case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId), + let profileNamePtr: UnsafePointer = user_profile_get_name(conf) + else { return nil } + + return String(cString: profileNamePtr) + } - return user_profile_get_blinded_msgreqs(conf) + @discardableResult func updateProfile( + displayName: String, + displayPictureUrl: String?, + displayPictureEncryptionKey: Data? + ) throws -> Profile? { + guard let config: LibSession.Config = config(for: .userProfile, sessionId: userSessionId) else { + throw LibSessionError.invalidConfigObject(wanted: .userProfile, got: nil) + } + guard case .userProfile(let conf) = config else { + throw LibSessionError.invalidConfigObject(wanted: .userProfile, got: config) + } + + // Get the old values to determine if something changed + let oldName: String? = user_profile_get_name(conf).map { String(cString: $0) } + let oldDisplayPic: user_profile_pic = user_profile_get_pic(conf) + let oldDisplayPictureUrl: String? = oldDisplayPic.get(\.url, nullIfEmpty: true) + + // Update the name + var cUpdatedName: [CChar] = try displayName.cString(using: .utf8) ?? { + throw LibSessionError.invalidCConversion + }() + user_profile_set_name(conf, &cUpdatedName) + try LibSessionError.throwIfNeeded(conf) + + // Either assign the updated profile pic, or sent a blank profile pic (to remove the current one) + var profilePic: user_profile_pic = user_profile_pic() + profilePic.set(\.url, to: displayPictureUrl) + profilePic.set(\.key, to: displayPictureEncryptionKey) + user_profile_set_pic(conf, profilePic) + try LibSessionError.throwIfNeeded(conf) + + /// Add a pending observation to notify any observers of the change once it's committed + if displayName != oldName || displayPictureUrl != oldDisplayPictureUrl { + return Profile( + id: userSessionId.hexString, + name: displayName, + displayPictureUrl: displayPictureUrl + ) + } + + return nil } } diff --git a/SessionMessagingKit/LibSession/Database/QueryInterfaceRequest+Utilities.swift b/SessionMessagingKit/LibSession/Database/QueryInterfaceRequest+Utilities.swift index efb9c13973..175562ba6c 100644 --- a/SessionMessagingKit/LibSession/Database/QueryInterfaceRequest+Utilities.swift +++ b/SessionMessagingKit/LibSession/Database/QueryInterfaceRequest+Utilities.swift @@ -35,7 +35,7 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table @discardableResult func updateAll( - _ db: Database, + _ db: ObservingDatabase, _ assignments: ConfigColumnAssignment... ) throws -> Int { return try updateAll(db, assignments) @@ -43,7 +43,7 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table @discardableResult func updateAll( - _ db: Database, + _ db: ObservingDatabase, _ assignments: [ConfigColumnAssignment] ) throws -> Int { return try self.updateAll(db, assignments.map { $0.assignment }) @@ -51,7 +51,7 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table @discardableResult func updateAllAndConfig( - _ db: Database, + _ db: ObservingDatabase, _ assignments: ConfigColumnAssignment..., using dependencies: Dependencies ) throws -> Int { @@ -64,7 +64,7 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table @discardableResult func updateAllAndConfig( - _ db: Database, + _ db: ObservingDatabase, _ assignments: [ConfigColumnAssignment], using dependencies: Dependencies ) throws -> Int { @@ -86,7 +86,7 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table @discardableResult func updateAndFetchAllAndUpdateConfig( - _ db: Database, + _ db: ObservingDatabase, _ assignments: ConfigColumnAssignment..., using dependencies: Dependencies ) throws -> [RowDecoder] { @@ -99,7 +99,7 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table @discardableResult func updateAndFetchAllAndUpdateConfig( - _ db: Database, + _ db: ObservingDatabase, _ assignments: [ConfigColumnAssignment], using dependencies: Dependencies ) throws -> [RowDecoder] { diff --git a/SessionMessagingKit/LibSession/Database/Setting+Utilities.swift b/SessionMessagingKit/LibSession/Database/Setting+Utilities.swift deleted file mode 100644 index 3d5614a810..0000000000 --- a/SessionMessagingKit/LibSession/Database/Setting+Utilities.swift +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import SessionUtilitiesKit - -public extension Database { - func setAndUpdateConfig( - _ key: Setting.BoolKey, - to newValue: Bool, - using dependencies: Dependencies - ) throws { - try updateConfigIfNeeded( - self, - key: key.rawValue, - updatedSetting: self.setting(key: key, to: newValue), - using: dependencies - ) - } - - func setAndUpdateConfig( - _ key: Setting.DoubleKey, - to newValue: Double?, - using dependencies: Dependencies - ) throws { - try updateConfigIfNeeded( - self, - key: key.rawValue, - updatedSetting: self.setting(key: key, to: newValue), - using: dependencies - ) - } - - func setAndUpdateConfig( - _ key: Setting.IntKey, - to newValue: Int?, - using dependencies: Dependencies - ) throws { - try updateConfigIfNeeded( - self, - key: key.rawValue, - updatedSetting: self.setting(key: key, to: newValue), - using: dependencies - ) - } - - func setAndUpdateConfig( - _ key: Setting.StringKey, - to newValue: String?, - using dependencies: Dependencies - ) throws { - try updateConfigIfNeeded( - self, - key: key.rawValue, - updatedSetting: self.setting(key: key, to: newValue), - using: dependencies - ) - } - - func setAndUpdateConfig( - _ key: Setting.EnumKey, - to newValue: T?, - using dependencies: Dependencies - ) throws { - try updateConfigIfNeeded( - self, - key: key.rawValue, - updatedSetting: self.setting(key: key, to: newValue), - using: dependencies - ) - } - - func setAndUpdateConfig( - _ key: Setting.EnumKey, - to newValue: T?, - using dependencies: Dependencies - ) throws { - try updateConfigIfNeeded( - self, - key: key.rawValue, - updatedSetting: self.setting(key: key, to: newValue), - using: dependencies - ) - } - - /// Value will be stored as a timestamp in seconds since 1970 - func setAndUpdateConfig( - _ key: Setting.DateKey, - to newValue: Date?, - using dependencies: Dependencies - ) throws { - try updateConfigIfNeeded( - self, - key: key.rawValue, - updatedSetting: self.setting(key: key, to: newValue), - using: dependencies - ) - } - - private func updateConfigIfNeeded( - _ db: Database, - key: String, - updatedSetting: Setting?, - using dependencies: Dependencies - ) throws { - // Before we do anything custom make sure the setting should trigger a change - guard LibSession.syncedSettings.contains(key) else { return } - - try LibSession.updatingSetting(db, updatedSetting, using: dependencies) - } -} diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index 7aa25e466d..943f683128 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -3,6 +3,7 @@ import Foundation import GRDB import SessionSnodeKit +import SessionUIKit import SessionUtil import SessionUtilitiesKit @@ -17,13 +18,6 @@ public extension Cache { ) } -// MARK: - LibSession - -public extension LibSession { - internal static func syncDedupeId(_ swarmPublicKey: String) -> String { - return "EnqueueConfigurationSyncJob-\(swarmPublicKey)" // stringlint:ignore - } -} // MARK: - Convenience @@ -124,9 +118,8 @@ private class ConfigStore { store.forEach { _, config in switch config { case .groupKeys: break // Shouldn't happen - case .userProfile(let conf), .contacts(let conf), - .convoInfoVolatile(let conf), .userGroups(let conf), - .groupInfo(let conf), .groupMembers(let conf): + case .userProfile(let conf), .contacts(let conf), .convoInfoVolatile(let conf), + .userGroups(let conf), .local(let conf), .groupInfo(let conf), .groupMembers(let conf): config_free(conf) } } @@ -179,18 +172,25 @@ private class BehaviourStore { ) } } - + // MARK: - SessionUtil Cache public extension LibSession { + typealias MergeResult = ( + sessionId: SessionId, + variant: ConfigDump.Variant, + dump: ConfigDump? + ) + enum CacheBehaviour { case skipAutomaticConfigSync case skipGroupAdminCheck } class Cache: LibSessionCacheType { - private var configStore: ConfigStore = ConfigStore() - private var behaviourStore: BehaviourStore = BehaviourStore() + private let configStore: ConfigStore = ConfigStore() + private let behaviourStore: BehaviourStore = BehaviourStore() + private var pendingEvents: [ObservedEvent] = [] public let dependencies: Dependencies public let userSessionId: SessionId @@ -205,13 +205,12 @@ public extension LibSession { // MARK: - State Management - public func loadState(_ db: Database, requestId: String?) { + public func loadState(_ db: ObservingDatabase, requestId: String?) { // Ensure we have the ed25519 key and that we haven't already loaded the state before // we continue - guard - configStore.isEmpty, - let ed25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) - else { return Log.warn(.libSession, "Ignoring loadState\(requestId.map { " for \($0)" } ?? "") due to existing state") } + guard configStore.isEmpty else { + return Log.warn(.libSession, "Ignoring loadState\(requestId.map { " for \($0)" } ?? "") due to existing state") + } /// Retrieve the existing dumps from the database typealias ConfigInfo = (sessionId: SessionId, variant: ConfigDump.Variant, dump: ConfigDump?) @@ -274,7 +273,7 @@ public extension LibSession { configStore[sessionId, variant] = try? loadState( for: variant, sessionId: sessionId, - userEd25519SecretKey: ed25519KeyPair.secretKey, + userEd25519SecretKey: dependencies[cache: .general].ed25519SecretKey, groupEd25519SecretKey: groupsByKey[sessionId.hexString]? .groupIdentityPrivateKey .map { Array($0) }, @@ -288,25 +287,27 @@ public extension LibSession { public func loadDefaultStateFor( variant: ConfigDump.Variant, sessionId: SessionId, - userEd25519KeyPair: KeyPair, + userEd25519SecretKey: [UInt8], groupEd25519SecretKey: [UInt8]? ) { configStore[sessionId, variant] = try? loadState( for: variant, sessionId: sessionId, - userEd25519SecretKey: userEd25519KeyPair.secretKey, + userEd25519SecretKey: userEd25519SecretKey, groupEd25519SecretKey: groupEd25519SecretKey, cachedData: nil ) } - internal func loadState( + @discardableResult public func loadState( for variant: ConfigDump.Variant, sessionId: SessionId, userEd25519SecretKey: [UInt8], groupEd25519SecretKey: [UInt8]?, cachedData: Data? - ) throws -> Config { + ) throws -> LibSession.Config { + guard userEd25519SecretKey.count >= 32 else { throw CryptoError.missingUserSecretKey } + var conf: UnsafeMutablePointer? = nil var keysConf: UnsafeMutablePointer? = nil var secretKey: [UInt8] = userEd25519SecretKey @@ -315,7 +316,8 @@ public extension LibSession { .userProfile: user_profile_init, .contacts: contacts_init, .convoInfoVolatile: convo_info_volatile_init, - .userGroups: user_groups_init + .userGroups: user_groups_init, + .local: local_init ] let groupConfigInitCalls: [ConfigDump.Variant: GroupConfigInitialiser] = [ .groupInfo: groups_info_init, @@ -328,7 +330,7 @@ public extension LibSession { throw LibSessionError.unableToCreateConfigObject(sessionId.hexString) .logging("Unable to create \(variant.rawValue) config object for: \(sessionId.hexString)") - case (.userProfile, _), (.contacts, _), (.convoInfoVolatile, _), (.userGroups, _): + case (.userProfile, _), (.contacts, _), (.convoInfoVolatile, _), (.userGroups, _), (.local, _): return try (userConfigInitCalls[variant]?( &conf, &secretKey, @@ -457,9 +459,9 @@ public extension LibSession { configs.forEach { config in switch config { case .groupKeys: break // Should be handled above - case .userProfile(let conf), .contacts(let conf), - .convoInfoVolatile(let conf), .userGroups(let conf), - .groupInfo(let conf), .groupMembers(let conf): + case .userProfile(let conf), .contacts(let conf), .convoInfoVolatile(let conf), + .userGroups(let conf), .local(let conf), .groupInfo(let conf), + .groupMembers(let conf): config_free(conf) } } @@ -488,7 +490,7 @@ public extension LibSession { // MARK: - Pushes - public func syncAllPendingChanges(_ db: Database) { + public func syncAllPendingPushes(_ db: ObservingDatabase) { configStore.swarmPublicKeys.forEach { swarmPublicKey in ConfigurationSyncJob.enqueue(db, swarmPublicKey: swarmPublicKey, using: dependencies) } @@ -506,7 +508,7 @@ public extension LibSession { } public func performAndPushChange( - _ db: Database, + _ db: ObservingDatabase, for variant: ConfigDump.Variant, sessionId: SessionId, change: (Config?) throws -> () @@ -525,50 +527,97 @@ public extension LibSession { default: break } - guard let config: Config = configStore[sessionId, variant] else { return } - do { + guard let config: Config = configStore[sessionId, variant] else { + throw LibSessionError.invalidConfigObject(wanted: variant, got: nil) + } + // Peform the change try change(config) + // Store the pending changes locally and clear them from the instance + let pendingEvents: [ObservedEvent] = self.pendingEvents + self.pendingEvents = [] + // If an error occurred during the change then actually throw it to prevent // any database change from completing try LibSessionError.throwIfNeeded(config) - - // Only create a config dump if we need to - if configNeedsDump(config) { - try createDump( - config: config, - for: variant, - sessionId: sessionId, - timestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - )?.upsert(db) - } + + // Create a mutation for the change and upsert it if needed + try Mutation( + config: config, + sessionId: sessionId, + skipAutomaticConfigSync: behaviourStore + .hasBehaviour(.skipAutomaticConfigSync, for: sessionId, variant), + pendingEvents: pendingEvents, + cache: self, + using: dependencies + ).upsert(db) } catch { Log.error(.libSession, "Failed to update/dump updated \(variant) config data due to error: \(error)") + self.pendingEvents = [] throw error } + } + + public func perform( + for variant: ConfigDump.Variant, + sessionId: SessionId, + change: (Config?) throws -> () + ) throws -> LibSession.Mutation { + // To prevent crashes by trying to make an invalid change due to incorrect state being + // provided by a client, if we want to change one of the group configs then check if we + // are a group admin first + switch variant { + case .groupInfo, .groupMembers, .groupKeys: + guard + behaviourStore.hasBehaviour(.skipGroupAdminCheck, for: sessionId, variant) || + isAdmin(groupSessionId: sessionId) + else { throw LibSessionError.attemptedToModifyGroupWithoutAdminKey.logging(as: .critical) } + + + default: break + } - // Make sure we need a push and enquing config syncs aren't blocked before scheduling one - guard - config.needsPush && - !behaviourStore.hasBehaviour(.skipAutomaticConfigSync, for: sessionId, variant) - else { return } - - db.afterNextTransactionNestedOnce(dedupeId: LibSession.syncDedupeId(sessionId.hexString), using: dependencies) { [dependencies] db in - ConfigurationSyncJob.enqueue(db, swarmPublicKey: sessionId.hexString, using: dependencies) + do { + guard let config: Config = configStore[sessionId, variant] else { + throw LibSessionError.invalidConfigObject(wanted: variant, got: nil) + } + + // Peform the change + try change(config) + + // Store the pending changes locally and clear them from the instance + let pendingEvents: [ObservedEvent] = self.pendingEvents + self.pendingEvents = [] + + // If an error occurred during the change then actually throw it to prevent + // any database change from completing + try LibSessionError.throwIfNeeded(config) + + // Create a mutation for the change + return try Mutation( + config: config, + sessionId: sessionId, + skipAutomaticConfigSync: behaviourStore + .hasBehaviour(.skipAutomaticConfigSync, for: sessionId, variant), + pendingEvents: pendingEvents, + cache: self, + using: dependencies + ) + } + catch { + Log.error(.libSession, "Failed to update/dump updated \(variant) config data due to error: \(error)") + self.pendingEvents = [] + throw error } } - public func pendingChanges( - _ db: Database, - swarmPublicKey: String - ) throws -> PendingChanges { - guard Identity.userExists(db, using: dependencies) else { throw LibSessionError.userDoesNotExist } + public func pendingPushes(swarmPublicKey: String) throws -> PendingPushes { + guard dependencies[cache: .general].userExists else { throw LibSessionError.userDoesNotExist } // Get a list of the different config variants for the provided publicKey - let userSessionId: SessionId = dependencies[cache: .general].sessionId let targetSessionId: SessionId = try SessionId(from: swarmPublicKey) let targetVariants: [(sessionId: SessionId, variant: ConfigDump.Variant)] = { switch (swarmPublicKey, targetSessionId) { @@ -591,23 +640,19 @@ public extension LibSession { .sorted { (lhs: (SessionId, ConfigDump.Variant), rhs: (SessionId, ConfigDump.Variant)) in lhs.1.sendOrder < rhs.1.sendOrder } - .reduce(into: PendingChanges()) { result, info in + .reduce(into: PendingPushes()) { result, info in guard let config: Config = configStore[info.sessionId, info.variant] else { return } - // Add any obsolete hashes to be removed (want to do this even if there isn't a pending push - // to ensure we clean things up) - result.append(hashes: config.obsoleteHashes()) - - // Only generate the push data if we need to do a push + /// Only generate the push data if we need to do a push guard config.needsPush else { return } - // Try to generate the push data (will throw if there is an error) - try result.append(data: config.push(variant: info.variant)) + /// Try to generate the push data (will throw if there is an error) + try result.append(config.push(variant: info.variant)) } } public func createDumpMarkingAsPushed( - data: [(pushData: PendingChanges.PushData, hash: String?)], + data: [(pushData: PendingPushes.PushData, hash: String?)], sentTimestamp: Int64, swarmPublicKey: String ) throws -> [ConfigDump] { @@ -644,14 +689,17 @@ public extension LibSession { } } + public func addEvent(_ event: ObservedEvent) { + pendingEvents.append(event) + } + // MARK: - Config Message Handling public func configNeedsDump(_ config: LibSession.Config?) -> Bool { switch config { case .none: return false - case .userProfile(let conf), .contacts(let conf), - .convoInfoVolatile(let conf), .userGroups(let conf), - .groupInfo(let conf), .groupMembers(let conf): + case .userProfile(let conf), .contacts(let conf), .convoInfoVolatile(let conf), + .userGroups(let conf), .local(let conf), .groupInfo(let conf), .groupMembers(let conf): return config_needs_dump(conf) case .groupKeys(let conf, _, _): return groups_keys_needs_dump(conf) } @@ -667,122 +715,195 @@ public extension LibSession { .reduce([], +) } - public func handleConfigMessages( - _ db: Database, + public func mergeConfigMessages( swarmPublicKey: String, - messages: [ConfigMessageReceiveJob.Details.MessageInfo] - ) throws { - guard !messages.isEmpty else { return } + messages: [ConfigMessageReceiveJob.Details.MessageInfo], + afterMerge: (SessionId, ConfigDump.Variant, LibSession.Config?, Int64, [ObservableKey: Any]) throws -> ConfigDump? + ) throws -> [MergeResult] { + guard !messages.isEmpty else { return [] } guard !swarmPublicKey.isEmpty else { throw MessageReceiverError.noThread } let groupedMessages: [ConfigDump.Variant: [ConfigMessageReceiveJob.Details.MessageInfo]] = messages .grouped(by: { ConfigDump.Variant(namespace: $0.namespace) }) - try groupedMessages + return try groupedMessages .sorted { lhs, rhs in lhs.key.namespace.processingOrder < rhs.key.namespace.processingOrder } - .forEach { variant, messages in + .compactMap { variant, messages -> MergeResult? in let sessionId: SessionId = SessionId(hex: swarmPublicKey, dumpVariant: variant) let config: Config? = configStore[sessionId, variant] do { + let oldState: [ObservableKey: Any] = try { + switch config { + case .userProfile: + return [ + .profile(profile.id): profile, + .setting(Setting.BoolKey.checkForCommunityMessageRequests): get(.checkForCommunityMessageRequests) + ] + + case .contacts(let conf): + return try LibSession + .extractContacts(from: conf, serverTimestampMs: -1, using: dependencies) + .reduce(into: [:]) { result, next in + result[.contact(next.key)] = next.value.contact + result[.profile(next.key)] = next.value.profile + } + + default: return [:] + } + }() + // Merge the messages (if it doesn't merge anything then don't bother trying // to handle the result) - guard let latestServerTimestampMs: Int64 = try config?.merge(messages) else { return } - - // Apply the updated states to the database - switch variant { - case .userProfile: - try handleUserProfileUpdate( - db, - in: config, - serverTimestampMs: latestServerTimestampMs - ) - - case .contacts: - try handleContactsUpdate( - db, - in: config, - serverTimestampMs: latestServerTimestampMs - ) - - case .convoInfoVolatile: - try handleConvoInfoVolatileUpdate( - db, - in: config - ) - - case .userGroups: - try handleUserGroupsUpdate( - db, - in: config, - serverTimestampMs: latestServerTimestampMs - ) - - case .groupInfo: - try handleGroupInfoUpdate( - db, - in: config, - groupSessionId: sessionId, - serverTimestampMs: latestServerTimestampMs - ) - - case .groupMembers: - try handleGroupMembersUpdate( - db, - in: config, - groupSessionId: sessionId, - serverTimestampMs: latestServerTimestampMs - ) - - case .groupKeys: - try handleGroupKeysUpdate( - db, - in: config, - groupSessionId: sessionId - ) - - case .invalid: Log.error(.libSession, "Failed to process merge of invalid config namespace") + Log.info(.libSession, "Attempting to merge \(variant) config messages") + guard let latestServerTimestampMs: Int64 = try config?.merge(messages) else { + return nil } - // Need to check if the config needs to be dumped (this might have changed - // after handling the merge changes) - guard configNeedsDump(config) else { - try ConfigDump - .filter( - ConfigDump.Columns.variant == variant && - ConfigDump.Columns.publicKey == sessionId.hexString - ) - .updateAll( - db, - ConfigDump.Columns.timestampMs.set(to: latestServerTimestampMs) - ) - - return - } + // Now that the config message has been merged, run any after-merge logic + let dump: ConfigDump? = try afterMerge( + sessionId, + variant, + config, + latestServerTimestampMs, + oldState + ) - try createDump( - config: config, - for: variant, - sessionId: sessionId, - timestampMs: latestServerTimestampMs - )?.upsert(db) + return (sessionId, variant, dump) } catch { Log.error(.libSession, "Failed to process merge of \(variant) config data") throw error } } + } + + public func handleConfigMessages( + _ db: ObservingDatabase, + swarmPublicKey: String, + messages: [ConfigMessageReceiveJob.Details.MessageInfo] + ) throws { + let results: [MergeResult] = try mergeConfigMessages( + swarmPublicKey: swarmPublicKey, + messages: messages + ) { sessionId, variant, config, latestServerTimestampMs, oldState in + // Apply the updated states to the database + switch variant { + case .userProfile: + try handleUserProfileUpdate( + db, + in: config, + oldState: oldState, + serverTimestampMs: latestServerTimestampMs + ) + + case .contacts: + try handleContactsUpdate( + db, + in: config, + oldState: oldState, + serverTimestampMs: latestServerTimestampMs + ) + + case .convoInfoVolatile: + try handleConvoInfoVolatileUpdate( + db, + in: config + ) + + case .userGroups: + try handleUserGroupsUpdate( + db, + in: config, + serverTimestampMs: latestServerTimestampMs + ) + + case .groupInfo: + try handleGroupInfoUpdate( + db, + in: config, + groupSessionId: sessionId, + serverTimestampMs: latestServerTimestampMs + ) + + case .groupMembers: + try handleGroupMembersUpdate( + db, + in: config, + groupSessionId: sessionId, + serverTimestampMs: latestServerTimestampMs + ) + + case .groupKeys: + try handleGroupKeysUpdate( + db, + in: config, + groupSessionId: sessionId + ) + + case .local: Log.error(.libSession, "Tried to process merge of local config") + case .invalid: Log.error(.libSession, "Failed to process merge of invalid config namespace") + } + + // Need to check if the config needs to be dumped (this might have changed + // after handling the merge changes) + guard configNeedsDump(config) else { + try ConfigDump + .filter( + ConfigDump.Columns.variant == variant && + ConfigDump.Columns.publicKey == sessionId.hexString + ) + .updateAll( + db, + ConfigDump.Columns.timestampMs.set(to: latestServerTimestampMs) + ) + return nil + } + + let dump: ConfigDump? = try createDump( + config: config, + for: variant, + sessionId: sessionId, + timestampMs: latestServerTimestampMs + ) + try dump?.upsert(db) + + return dump + } + + let needsPush: Bool = (try? SessionId(from: swarmPublicKey)).map { + configStore[$0].contains(where: { $0.needsPush }) && + !behaviourStore.hasBehaviour(.skipAutomaticConfigSync, for: $0) + }.defaulting(to: false) - // Now that the local state has been updated, schedule a config sync if needed (this will - // push any pending updates and properly update the state) + /// If we don't need to push and there were no merge results then no need to do anything else guard - let sessionId: SessionId = try? SessionId(from: swarmPublicKey), - configStore[sessionId].contains(where: { $0.needsPush }) && - !behaviourStore.hasBehaviour(.skipAutomaticConfigSync, for: sessionId) + needsPush || + results.contains(where: { $0.dump != nil }) else { return } - db.afterNextTransactionNestedOnce(dedupeId: LibSession.syncDedupeId(swarmPublicKey), using: dependencies) { [dependencies] db in - ConfigurationSyncJob.enqueue(db, swarmPublicKey: swarmPublicKey, using: dependencies) + db.afterCommit { [dependencies] in + if needsPush { + dependencies[singleton: .storage].writeAsync { db in + ConfigurationSyncJob.enqueue(db, swarmPublicKey: swarmPublicKey, using: dependencies) + } + } + + Task.detached(priority: .medium) { [dependencies] in + /// Replicate any dumps + for result in results { + switch result.dump { + case .some(let dump): + dependencies[singleton: .extensionHelper].replicate(dump: dump) + + case .none: + dependencies[singleton: .extensionHelper].refreshDumpModifiedDate( + sessionId: result.sessionId, + variant: result.variant + ) + } + } + } } } @@ -802,85 +923,6 @@ public extension LibSession { _ = try configStore[sessionId, variant]?.merge(message) } } - - // MARK: - Value Access - - public func pinnedPriority( - _ db: Database, - threadId: String, - threadVariant: SessionThread.Variant - ) -> Int32? { - let userSessionId: SessionId = dependencies[cache: .general].sessionId - - switch threadVariant { - case .contact where threadId == userSessionId.hexString: - return configStore[userSessionId, .userProfile]?.pinnedPriority( - db, - threadId: threadId, - threadVariant: threadVariant - ) - - case .contact: - return configStore[userSessionId, .contacts]?.pinnedPriority( - db, - threadId: threadId, - threadVariant: threadVariant - ) - - case .community, .group, .legacyGroup: - return configStore[userSessionId, .userGroups]?.pinnedPriority( - db, - threadId: threadId, - threadVariant: threadVariant - ) - } - } - - public func disappearingMessagesConfig( - threadId: String, - threadVariant: SessionThread.Variant - ) -> DisappearingMessagesConfiguration? { - let userSessionId: SessionId = dependencies[cache: .general].sessionId - - switch threadVariant { - case .contact where threadId == userSessionId.hexString: - return configStore[userSessionId, .userProfile]?.disappearingMessagesConfig( - threadId: threadId, - threadVariant: threadVariant - ) - - case .contact: - return configStore[userSessionId, .contacts]?.disappearingMessagesConfig( - threadId: threadId, - threadVariant: threadVariant - ) - - case .community, .legacyGroup: - return configStore[userSessionId, .userGroups]?.disappearingMessagesConfig( - threadId: threadId, - threadVariant: threadVariant - ) - - case .group: - guard - let groupSessionId: SessionId = try? SessionId(from: threadId), - groupSessionId.prefix == .group - else { return nil } - - return configStore[groupSessionId, .groupInfo]?.disappearingMessagesConfig( - threadId: threadId, - threadVariant: threadVariant - ) - } - } - - public func isAdmin(groupSessionId: SessionId) -> Bool { - guard let config: LibSession.Config = configStore[groupSessionId, .groupKeys] else { - return false - } - - return config.isAdmin() - } } } @@ -896,20 +938,27 @@ public protocol LibSessionImmutableCacheType: ImmutableCacheType { /// The majority `libSession` functions can only be accessed via the mutable cache because `libSession` isn't thread safe so if we try /// to read/write values while another thread is touching the same data then the app can crash due to bad memory issues -public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheType { +public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheType, ValueFetcher { var dependencies: Dependencies { get } var userSessionId: SessionId { get } var isEmpty: Bool { get } // MARK: - State Management - func loadState(_ db: Database, requestId: String?) + func loadState(_ db: ObservingDatabase, requestId: String?) func loadDefaultStateFor( variant: ConfigDump.Variant, sessionId: SessionId, - userEd25519KeyPair: KeyPair, + userEd25519SecretKey: [UInt8], groupEd25519SecretKey: [UInt8]? ) + @discardableResult func loadState( + for variant: ConfigDump.Variant, + sessionId: SessionId, + userEd25519SecretKey: [UInt8], + groupEd25519SecretKey: [UInt8]?, + cachedData: Data? + ) throws -> LibSession.Config func hasConfig(for variant: ConfigDump.Variant, sessionId: SessionId) -> Bool func config(for variant: ConfigDump.Variant, sessionId: SessionId) -> LibSession.Config? func setConfig(for variant: ConfigDump.Variant, sessionId: SessionId, to config: LibSession.Config) @@ -923,7 +972,7 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT // MARK: - Pushes - func syncAllPendingChanges(_ db: Database) + func syncAllPendingPushes(_ db: ObservingDatabase) func withCustomBehaviour( _ behaviour: LibSession.CacheBehaviour, for sessionId: SessionId, @@ -931,25 +980,36 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT change: @escaping () throws -> () ) throws func performAndPushChange( - _ db: Database, + _ db: ObservingDatabase, for variant: ConfigDump.Variant, sessionId: SessionId, change: @escaping (LibSession.Config?) throws -> () ) throws - func pendingChanges(_ db: Database, swarmPublicKey: String) throws -> LibSession.PendingChanges + func perform( + for variant: ConfigDump.Variant, + sessionId: SessionId, + change: @escaping (LibSession.Config?) throws -> () + ) throws -> LibSession.Mutation + func pendingPushes(swarmPublicKey: String) throws -> LibSession.PendingPushes func createDumpMarkingAsPushed( - data: [(pushData: LibSession.PendingChanges.PushData, hash: String?)], + data: [(pushData: LibSession.PendingPushes.PushData, hash: String?)], sentTimestamp: Int64, swarmPublicKey: String ) throws -> [ConfigDump] + func addEvent(_ event: ObservedEvent) // MARK: - Config Message Handling func configNeedsDump(_ config: LibSession.Config?) -> Bool func activeHashes(for swarmPublicKey: String) -> [String] + func mergeConfigMessages( + swarmPublicKey: String, + messages: [ConfigMessageReceiveJob.Details.MessageInfo], + afterMerge: (SessionId, ConfigDump.Variant, LibSession.Config?, Int64, [ObservableKey: Any]) throws -> ConfigDump? + ) throws -> [LibSession.MergeResult] func handleConfigMessages( - _ db: Database, + _ db: ObservingDatabase, swarmPublicKey: String, messages: [ConfigMessageReceiveJob.Details.MessageInfo] ) throws @@ -963,18 +1023,90 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT messages: [ConfigMessageReceiveJob.Details.MessageInfo] ) throws - // MARK: - Value Access + // MARK: - SettingFetcher - func pinnedPriority( - _ db: Database, + func has(_ key: Setting.BoolKey) -> Bool + func has(_ key: Setting.EnumKey) -> Bool + func get(_ key: Setting.BoolKey) -> Bool + func get(_ key: Setting.EnumKey) -> T? + + // MARK: - State Access + + func set(_ key: Setting.BoolKey, _ value: Bool?) + func set(_ key: Setting.EnumKey, _ value: T?) + + var displayName: String? { get } + @discardableResult func updateProfile( + displayName: String, + displayPictureUrl: String?, + displayPictureEncryptionKey: Data? + ) throws -> Profile? + + func canPerformChange( + threadId: String, + threadVariant: SessionThread.Variant, + changeTimestampMs: Int64 + ) -> Bool + func conversationInConfig( + threadId: String, + threadVariant: SessionThread.Variant, + visibleOnly: Bool, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Bool + func conversationDisplayName( + threadId: String, + threadVariant: SessionThread.Variant, + contactProfile: Profile?, + visibleMessage: VisibleMessage?, + openGroupName: String?, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> String + func conversationLastRead( + threadId: String, + threadVariant: SessionThread.Variant, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Int64? + + /// Returns whether the specified conversation is a message request + /// + /// **Note:** Defaults to `true` on failure + func isMessageRequest( threadId: String, threadVariant: SessionThread.Variant - ) -> Int32? + ) -> Bool + func pinnedPriority( + threadId: String, + threadVariant: SessionThread.Variant, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Int32 func disappearingMessagesConfig( threadId: String, threadVariant: SessionThread.Variant ) -> DisappearingMessagesConfiguration? + + func isContactBlocked(contactId: String) -> Bool + func profile( + contactId: String, + threadId: String?, + threadVariant: SessionThread.Variant?, + visibleMessage: VisibleMessage? + ) -> Profile? + func displayPictureUrl(threadId: String, threadVariant: SessionThread.Variant) -> String? + + func hasCredentials(groupSessionId: SessionId) -> Bool + func secretKey(groupSessionId: SessionId) -> [UInt8]? func isAdmin(groupSessionId: SessionId) -> Bool + func loadAdminKey( + groupIdentitySeed: Data, + groupSessionId: SessionId + ) throws + func markAsInvited(groupSessionIds: [String]) throws + func markAsKicked(groupSessionIds: [String]) throws + func wasKickedFromGroup(groupSessionId: SessionId) -> Bool + func groupName(groupSessionId: SessionId) -> String? + func groupIsDestroyed(groupSessionId: SessionId) -> Bool + func groupDeleteBefore(groupSessionId: SessionId) -> TimeInterval? + func groupDeleteAttachmentsBefore(groupSessionId: SessionId) -> TimeInterval? } public extension LibSessionCacheType { @@ -982,9 +1114,73 @@ public extension LibSessionCacheType { try withCustomBehaviour(behaviour, for: sessionId, variant: nil, change: change) } - func loadState(_ db: Database) { + func performAndPushChange( + _ db: ObservingDatabase, + for variant: ConfigDump.Variant, + sessionId: SessionId, + change: @escaping () throws -> () + ) throws { + try performAndPushChange(db, for: variant, sessionId: sessionId, change: { _ in try change() }) + } + + func performAndPushChange( + _ db: ObservingDatabase, + for variant: ConfigDump.Variant, + change: @escaping (LibSession.Config?) throws -> () + ) throws { + guard ConfigDump.Variant.userVariants.contains(variant) else { throw LibSessionError.invalidConfigAccess } + + try performAndPushChange(db, for: variant, sessionId: userSessionId, change: change) + } + + func performAndPushChange( + _ db: ObservingDatabase, + for variant: ConfigDump.Variant, + change: @escaping () throws -> () + ) throws { + guard ConfigDump.Variant.userVariants.contains(variant) else { throw LibSessionError.invalidConfigAccess } + + try performAndPushChange(db, for: variant, sessionId: userSessionId, change: { _ in try change() }) + } + + func perform( + for variant: ConfigDump.Variant, + change: @escaping (LibSession.Config?) throws -> () + ) throws -> LibSession.Mutation { + guard ConfigDump.Variant.userVariants.contains(variant) else { throw LibSessionError.invalidConfigAccess } + + return try perform(for: variant, sessionId: userSessionId, change: change) + } + + func perform( + for variant: ConfigDump.Variant, + change: @escaping () throws -> () + ) throws -> LibSession.Mutation { + guard ConfigDump.Variant.userVariants.contains(variant) else { throw LibSessionError.invalidConfigAccess } + + 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?) { + addEvent(ObservedEvent(key: key, value: value)) + } + + func addEvent(key: Setting.BoolKey, value: AnyHashable?) { + addEvent(ObservedEvent(key: .setting(key), value: value)) + } + + func addEvent(key: Setting.EnumKey, value: AnyHashable?) { + addEvent(ObservedEvent(key: .setting(key), value: value)) + } + + @discardableResult func updateProfile(displayName: String) throws -> Profile? { + return try updateProfile(displayName: displayName, displayPictureUrl: nil, displayPictureEncryptionKey: nil) + } } private final class NoopLibSessionCache: LibSessionCacheType { @@ -998,13 +1194,24 @@ private final class NoopLibSessionCache: LibSessionCacheType { // MARK: - State Management - func loadState(_ db: Database, requestId: String?) {} + func loadState(_ db: ObservingDatabase, requestId: String?) {} func loadDefaultStateFor( variant: ConfigDump.Variant, sessionId: SessionId, - userEd25519KeyPair: KeyPair, + userEd25519SecretKey: [UInt8], groupEd25519SecretKey: [UInt8]? ) {} + @discardableResult func loadState( + for variant: ConfigDump.Variant, + sessionId: SessionId, + userEd25519SecretKey: [UInt8], + groupEd25519SecretKey: [UInt8]?, + cachedData: Data? + ) throws -> LibSession.Config { throw LibSessionError.invalidConfigObject(wanted: .invalid, got: nil) } + func loadAdminKey( + groupIdentitySeed: Data, + groupSessionId: SessionId + ) throws {} func hasConfig(for variant: ConfigDump.Variant, sessionId: SessionId) -> Bool { return false } func config(for variant: ConfigDump.Variant, sessionId: SessionId) -> LibSession.Config? { return nil } func setConfig(for variant: ConfigDump.Variant, sessionId: SessionId, to config: LibSession.Config) {} @@ -1020,7 +1227,7 @@ private final class NoopLibSessionCache: LibSessionCacheType { // MARK: - Pushes - func syncAllPendingChanges(_ db: Database) {} + func syncAllPendingPushes(_ db: ObservingDatabase) {} func withCustomBehaviour( _ behaviour: LibSession.CacheBehaviour, for sessionId: SessionId, @@ -1028,30 +1235,50 @@ private final class NoopLibSessionCache: LibSessionCacheType { change: @escaping () throws -> () ) throws {} func performAndPushChange( - _ db: Database, + _ db: ObservingDatabase, for variant: ConfigDump.Variant, sessionId: SessionId, change: (LibSession.Config?) throws -> () ) throws {} + func perform( + for variant: ConfigDump.Variant, + sessionId: SessionId, + change: (LibSession.Config?) throws -> () + ) throws -> LibSession.Mutation { + return try LibSession.Mutation( + config: nil, + sessionId: .invalid, + skipAutomaticConfigSync: false, + pendingEvents: [], + cache: self, + using: dependencies + ) + } - func pendingChanges(_ db: Database, swarmPublicKey: String) throws -> LibSession.PendingChanges { - return LibSession.PendingChanges() + func pendingPushes(swarmPublicKey: String) throws -> LibSession.PendingPushes { + return LibSession.PendingPushes() } func createDumpMarkingAsPushed( - data: [(pushData: LibSession.PendingChanges.PushData, hash: String?)], + data: [(pushData: LibSession.PendingPushes.PushData, hash: String?)], sentTimestamp: Int64, swarmPublicKey: String ) throws -> [ConfigDump] { return [] } + func addEvent(_ event: ObservedEvent) {} // MARK: - Config Message Handling func configNeedsDump(_ config: LibSession.Config?) -> Bool { return false } func activeHashes(for swarmPublicKey: String) -> [String] { return [] } + func mergeConfigMessages( + swarmPublicKey: String, + messages: [ConfigMessageReceiveJob.Details.MessageInfo], + afterMerge: (SessionId, ConfigDump.Variant, LibSession.Config?, Int64, [ObservableKey: Any]) throws -> ConfigDump? + ) throws -> [LibSession.MergeResult] { return [] } func handleConfigMessages( - _ db: Database, + _ db: ObservingDatabase, swarmPublicKey: String, messages: [ConfigMessageReceiveJob.Details.MessageInfo] ) throws {} @@ -1060,18 +1287,85 @@ private final class NoopLibSessionCache: LibSessionCacheType { messages: [ConfigMessageReceiveJob.Details.MessageInfo] ) throws {} - // MARK: - Value Access + // MARK: - SettingFetcher - func pinnedPriority( - _ db: Database, + func has(_ key: Setting.BoolKey) -> Bool { return false } + func has(_ key: Setting.EnumKey) -> Bool { return false } + func get(_ key: Setting.BoolKey) -> Bool { return false } + func get(_ key: Setting.EnumKey) -> T? { return nil } + + // MARK: - State Access + + var displayName: String? { return nil } + + func set(_ key: Setting.BoolKey, _ value: Bool?) {} + func set(_ key: Setting.EnumKey, _ value: T?) {} + @discardableResult func updateProfile( + displayName: String, + displayPictureUrl: String?, + displayPictureEncryptionKey: Data? + ) throws -> Profile? { return nil } + + func canPerformChange( + threadId: String, + threadVariant: SessionThread.Variant, + changeTimestampMs: Int64 + ) -> Bool { return false } + func conversationInConfig( + threadId: String, + threadVariant: SessionThread.Variant, + visibleOnly: Bool, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Bool { return false } + func conversationDisplayName( + threadId: String, + threadVariant: SessionThread.Variant, + contactProfile: Profile?, + visibleMessage: VisibleMessage?, + openGroupName: String?, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> String { return "" } + func conversationLastRead( + threadId: String, + threadVariant: SessionThread.Variant, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Int64? { return nil } + + func isMessageRequest( threadId: String, threadVariant: SessionThread.Variant - ) -> Int32? { return nil } + ) -> Bool { return false } + func pinnedPriority( + threadId: String, + threadVariant: SessionThread.Variant, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Int32 { return LibSession.defaultNewThreadPriority } func disappearingMessagesConfig( threadId: String, threadVariant: SessionThread.Variant ) -> DisappearingMessagesConfiguration? { return nil } + + func isContactBlocked(contactId: String) -> Bool { return false } + func profile( + contactId: String, + threadId: String?, + threadVariant: SessionThread.Variant?, + visibleMessage: VisibleMessage? + ) -> Profile? { return nil } + func displayPictureUrl(threadId: String, threadVariant: SessionThread.Variant) -> String? { + return nil + } + + func hasCredentials(groupSessionId: SessionId) -> Bool { return false } + func secretKey(groupSessionId: SessionId) -> [UInt8]? { return nil } func isAdmin(groupSessionId: SessionId) -> Bool { return false } + func markAsInvited(groupSessionIds: [String]) throws {} + func markAsKicked(groupSessionIds: [String]) throws {} + func wasKickedFromGroup(groupSessionId: SessionId) -> Bool { return false } + func groupName(groupSessionId: SessionId) -> String? { return nil } + func groupIsDestroyed(groupSessionId: SessionId) -> Bool { return false } + func groupDeleteBefore(groupSessionId: SessionId) -> TimeInterval? { return nil } + func groupDeleteAttachmentsBefore(groupSessionId: SessionId) -> TimeInterval? { return nil } } // MARK: - Convenience @@ -1093,6 +1387,7 @@ private extension Optional where Wrapped == Int32 { case .contacts: return .contacts(conf) case .convoInfoVolatile: return .convoInfoVolatile(conf) case .userGroups: return .userGroups(conf) + case .local: return .local(conf) case .groupInfo: return .groupInfo(conf) case .groupMembers: return .groupMembers(conf) @@ -1126,7 +1421,7 @@ private extension SessionId { init(hex: String, dumpVariant: ConfigDump.Variant) { switch (try? SessionId(from: hex), dumpVariant) { case (.some(let sessionId), _): self = sessionId - case (_, .userProfile), (_, .contacts), (_, .convoInfoVolatile), (_, .userGroups): + case (_, .userProfile), (_, .contacts), (_, .convoInfoVolatile), (_, .userGroups), (_, .local): self = SessionId(.standard, hex: hex) case (_, .groupInfo), (_, .groupMembers), (_, .groupKeys): @@ -1136,3 +1431,10 @@ private extension SessionId { } } } + +public extension LibSessionError { + // stringlint:ignore_contents + static func invalidConfigObject(wanted: ConfigDump.Variant, got other: LibSession.Config?) -> Error { + return LibSessionError.invalidConfigObject(wanted.rawValue, (other?.variant.rawValue ?? "null")) + } +} diff --git a/SessionMessagingKit/LibSession/Types/Config.swift b/SessionMessagingKit/LibSession/Types/Config.swift index 7eb0816610..52c8057ef5 100644 --- a/SessionMessagingKit/LibSession/Types/Config.swift +++ b/SessionMessagingKit/LibSession/Types/Config.swift @@ -32,6 +32,7 @@ public extension LibSession { case contacts(UnsafeMutablePointer) case convoInfoVolatile(UnsafeMutablePointer) case userGroups(UnsafeMutablePointer) + case local(UnsafeMutablePointer) case groupInfo(UnsafeMutablePointer) case groupMembers(UnsafeMutablePointer) @@ -49,6 +50,7 @@ public extension LibSession { case .contacts: return .contacts case .convoInfoVolatile: return .convoInfoVolatile case .userGroups: return .userGroups + case .local: return .local case .groupInfo: return .groupInfo case .groupMembers: return .groupMembers @@ -62,6 +64,7 @@ public extension LibSession { case .contacts(let conf): return contacts_size(conf) case .convoInfoVolatile(let conf): return convo_info_volatile_size(conf) case .userGroups(let conf): return user_groups_size(conf) + case .local(let conf): return local_size_settings(conf) case .groupInfo: return 1 case .groupMembers(let conf): return groups_members_size(conf) @@ -75,6 +78,7 @@ public extension LibSession { case .contacts: return "\(count) contacts" case .userGroups: return "\(count) group conversations" case .convoInfoVolatile: return "\(count) volatile conversations" + case .local: return "\(count) settings" case .groupInfo: return "\(count) group info" case .groupMembers: return "\(count) group members" @@ -84,9 +88,8 @@ public extension LibSession { var needsPush: Bool { switch self { - case .userProfile(let conf), .contacts(let conf), - .convoInfoVolatile(let conf), .userGroups(let conf), - .groupInfo(let conf), .groupMembers(let conf): + case .userProfile(let conf), .contacts(let conf), .convoInfoVolatile(let conf), + .userGroups(let conf), .local(let conf), .groupInfo(let conf), .groupMembers(let conf): return config_needs_push(conf) case .groupKeys(let conf, _, _): @@ -99,9 +102,8 @@ public extension LibSession { private var lastErrorString: String? { switch self { - case .userProfile(let conf), .contacts(let conf), - .convoInfoVolatile(let conf), .userGroups(let conf), - .groupInfo(let conf), .groupMembers(let conf): + case .userProfile(let conf), .contacts(let conf), .convoInfoVolatile(let conf), + .userGroups(let conf), .local(let conf), .groupInfo(let conf), .groupMembers(let conf): guard conf.pointee.last_error != nil else { return nil } return String(cString: conf.pointee.last_error) @@ -124,11 +126,10 @@ public extension LibSession { // MARK: - Functions - func push(variant: ConfigDump.Variant) throws -> PendingChanges.PushData? { + func push(variant: ConfigDump.Variant) throws -> PendingPushes? { switch self { - case .userProfile(let conf), .contacts(let conf), - .convoInfoVolatile(let conf), .userGroups(let conf), - .groupInfo(let conf), .groupMembers(let conf): + case .userProfile(let conf), .contacts(let conf), .convoInfoVolatile(let conf), + .userGroups(let conf), .local(let conf), .groupInfo(let conf), .groupMembers(let conf): /// The `config_push` function implicitly unwraps it's value but can throw internally so call it in a guard /// statement to prevent the implicit unwrap from causing a crash (ideally it would return a standard optional /// so the compiler would warn us but it's not that straight forward when dealing with C) @@ -151,13 +152,20 @@ public extension LibSession { ) ) } + let obsoleteHashes: [String] = [String]( + cStringArray: cPushData.pointee.obsolete, + count: cPushData.pointee.obsolete_len + ).defaulting(to: []) let seqNo: Int64 = cPushData.pointee.seqno free(UnsafeMutableRawPointer(mutating: cPushData)) - return PendingChanges.PushData( - data: allPushData, - seqNo: seqNo, - variant: variant + return PendingPushes( + pushData: PendingPushes.PushData( + data: allPushData, + seqNo: seqNo, + variant: variant + ), + obsoleteHashes: Set(obsoleteHashes) ) case .groupKeys(let conf, _, _): @@ -166,10 +174,12 @@ public extension LibSession { guard groups_keys_pending_config(conf, &pushResult, &pushResultLen) else { return nil } - return PendingChanges.PushData( - data: [Data(bytes: pushResult, count: pushResultLen)], - seqNo: 0, - variant: variant + return PendingPushes( + pushData: PendingPushes.PushData( + data: [Data(bytes: pushResult, count: pushResultLen)], + seqNo: 0, + variant: variant + ) ) } } @@ -179,9 +189,8 @@ public extension LibSession { hashes: [String] ) throws { switch self { - case .userProfile(let conf), .contacts(let conf), - .convoInfoVolatile(let conf), .userGroups(let conf), - .groupInfo(let conf), .groupMembers(let conf): + case .userProfile(let conf), .contacts(let conf), .convoInfoVolatile(let conf), + .userGroups(let conf), .local(let conf), .groupInfo(let conf), .groupMembers(let conf): try hashes.withUnsafeCStrArray { cHashes in config_confirm_pushed( conf, @@ -200,9 +209,8 @@ public extension LibSession { var dumpResultLen: Int = 0 switch self { - case .userProfile(let conf), .contacts(let conf), - .convoInfoVolatile(let conf), .userGroups(let conf), - .groupInfo(let conf), .groupMembers(let conf): + case .userProfile(let conf), .contacts(let conf), .convoInfoVolatile(let conf), + .userGroups(let conf), .local(let conf), .groupInfo(let conf), .groupMembers(let conf): config_dump(conf, &dumpResult, &dumpResultLen) case .groupKeys(let conf, _, _): groups_keys_dump(conf, &dumpResult, &dumpResultLen) } @@ -220,9 +228,8 @@ public extension LibSession { func activeHashes() -> [String] { switch self { - case .userProfile(let conf), .contacts(let conf), - .convoInfoVolatile(let conf), .userGroups(let conf), - .groupInfo(let conf), .groupMembers(let conf): + case .userProfile(let conf), .contacts(let conf), .convoInfoVolatile(let conf), + .userGroups(let conf), .local(let conf), .groupInfo(let conf), .groupMembers(let conf): guard let hashList: UnsafeMutablePointer = config_active_hashes(conf) else { return [] } @@ -250,31 +257,10 @@ public extension LibSession { } } - func obsoleteHashes() -> [String] { - switch self { - case .groupKeys: return [] - case .userProfile(let conf), .contacts(let conf), - .convoInfoVolatile(let conf), .userGroups(let conf), - .groupInfo(let conf), .groupMembers(let conf): - guard let hashList: UnsafeMutablePointer = config_old_hashes(conf) else { - return [] - } - - let result: [String] = [String]( - cStringArray: hashList.pointee.value, - count: hashList.pointee.len - ).defaulting(to: []) - free(UnsafeMutableRawPointer(mutating: hashList)) - - return result - } - } - func merge(_ messages: [ConfigMessageReceiveJob.Details.MessageInfo]) throws -> Int64? { switch self { - case .userProfile(let conf), .contacts(let conf), - .convoInfoVolatile(let conf), .userGroups(let conf), - .groupInfo(let conf), .groupMembers(let conf): + case .userProfile(let conf), .contacts(let conf), .convoInfoVolatile(let conf), + .userGroups(let conf), .local(let conf), .groupInfo(let conf), .groupMembers(let conf): return try messages.map { $0.serverHash }.withUnsafeCStrArray { cMergeHashes in try messages.map { Array($0.data) }.withUnsafeUInt8CArray { cMergeData in let mergeSize: [size_t] = messages.map { size_t($0.data.count) } @@ -347,10 +333,10 @@ public extension LibSession { } } -// MARK: - PendingChanges +// MARK: - PendingPushes public extension LibSession { - struct PendingChanges { + struct PendingPushes { public struct PushData { let data: [Data] let seqNo: Int64 @@ -365,6 +351,18 @@ public extension LibSession { self.obsoleteHashes = obsoleteHashes } + init(pushData: PushData, obsoleteHashes: Set = []) { + self.pushData = [pushData] + self.obsoleteHashes = obsoleteHashes + } + + mutating func append(_ data: PendingPushes?) { + guard let data: PendingPushes = data else { return } + + pushData.append(contentsOf: data.pushData) + obsoleteHashes.insert(contentsOf: data.obsoleteHashes) + } + mutating func append(data: PushData? = nil, hashes: [String] = []) { if let data: PushData = data { pushData.append(data) @@ -398,9 +396,8 @@ public extension LibSessionError { Log.error("\(logMessage): \(self)") } - case .userProfile(let conf), .contacts(let conf), - .convoInfoVolatile(let conf), .userGroups(let conf), - .groupInfo(let conf), .groupMembers(let conf): + case .userProfile(let conf), .contacts(let conf), .convoInfoVolatile(let conf), + .userGroups(let conf), .local(let conf), .groupInfo(let conf), .groupMembers(let conf): self = LibSessionError(conf, fallbackError: fallbackError, logMessage: logMessage) case .groupKeys(let conf, _, _): self = LibSessionError(conf, fallbackError: fallbackError, logMessage: logMessage) } @@ -409,9 +406,8 @@ public extension LibSessionError { static func throwIfNeeded(_ config: LibSession.Config?) throws { switch config { case .none: return - case .userProfile(let conf), .contacts(let conf), - .convoInfoVolatile(let conf), .userGroups(let conf), - .groupInfo(let conf), .groupMembers(let conf): + case .userProfile(let conf), .contacts(let conf), .convoInfoVolatile(let conf), + .userGroups(let conf), .local(let conf), .groupInfo(let conf), .groupMembers(let conf): try LibSessionError.throwIfNeeded(conf) case .groupKeys(let conf, _, _): try LibSessionError.throwIfNeeded(conf) } diff --git a/SessionMessagingKit/LibSession/Types/Mutation.swift b/SessionMessagingKit/LibSession/Types/Mutation.swift new file mode 100644 index 0000000000..e49dde86e6 --- /dev/null +++ b/SessionMessagingKit/LibSession/Types/Mutation.swift @@ -0,0 +1,72 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public extension LibSession { + struct Mutation { + let sessionId: SessionId + let needsPush: Bool + let needsDump: Bool + let skipAutomaticConfigSync: Bool + let pendingEvents: [ObservedEvent] + let dump: ConfigDump? + let dependencies: Dependencies + + init( + config: LibSession.Config?, + sessionId: SessionId, + skipAutomaticConfigSync: Bool, + pendingEvents: [ObservedEvent], + cache: LibSessionCacheType, + using dependencies: Dependencies + ) throws { + self.sessionId = sessionId + self.needsPush = (config?.needsPush == true) + self.needsDump = cache.configNeedsDump(config) + self.skipAutomaticConfigSync = skipAutomaticConfigSync + self.pendingEvents = pendingEvents + self.dump = (!self.needsDump ? nil : + try config.map { + try cache.createDump( + config: $0, + for: $0.variant, + sessionId: sessionId, + timestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + ) + } + ) + self.dependencies = dependencies + } + + public func upsert(_ db: ObservingDatabase) throws { + /// Add and pending changes to the `db` so notifications go out for them after the transaction completes + pendingEvents.forEach { db.addEvent($0) } + + /// If we don't need to dump or push then don't bother continuing + guard needsDump || (needsPush && !skipAutomaticConfigSync) else { return } + + /// Only save the dump if needed + if needsDump { + try dump?.upsert(db) + } + + db.afterCommit { [dump, dependencies] in + /// Schedule a push if needed + if needsPush && !skipAutomaticConfigSync { + dependencies[singleton: .storage].writeAsync { db in + ConfigurationSyncJob.enqueue(db, swarmPublicKey: sessionId.hexString, using: dependencies) + } + } + + /// If we needed to dump then we should replicate it + if needsDump { + Task.detached(priority: .medium) { + dependencies[singleton: .extensionHelper].replicate(dump: dump) + } + } + } + } + } +} diff --git a/SessionMessagingKit/LibSession/Types/OpenGroupUrlInfo.swift b/SessionMessagingKit/LibSession/Types/OpenGroupUrlInfo.swift new file mode 100644 index 0000000000..f9181fd85f --- /dev/null +++ b/SessionMessagingKit/LibSession/Types/OpenGroupUrlInfo.swift @@ -0,0 +1,125 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public extension LibSession { + // MARK: - OpenGroupUrlInfo + + struct OpenGroupUrlInfo: FetchableRecord, Codable, Hashable { + let threadId: String + let server: String + let roomToken: String + let publicKey: String + + // MARK: - Queries + + public static func fetchOne(_ db: ObservingDatabase, id: String) throws -> OpenGroupUrlInfo? { + return try OpenGroup + .filter(id: id) + .select(.threadId, .server, .roomToken, .publicKey) + .asRequest(of: OpenGroupUrlInfo.self) + .fetchOne(db) + } + + public static func fetchAll(_ db: ObservingDatabase, ids: [String]) throws -> [OpenGroupUrlInfo] { + return try OpenGroup + .filter(ids: ids) + .select(.threadId, .server, .roomToken, .publicKey) + .asRequest(of: OpenGroupUrlInfo.self) + .fetchAll(db) + } + } + + // MARK: - OpenGroupCapabilityInfo + + struct OpenGroupCapabilityInfo: FetchableRecord, Codable, Hashable { + private let urlInfo: OpenGroupUrlInfo + + var threadId: String { urlInfo.threadId } + var server: String { urlInfo.server } + var roomToken: String { urlInfo.roomToken } + var publicKey: String { urlInfo.publicKey } + let capabilities: Set + + // MARK: - Initialization + + init( + urlInfo: OpenGroupUrlInfo, + capabilities: Set + ) { + self.urlInfo = urlInfo + self.capabilities = capabilities + } + + public init( + roomToken: String, + server: String, + publicKey: String, + capabilities: Set + ) { + self.urlInfo = OpenGroupUrlInfo( + threadId: OpenGroup.idFor(roomToken: roomToken, server: server), + server: server, + roomToken: roomToken, + publicKey: publicKey + ) + self.capabilities = capabilities + } + + // MARK: - Queries + + public static func fetchOne(_ db: ObservingDatabase, server: String, activeOnly: Bool = true) throws -> OpenGroupCapabilityInfo? { + var query: QueryInterfaceRequest = OpenGroup + .select(.threadId, .server, .roomToken, .publicKey) + .filter(OpenGroup.Columns.server == server.lowercased()) + .asRequest(of: OpenGroupUrlInfo.self) + + /// If we only want to retrieve data for active OpenGroups then add additional filters + if activeOnly { + query = query + .filter(OpenGroup.Columns.isActive == true) + .filter(OpenGroup.Columns.roomToken != "") + } + + guard let urlInfo: OpenGroupUrlInfo = try query.fetchOne(db) else { return nil } + + let capabilities: Set = (try? Capability + .select(.variant) + .filter(Capability.Columns.openGroupServer == urlInfo.server.lowercased()) + .filter(Capability.Columns.isMissing == false) + .asRequest(of: Capability.Variant.self) + .fetchSet(db)) + .defaulting(to: []) + + return OpenGroupCapabilityInfo( + urlInfo: urlInfo, + capabilities: capabilities + ) + } + + public static func fetchOne(_ db: ObservingDatabase, id: String) throws -> OpenGroupCapabilityInfo? { + let maybeUrlInfo: OpenGroupUrlInfo? = try OpenGroup + .filter(id: id) + .select(.threadId, .server, .roomToken, .publicKey) + .asRequest(of: OpenGroupUrlInfo.self) + .fetchOne(db) + + guard let urlInfo: OpenGroupUrlInfo = maybeUrlInfo else { return nil } + + let capabilities: Set = (try? Capability + .select(.variant) + .filter(Capability.Columns.openGroupServer == urlInfo.server.lowercased()) + .filter(Capability.Columns.isMissing == false) + .asRequest(of: Capability.Variant.self) + .fetchSet(db)) + .defaulting(to: []) + + return OpenGroupCapabilityInfo( + urlInfo: urlInfo, + capabilities: capabilities + ) + } + } +} diff --git a/SessionMessagingKit/LibSession/Types/ThreadUpdateInfo.swift b/SessionMessagingKit/LibSession/Types/ThreadUpdateInfo.swift new file mode 100644 index 0000000000..3f611f9b43 --- /dev/null +++ b/SessionMessagingKit/LibSession/Types/ThreadUpdateInfo.swift @@ -0,0 +1,21 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +extension LibSession { + struct ThreadUpdateInfo: Codable, FetchableRecord, Identifiable { + static let threadColumns: [SessionThread.Columns] = [ + .id, .variant, .pinnedPriority, .shouldBeVisible, + .mutedUntilTimestamp, .onlyNotifyForMentions + ] + + let id: String + let variant: SessionThread.Variant + let pinnedPriority: Int32? + let shouldBeVisible: Bool + let mutedUntilTimestamp: TimeInterval? + let onlyNotifyForMentions: Bool + } +} diff --git a/SessionMessagingKit/LibSession/Types/ValueFetcher.swift b/SessionMessagingKit/LibSession/Types/ValueFetcher.swift new file mode 100644 index 0000000000..9a9f5a1e76 --- /dev/null +++ b/SessionMessagingKit/LibSession/Types/ValueFetcher.swift @@ -0,0 +1,32 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUIKit +import SessionUtilitiesKit + +public protocol ValueFetcher { + var userSessionId: SessionId { get } + + func has(_ key: Setting.BoolKey) -> Bool + func has(_ key: Setting.EnumKey) -> Bool + func get(_ key: Setting.BoolKey) -> Bool + func get(_ key: Setting.EnumKey) -> T? + + func profile( + contactId: String, + threadId: String?, + threadVariant: SessionThread.Variant?, + visibleMessage: VisibleMessage? + ) -> Profile? +} + +public extension ValueFetcher { + var profile: Profile { + return profile(contactId: userSessionId.hexString, threadId: nil, threadVariant: nil, visibleMessage: nil) + .defaulting(to: Profile.defaultFor(userSessionId.hexString)) + } + + func profile(contactId: String) -> Profile? { + return profile(contactId: contactId, threadId: nil, threadVariant: nil, visibleMessage: nil) + } +} diff --git a/SessionMessagingKit/Messages/Control Messages/CallMessage.swift b/SessionMessagingKit/Messages/Control Messages/CallMessage.swift index b6ca42653b..fb4b38c0d9 100644 --- a/SessionMessagingKit/Messages/Control Messages/CallMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/CallMessage.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit /// See https://developer.mozilla.org/en-US/docs/Web/API/RTCSessionDescription for more information. @@ -14,6 +13,7 @@ public final class CallMessage: ControlMessage { public var uuid: String public var kind: Kind + public var state: MessageInfo.State? /// See https://developer.mozilla.org/en-US/docs/Glossary/SDP for more information. public var sdps: [String] @@ -29,7 +29,7 @@ public final class CallMessage: ControlMessage { // MARK: - Kind /// **Note:** Multiple ICE candidates may be batched together for performance - public enum Kind: Codable, CustomStringConvertible { + public enum Kind: Codable, Equatable, CustomStringConvertible { private enum CodingKeys: String, CodingKey { case description case sdpMLineIndexes @@ -105,13 +105,16 @@ public final class CallMessage: ControlMessage { uuid: String, kind: Kind, sdps: [String], - sentTimestampMs: UInt64? = nil + state: MessageInfo.State? = nil, + sentTimestampMs: UInt64? = nil, + sender: String? = nil ) { self.uuid = uuid self.kind = kind self.sdps = sdps + self.state = state - super.init(sentTimestampMs: sentTimestampMs) + super.init(sentTimestampMs: sentTimestampMs, sender: sender) } // MARK: - Codable @@ -167,7 +170,7 @@ public final class CallMessage: ControlMessage { ) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { let type: SNProtoCallMessage.SNProtoCallMessageType switch kind { @@ -223,7 +226,7 @@ public final class CallMessage: ControlMessage { public extension CallMessage { struct MessageInfo: Codable { - public enum State: Codable { + public enum State: Codable, CaseIterable { case incoming case outgoing case missed diff --git a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift index 50b4315990..a1521a47b1 100644 --- a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift +++ b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift @@ -3,7 +3,6 @@ // stringlint:disable import Foundation -import GRDB import SessionUtilitiesKit public final class DataExtractionNotification: ControlMessage { @@ -31,10 +30,12 @@ public final class DataExtractionNotification: ControlMessage { public init( kind: Kind, - sentTimestampMs: UInt64? = nil + sentTimestampMs: UInt64? = nil, + sender: String? = nil ) { super.init( - sentTimestampMs: sentTimestampMs + sentTimestampMs: sentTimestampMs, + sender: sender ) self.kind = kind @@ -83,7 +84,7 @@ public final class DataExtractionNotification: ControlMessage { return DataExtractionNotification(kind: kind) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { guard let kind = kind else { Log.warn(.messageSender, "Couldn't construct data extraction notification proto from: \(self).") return nil diff --git a/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift b/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift index bc33f2f113..dfce9ee33e 100644 --- a/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift +++ b/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public final class ExpirationTimerUpdate: ControlMessage { @@ -13,8 +12,8 @@ public final class ExpirationTimerUpdate: ControlMessage { public override var isSelfSendValid: Bool { true } - public init(syncTarget: String? = nil) { - super.init() + public init(syncTarget: String? = nil, sender: String? = nil) { + super.init(sender: sender) self.syncTarget = syncTarget } @@ -50,7 +49,7 @@ public final class ExpirationTimerUpdate: ControlMessage { ) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { let dataMessageProto = SNProtoDataMessage.builder() dataMessageProto.setFlags(UInt32(SNProtoDataMessage.SNProtoDataMessageFlags.expirationTimerUpdate.rawValue)) if let syncTarget = syncTarget { dataMessageProto.setSyncTarget(syncTarget) } diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateDeleteMemberContentMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateDeleteMemberContentMessage.swift index 883fd6891c..12c08361d6 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateDeleteMemberContentMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateDeleteMemberContentMessage.swift @@ -3,7 +3,6 @@ // stringlint:disable import Foundation -import GRDB import SessionUtilitiesKit public final class GroupUpdateDeleteMemberContentMessage: ControlMessage { @@ -51,13 +50,14 @@ public final class GroupUpdateDeleteMemberContentMessage: ControlMessage { internal init( memberSessionIds: [String], messageHashes: [String], - adminSignature: Authentication.Signature? + adminSignature: Authentication.Signature?, + sender: String? = nil ) { self.memberSessionIds = memberSessionIds self.messageHashes = messageHashes self.adminSignature = adminSignature - super.init() + super.init(sender: sender) } // MARK: - Signature Generation @@ -118,7 +118,7 @@ public final class GroupUpdateDeleteMemberContentMessage: ControlMessage { ) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { do { let deleteMemberContentMessageBuilder: SNProtoGroupUpdateDeleteMemberContentMessage.SNProtoGroupUpdateDeleteMemberContentMessageBuilder = SNProtoGroupUpdateDeleteMemberContentMessage.builder() deleteMemberContentMessageBuilder.setMemberSessionIds(memberSessionIds) diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInfoChangeMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInfoChangeMessage.swift index 8d777e7681..7dbd388901 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInfoChangeMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInfoChangeMessage.swift @@ -3,7 +3,6 @@ // stringlint:disable import Foundation -import GRDB import SessionUtilitiesKit public final class GroupUpdateInfoChangeMessage: ControlMessage { @@ -59,14 +58,15 @@ public final class GroupUpdateInfoChangeMessage: ControlMessage { changeType: ChangeType, updatedName: String? = nil, updatedExpiration: UInt32? = nil, - adminSignature: Authentication.Signature + adminSignature: Authentication.Signature, + sender: String? = nil ) { self.changeType = changeType self.updatedName = updatedName self.updatedExpiration = updatedExpiration self.adminSignature = adminSignature - super.init() + super.init(sender: sender) } // MARK: - Signature Generation @@ -130,7 +130,7 @@ public final class GroupUpdateInfoChangeMessage: ControlMessage { ) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { do { let infoChangeMessageBuilder: SNProtoGroupUpdateInfoChangeMessage.SNProtoGroupUpdateInfoChangeMessageBuilder = SNProtoGroupUpdateInfoChangeMessage.builder( type: { diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteMessage.swift index 54b7d6d19d..e614131dc8 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteMessage.swift @@ -3,7 +3,6 @@ // stringlint:disable import Foundation -import GRDB import SessionUtilitiesKit public final class GroupUpdateInviteMessage: ControlMessage { @@ -59,7 +58,9 @@ public final class GroupUpdateInviteMessage: ControlMessage { groupName: String, memberAuthData: Data, profile: VisibleMessage.VMProfile? = nil, - adminSignature: Authentication.Signature + adminSignature: Authentication.Signature, + sentTimestampMs: UInt64? = nil, + sender: String? = nil ) { self.inviteeSessionIdHexString = inviteeSessionIdHexString self.groupSessionId = groupSessionId @@ -68,7 +69,7 @@ public final class GroupUpdateInviteMessage: ControlMessage { self.profile = profile self.adminSignature = adminSignature - super.init() + super.init(sentTimestampMs: sentTimestampMs, sender: sender) } // MARK: - Signature Generation @@ -139,7 +140,7 @@ public final class GroupUpdateInviteMessage: ControlMessage { ) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { do { let inviteMessageBuilder: SNProtoGroupUpdateInviteMessage.SNProtoGroupUpdateInviteMessageBuilder = SNProtoGroupUpdateInviteMessage.builder( groupSessionID: groupSessionId.hexString, // Include the prefix diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteResponseMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteResponseMessage.swift index bab78e074d..0a74051d38 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteResponseMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteResponseMessage.swift @@ -1,7 +1,6 @@ // Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public final class GroupUpdateInviteResponseMessage: ControlMessage { @@ -20,13 +19,15 @@ public final class GroupUpdateInviteResponseMessage: ControlMessage { public init( isApproved: Bool, profile: VisibleMessage.VMProfile? = nil, // Added when sending via the `MessageWithProfile` protocol - sentTimestampMs: UInt64? = nil + sentTimestampMs: UInt64? = nil, + sender: String? = nil ) { self.isApproved = isApproved self.profile = profile super.init( - sentTimestampMs: sentTimestampMs + sentTimestampMs: sentTimestampMs, + sender: sender ) } @@ -64,7 +65,7 @@ public final class GroupUpdateInviteResponseMessage: ControlMessage { ) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { do { let inviteResponseMessageBuilder: SNProtoGroupUpdateInviteResponseMessage.SNProtoGroupUpdateInviteResponseMessageBuilder = SNProtoGroupUpdateInviteResponseMessage.builder( isApproved: isApproved diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberChangeMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberChangeMessage.swift index 0c2a49ccc6..32689fb1b5 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberChangeMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberChangeMessage.swift @@ -3,7 +3,6 @@ // stringlint:disable import Foundation -import GRDB import SessionUtilitiesKit public final class GroupUpdateMemberChangeMessage: ControlMessage { @@ -59,14 +58,15 @@ public final class GroupUpdateMemberChangeMessage: ControlMessage { changeType: ChangeType, memberSessionIds: [String], historyShared: Bool, - adminSignature: Authentication.Signature + adminSignature: Authentication.Signature, + sender: String? = nil ) { self.changeType = changeType self.memberSessionIds = memberSessionIds self.historyShared = historyShared self.adminSignature = adminSignature - super.init() + super.init(sender: sender) } // MARK: - Signature Generation @@ -130,7 +130,7 @@ public final class GroupUpdateMemberChangeMessage: ControlMessage { ) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { do { let memberChangeMessageBuilder: SNProtoGroupUpdateMemberChangeMessage.SNProtoGroupUpdateMemberChangeMessageBuilder = SNProtoGroupUpdateMemberChangeMessage.builder( type: { diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberLeftMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberLeftMessage.swift index a2a33c9d79..87944f8ecf 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberLeftMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberLeftMessage.swift @@ -3,7 +3,6 @@ // stringlint:disable import Foundation -import GRDB import SessionUtilitiesKit public final class GroupUpdateMemberLeftMessage: ControlMessage { @@ -28,7 +27,7 @@ public final class GroupUpdateMemberLeftMessage: ControlMessage { return GroupUpdateMemberLeftMessage() } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { do { let memberLeftMessageBuilder: SNProtoGroupUpdateMemberLeftMessage.SNProtoGroupUpdateMemberLeftMessageBuilder = SNProtoGroupUpdateMemberLeftMessage.builder() diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberLeftNotificationMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberLeftNotificationMessage.swift index a4aff61976..c3f5beb513 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberLeftNotificationMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberLeftNotificationMessage.swift @@ -3,7 +3,6 @@ // stringlint:disable import Foundation -import GRDB import SessionUtilitiesKit public final class GroupUpdateMemberLeftNotificationMessage: ControlMessage { @@ -28,7 +27,7 @@ public final class GroupUpdateMemberLeftNotificationMessage: ControlMessage { return GroupUpdateMemberLeftNotificationMessage() } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { do { let memberLeftNotificationMessageBuilder: SNProtoGroupUpdateMemberLeftNotificationMessage.SNProtoGroupUpdateMemberLeftNotificationMessageBuilder = SNProtoGroupUpdateMemberLeftNotificationMessage.builder() diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdatePromoteMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdatePromoteMessage.swift index 12c076d2c3..2ed730cc49 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdatePromoteMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdatePromoteMessage.swift @@ -1,7 +1,6 @@ // Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public final class GroupUpdatePromoteMessage: ControlMessage { @@ -23,14 +22,16 @@ public final class GroupUpdatePromoteMessage: ControlMessage { groupIdentitySeed: Data, groupName: String, profile: VisibleMessage.VMProfile? = nil, // Added when sending via the `MessageWithProfile` protocol - sentTimestampMs: UInt64? = nil + sentTimestampMs: UInt64? = nil, + sender: String? = nil ) { self.groupIdentitySeed = groupIdentitySeed self.groupName = groupName self.profile = profile super.init( - sentTimestampMs: sentTimestampMs + sentTimestampMs: sentTimestampMs, + sender: sender ) } @@ -71,7 +72,7 @@ public final class GroupUpdatePromoteMessage: ControlMessage { ) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { do { let promoteMessageBuilder: SNProtoGroupUpdatePromoteMessage.SNProtoGroupUpdatePromoteMessageBuilder = SNProtoGroupUpdatePromoteMessage.builder( groupIdentitySeed: groupIdentitySeed, diff --git a/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift b/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift index 407c303e27..65e0a81621 100644 --- a/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift +++ b/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public final class MessageRequestResponse: ControlMessage { @@ -18,13 +17,15 @@ public final class MessageRequestResponse: ControlMessage { public init( isApproved: Bool, profile: VisibleMessage.VMProfile? = nil, // Added when sending via the `MessageWithProfile` protocol - sentTimestampMs: UInt64? = nil + sentTimestampMs: UInt64? = nil, + sender: String? = nil ) { self.isApproved = isApproved self.profile = profile super.init( - sentTimestampMs: sentTimestampMs + sentTimestampMs: sentTimestampMs, + sender: sender ) } @@ -59,7 +60,7 @@ public final class MessageRequestResponse: ControlMessage { ) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { let messageRequestResponseProto: SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder // Profile diff --git a/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift b/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift index 5a154285fe..dc272f3701 100644 --- a/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift +++ b/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public final class ReadReceipt: ControlMessage { @@ -13,8 +12,8 @@ public final class ReadReceipt: ControlMessage { // MARK: - Initialization - internal init(timestamps: [UInt64]) { - super.init() + internal init(timestamps: [UInt64], sender: String? = nil) { + super.init(sender: sender) self.timestamps = timestamps } @@ -54,7 +53,7 @@ public final class ReadReceipt: ControlMessage { return ReadReceipt(timestamps: timestamps) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { guard let timestamps = timestamps else { Log.warn(.messageSender, "Couldn't construct read receipt proto from: \(self).") return nil diff --git a/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift b/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift index f6ca3d3c71..7bd5844223 100644 --- a/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift +++ b/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public final class TypingIndicator: ControlMessage { @@ -50,8 +49,8 @@ public final class TypingIndicator: ControlMessage { // MARK: - Initialization - internal init(kind: Kind) { - super.init() + internal init(kind: Kind, sender: String? = nil) { + super.init(sender: sender) self.kind = kind } @@ -82,7 +81,7 @@ public final class TypingIndicator: ControlMessage { return TypingIndicator(kind: kind) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { guard let timestampMs = sentTimestampMs, let kind = kind else { Log.warn(.messageSender, "Couldn't construct typing indicator proto from: \(self).") return nil diff --git a/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift b/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift index 711abb7d5b..88b84364f5 100644 --- a/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift +++ b/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public final class UnsendRequest: ControlMessage { @@ -25,8 +24,8 @@ public final class UnsendRequest: ControlMessage { // MARK: - Initialization - public init(timestamp: UInt64, author: String) { - super.init() + public init(timestamp: UInt64, author: String, sender: String? = nil) { + super.init(sender: sender) self.timestamp = timestamp self.author = author @@ -61,7 +60,7 @@ public final class UnsendRequest: ControlMessage { return UnsendRequest(timestamp: timestamp, author: author) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { guard let timestamp = timestamp, let author = author else { Log.warn(.messageSender, "Couldn't construct unsend request proto from: \(self).") return nil diff --git a/SessionMessagingKit/Messages/LibSessionMessage.swift b/SessionMessagingKit/Messages/LibSessionMessage.swift index 0ad28ca8d6..a5f552161b 100644 --- a/SessionMessagingKit/Messages/LibSessionMessage.swift +++ b/SessionMessagingKit/Messages/LibSessionMessage.swift @@ -1,7 +1,6 @@ // Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public final class LibSessionMessage: Message, NotProtoConvertible { @@ -19,10 +18,10 @@ public final class LibSessionMessage: Message, NotProtoConvertible { // MARK: - Initialization - internal init(ciphertext: Data) { + internal init(ciphertext: Data, sender: String? = nil) { self.ciphertext = ciphertext - super.init() + super.init(sender: sender) } // MARK: - Codable @@ -73,4 +72,23 @@ public extension LibSessionMessage { return (SessionId(.standard, publicKey: Array(plaintext[0..= currentKeysGen + else { throw MessageReceiverError.invalidMessage } + } } diff --git a/SessionMessagingKit/Messages/Message+Destination.swift b/SessionMessagingKit/Messages/Message+Destination.swift index cecaae3cb4..b7d393a02c 100644 --- a/SessionMessagingKit/Messages/Message+Destination.swift +++ b/SessionMessagingKit/Messages/Message+Destination.swift @@ -28,6 +28,17 @@ public extension Message { /// A message directed to an open group inbox case openGroupInbox(server: String, openGroupPublicKey: String, blindedPublicKey: String) + public var threadVariant: SessionThread.Variant { + switch self { + case .contact, .syncMessage, .openGroupInbox: return .contact + case .closedGroup(let groupId) where (try? SessionId.Prefix(from: groupId)) == .group: + return .group + + case .closedGroup: return .legacyGroup + case .openGroup: return .community + } + } + public var defaultNamespace: SnodeAPI.Namespace? { switch self { case .contact, .syncMessage: return .`default` @@ -40,7 +51,7 @@ public extension Message { } public static func from( - _ db: Database, + _ db: ObservingDatabase, threadId: String, threadVariant: SessionThread.Variant ) throws -> Message.Destination { @@ -50,7 +61,7 @@ public extension Message { if prefix == .blinded15 || prefix == .blinded25 { guard let lookup: BlindedIdLookup = try? BlindedIdLookup.fetchOne(db, id: threadId) else { - preconditionFailure("Attempting to send message to blinded id without the Open Group information") + throw OpenGroupAPIError.blindedLookupMissingCommunityInfo } return .openGroupInbox( @@ -65,11 +76,12 @@ public extension Message { case .legacyGroup, .group: return .closedGroup(groupPublicKey: threadId) case .community: - guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { - throw StorageError.objectNotFound - } + guard + let info: LibSession.OpenGroupUrlInfo = try? LibSession.OpenGroupUrlInfo + .fetchOne(db, id: threadId) + else { throw StorageError.objectNotFound } - return .openGroup(roomToken: openGroup.roomToken, server: openGroup.server) + return .openGroup(roomToken: info.roomToken, server: info.server) } } } diff --git a/SessionMessagingKit/Messages/Message+DisappearingMessages.swift b/SessionMessagingKit/Messages/Message+DisappearingMessages.swift index d706a68a4d..d511a374fc 100644 --- a/SessionMessagingKit/Messages/Message+DisappearingMessages.swift +++ b/SessionMessagingKit/Messages/Message+DisappearingMessages.swift @@ -62,7 +62,7 @@ extension Message { } public static func getExpirationForOutgoingDisappearingMessages( - _ db: Database, + _ db: ObservingDatabase, threadId: String, threadVariant: SessionThread.Variant, variant: Interaction.Variant, @@ -94,7 +94,7 @@ extension Message { } public static func updateExpiryForDisappearAfterReadMessages( - _ db: Database, + _ db: ObservingDatabase, threadId: String, threadVariant: SessionThread.Variant, serverHash: String?, diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index b0371a3dc9..a275cb3edc 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -120,8 +120,8 @@ public class Message: Codable { preconditionFailure("fromProto(_:sender:) is abstract and must be overridden.") } - public func toProto(_ db: Database, threadId: String) -> SNProtoContent? { - preconditionFailure("toProto(_:) is abstract and must be overridden.") + public func toProto() -> SNProtoContent? { + preconditionFailure("toProto() is abstract and must be overridden.") } public func setDisappearingMessagesConfigurationIfNeeded(on proto: SNProtoContent.SNProtoContentBuilder) { @@ -163,33 +163,46 @@ public enum ProcessedMessage { threadId: String, threadVariant: SessionThread.Variant, proto: SNProtoContent, - messageInfo: MessageReceiveJob.Details.MessageInfo + messageInfo: MessageReceiveJob.Details.MessageInfo, + uniqueIdentifier: String ) case config( publicKey: String, namespace: SnodeAPI.Namespace, serverHash: String, serverTimestampMs: Int64, - data: Data + data: Data, + uniqueIdentifier: String ) + case invalid - var threadId: String { + public var threadId: String { switch self { - case .standard(let threadId, _, _, _): return threadId - case .config(let publicKey, _, _, _, _): return publicKey + case .standard(let threadId, _, _, _, _): return threadId + case .config(let publicKey, _, _, _, _, _): return publicKey + case .invalid: return "" } } var namespace: SnodeAPI.Namespace { switch self { - case .standard(_, let threadVariant, _, _): + case .standard(_, let threadVariant, _, _, _): switch threadVariant { case .group: return .groupMessages case .legacyGroup: return .legacyClosedGroup case .contact, .community: return .default } - case .config(_, let namespace, _, _, _): return namespace + case .config(_, let namespace, _, _, _, _): return namespace + case .invalid: return .default + } + } + + var uniqueIdentifier: String { + switch self { + case .standard(_, _, _, _, let uniqueIdentifier): return uniqueIdentifier + case .config(_, _, _, _, _, let uniqueIdentifier): return uniqueIdentifier + case .invalid: return "" } } @@ -197,10 +210,13 @@ public enum ProcessedMessage { switch self { case .standard: return false case .config: return true + case .invalid: return false } } } +// MARK: - Variant + public extension Message { enum Variant: String, Codable, CaseIterable { case readReceipt @@ -335,7 +351,9 @@ public extension Message { } } } - +} + +public extension Message { static func createMessageFrom(_ proto: SNProtoContent, sender: String, using dependencies: Dependencies) throws -> Message { let decodedMessage: Message? = Variant .allCases @@ -355,7 +373,7 @@ public extension Message { case is VisibleMessage: return true case is ExpirationTimerUpdate: return true case is UnsendRequest: return true - + case let callMessage as CallMessage: switch callMessage.kind { case .answer, .endCall: return true @@ -405,174 +423,37 @@ public extension Message { } } - static func processRawReceivedMessage( - _ db: Database, - rawMessage: SnodeReceivedMessage, - swarmPublicKey: String, - shouldStoreMessages: Bool, - using dependencies: Dependencies - ) throws -> ProcessedMessage { - do { - let processedMessage: ProcessedMessage = try processRawReceivedMessage( - db, - data: rawMessage.data, - from: .swarm( - publicKey: swarmPublicKey, - namespace: rawMessage.namespace, - serverHash: rawMessage.info.hash, - serverTimestampMs: rawMessage.timestampMs, - serverExpirationTimestamp: TimeInterval(Double(rawMessage.info.expirationDateMs) / 1000) - ), - using: dependencies - ) - - /// If we don't want to store the messages then don't store any records for deduping purposes - guard shouldStoreMessages else { return processedMessage } - - // Ensure we actually want to de-dupe messages for this namespace, otherwise just - // succeed early - guard rawMessage.namespace.shouldDedupeMessages else { - // If we want to track the last hash then upsert the raw message info (don't - // want to fail if it already exists because we don't want to dedupe messages - // in this namespace) - if rawMessage.namespace.shouldFetchSinceLastHash { - try rawMessage.info.upserted(db) - } - - return processedMessage - } - - // Retrieve the number of entries we have for the hash of this message - let numExistingHashes: Int = (try? SnodeReceivedMessageInfo - .filter(SnodeReceivedMessageInfo.Columns.hash == rawMessage.info.hash) - .fetchCount(db)) - .defaulting(to: 0) - - // Try to insert the raw message info into the database (used for both request paging and - // de-duping purposes) - _ = try rawMessage.info.inserted(db) - - // If the above insertion worked then we hadn't processed this message for this specific - // service node, but may have done so for another node - if the hash already existed in - // the database before we inserted it for this node then we can ignore this message as a - // duplicate - guard numExistingHashes == 0 else { throw MessageReceiverError.duplicateMessageNewSnode } - - return processedMessage - } - catch { - // For some error cases we want to update the last hash so do so - if (error as? MessageReceiverError)?.shouldUpdateLastHash == true { - _ = try? rawMessage.info.inserted(db) - } - - throw error - } - } - - /// This method behaves slightly differently from the other `processRawReceivedMessage` methods as it doesn't - /// insert the "message info" for deduping (we want the poller to re-process the message) and also avoids handling any - /// closed group key update messages (the `NotificationServiceExtension` does this itself) - static func processRawReceivedMessageAsNotification( - _ db: Database, - data: Data, - metadata: PushNotificationAPI.NotificationMetadata, - using dependencies: Dependencies - ) throws -> ProcessedMessage { - return try processRawReceivedMessage( - db, - data: data, - from: .swarm( - publicKey: metadata.accountId, - namespace: metadata.namespace, - serverHash: metadata.hash, - serverTimestampMs: metadata.createdTimestampMs, - serverExpirationTimestamp: ( - TimeInterval(dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + - ControlMessageProcessRecord.defaultExpirationSeconds - ) - ), - using: dependencies - ) - } - - static func processReceivedOpenGroupMessage( - _ db: Database, - openGroupId: String, - openGroupServerPublicKey: String, - message: OpenGroupAPI.Message, - data: Data, - using dependencies: Dependencies - ) throws -> ProcessedMessage { - // Need a sender in order to process the message - guard - let sender: String = message.sender, - let timestamp = message.posted - else { throw MessageReceiverError.invalidMessage } - - return try processRawReceivedMessage( - db, - data: data, - from: .community( - openGroupId: openGroupId, - sender: sender, - timestamp: timestamp, - messageServerId: message.id, - whisper: message.whisper, - whisperMods: message.whisperMods, - whisperTo: message.whisperTo - ), - using: dependencies - ) - } - - static func processReceivedOpenGroupDirectMessage( - _ db: Database, - openGroupServerPublicKey: String, - message: OpenGroupAPI.DirectMessage, - data: Data, - using dependencies: Dependencies - ) throws -> ProcessedMessage { - return try processRawReceivedMessage( - db, - data: data, - from: .openGroupInbox( - timestamp: message.posted, - messageServerId: message.id, - serverPublicKey: openGroupServerPublicKey, - senderId: message.sender, - recipientId: message.recipient - ), - using: dependencies - ) - } - static func processRawReceivedReactions( - _ db: Database, + _ db: ObservingDatabase, openGroupId: String, message: OpenGroupAPI.Message, associatedPendingChanges: [OpenGroupAPI.PendingChange], using dependencies: Dependencies ) -> [Reaction] { - guard let reactions: [String: OpenGroupAPI.Message.Reaction] = message.reactions else { return [] } + guard + let reactions: [String: OpenGroupAPI.Message.Reaction] = message.reactions, + let openGroupCapabilityInfo: LibSession.OpenGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo + .fetchOne(db, id: openGroupId) + else { return [] } let currentUserSessionId: SessionId = dependencies[cache: .general].sessionId - let blinded15SessionId: SessionId? = SessionThread - .getCurrentUserBlindedSessionId( - db, + let currentUserSessionIds: Set = Set([ + currentUserSessionId, + SessionThread.getCurrentUserBlindedSessionId( threadId: openGroupId, threadVariant: .community, blindingPrefix: .blinded15, + openGroupCapabilityInfo: openGroupCapabilityInfo, using: dependencies - ) - let blinded25SessionId: SessionId? = SessionThread - .getCurrentUserBlindedSessionId( - db, + ), + SessionThread.getCurrentUserBlindedSessionId( threadId: openGroupId, threadVariant: .community, blindingPrefix: .blinded25, + openGroupCapabilityInfo: openGroupCapabilityInfo, using: dependencies ) + ].compactMap { $0 }.map { $0.hexString }) return reactions .reduce(into: []) { result, next in @@ -617,7 +498,7 @@ public extension Message { }() let shouldAddSelfReaction: Bool = ( pendingChangeSelfReaction ?? ( - (next.value.you || reactors.contains(currentUserSessionId.hexString)) && + (next.value.you || !Set(reactors).isDisjoint(with: currentUserSessionIds)) && !pendingChangeRemoveAllReaction ) ) @@ -626,11 +507,7 @@ public extension Message { let timestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() let maxLength: Int = shouldAddSelfReaction ? 4 : 5 let desiredReactorIds: [String] = reactors - .filter { id -> Bool in - id != blinded15SessionId?.hexString && - id != blinded25SessionId?.hexString && - id != currentUserSessionId.hexString - } // Remove current user for now, will add back if needed + .filter { !currentUserSessionIds.contains($0) } // Remove current user for now, will add back if needed .prefix(maxLength) .map { $0 } @@ -685,44 +562,6 @@ public extension Message { } } - private static func processRawReceivedMessage( - _ db: Database, - data: Data, - from origin: Message.Origin, - using dependencies: Dependencies - ) throws -> ProcessedMessage { - let processedMessage: ProcessedMessage = try MessageReceiver.parse( - db, - data: data, - origin: origin, - using: dependencies - ) - - switch processedMessage { - case .standard(let threadId, let threadVariant, _, let messageInfo): - // Prevent ControlMessages from being handled multiple times if not supported - do { - try ControlMessageProcessRecord( - threadId: threadId, - message: messageInfo.message, - serverExpirationTimestamp: origin.serverExpirationTimestamp - )?.insert(db) - } - catch { - // We want to custom handle this - if case DatabaseError.SQLITE_CONSTRAINT_UNIQUE = error { - throw MessageReceiverError.duplicateControlMessage - } - - throw error - } - - default: break - } - - return processedMessage - } - // MARK: - TTL for disappearing messages internal static func getSpecifiedTTL( @@ -736,7 +575,7 @@ public extension Message { switch (destination, message) { // Disappear after sent messages with exceptions case (_, is UnsendRequest): return message.ttl - + case (.closedGroup, is GroupUpdateInviteMessage), (.closedGroup, is GroupUpdateInviteResponseMessage), (.closedGroup, is GroupUpdatePromoteMessage), (.closedGroup, is GroupUpdateMemberLeftMessage), (.closedGroup, is GroupUpdateDeleteMemberContentMessage): @@ -755,6 +594,43 @@ public extension Message { } } +// MARK: - Conversion + +public extension Interaction.Variant { + /// This function can be used to create an `Interaction.Variant` from a `Message` instance + init?(message: Message, currentUserSessionIds: Set) { + switch message { + case is ReadReceipt, is TypingIndicator, is UnsendRequest, is GroupUpdatePromoteMessage, + is GroupUpdateMemberLeftMessage, is GroupUpdateInviteResponseMessage, + is GroupUpdateDeleteMemberContentMessage, is LibSessionMessage: + return nil + + case is TypingIndicator: return nil + case let message as DataExtractionNotification: + self = (message.kind == .screenshot ? + .infoScreenshotNotification : + .infoMediaSavedNotification + ) + + case is ExpirationTimerUpdate: self = .infoDisappearingMessagesUpdate + case is MessageRequestResponse: self = .infoMessageRequestAccepted + + case let message as VisibleMessage: + self = (currentUserSessionIds.contains(message.sender ?? "") ? + .standardOutgoing : + .standardIncoming + ) + + case is CallMessage: self = .infoCall + case is GroupUpdateInviteMessage: self = .infoGroupInfoInvited + case is GroupUpdateInfoChangeMessage: self = .infoGroupInfoUpdated + case is GroupUpdateMemberChangeMessage: self = .infoGroupMembersUpdated + case is GroupUpdateMemberLeftNotificationMessage: self = .infoGroupMembersUpdated + default: return nil + } + } +} + // MARK: - Mutation public extension Message { diff --git a/SessionMessagingKit/Messages/SNProtoContent+Utilities.swift b/SessionMessagingKit/Messages/SNProtoContent+Utilities.swift new file mode 100644 index 0000000000..e4fc9e9510 --- /dev/null +++ b/SessionMessagingKit/Messages/SNProtoContent+Utilities.swift @@ -0,0 +1,84 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +public extension SNProtoContent { + /// Add attachment proto information if required + /// + /// **Note:** This function expects the `attachments` array to be sorted in a way that matches the order of the + /// `InteractionAttachment` values + func addingAttachmentsIfNeeded( + _ message: Message, + _ attachments: [Attachment]? = nil + ) throws -> SNProtoContent? { + guard + let message: VisibleMessage = message as? VisibleMessage, ( + !message.attachmentIds.isEmpty || + message.linkPreview?.attachmentId != nil + ) + else { return self } + + /// Calculate attachment information + let expectedAttachmentUploadCount: Int = ( + message.attachmentIds.count + + (message.linkPreview?.attachmentId != nil ? 1 : 0) + ) + let uniqueAttachmentIds: Set = Set(message.attachmentIds) + .inserting(message.linkPreview?.attachmentId) + + /// We need to ensure we don't send a message which should have uploaded files but hasn't, we do this by comparing the + /// `attachmentIds` on the `VisibleMessage` to the `attachments` value + guard expectedAttachmentUploadCount == (attachments?.count ?? 0) else { + throw MessageSenderError.attachmentsNotUploaded + } + + /// Ensure we haven't incorrectly included the `linkPreview` or `quote` attachments in the main `attachmentIds` + guard uniqueAttachmentIds.count == expectedAttachmentUploadCount else { + throw MessageSenderError.attachmentsInvalid + } + + do { + var processedAttachments: [Attachment] = (attachments ?? []) + + /// Recreate the builder for the proto + guard let dataMessage = dataMessage?.asBuilder() else { + Log.warn(.messageSender, "Couldn't recreate dataMessage builder from: \(message).") + return nil + } + + let builder = self.asBuilder() + var attachmentIds: [String] = message.attachmentIds + + /// Link preview + if let attachmentId: String = message.linkPreview?.attachmentId { + if let index: Array.Index = attachmentIds.firstIndex(of: attachmentId) { + attachmentIds.remove(at: index) + } + + if + let linkPreviewBuilder = self.dataMessage?.preview.first?.asBuilder(), + let attachment: Attachment = processedAttachments.first(where: { $0.id == attachmentId }), + let attachmentProto = attachment.buildProto() + { + linkPreviewBuilder.setImage(attachmentProto) + try dataMessage.setPreview([ linkPreviewBuilder.build() ]) + } + + /// Remove the `linkPreview` attachment from the general attachments set + processedAttachments = processedAttachments.filter { $0.id != attachmentId } + } + + /// Attachments + let attachmentProtos = processedAttachments.compactMap { $0.buildProto() } + dataMessage.setAttachments(attachmentProtos) + + /// Build + builder.setDataMessage(try dataMessage.build()) + return try builder.build() + } catch { + Log.warn(.messageSender, "Couldn't add attachments to proto from: \(message).") + return nil + } + } +} diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift index bc7adb3f0c..3d64e8e482 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public extension VisibleMessage { @@ -31,10 +30,6 @@ public extension VisibleMessage { } public func toProto() -> SNProtoDataMessagePreview? { - preconditionFailure("Use toProto(using:) instead.") - } - - public func toProto(_ db: Database) -> SNProtoDataMessagePreview? { guard let url = url else { Log.warn(.messageSender, "Couldn't construct link preview proto from: \(self).") return nil @@ -42,14 +37,6 @@ public extension VisibleMessage { let linkPreviewProto = SNProtoDataMessagePreview.builder(url: url) if let title = title { linkPreviewProto.setTitle(title) } - if - let attachmentId = attachmentId, - let attachment: Attachment = try? Attachment.fetchOne(db, id: attachmentId), - let attachmentProto = attachment.buildProto() - { - linkPreviewProto.setImage(attachmentProto) - } - do { return try linkPreviewProto.build() } catch { @@ -75,7 +62,7 @@ public extension VisibleMessage { // MARK: - Database Type Conversion public extension VisibleMessage.VMLinkPreview { - static func from(_ db: Database, linkPreview: LinkPreview) -> VisibleMessage.VMLinkPreview { + static func from(linkPreview: LinkPreview) -> VisibleMessage.VMLinkPreview { return VisibleMessage.VMLinkPreview( title: linkPreview.title, url: linkPreview.url, diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift index f0e3edbae1..eb94c6942c 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public extension VisibleMessage { @@ -55,7 +54,7 @@ public extension VisibleMessage { // MARK: - Database Type Conversion public extension VisibleMessage.VMOpenGroupInvitation { - static func from(_ db: Database, linkPreview: LinkPreview) -> VisibleMessage.VMOpenGroupInvitation? { + static func from(linkPreview: LinkPreview) -> VisibleMessage.VMOpenGroupInvitation? { guard let name: String = linkPreview.title else { return nil } return VisibleMessage.VMOpenGroupInvitation( diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift index 6cc6ca9e8e..2f3867a06d 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift @@ -129,20 +129,6 @@ public extension VisibleMessage { } } -// MARK: - Conversion - -extension VisibleMessage.VMProfile { - init( - profile: Profile, - blocksCommunityMessageRequests: Bool? - ) { - self.displayName = profile.name - self.profileKey = profile.profileEncryptionKey - self.profilePictureUrl = profile.profilePictureUrl - self.blocksCommunityMessageRequests = blocksCommunityMessageRequests - } -} - // MARK: - MessageWithProfile public protocol MessageWithProfile { diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift index 6e898cb7c6..d37de1670a 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift @@ -1,26 +1,23 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public extension VisibleMessage { struct VMQuote: Codable { public let timestamp: UInt64? - public let publicKey: String? + public let authorId: String? public let text: String? - public let attachmentId: String? - public func isValid(isSending: Bool) -> Bool { timestamp != nil && publicKey != nil } + public func isValid(isSending: Bool) -> Bool { timestamp != nil && authorId != nil } // MARK: - Initialization - internal init(timestamp: UInt64, publicKey: String, text: String?, attachmentId: String?) { + internal init(timestamp: UInt64, authorId: String, text: String?) { self.timestamp = timestamp - self.publicKey = publicKey + self.authorId = authorId self.text = text - self.attachmentId = attachmentId } // MARK: - Proto Conversion @@ -28,24 +25,18 @@ public extension VisibleMessage { public static func fromProto(_ proto: SNProtoDataMessageQuote) -> VMQuote? { return VMQuote( timestamp: proto.id, - publicKey: proto.author, - text: proto.text, - attachmentId: nil + authorId: proto.author, + text: proto.text ) } public func toProto() -> SNProtoDataMessageQuote? { - preconditionFailure("Use toProto(_:) instead.") - } - - public func toProto(_ db: Database) -> SNProtoDataMessageQuote? { - guard let timestamp = timestamp, let publicKey = publicKey else { + guard let timestamp = timestamp, let authorId = authorId else { Log.warn(.messageSender, "Couldn't construct quote proto from: \(self).") return nil } - let quoteProto = SNProtoDataMessageQuote.builder(id: timestamp, author: publicKey) + let quoteProto = SNProtoDataMessageQuote.builder(id: timestamp, author: authorId) if let text = text { quoteProto.setText(text) } - addAttachmentsIfNeeded(db, to: quoteProto) do { return try quoteProto.build() } catch { @@ -53,32 +44,6 @@ public extension VisibleMessage { return nil } } - - private func addAttachmentsIfNeeded(_ db: Database, to quoteProto: SNProtoDataMessageQuote.SNProtoDataMessageQuoteBuilder) { - guard let attachmentId = attachmentId else { return } - guard - let attachment: Attachment = try? Attachment.fetchOne(db, id: attachmentId), - attachment.state == .uploaded - else { - #if DEBUG - preconditionFailure("Sending a message before all associated attachments have been uploaded.") - #else - return - #endif - } - let quotedAttachmentProto = SNProtoDataMessageQuoteQuotedAttachment.builder() - quotedAttachmentProto.setContentType(attachment.contentType) - if let fileName = attachment.sourceFilename { quotedAttachmentProto.setFileName(fileName) } - guard let attachmentProto = attachment.buildProto() else { - return Log.warn(.messageSender, "Ignoring invalid attachment for quoted message.") - } - quotedAttachmentProto.setThumbnail(attachmentProto) - do { - try quoteProto.addAttachments(quotedAttachmentProto.build()) - } catch { - Log.warn(.messageSender, "Couldn't construct quoted attachment proto from: \(self).") - } - } // MARK: - Description @@ -86,9 +51,8 @@ public extension VisibleMessage { """ Quote( timestamp: \(timestamp?.description ?? "null"), - publicKey: \(publicKey ?? "null"), - text: \(text ?? "null"), - attachmentId: \(attachmentId ?? "null") + authorId: \(authorId ?? "null"), + text: \(text ?? "null") ) """ } @@ -98,12 +62,11 @@ public extension VisibleMessage { // MARK: - Database Type Conversion public extension VisibleMessage.VMQuote { - static func from(_ db: Database, quote: Quote) -> VisibleMessage.VMQuote { + static func from(quote: Quote) -> VisibleMessage.VMQuote { return VisibleMessage.VMQuote( timestamp: UInt64(quote.timestampMs), - publicKey: quote.authorId, - text: quote.body, - attachmentId: quote.attachmentId + authorId: quote.authorId, + text: quote.body ) } } diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Reaction.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Reaction.swift index edf541920a..7f7b3e63b1 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Reaction.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Reaction.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public extension VisibleMessage { diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift index 3c75d60e83..6fe8111fdc 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public final class VisibleMessage: Message { @@ -126,11 +125,10 @@ public final class VisibleMessage: Message { ) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { let proto = SNProtoContent.builder() if let sigTimestampMs = sigTimestampMs { proto.setSigTimestamp(sigTimestampMs) } - var attachmentIds = self.attachmentIds let dataMessage: SNProtoDataMessage.SNProtoDataMessageBuilder // Profile @@ -146,11 +144,7 @@ public final class VisibleMessage: Message { // Quote - if let quotedAttachmentId = quote?.attachmentId, let index = attachmentIds.firstIndex(of: quotedAttachmentId) { - attachmentIds.remove(at: index) - } - - if let quote = quote, let quoteProto = quote.toProto(db) { + if let quote = quote, let quoteProto = quote.toProto() { dataMessage.setQuote(quoteProto) } @@ -159,23 +153,10 @@ public final class VisibleMessage: Message { attachmentIds.remove(at: index) } - if let linkPreview = linkPreview, let linkPreviewProto = linkPreview.toProto(db) { + if let linkPreview = linkPreview, let linkPreviewProto = linkPreview.toProto() { dataMessage.setPreview([ linkPreviewProto ]) } - // Attachments - - let attachmentIdIndexes: [String: Int] = (try? InteractionAttachment - .filter(self.attachmentIds.contains(InteractionAttachment.Columns.attachmentId)) - .fetchAll(db)) - .defaulting(to: []) - .reduce(into: [:]) { result, next in result[next.attachmentId] = next.albumIndex } - let attachments: [Attachment] = (try? Attachment.fetchAll(db, ids: self.attachmentIds)) - .defaulting(to: []) - .sorted { lhs, rhs in (attachmentIdIndexes[lhs.id] ?? 0) < (attachmentIdIndexes[rhs.id] ?? 0) } - let attachmentProtos = attachments.compactMap { $0.buildProto() } - dataMessage.setAttachments(attachmentProtos) - // Open group invitation if let openGroupInvitation = openGroupInvitation, @@ -223,47 +204,3 @@ public final class VisibleMessage: Message { """ } } - -// MARK: - Database Type Conversion - -public extension VisibleMessage { - static func from(_ db: Database, interaction: Interaction) -> VisibleMessage { - let linkPreview: LinkPreview? = try? interaction.linkPreview.fetchOne(db) - - let visibleMessage: VisibleMessage = VisibleMessage( - sender: interaction.authorId, - sentTimestampMs: UInt64(interaction.timestampMs), - syncTarget: nil, - text: interaction.body, - attachmentIds: ((try? interaction.attachments.fetchAll(db)) ?? []) - .map { $0.id }, - quote: (try? interaction.quote.fetchOne(db)) - .map { VMQuote.from(db, quote: $0) }, - linkPreview: linkPreview - .map { linkPreview in - guard linkPreview.variant == .standard else { return nil } - - return VMLinkPreview.from(db, linkPreview: linkPreview) - }, - profile: nil, // Don't attach the profile to avoid sending a legacy version (set in MessageSender) - openGroupInvitation: linkPreview.map { linkPreview in - guard linkPreview.variant == .openGroupInvitation else { return nil } - - return VMOpenGroupInvitation.from( - db, - linkPreview: linkPreview - ) - }, - reaction: nil // Reactions are custom messages sent separately - ) - .with( - expiresInSeconds: interaction.expiresInSeconds, - expiresStartedAtMs: interaction.expiresStartedAtMs - ) - - visibleMessage.expiresInSeconds = interaction.expiresInSeconds - visibleMessage.expiresStartedAtMs = interaction.expiresStartedAtMs - - return visibleMessage - } -} diff --git a/SessionMessagingKit/Open Groups/Crypto/Crypto+OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/Crypto/Crypto+OpenGroupAPI.swift index 9be17f6bc1..516fb0a6ac 100644 --- a/SessionMessagingKit/Open Groups/Crypto/Crypto+OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/Crypto/Crypto+OpenGroupAPI.swift @@ -4,7 +4,6 @@ import Foundation import CryptoKit -import GRDB import SessionUtil import SessionUtilitiesKit @@ -158,27 +157,22 @@ public extension Crypto.Verification { public extension Crypto.Generator { static func ciphertextWithSessionBlindingProtocol( - _ db: Database, plaintext: Data, recipientBlindedId: String, - serverPublicKey: String, - using dependencies: Dependencies + serverPublicKey: String ) -> Crypto.Generator { return Crypto.Generator( id: "ciphertextWithSessionBlindingProtocol", args: [plaintext, serverPublicKey] - ) { - let ed25519KeyPair: KeyPair = try Identity.fetchUserEd25519KeyPair(db) ?? { - throw MessageSenderError.noUserED25519KeyPair - }() - + ) { dependencies in var cPlaintext: [UInt8] = Array(plaintext) - var cEd25519SecretKey: [UInt8] = ed25519KeyPair.secretKey + var cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey var cRecipientBlindedId: [UInt8] = Array(Data(hex: recipientBlindedId)) var cServerPublicKey: [UInt8] = Array(Data(hex: serverPublicKey)) var maybeCiphertext: UnsafeMutablePointer? = nil var ciphertextLen: Int = 0 + guard !cEd25519SecretKey.isEmpty else { throw MessageSenderError.noUserED25519KeyPair } guard cEd25519SecretKey.count == 64, cServerPublicKey.count == 32, @@ -202,23 +196,17 @@ public extension Crypto.Generator { } static func plaintextWithSessionBlindingProtocol( - _ db: Database, ciphertext: Data, senderId: String, recipientId: String, - serverPublicKey: String, - using dependencies: Dependencies + serverPublicKey: String ) -> Crypto.Generator<(plaintext: Data, senderSessionIdHex: String)> { return Crypto.Generator( id: "plaintextWithSessionBlindingProtocol", args: [ciphertext, senderId, recipientId] - ) { - let ed25519KeyPair: KeyPair = try Identity.fetchUserEd25519KeyPair(db) ?? { - throw MessageSenderError.noUserED25519KeyPair - }() - + ) { dependencies in var cCiphertext: [UInt8] = Array(ciphertext) - var cEd25519SecretKey: [UInt8] = ed25519KeyPair.secretKey + var cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey var cSenderId: [UInt8] = Array(Data(hex: senderId)) var cRecipientId: [UInt8] = Array(Data(hex: recipientId)) var cServerPublicKey: [UInt8] = Array(Data(hex: serverPublicKey)) @@ -226,6 +214,7 @@ public extension Crypto.Generator { var maybePlaintext: UnsafeMutablePointer? = nil var plaintextLen: Int = 0 + guard !cEd25519SecretKey.isEmpty else { throw MessageSenderError.noUserED25519KeyPair } guard cEd25519SecretKey.count == 64, cServerPublicKey.count == 32, diff --git a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift index da11fef449..219021f442 100644 --- a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift +++ b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift @@ -25,7 +25,7 @@ extension OpenGroupAPI { public let id: Int64 public let sender: String? - public let posted: TimeInterval? + public let posted: TimeInterval public let edited: TimeInterval? public let deleted: Bool? public let seqNo: Int64 @@ -60,10 +60,10 @@ extension OpenGroupAPI.Message { public init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - let maybeSender: String? = try? container.decode(String.self, forKey: .sender) - let maybeBase64EncodedData: String? = try? container.decode(String.self, forKey: .base64EncodedData) - let maybeBase64EncodedSignature: String? = try? container.decode(String.self, forKey: .base64EncodedSignature) - let maybeReactions: [String:Reaction]? = try? container.decode([String:Reaction].self, forKey: .reactions) + let maybeSender: String? = try container.decodeIfPresent(String.self, forKey: .sender) + let maybeBase64EncodedData: String? = try container.decodeIfPresent(String.self, forKey: .base64EncodedData) + let maybeBase64EncodedSignature: String? = try container.decodeIfPresent(String.self, forKey: .base64EncodedSignature) + let maybeReactions: [String: Reaction]? = try container.decodeIfPresent([String: Reaction].self, forKey: .reactions) // If we have data and a signature (ie. the message isn't a deletion) then validate the signature if let base64EncodedData: String = maybeBase64EncodedData, let base64EncodedSignature: String = maybeBase64EncodedSignature { @@ -104,14 +104,14 @@ extension OpenGroupAPI.Message { self = OpenGroupAPI.Message( id: try container.decode(Int64.self, forKey: .id), - sender: try? container.decode(String.self, forKey: .sender), - posted: try? container.decode(TimeInterval.self, forKey: .posted), - edited: try? container.decode(TimeInterval.self, forKey: .edited), - deleted: try? container.decode(Bool.self, forKey: .deleted), + sender: try container.decodeIfPresent(String.self, forKey: .sender), + posted: try container.decode(TimeInterval.self, forKey: .posted), + edited: try container.decodeIfPresent(TimeInterval.self, forKey: .edited), + deleted: try container.decodeIfPresent(Bool.self, forKey: .deleted), seqNo: try container.decode(Int64.self, forKey: .seqNo), - whisper: ((try? container.decode(Bool.self, forKey: .whisper)) ?? false), - whisperMods: ((try? container.decode(Bool.self, forKey: .whisperMods)) ?? false), - whisperTo: try? container.decode(String.self, forKey: .whisperTo), + whisper: ((try container.decodeIfPresent(Bool.self, forKey: .whisper)) ?? false), + whisperMods: ((try container.decodeIfPresent(Bool.self, forKey: .whisperMods)) ?? false), + whisperTo: try container.decodeIfPresent(String.self, forKey: .whisperTo), base64EncodedData: maybeBase64EncodedData, base64EncodedSignature: maybeBase64EncodedSignature, reactions: !container.contains(.reactions) ? nil : (maybeReactions ?? [:]) @@ -125,8 +125,8 @@ extension OpenGroupAPI.Message.Reaction { self = OpenGroupAPI.Message.Reaction( count: try container.decode(Int64.self, forKey: .count), - reactors: try? container.decode([String].self, forKey: .reactors), - you: (try? container.decode(Bool.self, forKey: .you)) ?? false, + reactors: try container.decodeIfPresent([String].self, forKey: .reactors), + you: ((try container.decodeIfPresent(Bool.self, forKey: .you)) ?? false), index: (try container.decode(Int64.self, forKey: .index)) ) } diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index e274a59415..78b97e3f08 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -3,12 +3,16 @@ // stringlint:disable import Foundation -import Combine -import GRDB import SessionSnodeKit import SessionUtilitiesKit public enum OpenGroupAPI { + public struct RoomInfo: Codable { + let roomToken: String + let infoUpdates: Int64 + let sequenceNumber: Int64 + } + // MARK: - Settings public static let legacyDefaultServerIP = "116.203.70.33" @@ -29,49 +33,29 @@ public enum OpenGroupAPI { /// - Inbox for the server /// - Outbox for the server public static func preparedPoll( - _ db: Database, - server: String, + roomInfo: [RoomInfo], + lastInboxMessageId: Int64, + lastOutboxMessageId: Int64, hasPerformedInitialPoll: Bool, timeSinceLastPoll: TimeInterval, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest> { - let lastInboxMessageId: Int64 = (try? OpenGroup - .select(.inboxLatestMessageId) - .filter(OpenGroup.Columns.server == server) - .asRequest(of: Int64.self) - .fetchOne(db)) - .defaulting(to: 0) - let lastOutboxMessageId: Int64 = (try? OpenGroup - .select(.outboxLatestMessageId) - .filter(OpenGroup.Columns.server == server) - .asRequest(of: Int64.self) - .fetchOne(db)) - .defaulting(to: 0) - let capabilities: Set = (try? Capability - .select(.variant) - .filter(Capability.Columns.openGroupServer == server) - .asRequest(of: Capability.Variant.self) - .fetchSet(db)) - .defaulting(to: []) - let openGroupRooms: [OpenGroup] = (try? OpenGroup - .filter(OpenGroup.Columns.server == server.lowercased()) // Note: The `OpenGroup` type converts to lowercase in init - .filter(OpenGroup.Columns.isActive == true) - .filter(OpenGroup.Columns.roomToken != "") - .fetchAll(db)) - .defaulting(to: []) - + guard case .community(_, _, _, let supportsBlinding, _) = authMethod.info else { + throw NetworkError.invalidPreparedRequest + } + let preparedRequests: [any ErasedPreparedRequest] = [ try preparedCapabilities( - db, - server: server, + authMethod: authMethod, using: dependencies ) ].appending( // Per-room requests - contentsOf: try openGroupRooms - .flatMap { openGroup -> [any ErasedPreparedRequest] in + contentsOf: try roomInfo + .flatMap { roomInfo -> [any ErasedPreparedRequest] in let shouldRetrieveRecentMessages: Bool = ( - openGroup.sequenceNumber == 0 || ( + roomInfo.sequenceNumber == 0 || ( // If it's the first poll for this launch and it's been longer than // 'maxInactivityPeriod' then just retrieve recent messages instead // of trying to get all messages since the last one retrieved @@ -82,24 +66,21 @@ public enum OpenGroupAPI { return [ try preparedRoomPollInfo( - db, - lastUpdated: openGroup.infoUpdates, - for: openGroup.roomToken, - on: openGroup.server, + lastUpdated: roomInfo.infoUpdates, + roomToken: roomInfo.roomToken, + authMethod: authMethod, using: dependencies ), (shouldRetrieveRecentMessages ? try preparedRecentMessages( - db, - in: openGroup.roomToken, - on: openGroup.server, + roomToken: roomInfo.roomToken, + authMethod: authMethod, using: dependencies ) : try preparedMessagesSince( - db, - seqNo: openGroup.sequenceNumber, - in: openGroup.roomToken, - on: openGroup.server, + seqNo: roomInfo.sequenceNumber, + roomToken: roomInfo.roomToken, + authMethod: authMethod, using: dependencies ) ) @@ -109,20 +90,28 @@ public enum OpenGroupAPI { .appending( contentsOf: ( // The 'inbox' and 'outbox' only work with blinded keys so don't bother polling them if not blinded - !capabilities.contains(.blind) ? [] : + !supportsBlinding ? [] : [ // Inbox (only check the inbox if the user want's community message requests) - (!db[.checkForCommunityMessageRequests] ? nil : + (!dependencies.mutate(cache: .libSession) { $0.get(.checkForCommunityMessageRequests) } ? nil : (lastInboxMessageId == 0 ? - try preparedInbox(db, on: server, using: dependencies) : - try preparedInboxSince(db, id: lastInboxMessageId, on: server, using: dependencies) + try preparedInbox(authMethod: authMethod, using: dependencies) : + try preparedInboxSince( + id: lastInboxMessageId, + authMethod: authMethod, + using: dependencies + ) ) ), // Outbox (lastOutboxMessageId == 0 ? - try preparedOutbox(db, on: server, using: dependencies) : - try preparedOutboxSince(db, id: lastOutboxMessageId, on: server, using: dependencies) + try preparedOutbox(authMethod: authMethod, using: dependencies) : + try preparedOutboxSince( + id: lastOutboxMessageId, + authMethod: authMethod, + using: dependencies + ) ), ].compactMap { $0 } ) @@ -130,12 +119,11 @@ public enum OpenGroupAPI { return try OpenGroupAPI .preparedBatch( - db, - server: server, requests: preparedRequests, + authMethod: authMethod, using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Submits multiple requests wrapped up in a single request, runs them all, then returns the result of each one @@ -146,24 +134,22 @@ public enum OpenGroupAPI { /// For contained subrequests that specify a body (i.e. POST or PUT requests) exactly one of `json`, `b64`, or `bytes` must be provided /// with the request body. public static func preparedBatch( - _ db: Database, - server: String, requests: [any ErasedPreparedRequest], + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest> { return try Network.PreparedRequest( request: Request( - db, method: .post, - server: server, endpoint: Endpoint.batch, - body: Network.BatchRequest(requests: requests) + body: Network.BatchRequest(requests: requests), + authMethod: authMethod ), responseType: Network.BatchResponseMap.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// This is like `/batch`, except that it guarantees to perform requests sequentially in the order provided and will stop processing requests @@ -177,24 +163,22 @@ public enum OpenGroupAPI { /// list (if requests were stopped because of a non-2xx response) - In such a case, the final, non-2xx response is still included as the final /// response value private static func preparedSequence( - _ db: Database, - server: String, requests: [any ErasedPreparedRequest], + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest> { return try Network.PreparedRequest( request: Request( - db, method: .post, - server: server, endpoint: Endpoint.sequence, - body: Network.BatchRequest(requests: requests) + body: Network.BatchRequest(requests: requests), + authMethod: authMethod ), responseType: Network.BatchResponseMap.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } // MARK: - Capabilities @@ -207,25 +191,19 @@ public enum OpenGroupAPI { /// Eg. `GET /capabilities` could return `{"capabilities": ["sogs", "batch"]}` `GET /capabilities?required=magic,batch` /// could return: `{"capabilities": ["sogs", "batch"], "missing": ["magic"]}` public static func preparedCapabilities( - _ db: Database, - server: String, - forceBlinded: Bool = false, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, - server: server, - endpoint: .capabilities + endpoint: .capabilities, + authMethod: authMethod ), responseType: Capabilities.self, - additionalSignatureData: AdditionalSigningData( - server: server, - forceBlinded: forceBlinded - ), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } // MARK: - Room @@ -234,41 +212,37 @@ public enum OpenGroupAPI { /// /// Rooms to which the user does not have access (e.g. because they are banned, or the room has restricted access permissions) are not included public static func preparedRooms( - _ db: Database, - server: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest<[Room]> { return try Network.PreparedRequest( request: Request( - db, - server: server, - endpoint: .rooms + endpoint: .rooms, + authMethod: authMethod ), responseType: [Room].self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Returns the details of a single room public static func preparedRoom( - _ db: Database, - for roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, - server: server, - endpoint: .room(roomToken) + endpoint: .room(roomToken), + authMethod: authMethod ), responseType: Room.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Polls a room for metadata updates @@ -276,23 +250,21 @@ public enum OpenGroupAPI { /// The endpoint polls room metadata for this room, always including the instantaneous room details (such as the user's permission and current /// number of active users), and including the full room metadata if the room's info_updated counter has changed from the provided value public static func preparedRoomPollInfo( - _ db: Database, lastUpdated: Int64, - for roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, - server: server, - endpoint: .roomPollInfo(roomToken, lastUpdated) + endpoint: .roomPollInfo(roomToken, lastUpdated), + authMethod: authMethod ), responseType: RoomPollInfo.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } public typealias CapabilitiesAndRoomResponse = ( @@ -303,24 +275,22 @@ public enum OpenGroupAPI { /// This is a convenience method which constructs a `/sequence` of the `capabilities` and `room` requests, refer to those /// methods for the documented behaviour of each method public static func preparedCapabilitiesAndRoom( - _ db: Database, - for roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try OpenGroupAPI .preparedSequence( - db, - server: server, requests: [ // Get the latest capabilities for the server (in case it's a new server or the // cached ones are stale) - preparedCapabilities(db, server: server, using: dependencies), - preparedRoom(db, for: roomToken, on: server, using: dependencies) + preparedCapabilities(authMethod: authMethod, using: dependencies), + preparedRoom(roomToken: roomToken, authMethod: authMethod, using: dependencies) ], + authMethod: authMethod, using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) .tryMap { (info: ResponseInfoType, response: Network.BatchResponseMap) -> CapabilitiesAndRoomResponse in let maybeCapabilities: Network.BatchSubResponse? = (response[.capabilities] as? Network.BatchSubResponse) let maybeRoomResponse: Any? = response.data @@ -355,23 +325,21 @@ public enum OpenGroupAPI { /// This is a convenience method which constructs a `/sequence` of the `capabilities` and `rooms` requests, refer to those /// methods for the documented behaviour of each method public static func preparedCapabilitiesAndRooms( - _ db: Database, - on server: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try OpenGroupAPI .preparedSequence( - db, - server: server, requests: [ // Get the latest capabilities for the server (in case it's a new server or the // cached ones are stale) - preparedCapabilities(db, server: server, using: dependencies), - preparedRooms(db, server: server, using: dependencies) + preparedCapabilities(authMethod: authMethod, using: dependencies), + preparedRooms(authMethod: authMethod, using: dependencies) ], + authMethod: authMethod, using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) .tryMap { (info: ResponseInfoType, response: Network.BatchResponseMap) -> CapabilitiesAndRoomsResponse in let maybeCapabilities: Network.BatchSubResponse? = (response[.capabilities] as? Network.BatchSubResponse) let maybeRooms: Network.BatchSubResponse<[Room]>? = response.data @@ -403,28 +371,24 @@ public enum OpenGroupAPI { /// Posts a new message to a room public static func preparedSend( - _ db: Database, plaintext: Data, - to roomToken: String, - on server: String, + roomToken: String, whisperTo: String?, whisperMods: Bool, fileIds: [String]?, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { let signResult: (publicKey: String, signature: [UInt8]) = try sign( - db, messageBytes: plaintext.bytes, - for: server, + authMethod: authMethod, fallbackSigningType: .standard, using: dependencies ) return try Network.PreparedRequest( request: Request( - db, method: .post, - server: server, endpoint: Endpoint.roomMessage(roomToken), body: SendMessageRequest( data: plaintext, @@ -432,95 +396,89 @@ public enum OpenGroupAPI { whisperTo: whisperTo, whisperMods: whisperMods, fileIds: fileIds - ) + ), + authMethod: authMethod ), responseType: Message.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Returns a single message by ID public static func preparedMessage( - _ db: Database, id: Int64, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, - server: server, - endpoint: .roomMessageIndividual(roomToken, id: id) + endpoint: .roomMessageIndividual(roomToken, id: id), + authMethod: authMethod ), responseType: Message.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Edits a message, replacing its existing content with new content and a new signature /// /// **Note:** This edit may only be initiated by the creator of the post, and the poster must currently have write permissions in the room public static func preparedMessageUpdate( - _ db: Database, id: Int64, plaintext: Data, fileIds: [Int64]?, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { let signResult: (publicKey: String, signature: [UInt8]) = try sign( - db, messageBytes: plaintext.bytes, - for: server, + authMethod: authMethod, fallbackSigningType: .standard, using: dependencies ) return try Network.PreparedRequest( request: Request( - db, method: .put, - server: server, endpoint: Endpoint.roomMessageIndividual(roomToken, id: id), body: UpdateMessageRequest( data: plaintext, signature: Data(signResult.signature), fileIds: fileIds - ) + ), + authMethod: authMethod ), responseType: NoResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Remove a message by its message id public static func preparedMessageDelete( - _ db: Database, id: Int64, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, method: .delete, - server: server, - endpoint: .roomMessageIndividual(roomToken, id: id) + endpoint: .roomMessageIndividual(roomToken, id: id), + authMethod: authMethod ), responseType: NoResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Retrieves recent messages posted to this room @@ -529,26 +487,24 @@ public enum OpenGroupAPI { /// versions: that is, deleted message indicators and pre-editing versions of messages are not returned. Messages are returned in order /// from most recent to least recent public static func preparedRecentMessages( - _ db: Database, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest<[Failable]> { return try Network.PreparedRequest( request: Request( - db, - server: server, endpoint: .roomMessagesRecent(roomToken), queryParameters: [ .updateTypes: UpdateTypes.reaction.rawValue, .reactors: "5" - ] + ], + authMethod: authMethod ), responseType: [Failable].self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Retrieves messages from the room preceding a given id. @@ -558,27 +514,25 @@ public enum OpenGroupAPI { /// /// As with .../recent, this endpoint does not include deleted messages and always returns the current version, for edited messages. public static func preparedMessagesBefore( - _ db: Database, messageId: Int64, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest<[Failable]> { return try Network.PreparedRequest( request: Request( - db, - server: server, endpoint: .roomMessagesBefore(roomToken, id: messageId), queryParameters: [ .updateTypes: UpdateTypes.reaction.rawValue, .reactors: "5" - ] + ], + authMethod: authMethod ), responseType: [Failable].self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Retrieves message updates from a room. This is the main message polling endpoint in SOGS. @@ -588,27 +542,25 @@ public enum OpenGroupAPI { /// to existing messages (i.e. edits), and message deletions made to the room since the given update id. Messages are returned in "update" /// order, that is, in the order in which the change was applied to the room, from oldest the newest. public static func preparedMessagesSince( - _ db: Database, seqNo: Int64, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest<[Failable]> { return try Network.PreparedRequest( request: Request( - db, - server: server, endpoint: .roomMessagesSince(roomToken, seqNo: seqNo), queryParameters: [ .updateTypes: UpdateTypes.reaction.rawValue, .reactors: "5" - ] + ], + authMethod: authMethod ), responseType: [Failable].self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Deletes all messages from a given sessionId within the provided rooms (or globally) on a server @@ -625,35 +577,32 @@ public enum OpenGroupAPI { /// /// - dependencies: Injected dependencies (used for unit testing) public static func preparedMessagesDeleteAll( - _ db: Database, sessionId: String, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, method: .delete, - server: server, - endpoint: Endpoint.roomDeleteMessages(roomToken, sessionId: sessionId) + endpoint: Endpoint.roomDeleteMessages(roomToken, sessionId: sessionId), + authMethod: authMethod ), responseType: NoResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } // MARK: - Reactions /// Returns the list of all reactors who have added a particular reaction to a particular message. public static func preparedReactors( - _ db: Database, emoji: String, id: Int64, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. @@ -664,16 +613,15 @@ public enum OpenGroupAPI { return try Network.PreparedRequest( request: Request( - db, method: .get, - server: server, - endpoint: .reactors(roomToken, id: id, emoji: encodedEmoji) + endpoint: .reactors(roomToken, id: id, emoji: encodedEmoji), + authMethod: authMethod ), responseType: NoResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Adds a reaction to the given message in this room. The user must have read access in the room. @@ -681,11 +629,10 @@ public enum OpenGroupAPI { /// Reactions are short strings of 1-12 unicode codepoints, typically emoji (or character sequences to produce an emoji variant, /// such as 👨🏿‍🦰, which is composed of 4 unicode "characters" but usually renders as a single emoji "Man: Dark Skin Tone, Red Hair"). public static func preparedReactionAdd( - _ db: Database, emoji: String, id: Int64, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. @@ -696,26 +643,24 @@ public enum OpenGroupAPI { return try Network.PreparedRequest( request: Request( - db, method: .put, - server: server, - endpoint: .reaction(roomToken, id: id, emoji: encodedEmoji) + endpoint: .reaction(roomToken, id: id, emoji: encodedEmoji), + authMethod: authMethod ), responseType: ReactionAddResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Removes a reaction from a post this room. The user must have read access in the room. This only removes the user's own reaction /// but does not affect the reactions of other users. public static func preparedReactionDelete( - _ db: Database, emoji: String, id: Int64, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. @@ -726,27 +671,25 @@ public enum OpenGroupAPI { return try Network.PreparedRequest( request: Request( - db, method: .delete, - server: server, - endpoint: .reaction(roomToken, id: id, emoji: encodedEmoji) + endpoint: .reaction(roomToken, id: id, emoji: encodedEmoji), + authMethod: authMethod ), responseType: ReactionRemoveResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Removes all reactions of all users from a post in this room. The calling must have moderator permissions in the room. This endpoint /// can either remove a single reaction (e.g. remove all 🍆 reactions) by specifying it after the message id (following a /), or remove all /// reactions from the post by not including the / suffix of the URL. public static func preparedReactionDeleteAll( - _ db: Database, emoji: String, id: Int64, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. @@ -757,16 +700,15 @@ public enum OpenGroupAPI { return try Network.PreparedRequest( request: Request( - db, method: .delete, - server: server, - endpoint: .reactionDelete(roomToken, id: id, emoji: encodedEmoji) + endpoint: .reactionDelete(roomToken, id: id, emoji: encodedEmoji), + authMethod: authMethod ), responseType: ReactionRemoveAllResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } // MARK: - Pinning @@ -782,107 +724,96 @@ public enum OpenGroupAPI { /// messages are returned in pinning-order this allows admins to order multiple pinned messages in a room by re-pinning (via this endpoint) in the /// order in which pinned messages should be displayed public static func preparedPinMessage( - _ db: Database, id: Int64, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, method: .post, - server: server, - endpoint: .roomPinMessage(roomToken, id: id) + endpoint: .roomPinMessage(roomToken, id: id), + authMethod: authMethod ), responseType: NoResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Remove a message from this room's pinned message list /// /// The user must have `admin` (not just `moderator`) permissions in the room public static func preparedUnpinMessage( - _ db: Database, id: Int64, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, method: .post, - server: server, - endpoint: .roomUnpinMessage(roomToken, id: id) + endpoint: .roomUnpinMessage(roomToken, id: id), + authMethod: authMethod ), responseType: NoResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Removes _all_ pinned messages from this room /// /// The user must have `admin` (not just `moderator`) permissions in the room public static func preparedUnpinAll( - _ db: Database, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, method: .post, - server: server, - endpoint: .roomUnpinAll(roomToken) + endpoint: .roomUnpinAll(roomToken), + authMethod: authMethod ), responseType: NoResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } // MARK: - Files public static func preparedUpload( - _ db: Database, data: Data, - to roomToken: String, - on server: String, + roomToken: String, fileName: String? = nil, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { - let maybePublicKey: String? = try? OpenGroup - .select(.publicKey) - .filter(OpenGroup.Columns.server == server.lowercased()) - .asRequest(of: String.self) - .fetchOne(db) - - guard let serverPublicKey: String = maybePublicKey else { throw OpenGroupAPIError.noPublicKey } + guard case .community(let server, let publicKey, _, _, _) = authMethod.info else { + throw NetworkError.invalidPreparedRequest + } return try Network.PreparedRequest( request: Request( endpoint: Endpoint.roomFile(roomToken), destination: .serverUpload( server: server, - x25519PublicKey: serverPublicKey, + x25519PublicKey: publicKey, fileName: fileName ), body: data ), responseType: FileUploadResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), requestTimeout: Network.fileUploadTimeout, using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } public static func downloadUrlString( @@ -894,36 +825,33 @@ public enum OpenGroupAPI { } public static func preparedDownload( - _ db: Database, url: URL, - from roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { guard let fileId: String = Attachment.fileId(for: url.absoluteString) else { throw NetworkError.invalidURL } - return try preparedDownload(db, fileId: fileId, from: roomToken, on: server, using: dependencies) + return try preparedDownload(fileId: fileId, roomToken: roomToken, authMethod: authMethod, using: dependencies) } public static func preparedDownload( - _ db: Database, fileId: String, - from roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, - server: server, - endpoint: .roomFileIndividual(roomToken, fileId) + endpoint: .roomFileIndividual(roomToken, fileId), + authMethod: authMethod ), responseType: Data.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), requestTimeout: Network.fileDownloadTimeout, using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } // MARK: - Inbox/Outbox (Message Requests) @@ -932,137 +860,125 @@ public enum OpenGroupAPI { /// /// **Note:** `inbox` will return a `304` with an empty response if no messages (hence the optional return type) public static func preparedInbox( - _ db: Database, - on server: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest<[DirectMessage]?> { return try Network.PreparedRequest( request: Request( - db, - server: server, - endpoint: .inbox + endpoint: .inbox, + authMethod: authMethod ), responseType: [DirectMessage]?.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Polls for any DMs received since the given id, this method will return a `304` with an empty response if there are no messages /// /// **Note:** `inboxSince` will return a `304` with an empty response if no messages (hence the optional return type) public static func preparedInboxSince( - _ db: Database, id: Int64, - on server: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest<[DirectMessage]?> { return try Network.PreparedRequest( request: Request( - db, - server: server, - endpoint: .inboxSince(id: id) + endpoint: .inboxSince(id: id), + authMethod: authMethod ), responseType: [DirectMessage]?.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Remove all message requests from inbox, this methrod will return the number of messages deleted public static func preparedClearInbox( - _ db: Database, - on server: String, requestTimeout: TimeInterval = Network.defaultTimeout, requestAndPathBuildTimeout: TimeInterval? = nil, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, method: .delete, - server: server, - endpoint: .inbox + endpoint: .inbox, + authMethod: authMethod ), responseType: DeleteInboxResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), requestTimeout: requestTimeout, requestAndPathBuildTimeout: requestAndPathBuildTimeout, using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Delivers a direct message to a user via their blinded Session ID /// /// The body of this request is a JSON object containing a message key with a value of the encrypted-then-base64-encoded message to deliver public static func preparedSend( - _ db: Database, ciphertext: Data, toInboxFor blindedSessionId: String, - on server: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, method: .post, - server: server, endpoint: Endpoint.inboxFor(sessionId: blindedSessionId), body: SendDirectMessageRequest( message: ciphertext - ) + ), + authMethod: authMethod ), responseType: SendDirectMessageResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Retrieves all of the user's sent DMs (up to limit) /// /// **Note:** `outbox` will return a `304` with an empty response if no messages (hence the optional return type) public static func preparedOutbox( - _ db: Database, - on server: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest<[DirectMessage]?> { return try Network.PreparedRequest( request: Request( - db, - server: server, - endpoint: .outbox + endpoint: .outbox, + authMethod: authMethod ), responseType: [DirectMessage]?.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Polls for any DMs sent since the given id, this method will return a `304` with an empty response if there are no messages /// /// **Note:** `outboxSince` will return a `304` with an empty response if no messages (hence the optional return type) public static func preparedOutboxSince( - _ db: Database, id: Int64, - on server: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest<[DirectMessage]?> { return try Network.PreparedRequest( request: Request( - db, - server: server, - endpoint: .outboxSince(id: id) + endpoint: .outboxSince(id: id), + authMethod: authMethod ), responseType: [DirectMessage]?.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } // MARK: - Users @@ -1099,30 +1015,28 @@ public enum OpenGroupAPI { /// /// - dependencies: Injected dependencies (used for unit testing) public static func preparedUserBan( - _ db: Database, sessionId: String, for timeout: TimeInterval? = nil, from roomTokens: [String]? = nil, - on server: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, method: .post, - server: server, endpoint: Endpoint.userBan(sessionId), body: UserBanRequest( rooms: roomTokens, global: (roomTokens == nil ? true : nil), timeout: timeout - ) + ), + authMethod: authMethod ), responseType: NoResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Removes a user ban from specific rooms, or from the server globally @@ -1150,28 +1064,26 @@ public enum OpenGroupAPI { /// /// - dependencies: Injected dependencies (used for unit testing) public static func preparedUserUnban( - _ db: Database, sessionId: String, from roomTokens: [String]?, - on server: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, method: .post, - server: server, endpoint: Endpoint.userUnban(sessionId), body: UserUnbanRequest( rooms: roomTokens, global: (roomTokens == nil ? true : nil) - ) + ), + authMethod: authMethod ), responseType: NoResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Appoints or removes a moderator or admin @@ -1226,13 +1138,12 @@ public enum OpenGroupAPI { /// /// - dependencies: Injected dependencies (used for unit testing) public static func preparedUserModeratorUpdate( - _ db: Database, sessionId: String, moderator: Bool? = nil, admin: Bool? = nil, visible: Bool, for roomTokens: [String]?, - on server: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { guard (moderator != nil && admin == nil) || (moderator == nil && admin != nil) else { @@ -1241,9 +1152,7 @@ public enum OpenGroupAPI { return try Network.PreparedRequest( request: Request( - db, method: .post, - server: server, endpoint: Endpoint.userModerator(sessionId), body: UserModeratorRequest( rooms: roomTokens, @@ -1251,69 +1160,63 @@ public enum OpenGroupAPI { moderator: moderator, admin: admin, visible: visible - ) + ), + authMethod: authMethod ), responseType: NoResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// This is a convenience method which constructs a `/sequence` of the `userBan` and `userDeleteMessages` requests, refer to those /// methods for the documented behaviour of each method public static func preparedUserBanAndDeleteAllMessages( - _ db: Database, sessionId: String, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest> { return try OpenGroupAPI .preparedSequence( - db, - server: server, requests: [ preparedUserBan( - db, sessionId: sessionId, from: [roomToken], - on: server, + authMethod: authMethod, using: dependencies ), preparedMessagesDeleteAll( - db, sessionId: sessionId, - in: roomToken, - on: server, + roomToken: roomToken, + authMethod: authMethod, using: dependencies ) ], + authMethod: authMethod, using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } // MARK: - Authentication fileprivate static func signatureHeaders( - _ db: Database, url: URL, method: HTTPMethod, - server: String, - serverPublicKey: String, body: Data?, - forceBlinded: Bool, + 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)) - let serverPublicKeyData: Data = Data(hex: serverPublicKey) guard - !serverPublicKeyData.isEmpty, + case .community(_, let publicKey, _, _, _) = authMethod.info, + !publicKey.isEmpty, let nonce: [UInt8] = dependencies[singleton: .crypto].generate(.randomBytes(16)), let timestampBytes: [UInt8] = "\(timestamp)".data(using: .ascii).map({ Array($0) }) else { throw OpenGroupAPIError.signingFailed } @@ -1333,7 +1236,7 @@ public enum OpenGroupAPI { /// `Method` /// `Path` /// `Body` is a Blake2b hash of the data (if there is a body) - let messageBytes: [UInt8] = serverPublicKeyData.bytes + let messageBytes: [UInt8] = Data(hex: publicKey).bytes .appending(contentsOf: nonce) .appending(contentsOf: timestampBytes) .appending(contentsOf: method.bytes) @@ -1342,11 +1245,9 @@ public enum OpenGroupAPI { /// Sign the above message let signResult: (publicKey: String, signature: [UInt8]) = try sign( - db, messageBytes: messageBytes, - for: server, + authMethod: authMethod, fallbackSigningType: .unblinded, - forceBlinded: forceBlinded, using: dependencies ) @@ -1360,37 +1261,32 @@ public enum OpenGroupAPI { /// Sign a message to be sent to SOGS (handles both un-blinded and blinded signing based on the server capabilities) private static func sign( - _ db: Database, messageBytes: [UInt8], - for serverName: String, + authMethod: AuthenticationMethod, fallbackSigningType signingType: SessionId.Prefix, - forceBlinded: Bool = false, using dependencies: Dependencies ) throws -> (publicKey: String, signature: [UInt8]) { guard - let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), - let serverPublicKey: String = try? OpenGroup - .select(.publicKey) - .filter(OpenGroup.Columns.server == serverName.lowercased()) - .asRequest(of: String.self) - .fetchOne(db) + !dependencies[cache: .general].ed25519SecretKey.isEmpty, + !dependencies[cache: .general].ed25519Seed.isEmpty, + case .community(_, let publicKey, let hasCapabilities, let supportsBlinding, let forceBlinded) = authMethod.info else { throw OpenGroupAPIError.signingFailed } - let capabilities: Set = (try? Capability - .select(.variant) - .filter(Capability.Columns.openGroupServer == serverName.lowercased()) - .asRequest(of: Capability.Variant.self) - .fetchSet(db)) - .defaulting(to: []) - // If we have no capabilities or if the server supports blinded keys then sign using the blinded key - if forceBlinded || capabilities.isEmpty || capabilities.contains(.blind) { + if forceBlinded || !hasCapabilities || supportsBlinding { guard let blinded15KeyPair: KeyPair = dependencies[singleton: .crypto].generate( - .blinded15KeyPair(serverPublicKey: serverPublicKey, ed25519SecretKey: userEdKeyPair.secretKey) + .blinded15KeyPair( + serverPublicKey: publicKey, + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey + ) ), let signatureResult: [UInt8] = dependencies[singleton: .crypto].generate( - .signatureBlind15(message: messageBytes, serverPublicKey: serverPublicKey, ed25519SecretKey: userEdKeyPair.secretKey) + .signatureBlind15( + message: messageBytes, + serverPublicKey: publicKey, + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey + ) ) else { throw OpenGroupAPIError.signingFailed } @@ -1405,27 +1301,41 @@ public enum OpenGroupAPI { case .unblinded: guard let signature: Authentication.Signature = dependencies[singleton: .crypto].generate( - .signature(message: messageBytes, ed25519SecretKey: userEdKeyPair.secretKey) + .signature( + message: messageBytes, + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey + ) + ), + let ed25519KeyPair: KeyPair = dependencies[singleton: .crypto].generate( + .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) ), case .standard(let signatureResult) = signature else { throw OpenGroupAPIError.signingFailed } return ( - publicKey: SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString, + publicKey: SessionId(.unblinded, publicKey: ed25519KeyPair.publicKey).hexString, signature: signatureResult ) // Default to using the 'standard' key default: guard - let userKeyPair: KeyPair = Identity.fetchUserKeyPair(db), + let ed25519KeyPair: KeyPair = dependencies[singleton: .crypto].generate( + .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) + ), + let x25519PublicKey: [UInt8] = dependencies[singleton: .crypto].generate( + .x25519(ed25519Pubkey: ed25519KeyPair.publicKey) + ), + let x25519SecretKey: [UInt8] = dependencies[singleton: .crypto].generate( + .x25519(ed25519Seckey: ed25519KeyPair.secretKey) + ), let signatureResult: [UInt8] = dependencies[singleton: .crypto].generate( - .signatureXed25519(data: messageBytes, curve25519PrivateKey: userKeyPair.secretKey) + .signatureXed25519(data: messageBytes, curve25519PrivateKey: x25519SecretKey) ) else { throw OpenGroupAPIError.signingFailed } return ( - publicKey: SessionId(.standard, publicKey: userKeyPair.publicKey).hexString, + publicKey: SessionId(.standard, publicKey: x25519PublicKey).hexString, signature: signatureResult ) } @@ -1433,7 +1343,6 @@ public enum OpenGroupAPI { /// Sign a request to be sent to SOGS (handles both un-blinded and blinded signing based on the server capabilities) private static func signRequest( - _ db: Database, preparedRequest: Network.PreparedRequest, using dependencies: Dependencies ) throws -> Network.Destination { @@ -1442,47 +1351,42 @@ public enum OpenGroupAPI { } return try preparedRequest.destination - .signed(db, data: signingData, body: preparedRequest.body, using: dependencies) + .signed(data: signingData, body: preparedRequest.body, using: dependencies) } } private extension OpenGroupAPI { struct AdditionalSigningData { - let server: String - let forceBlinded: Bool + let authMethod: AuthenticationMethod - init(server: String, forceBlinded: Bool = false) { - self.server = server - self.forceBlinded = forceBlinded + init(_ authMethod: AuthenticationMethod) { + self.authMethod = authMethod } } } private extension Network.Destination { - func signed(_ db: Database, data: OpenGroupAPI.AdditionalSigningData, body: Data?, using dependencies: Dependencies) throws -> Network.Destination { + func signed(data: OpenGroupAPI.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(db, data, body, using: dependencies)) + 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(db, data, body, using: dependencies), fileName: fileName) + return .serverUpload(info: try info.signed(data, body, using: dependencies), fileName: fileName) case .serverDownload(let info): - return .serverDownload(info: try info.signed(db, data, body, using: dependencies)) + return .serverDownload(info: try info.signed(data, body, using: dependencies)) } } } private extension Network.Destination.ServerInfo { - func signed(_ db: Database, _ data: OpenGroupAPI.AdditionalSigningData, _ body: Data?, using dependencies: Dependencies) throws -> Network.Destination.ServerInfo { + func signed(_ data: OpenGroupAPI.AdditionalSigningData, _ body: Data?, using dependencies: Dependencies) throws -> Network.Destination.ServerInfo { return updated(with: try OpenGroupAPI.signatureHeaders( - db, url: url, method: method, - server: data.server, - serverPublicKey: x25519PublicKey, body: body, - forceBlinded: data.forceBlinded, + authMethod: data.authMethod, using: dependencies )) } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 2b9f470b08..4c6d534abe 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -89,17 +89,7 @@ public final class OpenGroupManager { } public func hasExistingOpenGroup( - roomToken: String, - server: String, - publicKey: String - ) -> Bool? { - return dependencies[singleton: .storage].read { [weak self] db in - self?.hasExistingOpenGroup(db, roomToken: roomToken, server: server, publicKey: publicKey) - } - } - - public func hasExistingOpenGroup( - _ db: Database, + _ db: ObservingDatabase, roomToken: String, server: String, publicKey: String @@ -153,7 +143,7 @@ public final class OpenGroupManager { } public func add( - _ db: Database, + _ db: ObservingDatabase, roomToken: String, server: String, publicKey: String, @@ -236,17 +226,24 @@ public final class OpenGroupManager { return OpenGroupAPI.defaultServer }() - return dependencies[singleton: .storage] - .readPublisher { [dependencies] db in - try OpenGroupAPI.preparedCapabilitiesAndRoom( - db, - for: roomToken, - on: targetServer, + return Result { + try OpenGroupAPI + .preparedCapabilitiesAndRoom( + roomToken: roomToken, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: roomToken, + server: server, + publicKey: publicKey, + capabilities: [] /// We won't have `capabilities` before the first request so just hard code + ) + ), using: dependencies ) } - .flatMap { [dependencies] request in request.send(using: dependencies) } - .flatMapStorageWritePublisher(using: dependencies) { [dependencies] (db: Database, response: (info: ResponseInfoType, value: OpenGroupAPI.CapabilitiesAndRoomResponse)) -> Void in + .publisher + .flatMap { [dependencies] in $0.send(using: dependencies) } + .flatMapStorageWritePublisher(using: dependencies) { [dependencies] (db: ObservingDatabase, response: (info: ResponseInfoType, value: OpenGroupAPI.CapabilitiesAndRoomResponse)) -> Void in // Add the new open group to libSession try LibSession.add( db, @@ -293,7 +290,7 @@ public final class OpenGroupManager { } public func delete( - _ db: Database, + _ db: ObservingDatabase, openGroupId: String, skipLibSessionUpdate: Bool ) throws { @@ -330,11 +327,10 @@ public final class OpenGroupManager { .filter(id: openGroupId) .deleteAll(db) - // Remove any MessageProcessRecord entries (we will want to reprocess all OpenGroup messages - // if they get re-added) - _ = try? ControlMessageProcessRecord - .filter(ControlMessageProcessRecord.Columns.threadId == openGroupId) - .deleteAll(db) + 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) // Remove the open group (no foreign key to the thread so it won't auto-delete) if server?.lowercased() != OpenGroupAPI.defaultServer.lowercased() { @@ -361,7 +357,7 @@ public final class OpenGroupManager { // MARK: - Response Processing internal static func handleCapabilities( - _ db: Database, + _ db: ObservingDatabase, capabilities: OpenGroupAPI.Capabilities, on server: String ) { @@ -390,7 +386,7 @@ public final class OpenGroupManager { } internal static func handlePollInfo( - _ db: Database, + _ db: ObservingDatabase, pollInfo: OpenGroupAPI.RoomPollInfo, publicKey maybePublicKey: String?, for roomToken: String, @@ -488,7 +484,7 @@ public final class OpenGroupManager { if let imageId: String = (pollInfo.details?.imageId ?? openGroup.imageId), ( - openGroup.displayPictureFilename == nil || + openGroup.displayPictureOriginalUrl == nil || openGroup.imageId != imageId ) { @@ -509,18 +505,39 @@ public final class OpenGroupManager { canStartJob: true ) } + + /// Emit events + if hasDetails { + if openGroup.name != pollInfo.details?.name { + db.addConversationEvent( + id: openGroup.id, + type: .updated(.displayName(pollInfo.details?.name ?? openGroup.name)) + ) + } + + if openGroup.roomDescription == pollInfo.details?.roomDescription { + db.addConversationEvent( + id: openGroup.id, + type: .updated(.description(pollInfo.details?.roomDescription)) + ) + } + + if pollInfo.details?.imageId == nil { + db.addConversationEvent(id: openGroup.id, type: .updated(.displayPictureUrl(nil))) + } + } } internal static func handleMessages( - _ db: Database, + _ db: ObservingDatabase, messages: [OpenGroupAPI.Message], for roomToken: String, on server: String, using dependencies: Dependencies - ) { + ) -> [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.") - return + return [] } // Sorting the messages by server ID before importing them fixes an issue where messages @@ -532,6 +549,7 @@ public final class OpenGroupManager { .filter { $0.deleted == true } .map { ($0.id, $0.seqNo) } var largestValidSeqNo: Int64 = openGroup.sequenceNumber + var insertedInteractionInfo: [MessageReceiver.InsertedInteractionInfo?] = [] // Process the messages sortedMessages.forEach { message in @@ -541,30 +559,46 @@ public final class OpenGroupManager { } // Handle messages - if let base64EncodedString: String = message.base64EncodedData, - let data = Data(base64Encoded: base64EncodedString) + if + let base64EncodedString: String = message.base64EncodedData, + let data = Data(base64Encoded: base64EncodedString), + let sender: String = message.sender { do { - let processedMessage: ProcessedMessage? = try Message.processReceivedOpenGroupMessage( - db, - openGroupId: openGroup.id, - openGroupServerPublicKey: openGroup.publicKey, - message: message, + let processedMessage: ProcessedMessage = try MessageReceiver.parse( data: data, + origin: .community( + openGroupId: openGroup.id, + sender: sender, + timestamp: message.posted, + messageServerId: message.id, + whisper: message.whisper, + whisperMods: message.whisperMods, + whisperTo: message.whisperTo + ), + using: dependencies + ) + try MessageDeduplication.insert( + db, + processedMessage: processedMessage, + ignoreDedupeFiles: false, using: dependencies ) switch processedMessage { - case .config, .none: break - case .standard(_, _, _, let messageInfo): - try MessageReceiver.handle( - db, - threadId: openGroup.id, - threadVariant: .community, - message: messageInfo.message, - serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, - associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), - using: dependencies + case .config, .invalid: break + case .standard(_, _, _, let messageInfo, _): + insertedInteractionInfo.append( + try MessageReceiver.handle( + db, + threadId: openGroup.id, + threadVariant: .community, + message: messageInfo.message, + serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, + associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), + suppressNotifications: false, + using: dependencies + ) ) largestValidSeqNo = max(largestValidSeqNo, message.seqNo) } @@ -576,7 +610,6 @@ public final class OpenGroupManager { case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, DatabaseError.SQLITE_CONSTRAINT, // Sometimes thrown for UNIQUE MessageReceiverError.duplicateMessage, - MessageReceiverError.duplicateControlMessage, MessageReceiverError.selfSend: break @@ -643,20 +676,22 @@ public final class OpenGroupManager { $0.pendingChanges = $0.pendingChanges .filter { $0.seqNo == nil || $0.seqNo! > largestValidSeqNo } } + + return insertedInteractionInfo } internal static func handleDirectMessages( - _ db: Database, + _ db: ObservingDatabase, messages: [OpenGroupAPI.DirectMessage], fromOutbox: Bool, on server: String, using dependencies: Dependencies - ) { + ) -> [MessageReceiver.InsertedInteractionInfo?] { // Don't need to do anything if we have no messages (it's a valid case) - guard !messages.isEmpty else { return } + guard !messages.isEmpty else { return [] } guard let openGroup: OpenGroup = try? OpenGroup.filter(OpenGroup.Columns.server == server.lowercased()).fetchOne(db) else { Log.error(.openGroup, "Couldn't receive inbox message due to missing group.") - return + return [] } // Sorting the messages by server ID before importing them fixes an issue where messages @@ -665,6 +700,7 @@ public final class OpenGroupManager { .sorted { lhs, rhs in lhs.id < rhs.id } let latestMessageId: Int64 = sortedMessages[sortedMessages.count - 1].id var lookupCache: [String: BlindedIdLookup] = [:] // Only want this cache to exist for the current loop + var insertedInteractionInfo: [MessageReceiver.InsertedInteractionInfo?] = [] // Update the 'latestMessageId' value if fromOutbox { @@ -686,23 +722,33 @@ public final class OpenGroupManager { } do { - let processedMessage: ProcessedMessage? = try Message.processReceivedOpenGroupDirectMessage( - db, - openGroupServerPublicKey: openGroup.publicKey, - message: message, + let processedMessage: ProcessedMessage = try MessageReceiver.parse( data: messageData, + origin: .openGroupInbox( + timestamp: message.posted, + messageServerId: message.id, + serverPublicKey: openGroup.publicKey, + senderId: message.sender, + recipientId: message.recipient + ), + using: dependencies + ) + try MessageDeduplication.insert( + db, + processedMessage: processedMessage, + ignoreDedupeFiles: false, using: dependencies ) switch processedMessage { - case .config, .none: break - case .standard(let threadId, _, let proto, let messageInfo): - // We want to update the BlindedIdLookup cache with the message info so we can avoid using the - // "expensive" lookup when possible + case .config, .invalid: break + case .standard(let threadId, _, let proto, let messageInfo, _): + /// We want to update the BlindedIdLookup cache with the message info so we can avoid using the + /// "expensive" lookup when possible let lookup: BlindedIdLookup = try { - // Minor optimisation to avoid processing the same sender multiple times in the same - // 'handleMessages' call (since the 'mapping' call is done within a transaction we - // will never have a mapping come through part-way through processing these messages) + /// Minor optimisation to avoid processing the same sender multiple times in the same + /// 'handleMessages' call (since the 'mapping' call is done within a transaction we + /// will never have a mapping come through part-way through processing these messages) if let result: BlindedIdLookup = lookupCache[message.recipient] { return result } @@ -741,14 +787,17 @@ public final class OpenGroupManager { } } - try MessageReceiver.handle( - db, - threadId: (lookup.sessionId ?? lookup.blindedId), - threadVariant: .contact, // Technically not open group messages - message: messageInfo.message, - serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, - associatedWithProto: proto, - using: dependencies + insertedInteractionInfo.append( + try MessageReceiver.handle( + db, + threadId: (lookup.sessionId ?? lookup.blindedId), + threadVariant: .contact, // Technically not open group messages + message: messageInfo.message, + serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, + associatedWithProto: proto, + suppressNotifications: false, + using: dependencies + ) ) } } @@ -759,7 +808,6 @@ public final class OpenGroupManager { case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, DatabaseError.SQLITE_CONSTRAINT, // Sometimes thrown for UNIQUE MessageReceiverError.duplicateMessage, - MessageReceiverError.duplicateControlMessage, MessageReceiverError.selfSend: break @@ -768,6 +816,8 @@ public final class OpenGroupManager { } } } + + return insertedInteractionInfo } // MARK: - Convenience @@ -815,16 +865,11 @@ public final class OpenGroupManager { /// This method specifies if the given capability is supported on a specified Open Group public func doesOpenGroupSupport( - _ db: Database? = nil, + _ db: ObservingDatabase, capability: Capability.Variant, on server: String? ) -> Bool { guard let server: String = server else { return false } - guard let db: Database = db else { - return dependencies[singleton: .storage] - .read { [weak self] db in self?.doesOpenGroupSupport(db, capability: capability, on: server) } - .defaulting(to: false) - } let capabilities: [Capability.Variant] = (try? Capability .select(.variant) @@ -839,93 +884,99 @@ 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( - _ db: Database? = nil, + _ db: ObservingDatabase, publicKey: String, for roomToken: String?, - on server: String? + on server: String?, + currentUserSessionIds: Set ) -> Bool { guard let roomToken: String = roomToken, let server: String = server else { return false } - guard let db: Database = db else { - return dependencies[singleton: .storage] - .read { [weak self] db in self?.isUserModeratorOrAdmin(db, publicKey: publicKey, for: roomToken, on: server) } - .defaulting(to: false) - } - + let groupId: String = OpenGroup.idFor(roomToken: roomToken, server: server) let targetRoles: [GroupMember.Role] = [.moderator, .admin] - let isDirectModOrAdmin: Bool = GroupMember + var possibleKeys: Set = [publicKey] + + /// If the `publicKey` is in `currentUserSessionIds` then we want to use `currentUserSessionIds` to do + /// the lookup + if currentUserSessionIds.contains(publicKey) { + 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) + ) + if let userEdPublicKey: [UInt8] = userEdKeyPair?.publicKey { + possibleKeys.insert(SessionId(.unblinded, publicKey: userEdPublicKey).hexString) + } + } + + return GroupMember .filter(GroupMember.Columns.groupId == groupId) - .filter(GroupMember.Columns.profileId == publicKey) + .filter(possibleKeys.contains(GroupMember.Columns.profileId)) .filter(targetRoles.contains(GroupMember.Columns.role)) .isNotEmpty(db) + } +} + +// MARK: - Deprecated Conveneince Functions + +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 doesOpenGroupSupport( + capability: Capability.Variant, + on server: String? + ) -> Bool { + guard let server: String = server else { return false } - // If the publicKey provided matches a mod or admin directly then just return immediately - if isDirectModOrAdmin { return true } - - // Otherwise we need to check if it's a variant of the current users key and if so we want - // to check if any of those have mod/admin entries - guard let sessionId: SessionId = try? SessionId(from: publicKey) else { return false } - - // Conveniently the logic for these different cases works in order so we can fallthrough each - // case with only minor efficiency losses - let userSessionId: SessionId = dependencies[cache: .general].sessionId - - switch sessionId.prefix { - case .standard: - guard publicKey == userSessionId.hexString else { return false } - fallthrough - - case .unblinded: - guard let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { - return false + var openGroupSupportsCapability: Bool = false + let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + dependencies[singleton: .storage].readAsync( + retrieve: { [weak self] db in + self?.doesOpenGroupSupport(db, capability: capability, on: server) + }, + completion: { result in + switch result { + case .failure: break + case .success(let value): openGroupSupportsCapability = (value == true) } - guard sessionId.prefix != .unblinded || publicKey == SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString else { - return false + semaphore.signal() + } + ) + semaphore.wait() + return openGroupSupportsCapability + } + + @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( + publicKey: String, + for roomToken: String?, + on server: String?, + currentUserSessionIds: Set + ) -> Bool { + guard let roomToken: String = roomToken, let server: String = server else { return false } + + var userIsModeratorOrAdmin: Bool = false + let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + dependencies[singleton: .storage].readAsync( + retrieve: { [weak self] db in + self?.isUserModeratorOrAdmin( + db, + publicKey: publicKey, + for: roomToken, + on: server, + currentUserSessionIds: currentUserSessionIds + ) + }, + completion: { result in + switch result { + case .failure: break + case .success(let value): userIsModeratorOrAdmin = (value == true) } - fallthrough - - case .blinded15, .blinded25: - guard - let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), - let openGroupPublicKey: String = try? OpenGroup - .select(.publicKey) - .filter(id: groupId) - .asRequest(of: String.self) - .fetchOne(db), - let blinded15KeyPair: KeyPair = dependencies[singleton: .crypto].generate( - .blinded15KeyPair(serverPublicKey: openGroupPublicKey, ed25519SecretKey: userEdKeyPair.secretKey) - ), - let blinded25KeyPair: KeyPair = dependencies[singleton: .crypto].generate( - .blinded25KeyPair(serverPublicKey: openGroupPublicKey, ed25519SecretKey: userEdKeyPair.secretKey) - ) - else { return false } - guard - ( - sessionId.prefix != .blinded15 && - sessionId.prefix != .blinded25 - ) || - publicKey == SessionId(.blinded15, publicKey: blinded15KeyPair.publicKey).hexString || - publicKey == SessionId(.blinded25, publicKey: blinded25KeyPair.publicKey).hexString - else { return false } - - // If we got to here that means that the 'publicKey' value matches one of the current - // users 'standard', 'unblinded' or 'blinded' keys and as such we should check if any - // of them exist in the `modsAndAminKeys` Set - let possibleKeys: Set = Set([ - userSessionId.hexString, - SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString, - SessionId(.blinded15, publicKey: blinded15KeyPair.publicKey).hexString, - SessionId(.blinded25, publicKey: blinded25KeyPair.publicKey).hexString - ]) - - return GroupMember - .filter(GroupMember.Columns.groupId == groupId) - .filter(possibleKeys.contains(GroupMember.Columns.profileId)) - .filter(targetRoles.contains(GroupMember.Columns.role)) - .isNotEmpty(db) - - case .group, .versionBlinded07: return false - } + semaphore.signal() + } + ) + semaphore.wait() + return userIsModeratorOrAdmin } } diff --git a/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift b/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift index 53e0010ae4..d5ab81cbe1 100644 --- a/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift +++ b/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift @@ -10,6 +10,7 @@ public enum OpenGroupAPIError: Error, CustomStringConvertible { case noPublicKey case invalidEmoji case invalidPoll + case blindedLookupMissingCommunityInfo public var description: String { switch self { @@ -18,6 +19,7 @@ public enum OpenGroupAPIError: Error, CustomStringConvertible { case .noPublicKey: return "Couldn't find server public key." case .invalidEmoji: return "The emoji is invalid." case .invalidPoll: return "Poller in invalid state." + case .blindedLookupMissingCommunityInfo: return "Blinded lookup missing community info." } } } diff --git a/SessionMessagingKit/Open Groups/Types/Request+OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/Types/Request+OpenGroupAPI.swift index cf5ffd9093..6242a05447 100644 --- a/SessionMessagingKit/Open Groups/Types/Request+OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/Types/Request+OpenGroupAPI.swift @@ -9,21 +9,16 @@ import SessionUtilitiesKit public extension Request where Endpoint == OpenGroupAPI.Endpoint { init( - _ db: Database, method: HTTPMethod = .get, - server: String, endpoint: Endpoint, queryParameters: [HTTPQueryParam: String] = [:], headers: [HTTPHeader: String] = [:], - body: T? = nil + body: T? = nil, + authMethod: AuthenticationMethod ) throws { - let maybePublicKey: String? = try? OpenGroup - .select(.publicKey) - .filter(OpenGroup.Columns.server == server.lowercased()) - .asRequest(of: String.self) - .fetchOne(db) - - guard let publicKey: String = maybePublicKey else { throw OpenGroupAPIError.noPublicKey } + guard case .community(let server, let publicKey, _, _, _) = authMethod.info else { + throw CryptoError.signatureGenerationFailed + } self = try Request( endpoint: endpoint, diff --git a/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift b/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift new file mode 100644 index 0000000000..028583da17 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift @@ -0,0 +1,231 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import GRDB +import SessionSnodeKit +import SessionUtilitiesKit + +// MARK: - AttachmentUploader + +public final class AttachmentUploader { + private enum Destination { + case fileServer + case community(LibSession.OpenGroupCapabilityInfo) + + var shouldEncrypt: Bool { + switch self { + case .fileServer: return true + case .community: return false + } + } + } + + public static func prepare(attachments: [SignalAttachment], using dependencies: Dependencies) -> [Attachment] { + return attachments.compactMap { signalAttachment in + Attachment( + variant: (signalAttachment.isVoiceMessage ? + .voiceMessage : + .standard + ), + contentType: signalAttachment.mimeType, + dataSource: signalAttachment.dataSource, + sourceFilename: signalAttachment.sourceFilename, + caption: signalAttachment.captionText, + using: dependencies + ) + } + } + + public static func process( + _ db: ObservingDatabase, + attachments: [Attachment]?, + for interactionId: Int64? + ) throws { + guard + let attachments: [Attachment] = attachments, + let interactionId: Int64 = interactionId + else { return } + + try attachments + .enumerated() + .forEach { index, attachment in + let interactionAttachment: InteractionAttachment = InteractionAttachment( + albumIndex: index, + interactionId: interactionId, + attachmentId: attachment.id + ) + + try attachment.insert(db) + try interactionAttachment.insert(db) + } + } + + public static func preparedUpload( + attachment: Attachment, + logCategory cat: Log.Category?, + authMethod: AuthenticationMethod, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest<(attachment: Attachment, fileId: String)> { + typealias UploadInfo = ( + attachment: Attachment, + preparedRequest: Network.PreparedRequest, + encryptionKey: Data?, + digest: Data? + ) + typealias EncryptionData = (ciphertext: Data, encryptionKey: Data, digest: Data) + + // Generate the correct upload info based on the state of the attachment + let destination: AttachmentUploader.Destination = { + switch authMethod { + case let auth as Authentication.community: return .community(auth.openGroupCapabilityInfo) + default: return .fileServer + } + }() + let uploadInfo: UploadInfo = try { + let endpoint: (any EndpointType) = { + switch destination { + case .fileServer: return Network.FileServer.Endpoint.file + case .community(let info): return OpenGroupAPI.Endpoint.roomFile(info.roomToken) + } + }() + + // This can occur if an AttachmentUploadJob was explicitly created for a message + // dependant on the attachment being uploaded (in this case the attachment has + // already been uploaded so just succeed) + if attachment.state == .uploaded, let fileId: String = Attachment.fileId(for: attachment.downloadUrl) { + return ( + attachment, + try Network.PreparedRequest.cached( + FileUploadResponse(id: fileId), + endpoint: endpoint, + using: dependencies + ), + attachment.encryptionKey, + attachment.digest + ) + } + + // If the attachment is a downloaded attachment, check if it came from + // the server and if so just succeed immediately (no use re-uploading + // an attachment that is already present on the server) - or if we want + // it to be encrypted and it's not then encrypt it + // + // Note: The most common cases for this will be for LinkPreviews or Quotes + if + attachment.state == .downloaded, + attachment.serverId != nil, + let fileId: String = Attachment.fileId(for: attachment.downloadUrl), + ( + !destination.shouldEncrypt || ( + attachment.encryptionKey != nil && + attachment.digest != nil + ) + ) + { + return ( + attachment, + try Network.PreparedRequest.cached( + FileUploadResponse(id: fileId), + endpoint: endpoint, + using: dependencies + ), + attachment.encryptionKey, + attachment.digest + ) + } + + // Get the raw attachment data + guard let rawData: Data = try? attachment.readDataFromFile(using: dependencies) else { + Log.error([cat].compactMap { $0 }, "Couldn't read attachment from disk.") + throw AttachmentError.noAttachment + } + + // Encrypt the attachment if needed + var finalData: Data = rawData + var encryptionKey: Data? + var digest: Data? + + if destination.shouldEncrypt { + guard + let result: EncryptionData = dependencies[singleton: .crypto].generate( + .encryptAttachment(plaintext: rawData) + ) + else { + Log.error([cat].compactMap { $0 }, "Couldn't encrypt attachment.") + throw AttachmentError.encryptionFailed + } + + finalData = result.ciphertext + encryptionKey = result.encryptionKey + digest = result.digest + } + + // Ensure the file size is smaller than our upload limit + Log.info([cat].compactMap { $0 }, "File size: \(finalData.count) bytes.") + guard finalData.count <= Network.maxFileSize else { throw NetworkError.maxFileSizeExceeded } + + // Generate the request + switch destination { + case .fileServer: + return ( + attachment, + try Network.preparedUpload(data: finalData, using: dependencies), + encryptionKey, + digest + ) + + case .community(let info): + return ( + attachment, + try OpenGroupAPI.preparedUpload( + data: finalData, + roomToken: info.roomToken, + authMethod: Authentication.community(info: info), + using: dependencies + ), + encryptionKey, + digest + ) + } + }() + + return uploadInfo.preparedRequest.map { _, response in + /// Generate the updated attachment info + /// + /// **Note:** We **MUST** use the `.with` function here to ensure the `isValid` flag is + /// updated correctly + let updatedAttachment: Attachment = uploadInfo.attachment + .with( + serverId: response.id, + state: .uploaded, + creationTimestamp: ( + uploadInfo.attachment.creationTimestamp ?? + (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + ), + downloadUrl: { + let isPlaceholderUploadUrl: Bool = dependencies[singleton: .attachmentManager] + .isPlaceholderUploadUrl(uploadInfo.attachment.downloadUrl) + + switch (uploadInfo.attachment.downloadUrl, isPlaceholderUploadUrl, destination) { + case (.some(let downloadUrl), false, _): return downloadUrl + case (_, _, .fileServer): + return Network.FileServer.downloadUrlString(for: response.id) + + case (_, _, .community(let info)): + return OpenGroupAPI.downloadUrlString( + for: response.id, + server: info.server, + roomToken: info.roomToken + ) + } + }(), + encryptionKey: uploadInfo.encryptionKey, + digest: uploadInfo.digest, + using: dependencies + ) + + return (updatedAttachment, response.id) + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift b/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift index f96131555e..152819c82f 100644 --- a/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift +++ b/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift @@ -96,6 +96,7 @@ public enum TSImageQuality: UInt { // [SignalAttachment hasError] will be true for non-valid attachments. // // TODO: Perhaps do conversion off the main thread? +// FIXME: Would be nice to replace the `SignalAttachment` and use our internal types (eg. `ImageDataManager`) public class SignalAttachment: Equatable { // MARK: Properties @@ -220,15 +221,22 @@ public class SignalAttachment: Equatable { do { let filePath = mediaUrl.path - guard dependencies[singleton: .fileManager].fileExists(atPath: filePath) else { - return nil - } - - let asset = AVURLAsset(url: mediaUrl) - let generator = AVAssetImageGenerator(asset: asset) + guard + dependencies[singleton: .fileManager].fileExists(atPath: filePath), + let mimeType: String = dataType.sessionMimeType, + let assetInfo: (asset: AVURLAsset, cleanup: () -> Void) = AVURLAsset.asset( + for: filePath, + mimeType: mimeType, + sourceFilename: sourceFilename, + using: dependencies + ) + else { return nil } + + let generator = AVAssetImageGenerator(asset: assetInfo.asset) generator.appliesPreferredTrackTransform = true let cgImage = try generator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil) let image = UIImage(cgImage: cgImage) + assetInfo.cleanup() cachedVideoPreview = image return image @@ -246,7 +254,7 @@ public class SignalAttachment: Equatable { return text } - public func duration() -> TimeInterval? { + public func duration(using dependencies: Dependencies) -> TimeInterval? { switch (isAudio, isVideo) { case (true, _): let audioPlayer: AVAudioPlayer? = try? AVAudioPlayer(data: dataSource.data) @@ -254,12 +262,22 @@ public class SignalAttachment: Equatable { return (audioPlayer?.duration).map { $0 > 0 ? $0 : nil } case (_, true): - return dataUrl.map { url in - let asset: AVURLAsset = AVURLAsset(url: url, options: nil) - - // According to the CMTime docs "value/timescale = seconds" - return (TimeInterval(asset.duration.value) / TimeInterval(asset.duration.timescale)) - } + guard + let mimeType: String = dataType.sessionMimeType, + let url: URL = dataUrl, + let assetInfo: (asset: AVURLAsset, cleanup: () -> Void) = AVURLAsset.asset( + for: url.path, + mimeType: mimeType, + sourceFilename: sourceFilename, + using: dependencies + ) + else { return nil } + + // According to the CMTime docs "value/timescale = seconds" + let duration: TimeInterval = (TimeInterval(assetInfo.asset.duration.value) / TimeInterval(assetInfo.asset.duration.timescale)) + assetInfo.cleanup() + + return duration default: return nil } diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/ThumbnailService.swift b/SessionMessagingKit/Sending & Receiving/Attachments/ThumbnailService.swift deleted file mode 100644 index c4f55a728f..0000000000 --- a/SessionMessagingKit/Sending & Receiving/Attachments/ThumbnailService.swift +++ /dev/null @@ -1,192 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import UIKit -import AVFoundation -import SessionUtilitiesKit - -// MARK: - Singleton - -public extension Singleton { - static let thumbnailService: SingletonConfig = Dependencies.create( - identifier: "thumbnailService", - createInstance: { dependencies in ThumbnailService(using: dependencies) } - ) -} - -// MARK: - ThumbnailService - -public class ThumbnailService { - public typealias SuccessBlock = (LoadedThumbnail) -> Void - public typealias FailureBlock = (Error) -> Void - - private let dependencies: Dependencies - private let serialQueue = DispatchQueue(label: "ThumbnailService") - - // This property should only be accessed on the serialQueue. - // - // We want to process requests in _reverse_ order in which they - // arrive so that we prioritize the most recent view state. - private var requestStack = [Request]() - - // MARK: - Initialization - - init(using dependencies: Dependencies) { - self.dependencies = dependencies - } - - // MARK: - Functions - - private func canThumbnailAttachment(attachment: Attachment) -> Bool { - return attachment.isImage || attachment.isAnimated || attachment.isVideo - } - - public func ensureThumbnail( - for attachment: Attachment, - dimensions: UInt, - success: @escaping SuccessBlock, - failure: @escaping FailureBlock - ) { - serialQueue.async { - self.requestStack.append( - Request( - attachment: attachment, - dimensions: dimensions, - success: success, - failure: failure - ) - ) - - self.processNextRequestSync() - } - } - - private func processNextRequestAsync() { - serialQueue.async { - self.processNextRequestSync() - } - } - - // This should only be called on the serialQueue. - private func processNextRequestSync() { - guard let thumbnailRequest = requestStack.popLast() else { return } - - do { - let loadedThumbnail = try process(thumbnailRequest: thumbnailRequest) - DispatchQueue.global().async { - thumbnailRequest.success(loadedThumbnail) - } - } - catch { - DispatchQueue.global().async { - thumbnailRequest.failure(error) - } - } - } - - // This should only be called on the serialQueue. - // - // It should be safe to assume that an attachment will never end up with two thumbnails of - // the same size since: - // - // * Thumbnails are only added by this method. - // * This method checks for an existing thumbnail using the same connection. - // * This method is performed on the serial queue. - private func process(thumbnailRequest: Request) throws -> LoadedThumbnail { - let attachment = thumbnailRequest.attachment - - guard canThumbnailAttachment(attachment: attachment) else { - throw ThumbnailError.failure(description: "Cannot thumbnail attachment.") - } - - let thumbnailPath = attachment.thumbnailPath(for: thumbnailRequest.dimensions) - - if dependencies[singleton: .fileManager].fileExists(atPath: thumbnailPath) { - return LoadedThumbnail(filePath: thumbnailPath) - } - - let thumbnailDirPath = (thumbnailPath as NSString).deletingLastPathComponent - - guard case .success = Result(try dependencies[singleton: .fileManager].ensureDirectoryExists(at: thumbnailDirPath)) else { - throw ThumbnailError.failure(description: "Could not create attachment's thumbnail directory.") - } - guard let originalFilePath = attachment.originalFilePath(using: dependencies) else { - throw ThumbnailError.failure(description: "Missing original file path.") - } - - let maxDimension = CGFloat(thumbnailRequest.dimensions) - let thumbnailImage: UIImage - - if attachment.isImage || attachment.isAnimated { - thumbnailImage = try MediaUtils.thumbnail(forImageAtPath: originalFilePath, maxDimension: maxDimension, type: attachment.contentType, using: dependencies) - } - else if attachment.isVideo { - thumbnailImage = try MediaUtils.thumbnail(forVideoAtPath: originalFilePath, maxDimension: maxDimension, using: dependencies) - } - else { - throw ThumbnailError.assertionFailure(description: "Invalid attachment type.") - } - - guard let thumbnailData = thumbnailImage.jpegData(compressionQuality: 0.85) else { - throw ThumbnailError.failure(description: "Could not convert thumbnail to JPEG.") - } - - do { - try thumbnailData.write(to: URL(fileURLWithPath: thumbnailPath, isDirectory: false), options: .atomic) - } - catch let error as NSError { - throw ThumbnailError.externalError(description: "File write failed: \(thumbnailPath), \(error)", underlyingError: error) - } - - try? dependencies[singleton: .fileManager].protectFileOrFolder(at: thumbnailPath) - - return LoadedThumbnail(image: thumbnailImage, data: thumbnailData) - } -} - -public extension ThumbnailService { - enum ThumbnailError: Error { - case failure(description: String) - case assertionFailure(description: String) - case externalError(description: String, underlyingError: Error) - } - - struct LoadedThumbnail { - public typealias ImageSourceBlock = () -> UIImage? - public typealias DataSourceBlock = () throws -> Data - - public let imageSourceBlock: ImageSourceBlock - public let dataSourceBlock: DataSourceBlock - - public init(filePath: String) { - self.imageSourceBlock = { - return UIImage(contentsOfFile: filePath) - } - self.dataSourceBlock = { - return try Data(contentsOf: URL(fileURLWithPath: filePath)) - } - } - - public init(image: UIImage, data: Data) { - self.imageSourceBlock = { image } - self.dataSourceBlock = { data } - } - - public func image() -> UIImage? { - return imageSourceBlock() - } - - public func data() throws -> Data { - return try dataSourceBlock() - } - } - - private struct Request { - public typealias SuccessBlock = (LoadedThumbnail) -> Void - public typealias FailureBlock = (Error) -> Void - - let attachment: Attachment - let dimensions: UInt - let success: SuccessBlock - let failure: FailureBlock - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift b/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift index eeec1ef9c6..0a84310cfd 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift @@ -10,6 +10,7 @@ public enum AttachmentError: LocalizedError { case notUploaded case invalidData case encryptionFailed + case uploadIsStillPendingDownload public var errorDescription: String? { switch self { @@ -18,6 +19,7 @@ public enum AttachmentError: LocalizedError { case .notUploaded: return "Attachment not uploaded." case .invalidData: return "Invalid attachment data." case .encryptionFailed: return "Couldn't encrypt file." + case .uploadIsStillPendingDownload: return "Upload is still pending download." } } } diff --git a/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift b/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift index 7aa18d9c29..2114e50726 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift @@ -6,8 +6,6 @@ import Foundation public enum MessageReceiverError: Error, CustomStringConvertible { case duplicateMessage - case duplicateMessageNewSnode - case duplicateControlMessage case invalidMessage case invalidSender case unknownMessage(SNProtoContent?) @@ -22,20 +20,19 @@ public enum MessageReceiverError: Error, CustomStringConvertible { case decryptionFailed case noGroupKeyPair case invalidConfigMessageHandling - case requiredThreadNotInConfig case outdatedMessage case ignorableMessage + case ignorableMessageRequestMessage(String) case duplicatedCall case missingRequiredAdminPrivileges case deprecatedMessage public var isRetryable: Bool { switch self { - case .duplicateMessage, .duplicateMessageNewSnode, .duplicateControlMessage, - .invalidMessage, .unknownMessage, .unknownEnvelopeType, .invalidSignature, - .noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed, - .invalidConfigMessageHandling, .requiredThreadNotInConfig, - .outdatedMessage, .ignorableMessage, .missingRequiredAdminPrivileges: + case .duplicateMessage, .invalidMessage, .unknownMessage, .unknownEnvelopeType, + .invalidSignature, .noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed, + .invalidConfigMessageHandling, .outdatedMessage, .ignorableMessage, .ignorableMessageRequestMessage, + .missingRequiredAdminPrivileges: return false default: return true @@ -48,7 +45,7 @@ public enum MessageReceiverError: Error, CustomStringConvertible { // retrieving and attempting to process the same messages again (as well as ensure the // next poll doesn't retrieve the same message - these errors are essentially considered // "already successfully processed") - case .selfSend, .duplicateControlMessage, .outdatedMessage, .missingRequiredAdminPrivileges: + case .selfSend, .duplicateMessage, .outdatedMessage, .missingRequiredAdminPrivileges: return true default: return false @@ -58,8 +55,6 @@ public enum MessageReceiverError: Error, CustomStringConvertible { public var description: String { switch self { case .duplicateMessage: return "Duplicate message." - case .duplicateMessageNewSnode: return "Duplicate message from different service node." - case .duplicateControlMessage: return "Duplicate control message." case .invalidMessage: return "Invalid message." case .invalidSender: return "Invalid sender." case .unknownMessage(let content): @@ -111,9 +106,9 @@ public enum MessageReceiverError: Error, CustomStringConvertible { case .noGroupKeyPair: return "Missing group key pair." case .invalidConfigMessageHandling: return "Invalid handling of a config message." - case .requiredThreadNotInConfig: return "Required thread not in config." case .outdatedMessage: return "Message was sent before a config change which would have removed the message." case .ignorableMessage: return "Message should be ignored." + case .ignorableMessageRequestMessage: return "Message request message should be ignored." case .duplicatedCall: return "Duplicate call." 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." diff --git a/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift b/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift index ea7a467bb0..4a4062cfd4 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift @@ -14,6 +14,7 @@ public enum MessageSenderError: Error, CustomStringConvertible, Equatable { case encryptionFailed case noUsername case attachmentsNotUploaded + case attachmentsInvalid case blindingFailed // Closed groups @@ -45,6 +46,7 @@ public enum MessageSenderError: Error, CustomStringConvertible, Equatable { case .encryptionFailed: return "Couldn't encrypt message (MessageSenderError.encryptionFailed)." case .noUsername: return "Missing username (MessageSenderError.noUsername)." case .attachmentsNotUploaded: return "Attachments for this message have not been uploaded (MessageSenderError.attachmentsNotUploaded)." + case .attachmentsInvalid: return "Attachments Invalid (MessageSenderError.attachmentsInvalid)." case .blindingFailed: return "Couldn't blind the sender (MessageSenderError.blindingFailed)." // Closed groups diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift index a20c3e0cf8..314567b32e 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift @@ -17,39 +17,64 @@ public extension Log.Category { extension MessageReceiver { public static func handleCallMessage( - _ db: Database, + _ db: ObservingDatabase, threadId: String, threadVariant: SessionThread.Variant, message: CallMessage, + suppressNotifications: Bool, using dependencies: Dependencies - ) throws { + ) throws -> InsertedInteractionInfo? { // Only support calls from contact threads - guard threadVariant == .contact else { return } + guard threadVariant == .contact else { throw MessageReceiverError.invalidMessage } - switch message.kind { - case .preOffer: try MessageReceiver.handleNewCallMessage(db, message: message, using: dependencies) - case .offer: MessageReceiver.handleOfferCallMessage(db, message: message, using: dependencies) - case .answer: MessageReceiver.handleAnswerCallMessage(db, message: message, using: dependencies) - case .provisionalAnswer: break // TODO: [CALLS] Implement + switch (message.kind, message.state) { + case (.preOffer, _): + return try MessageReceiver.handleNewCallMessage( + db, + threadId: threadId, + threadVariant: threadVariant, + message: message, + suppressNotifications: suppressNotifications, + using: dependencies + ) + + case (.offer, _): MessageReceiver.handleOfferCallMessage(db, message: message, using: dependencies) + case (.answer, _): MessageReceiver.handleAnswerCallMessage(db, message: message, using: dependencies) + case (.provisionalAnswer, _): break // TODO: [CALLS] Implement - case let .iceCandidates(sdpMLineIndexes, sdpMids): + case (.iceCandidates(let sdpMLineIndexes, let sdpMids), _): dependencies[singleton: .callManager].handleICECandidates( message: message, sdpMLineIndexes: sdpMLineIndexes, sdpMids: sdpMids ) + + case (.endCall, .missed): + return try MessageReceiver.handleIncomingCallOfferInBusyState( + db, + threadId: threadId, + threadVariant: threadVariant, + message: message, + suppressNotifications: suppressNotifications, + using: dependencies + ) - case .endCall: MessageReceiver.handleEndCallMessage(db, message: message, using: dependencies) + case (.endCall, _): MessageReceiver.handleEndCallMessage(db, message: message, using: dependencies) } + + return nil } // MARK: - Specific Handling private static func handleNewCallMessage( - _ db: Database, + _ db: ObservingDatabase, + threadId: String, + threadVariant: SessionThread.Variant, message: CallMessage, + suppressNotifications: Bool, using dependencies: Dependencies - ) throws { + ) throws -> InsertedInteractionInfo? { Log.info(.calls, "Received pre-offer message with uuid: \(message.uuid).") // Determine whether the app is active based on the prefs rather than the UIApplication state to avoid @@ -61,17 +86,14 @@ extension MessageReceiver { guard dependencies[singleton: .appContext].isMainApp, let sender: String = message.sender, - (try? Contact - .filter(id: sender) - .select(.isApproved) - .asRequest(of: Bool.self) - .fetchOne(db)) - .defaulting(to: false) - else { return } + dependencies.mutate(cache: .libSession, { cache in + !cache.isMessageRequest(threadId: threadId, threadVariant: threadVariant) + }) + else { throw MessageReceiverError.invalidMessage } guard let timestampMs = message.sentTimestampMs, TimestampUtils.isWithinOneMinute(timestampMs: timestampMs) else { // Add missed call message for call offer messages from more than one minute Log.info(.calls, "Got an expired call offer message with uuid: \(message.uuid). Sent at \(message.sentTimestampMs ?? 0), now is \(Date().timeIntervalSince1970 * 1000)") - if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, for: message, state: .missed, using: dependencies) { + if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, threadId: threadId, threadVariant: threadVariant, for: message, state: .missed, using: dependencies), let interactionId: Int64 = interaction.id { let thread: SessionThread = try SessionThread.upsert( db, id: sender, @@ -80,24 +102,64 @@ extension MessageReceiver { using: dependencies ) - if !interaction.wasRead { - dependencies[singleton: .notificationsManager].notifyUser( - db, - forIncomingCall: interaction, - in: thread, - applicationState: (isMainAppActive ? .active : .background) + if !suppressNotifications && !interaction.wasRead { + /// Update the `CallMessage.state` value so the correct notification logic can occur + message.state = .missed + + try? dependencies[singleton: .notificationsManager].notifyUser( + message: message, + threadId: thread.id, + threadVariant: thread.variant, + interactionIdentifier: (interaction.serverHash ?? "\(interactionId)"), + interactionVariant: interaction.variant, + attachmentDescriptionInfo: nil, + openGroupUrlInfo: nil, + applicationState: (isMainAppActive ? .active : .background), + extensionBaseUnreadCount: nil, + currentUserSessionIds: [dependencies[cache: .general].sessionId.hexString], + displayNameRetriever: { sessionId in + Profile.displayNameNoFallback( + db, + id: sessionId, + threadVariant: thread.variant + ) + }, + groupNameRetriever: { threadId, threadVariant in + switch threadVariant { + case .group: + let groupId: SessionId = SessionId(.group, hex: threadId) + return dependencies.mutate(cache: .libSession) { cache in + cache.groupName(groupSessionId: groupId) + } + + case .community: + return try? OpenGroup + .select(.name) + .filter(id: threadId) + .asRequest(of: String.self) + .fetchOne(db) + + default: return nil + } + }, + shouldShowForMessageRequest: { false } ) } + + return (threadId, threadVariant, interactionId, interaction.variant, interaction.wasRead, 0) } - return + + return nil } - guard db[.areCallsEnabled] && Permissions.microphone == .granted else { - let state: CallMessage.MessageInfo.State = (db[.areCallsEnabled] ? .permissionDeniedMicrophone : .permissionDenied) + guard dependencies.mutate(cache: .libSession, { $0.get(.areCallsEnabled) }) && Permissions.microphone == .granted else { + let state: CallMessage.MessageInfo.State = (dependencies.mutate(cache: .libSession) { cache in + (cache.get(.areCallsEnabled) ? .permissionDeniedMicrophone : .permissionDenied) + }) Log.info(.calls, "Microphone permission is \(AVAudioSession.sharedInstance().recordPermission)") - if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, for: message, state: state, using: dependencies) { + if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, threadId: threadId, threadVariant: threadVariant, for: message, state: state, using: dependencies), let interactionId: Int64 = interaction.id { let thread: SessionThread = try SessionThread.upsert( db, id: sender, @@ -106,12 +168,47 @@ extension MessageReceiver { using: dependencies ) - if !interaction.wasRead { - dependencies[singleton: .notificationsManager].notifyUser( - db, - forIncomingCall: interaction, - in: thread, - applicationState: (isMainAppActive ? .active : .background) + if !suppressNotifications && !interaction.wasRead { + /// Update the `CallMessage.state` value so the correct notification logic can occur + message.state = state + + try? dependencies[singleton: .notificationsManager].notifyUser( + message: message, + threadId: thread.id, + threadVariant: thread.variant, + interactionIdentifier: (interaction.serverHash ?? "\(interactionId)"), + interactionVariant: interaction.variant, + attachmentDescriptionInfo: nil, + openGroupUrlInfo: nil, + applicationState: (isMainAppActive ? .active : .background), + extensionBaseUnreadCount: nil, + currentUserSessionIds: [dependencies[cache: .general].sessionId.hexString], + displayNameRetriever: { sessionId in + Profile.displayNameNoFallback( + db, + id: sessionId, + threadVariant: thread.variant + ) + }, + groupNameRetriever: { threadId, threadVariant in + switch threadVariant { + case .group: + let groupId: SessionId = SessionId(.group, hex: threadId) + return dependencies.mutate(cache: .libSession) { cache in + cache.groupName(groupSessionId: groupId) + } + + case .community: + return try? OpenGroup + .select(.name) + .filter(id: threadId) + .asRequest(of: String.self) + .fetchOne(db) + + default: return nil + } + }, + shouldShowForMessageRequest: { false } ) } @@ -121,33 +218,58 @@ extension MessageReceiver { object: nil, userInfo: [ Notification.Key.senderId.rawValue: sender ] ) + return (threadId, threadVariant, interactionId, interaction.variant, interaction.wasRead, 0) } - return + + return nil } - // Ignore pre offer message after the same call instance has been generated - if let currentCall: CurrentCallProtocol = dependencies[singleton: .callManager].currentCall, currentCall.uuid == message.uuid { - Log.info(.calls, "Ignoring pre-offer message for call[\(currentCall.uuid)] instance because it is already active.") - return + /// If we are already on a call that is different from the current one then we are in a busy state + guard + dependencies[singleton: .callManager].currentCall == nil || + dependencies[singleton: .callManager].currentCall?.uuid == message.uuid + else { + return try handleIncomingCallOfferInBusyState( + db, + threadId: threadId, + threadVariant: threadVariant, + message: message, + suppressNotifications: suppressNotifications, + using: dependencies + ) } + /// Insert the call info message for the message (this needs to happen whether it's a new call or an existing call since the PN + /// extension will no longer insert this itself) + let interaction: Interaction? = try MessageReceiver.insertCallInfoMessage( + db, + threadId: threadId, + threadVariant: threadVariant, + for: message, + using: dependencies + ) + + /// Ignore pre offer message after the same call instance has been generated guard dependencies[singleton: .callManager].currentCall == nil else { - try MessageReceiver.handleIncomingCallOfferInBusyState(db, message: message, using: dependencies) - return + Log.info(.calls, "Ignoring pre-offer message for call[\(message.uuid)] instance because it is already active.") + return interaction.map { interaction in + interaction.id.map { (threadId, threadVariant, $0, interaction.variant, interaction.wasRead, 0) } + } } - let interaction: Interaction? = try MessageReceiver.insertCallInfoMessage(db, for: message, using: dependencies) - - // Handle UI + /// Handle UI for the new call dependencies[singleton: .callManager].showCallUIForCall( caller: sender, uuid: message.uuid, mode: .answer, interactionId: interaction?.id ) + return interaction.map { interaction in + interaction.id.map { (threadId, threadVariant, $0, interaction.variant, interaction.wasRead, 0) } + } } - private static func handleOfferCallMessage(_ db: Database, message: CallMessage, using dependencies: Dependencies) { + private static func handleOfferCallMessage(_ db: ObservingDatabase, message: CallMessage, using dependencies: Dependencies) { Log.info(.calls, "Received offer message.") // Ensure we have a call manager before continuing @@ -162,7 +284,7 @@ extension MessageReceiver { } private static func handleAnswerCallMessage( - _ db: Database, + _ db: ObservingDatabase, message: CallMessage, using dependencies: Dependencies ) { @@ -191,7 +313,7 @@ extension MessageReceiver { } private static func handleEndCallMessage( - _ db: Database, + _ db: ObservingDatabase, message: CallMessage, using dependencies: Dependencies ) { @@ -217,45 +339,42 @@ extension MessageReceiver { // MARK: - Convenience public static func handleIncomingCallOfferInBusyState( - _ db: Database, + _ db: ObservingDatabase, + threadId: String, + threadVariant: SessionThread.Variant, message: CallMessage, + suppressNotifications: Bool, using dependencies: Dependencies - ) throws { + ) throws -> InsertedInteractionInfo? { let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(state: .missed) guard let caller: String = message.sender, let messageInfoData: Data = try? JSONEncoder(using: dependencies).encode(messageInfo), - !SessionThread.isMessageRequest( - db, - threadId: caller, - userSessionId: dependencies[cache: .general].sessionId - ), - let thread: SessionThread = try SessionThread.fetchOne(db, id: caller) - else { return } - - Log.info(.calls, "Sending end call message because there is an ongoing call.") + dependencies.mutate(cache: .libSession, { cache in + !cache.isMessageRequest(threadId: caller, threadVariant: threadVariant) + }) + else { throw MessageReceiverError.invalidMessage } let messageSentTimestampMs: Int64 = ( message.sentTimestampMs.map { Int64($0) } ?? dependencies[cache: .snodeAPI].currentOffsetTimestampMs() ) - _ = try Interaction( + let interaction: Interaction = try Interaction( serverHash: message.serverHash, messageUuid: message.uuid, - threadId: thread.id, - threadVariant: thread.variant, + threadId: threadId, + threadVariant: threadVariant, authorId: caller, variant: .infoCall, body: String(data: messageInfoData, encoding: .utf8), timestampMs: messageSentTimestampMs, wasRead: dependencies.mutate(cache: .libSession) { cache in cache.timestampAlreadyRead( - threadId: thread.id, - threadVariant: thread.variant, - timestampMs: (messageSentTimestampMs * 1000), - userSessionId: dependencies[cache: .general].sessionId, - openGroup: nil + threadId: threadId, + threadVariant: threadVariant, + timestampMs: messageSentTimestampMs, + openGroupUrlInfo: nil ) }, expiresInSeconds: message.expiresInSeconds, @@ -263,35 +382,60 @@ extension MessageReceiver { using: dependencies ) .inserted(db) - - try MessageSender - .preparedSend( - db, - message: CallMessage( - uuid: message.uuid, - kind: .endCall, - sdps: [], - sentTimestampMs: nil // Explicitly nil as it's a separate message from above - ) - .with(try? thread.disappearingMessagesConfiguration - .fetchOne(db)? - .forcedWithDisappearAfterReadIfNeeded() - ), - to: try Message.Destination.from(db, threadId: thread.id, threadVariant: thread.variant), - namespace: try Message.Destination - .from(db, threadId: thread.id, threadVariant: thread.variant) - .defaultNamespace, - interactionId: nil, // Explicitly nil as it's a separate message from above - fileIds: [], + + /// If we are suppressing notifications then we are loading in messages that were cached by the extensions, in which case it's + /// an old message so we would have already sent the response (all we would have needed to do in this case was save the + /// `interaction` above to the database) + if !suppressNotifications { + Log.info(.calls, "Sending end call message because there is an ongoing call.") + + try sendIncomingCallOfferInBusyStateResponse( + threadId: threadId, + message: message, + disappearingMessagesConfiguration: try? DisappearingMessagesConfiguration + .fetchOne(db, id: threadId), + authMethod: try Authentication.with(db, swarmPublicKey: threadId, using: dependencies), + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) .send(using: dependencies) .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .sinkUntilComplete() + } + + return interaction.id.map { (threadId, threadVariant, $0, interaction.variant, interaction.wasRead, 0) } + } + + public static func sendIncomingCallOfferInBusyStateResponse( + threadId: String, + message: CallMessage, + disappearingMessagesConfiguration: DisappearingMessagesConfiguration?, + authMethod: AuthenticationMethod, + onEvent: ((MessageSender.Event) -> Void)?, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + return try MessageSender.preparedSend( + message: CallMessage( + uuid: message.uuid, + kind: .endCall, + sdps: [], + sentTimestampMs: nil // Explicitly nil as it's a separate message from above + ) + .with(disappearingMessagesConfiguration?.forcedWithDisappearAfterReadIfNeeded()), + to: .contact(publicKey: threadId), + namespace: .default, + interactionId: nil, // Explicitly nil as it's a separate message from above + attachments: nil, + authMethod: authMethod, + onEvent: onEvent, + using: dependencies + ) } @discardableResult public static func insertCallInfoMessage( - _ db: Database, + _ db: ObservingDatabase, + threadId: String, + threadVariant: SessionThread.Variant, for message: CallMessage, state: CallMessage.MessageInfo.State? = nil, using dependencies: Dependencies @@ -301,17 +445,14 @@ extension MessageReceiver { .filter(Interaction.Columns.variant == Interaction.Variant.infoCall) .filter(Interaction.Columns.messageUuid == message.uuid) .isEmpty(db) - ).defaulting(to: false) + ).defaulting(to: false) else { throw MessageReceiverError.duplicatedCall } guard let sender: String = message.sender, - !SessionThread.isMessageRequest( - db, - threadId: sender, - userSessionId: dependencies[cache: .general].sessionId - ), - let thread: SessionThread = try SessionThread.fetchOne(db, id: sender) + dependencies.mutate(cache: .libSession, { cache in + !cache.isMessageRequest(threadId: sender, threadVariant: threadVariant) + }) else { return nil } let userSessionId: SessionId = dependencies[cache: .general].sessionId @@ -335,19 +476,18 @@ extension MessageReceiver { return try Interaction( serverHash: message.serverHash, messageUuid: message.uuid, - threadId: thread.id, - threadVariant: thread.variant, + threadId: threadId, + threadVariant: threadVariant, authorId: sender, variant: .infoCall, body: String(data: messageInfoData, encoding: .utf8), timestampMs: timestampMs, wasRead: dependencies.mutate(cache: .libSession) { cache in cache.timestampAlreadyRead( - threadId: thread.id, - threadVariant: thread.variant, - timestampMs: (timestampMs * 1000), - userSessionId: userSessionId, - openGroup: nil + threadId: threadId, + threadVariant: threadVariant, + timestampMs: timestampMs, + openGroupUrlInfo: nil ) }, expiresInSeconds: message.expiresInSeconds, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift index 62667a78f1..4186c65e0b 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift @@ -7,13 +7,13 @@ import SessionUtilitiesKit extension MessageReceiver { internal static func handleDataExtractionNotification( - _ db: Database, + _ db: ObservingDatabase, threadId: String, threadVariant: SessionThread.Variant, message: DataExtractionNotification, serverExpirationTimestamp: TimeInterval?, using dependencies: Dependencies - ) throws { + ) throws -> InsertedInteractionInfo? { guard threadVariant == .contact, let sender: String = message.sender, @@ -32,9 +32,8 @@ extension MessageReceiver { cache.timestampAlreadyRead( threadId: threadId, threadVariant: threadVariant, - timestampMs: (timestampMs * 1000), - userSessionId: dependencies[cache: .general].sessionId, - openGroup: nil + timestampMs: timestampMs, + openGroupUrlInfo: nil ) } let messageExpirationInfo: Message.MessageExpirationInfo = Message.getMessageExpirationInfo( @@ -45,7 +44,7 @@ extension MessageReceiver { expiresStartedAtMs: message.expiresStartedAtMs, using: dependencies ) - _ = try Interaction( + let interaction: Interaction = try Interaction( serverHash: message.serverHash, threadId: threadId, threadVariant: threadVariant, @@ -70,5 +69,7 @@ extension MessageReceiver { using: dependencies ) } + + return interaction.id.map { (threadId, threadVariant, $0, interaction.variant, wasRead, 0) } } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift index 6588fcb4f5..51d22cfba3 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift @@ -7,20 +7,20 @@ import SessionUtilitiesKit extension MessageReceiver { internal static func handleExpirationTimerUpdate( - _ db: Database, + _ db: ObservingDatabase, threadId: String, threadVariant: SessionThread.Variant, message: Message, serverExpirationTimestamp: TimeInterval?, proto: SNProtoContent, using dependencies: Dependencies - ) throws { - guard proto.hasExpirationType || proto.hasExpirationTimer else { return } + ) throws -> InsertedInteractionInfo? { + guard proto.hasExpirationType || proto.hasExpirationTimer else { throw MessageReceiverError.invalidMessage } guard threadVariant == .contact, // Groups are handled via the GROUP_INFO config instead let sender: String = message.sender, let timestampMs: UInt64 = message.sentTimestampMs - else { return } + else { throw MessageReceiverError.invalidMessage } let localConfig: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration .fetchOne(db, id: threadId) @@ -42,10 +42,10 @@ extension MessageReceiver { // If the updated config from this message is different from local config, // this control message should already be removed. if threadId == dependencies[cache: .general].sessionId.hexString && updatedConfig != localConfig { - return + throw MessageReceiverError.ignorableMessage } - _ = try updatedConfig.insertControlMessage( + return try updatedConfig.insertControlMessage( db, threadVariant: threadVariant, authorId: sender, @@ -57,7 +57,7 @@ extension MessageReceiver { } public static func updateContactDisappearingMessagesVersionIfNeeded( - _ db: Database, + _ db: ObservingDatabase, messageVariant: Message.Variant?, contactId: String?, version: FeatureVersion?, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift index ce46c6d7dc..b64061f9f2 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift @@ -8,30 +8,33 @@ import SessionUtilitiesKit extension MessageReceiver { public static func handleGroupUpdateMessage( - _ db: Database, + _ db: ObservingDatabase, threadId: String, threadVariant: SessionThread.Variant, message: Message, serverExpirationTimestamp: TimeInterval?, + suppressNotifications: Bool, using dependencies: Dependencies - ) throws { + ) throws -> InsertedInteractionInfo? { switch (message, try? SessionId(from: threadId)) { case (let message as GroupUpdateInviteMessage, _): - try MessageReceiver.handleGroupInvite( + return try MessageReceiver.handleGroupInvite( db, message: message, + suppressNotifications: suppressNotifications, using: dependencies ) case (let message as GroupUpdatePromoteMessage, _): - try MessageReceiver.handleGroupPromotion( + return try MessageReceiver.handleGroupPromotion( db, message: message, + suppressNotifications: suppressNotifications, using: dependencies ) case (let message as GroupUpdateInfoChangeMessage, .some(let sessionId)) where sessionId.prefix == .group: - try MessageReceiver.handleGroupInfoChanged( + return try MessageReceiver.handleGroupInfoChanged( db, groupSessionId: sessionId, message: message, @@ -40,7 +43,7 @@ extension MessageReceiver { ) case (let message as GroupUpdateMemberChangeMessage, .some(let sessionId)) where sessionId.prefix == .group: - try MessageReceiver.handleGroupMemberChanged( + return try MessageReceiver.handleGroupMemberChanged( db, groupSessionId: sessionId, message: message, @@ -55,9 +58,10 @@ extension MessageReceiver { message: message, using: dependencies ) + return nil case (let message as GroupUpdateMemberLeftNotificationMessage, .some(let sessionId)) where sessionId.prefix == .group: - try MessageReceiver.handleGroupMemberLeftNotification( + return try MessageReceiver.handleGroupMemberLeftNotification( db, groupSessionId: sessionId, message: message, @@ -72,6 +76,7 @@ extension MessageReceiver { message: message, using: dependencies ) + return nil case (let message as GroupUpdateDeleteMemberContentMessage, .some(let sessionId)) where sessionId.prefix == .group: try MessageReceiver.handleGroupDeleteMemberContent( @@ -80,22 +85,21 @@ extension MessageReceiver { message: message, using: dependencies ) + return nil default: throw MessageReceiverError.invalidMessage } } - // MARK: - Specific Handling + // MARK: - Validation - private static func handleGroupInvite( - _ db: Database, + public static func validateGroupInvite( message: GroupUpdateInviteMessage, using dependencies: Dependencies ) throws { let userSessionId: SessionId = dependencies[cache: .general].sessionId guard - let sender: String = message.sender, let sentTimestampMs: UInt64 = message.sentTimestampMs, Authentication.verify( signature: message.adminSignature, @@ -108,15 +112,31 @@ extension MessageReceiver { ), // Somewhat redundant because we know the sender was a group admin but this confirms the // authData is valid so protects against invalid invite spam from a group admin - let userEd25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), dependencies[singleton: .crypto].verify( .memberAuthData( groupSessionId: message.groupSessionId, - ed25519SecretKey: userEd25519KeyPair.secretKey, + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey, memberAuthData: message.memberAuthData ) ) else { throw MessageReceiverError.invalidMessage } + } + + // MARK: - Specific Handling + + private static func handleGroupInvite( + _ db: ObservingDatabase, + message: GroupUpdateInviteMessage, + suppressNotifications: Bool, + using dependencies: Dependencies + ) throws -> InsertedInteractionInfo? { + guard + let sender: String = message.sender, + let sentTimestampMs: UInt64 = message.sentTimestampMs + else { throw MessageReceiverError.invalidMessage } + + // Ensure the message is valid + try validateGroupInvite(message: message, using: dependencies) // Update profile if needed if let profile = message.profile { @@ -124,40 +144,30 @@ extension MessageReceiver { db, publicKey: sender, displayNameUpdate: .contactUpdate(profile.displayName), - displayPictureUpdate: { - guard - let profilePictureUrl: String = profile.profilePictureUrl, - let profileKey: Data = profile.profileKey - else { return .contactRemove } - - return .contactUpdateTo( - url: profilePictureUrl, - key: profileKey, - fileName: nil - ) - }(), + displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, sentTimestamp: TimeInterval(Double(sentTimestampMs) / 1000), using: dependencies ) } - try processGroupInvite( + return try processGroupInvite( db, + message: message, sender: sender, - serverHash: message.serverHash, sentTimestampMs: Int64(sentTimestampMs), groupSessionId: message.groupSessionId, groupName: message.groupName, memberAuthData: message.memberAuthData, groupIdentityPrivateKey: nil, + suppressNotifications: suppressNotifications, using: dependencies ) } /// This returns the `resultPublisher` for the group poller so can be ignored if we don't need to wait for the first poll to succeed internal static func handleNewGroup( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: String, groupIdentityPrivateKey: Data?, name: String, @@ -190,11 +200,7 @@ extension MessageReceiver { if forceMarkAsInvited { dependencies.mutate(cache: .libSession) { cache in - try? cache.markAsInvited( - db, - groupSessionIds: [groupSessionId], - using: dependencies - ) + try? cache.markAsInvited(groupSessionIds: [groupSessionId]) } } @@ -221,10 +227,11 @@ extension MessageReceiver { } private static func handleGroupPromotion( - _ db: Database, + _ db: ObservingDatabase, message: GroupUpdatePromoteMessage, + suppressNotifications: Bool, using dependencies: Dependencies - ) throws { + ) throws -> InsertedInteractionInfo? { guard let sender: String = message.sender, let sentTimestampMs: UInt64 = message.sentTimestampMs, @@ -241,18 +248,7 @@ extension MessageReceiver { db, publicKey: sender, displayNameUpdate: .contactUpdate(profile.displayName), - displayPictureUpdate: { - guard - let profilePictureUrl: String = profile.profilePictureUrl, - let profileKey: Data = profile.profileKey - else { return .contactRemove } - - return .contactUpdateTo( - url: profilePictureUrl, - key: profileKey, - fileName: nil - ) - }(), + displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, sentTimestamp: TimeInterval(Double(sentTimestampMs) / 1000), using: dependencies @@ -260,15 +256,16 @@ extension MessageReceiver { } // Process the promotion as a group invite (if needed) - try processGroupInvite( + let insertedInteractionInfo: InsertedInteractionInfo? = try processGroupInvite( db, + message: message, sender: sender, - serverHash: message.serverHash, sentTimestampMs: Int64(sentTimestampMs), groupSessionId: groupSessionId, groupName: message.groupName, memberAuthData: nil, groupIdentityPrivateKey: Data(groupIdentityKeyPair.secretKey), + suppressNotifications: suppressNotifications, using: dependencies ) @@ -280,7 +277,7 @@ extension MessageReceiver { // If the group is in it's invited state then the admin won't be able to update their admin status // so don't bother trying - guard !groupInInvitedState else { return } + guard !groupInInvitedState else { return insertedInteractionInfo } // Load the admin key into libSession (the users member role and status will be updated after // receiving the GROUP_MEMBERS config message) @@ -316,15 +313,17 @@ extension MessageReceiver { SnodeReceivedMessageInfo.Columns.wasDeletedOrInvalid.set(to: true), using: dependencies ) + + return insertedInteractionInfo } private static func handleGroupInfoChanged( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: SessionId, message: GroupUpdateInfoChangeMessage, serverExpirationTimestamp: TimeInterval?, using dependencies: Dependencies - ) throws { + ) throws -> InsertedInteractionInfo? { guard let sender: String = message.sender, let sentTimestampMs: UInt64 = message.sentTimestampMs, @@ -350,9 +349,10 @@ extension MessageReceiver { using: dependencies ) + let interaction: Interaction switch message.changeType { case .name: - _ = try Interaction( + interaction = try Interaction( serverHash: message.serverHash, threadId: groupSessionId.hexString, threadVariant: .group, @@ -369,7 +369,7 @@ extension MessageReceiver { ).inserted(db) case .avatar: - _ = try Interaction( + interaction = try Interaction( serverHash: message.serverHash, threadId: groupSessionId.hexString, threadVariant: .group, @@ -393,7 +393,7 @@ extension MessageReceiver { durationSeconds: TimeInterval((message.updatedExpiration ?? 0)), type: .disappearAfterSend ) - _ = try config.insertControlMessage( + return try config.insertControlMessage( db, threadVariant: .group, authorId: sender, @@ -403,15 +403,19 @@ extension MessageReceiver { using: dependencies ) } + + return interaction.id.map { + (groupSessionId.hexString, .group, $0, interaction.variant, interaction.wasRead, 0) + } } private static func handleGroupMemberChanged( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: SessionId, message: GroupUpdateMemberChangeMessage, serverExpirationTimestamp: TimeInterval?, using dependencies: Dependencies - ) throws { + ) throws -> InsertedInteractionInfo? { guard let sender: String = message.sender, let sentTimestampMs: UInt64 = message.sentTimestampMs, @@ -486,7 +490,7 @@ extension MessageReceiver { using: dependencies ) - _ = try Interaction( + let interaction: Interaction = try Interaction( threadId: groupSessionId.hexString, threadVariant: .group, authorId: sender, @@ -497,11 +501,17 @@ extension MessageReceiver { expiresStartedAtMs: messageExpirationInfo.expiresStartedAtMs, using: dependencies ).inserted(db) + + return interaction.id.map { + (groupSessionId.hexString, .group, $0, interaction.variant, interaction.wasRead, 0) + } } + + return nil } private static func handleGroupMemberLeft( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: SessionId, message: GroupUpdateMemberLeftMessage, using dependencies: Dependencies @@ -511,11 +521,13 @@ extension MessageReceiver { guard let sender: String = message.sender, let sentTimestampMs: UInt64 = message.sentTimestampMs, - LibSession.isAdmin(groupSessionId: groupSessionId, using: dependencies) + dependencies.mutate(cache: .libSession, { cache in + cache.isAdmin(groupSessionId: groupSessionId) + }) else { throw MessageReceiverError.invalidMessage } // Trigger this removal in a separate process because it requires a number of requests to be made - db.afterNextTransactionNested(using: dependencies) { _ in + db.afterCommit { MessageSender .removeGroupMembers( groupSessionId: groupSessionId.hexString, @@ -531,12 +543,12 @@ extension MessageReceiver { } private static func handleGroupMemberLeftNotification( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: SessionId, message: GroupUpdateMemberLeftNotificationMessage, serverExpirationTimestamp: TimeInterval?, using dependencies: Dependencies - ) throws { + ) throws -> InsertedInteractionInfo? { guard let sender: String = message.sender, let sentTimestampMs: UInt64 = message.sentTimestampMs @@ -553,7 +565,7 @@ extension MessageReceiver { using: dependencies ) - _ = try Interaction( + let interaction: Interaction = try Interaction( threadId: groupSessionId.hexString, threadVariant: .group, authorId: sender, @@ -572,10 +584,14 @@ extension MessageReceiver { expiresStartedAtMs: messageExpirationInfo.expiresStartedAtMs, using: dependencies ).inserted(db) + + return interaction.id.map { + (groupSessionId.hexString, .group, $0, interaction.variant, interaction.wasRead, 0) + } } private static func handleGroupInviteResponse( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: SessionId, message: GroupUpdateInviteResponseMessage, using dependencies: Dependencies @@ -592,18 +608,7 @@ extension MessageReceiver { db, publicKey: sender, displayNameUpdate: .contactUpdate(profile.displayName), - displayPictureUpdate: { - guard - let profilePictureUrl: String = profile.profilePictureUrl, - let profileKey: Data = profile.profileKey - else { return .contactRemove } - - return .contactUpdateTo( - url: profilePictureUrl, - key: profileKey, - fileName: nil - ) - }(), + displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, sentTimestamp: TimeInterval(Double(sentTimestampMs) / 1000), using: dependencies @@ -620,8 +625,9 @@ extension MessageReceiver { Profile( id: sender, name: $0, - profilePictureUrl: profile.profilePictureUrl, - profileEncryptionKey: profile.profileKey + displayPictureUrl: profile.profilePictureUrl, + displayPictureEncryptionKey: profile.profileKey, + displayPictureLastUpdated: (Double(sentTimestampMs) / 1000) ) } }, @@ -630,7 +636,7 @@ extension MessageReceiver { } private static func handleGroupDeleteMemberContent( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: SessionId, message: GroupUpdateDeleteMemberContentMessage, using dependencies: Dependencies @@ -736,7 +742,9 @@ extension MessageReceiver { /// messages from the swarm as well guard !hashes.isEmpty, - LibSession.isAdmin(groupSessionId: groupSessionId, using: dependencies), + dependencies.mutate(cache: .libSession, { cache in + cache.isAdmin(groupSessionId: groupSessionId) + }), let authMethod: AuthenticationMethod = try? Authentication.with( db, swarmPublicKey: groupSessionId.hexString, @@ -780,34 +788,30 @@ extension MessageReceiver { /// /// **Note:** Admins can't be removed from a group so this only clears the `authData` internal static func handleGroupDelete( - _ db: Database, + _ db: ObservingDatabase, groupSessionId: SessionId, plaintext: Data, using dependencies: Dependencies ) throws { let userSessionId: SessionId = dependencies[cache: .general].sessionId - /// Ignore the message if the `memberSessionIds` doesn't contain the current users session id, - /// it was sent before the user joined the group or if the `adminSignature` isn't valid - guard - let (memberId, keysGen): (SessionId, Int) = try? LibSessionMessage.groupKicked(plaintext: plaintext), - let currentKeysGen: Int = try? LibSession.currentGeneration( - groupSessionId: groupSessionId, - using: dependencies - ), - memberId == userSessionId, - keysGen >= currentKeysGen - else { throw MessageReceiverError.invalidMessage } + /// Ensure the `groupKicked` message was valid before continuing + try LibSessionMessage.validateGroupKickedMessage( + plaintext: plaintext, + userSessionId: userSessionId, + groupSessionId: groupSessionId, + using: dependencies + ) /// If we haven't already handled being kicked from the group then update the name of the group in `USER_GROUPS` so /// that if the user doesn't delete the group and links a new device, the group will have the same name as on the current device - if !LibSession.wasKickedFromGroup(groupSessionId: groupSessionId, using: dependencies) { + let wasKickedFromGroup: Bool = dependencies.mutate(cache: .libSession) { cache in + cache.wasKickedFromGroup(groupSessionId: groupSessionId) + } + + if !wasKickedFromGroup { dependencies.mutate(cache: .libSession) { cache in - let groupInfoConfig: LibSession.Config? = cache.config(for: .groupInfo, sessionId: groupSessionId) - let userGroupsConfig: LibSession.Config? = cache.config(for: .userGroups, sessionId: userSessionId) - let groupName: String? = try? LibSession.groupName(in: groupInfoConfig) - - switch groupName { + switch cache.groupName(groupSessionId: groupSessionId) { case .none: Log.warn(.messageReceiver, "Failed to update group name before being kicked.") case .some(let name): try? LibSession.upsert( @@ -817,7 +821,7 @@ extension MessageReceiver { name: name ) ], - in: userGroupsConfig, + in: cache.config(for: .userGroups, sessionId: userSessionId), using: dependencies ) } @@ -858,16 +862,17 @@ extension MessageReceiver { // MARK: - Shared internal static func processGroupInvite( - _ db: Database, + _ db: ObservingDatabase, + message: Message, sender: String, - serverHash: String?, sentTimestampMs: Int64, groupSessionId: SessionId, groupName: String, memberAuthData: Data?, groupIdentityPrivateKey: Data?, + suppressNotifications: Bool, using dependencies: Dependencies - ) throws { + ) throws -> InsertedInteractionInfo? { let userSessionId: SessionId = dependencies[cache: .general].sessionId /// With updated groups they should be considered message requests (`invited: true`) unless person sending the invitation is @@ -882,10 +887,9 @@ extension MessageReceiver { /// If we had previously been kicked from a group then we need to update the flag in `UserGroups` so that we don't consider /// ourselves as kicked anymore - let wasKickedFromGroup: Bool = LibSession.wasKickedFromGroup( - groupSessionId: groupSessionId, - using: dependencies - ) + let wasKickedFromGroup: Bool = dependencies.mutate(cache: .libSession) { cache in + cache.wasKickedFromGroup(groupSessionId: groupSessionId) + } try MessageReceiver.handleNewGroup( db, groupSessionId: groupSessionId.hexString, @@ -910,22 +914,24 @@ extension MessageReceiver { /// Now that we've added the group info into the `USER_GROUPS` config we should try to delete the original invitation/promotion /// from the swarm so we don't need to worry about it being reprocessed on another device if the user happens to leave or get /// removed from the group before another device has received it (ie. stop the group from incorrectly reappearing) - switch serverHash { + switch message.serverHash { case .none: break case .some(let serverHash): - db.afterNextTransaction { db in - try? SnodeAPI - .preparedDeleteMessages( - serverHashes: [serverHash], - requireSuccessfulDeletion: false, - authMethod: try Authentication.with( - db, - swarmPublicKey: userSessionId.hexString, + db.afterCommit { + dependencies[singleton: .storage] + .readPublisher { db in + try SnodeAPI.preparedDeleteMessages( + serverHashes: [serverHash], + requireSuccessfulDeletion: false, + authMethod: try Authentication.with( + db, + swarmPublicKey: userSessionId.hexString, + using: dependencies + ), using: dependencies - ), - using: dependencies - ) - .send(using: dependencies) + ) + } + .flatMap { $0.send(using: dependencies) } .subscribe(on: DispatchQueue.global(qos: .background), using: dependencies) .sinkUntilComplete() } @@ -933,7 +939,7 @@ extension MessageReceiver { /// If the thread didn't already exist, or the user had previously been kicked but has since been re-added to the group, then insert /// an 'invited' info message - guard !threadAlreadyExisted || wasKickedFromGroup else { return } + guard !threadAlreadyExisted || wasKickedFromGroup else { return nil } /// Remove any existing `infoGroupInfoInvited` interactions from the group (don't want to have a duplicate one in case /// the group was created via a `USER_GROUPS` config when syncing a new device) @@ -977,8 +983,7 @@ extension MessageReceiver { threadId: groupSessionId.hexString, threadVariant: .group, timestampMs: sentTimestampMs, - userSessionId: userSessionId, - openGroup: nil + openGroupUrlInfo: nil ) }, using: dependencies @@ -1007,31 +1012,71 @@ extension MessageReceiver { /// If the sender wasn't approved this is a message request so we should notify the user about the invite case (false, _): let isMainAppActive: Bool = dependencies[defaults: .appGroup, key: .isMainAppActive] - dependencies[singleton: .notificationsManager].notifyUser( + let thread: SessionThread = try SessionThread.upsert( db, - for: interaction, - in: try SessionThread.upsert( - db, - id: groupSessionId.hexString, - variant: .group, - values: SessionThread.TargetValues( - creationDateTimestamp: .useExistingOrSetTo( - dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000 - ), - shouldBeVisible: .useExisting + id: groupSessionId.hexString, + variant: .group, + values: SessionThread.TargetValues( + creationDateTimestamp: .useExistingOrSetTo( + dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000 ), - using: dependencies + shouldBeVisible: .useExisting ), - applicationState: (isMainAppActive ? .active : .background) + using: dependencies ) + + if !suppressNotifications { + try? dependencies[singleton: .notificationsManager].notifyUser( + message: message, + threadId: thread.id, + threadVariant: thread.variant, + interactionIdentifier: (interaction.serverHash ?? "\(interaction.id ?? 0)"), + interactionVariant: interaction.variant, + attachmentDescriptionInfo: nil, + openGroupUrlInfo: nil, + applicationState: (isMainAppActive ? .active : .background), + extensionBaseUnreadCount: nil, + currentUserSessionIds: [dependencies[cache: .general].sessionId.hexString], + displayNameRetriever: { sessionId in + Profile.displayNameNoFallback( + db, + id: sessionId, + threadVariant: thread.variant + ) + }, + groupNameRetriever: { threadId, threadVariant in + switch threadVariant { + case .group: + let groupId: SessionId = SessionId(.group, hex: threadId) + return dependencies.mutate(cache: .libSession) { cache in + cache.groupName(groupSessionId: groupId) + } + + case .community: + return try? OpenGroup + .select(.name) + .filter(id: threadId) + .asRequest(of: String.self) + .fetchOne(db) + + default: return nil + } + }, + shouldShowForMessageRequest: { false } + ) + } /// If the sender is approved and this was an admin invitation then do nothing case (true, false): break } + + return interaction.id.map { + (groupSessionId.hexString, .group, $0, interaction.variant, interaction.wasRead, 0) + } } internal static func updateMemberApprovalStatusIfNeeded( - _ db: Database, + _ db: ObservingDatabase, senderSessionId: String, groupSessionIdHexString: String?, profile: Profile?, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LegacyClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LegacyClosedGroups.swift index f86cddef1a..8396652b28 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LegacyClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LegacyClosedGroups.swift @@ -8,7 +8,7 @@ import SessionSnodeKit extension MessageReceiver { internal static func handleNewLegacyClosedGroup( - _ db: Database, + _ db: ObservingDatabase, legacyGroupSessionId: String, name: String, members: [String], @@ -21,7 +21,6 @@ extension MessageReceiver { // approved contact (to prevent spam via closed groups getting around message requests if users are // on old or modified clients) var hasApprovedAdmin: Bool = false - let receivedTimestamp: TimeInterval = (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) for adminId in admins { if let contact: Contact = try? Contact.fetchOne(db, id: adminId), contact.isApproved { @@ -35,7 +34,7 @@ extension MessageReceiver { guard hasApprovedAdmin || forceApprove else { return } // Create the group - let thread: SessionThread = try SessionThread.upsert( + _ = try SessionThread.upsert( db, id: legacyGroupSessionId, variant: .legacyGroup, @@ -45,7 +44,7 @@ extension MessageReceiver { ), using: dependencies ) - let closedGroup: ClosedGroup = try ClosedGroup( + _ = try ClosedGroup( threadId: legacyGroupSessionId, name: name, formationTimestamp: (TimeInterval(formationTimestampMs) / 1000), diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LibSession.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LibSession.swift index 2b39844569..acfe478541 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LibSession.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LibSession.swift @@ -7,52 +7,72 @@ import SessionSnodeKit import SessionUtilitiesKit extension MessageReceiver { + public typealias LibSessionMessageInfo = ( + senderSessionId: SessionId, + domain: LibSession.Crypto.Domain, + plaintext: Data + ) + /// Some messages are encrypted with `libSession` and don't use Protobuf, this function decrypts those messages and /// routes them accordingly - public static func handleLibSessionMessage( - _ db: Database, + public static func decryptLibSessionMessage( threadId: String, threadVariant: SessionThread.Variant, message: LibSessionMessage, using dependencies: Dependencies - ) throws { + ) throws -> [LibSessionMessageInfo] { guard let sender: String = message.sender, - let senderSessionId: SessionId = try? SessionId(from: sender), - let userEd25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) + let senderSessionId: SessionId = try? SessionId(from: sender) else { throw MessageReceiverError.decryptionFailed } let supportedEncryptionDomains: [LibSession.Crypto.Domain] = [ .kickedMessage ] - try supportedEncryptionDomains - .map { domain -> (domain: LibSession.Crypto.Domain, plaintext: Data) in - ( - domain, - try dependencies[singleton: .crypto].tryGenerate( - .plaintextWithMultiEncrypt( - ciphertext: message.ciphertext, - senderSessionId: senderSessionId, - ed25519PrivateKey: userEd25519KeyPair.secretKey, - domain: domain - ) + return try supportedEncryptionDomains.map { domain -> LibSessionMessageInfo in + ( + senderSessionId, + domain, + try dependencies[singleton: .crypto].tryGenerate( + .plaintextWithMultiEncrypt( + ciphertext: message.ciphertext, + senderSessionId: senderSessionId, + ed25519PrivateKey: dependencies[cache: .general].ed25519SecretKey, + domain: domain ) ) + ) + } + } + + public static func handleLibSessionMessage( + _ db: ObservingDatabase, + threadId: String, + threadVariant: SessionThread.Variant, + message: LibSessionMessage, + using dependencies: Dependencies + ) throws { + let result: [LibSessionMessageInfo] = try decryptLibSessionMessage( + threadId: threadId, + threadVariant: threadVariant, + message: message, + using: dependencies + ) + + try result.forEach { senderSessionId, domain, plaintext in + switch domain { + case LibSession.Crypto.Domain.kickedMessage: + try handleGroupDelete( + db, + groupSessionId: senderSessionId, + plaintext: plaintext, + using: dependencies + ) + + default: Log.error(.messageReceiver, "Received libSession encrypted message with unsupported domain: \(domain)") } - .forEach { domain, plaintext in - switch domain { - case LibSession.Crypto.Domain.kickedMessage: - try handleGroupDelete( - db, - groupSessionId: senderSessionId, - plaintext: plaintext, - using: dependencies - ) - - default: Log.error(.messageReceiver, "Received libSession encrypted message with unsupported domain: \(domain)") - } - } + } } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift index 065b52a25e..b5d2893a4d 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -10,10 +10,10 @@ import SessionSnodeKit extension MessageReceiver { internal static func handleMessageRequestResponse( - _ db: Database, + _ db: ObservingDatabase, message: MessageRequestResponse, using dependencies: Dependencies - ) throws { + ) throws -> InsertedInteractionInfo? { let userSessionId = dependencies[cache: .general].sessionId var blindedContactIds: [String] = [] @@ -32,18 +32,7 @@ extension MessageReceiver { db, publicKey: senderId, displayNameUpdate: .contactUpdate(profile.displayName), - displayPictureUpdate: { - guard - let profilePictureUrl: String = profile.profilePictureUrl, - let profileKey: Data = profile.profileKey - else { return .none } - - return .contactUpdateTo( - url: profilePictureUrl, - key: profileKey, - fileName: nil - ) - }(), + displayPictureUpdate: .from(profile, fallback: .none, using: dependencies), sentTimestamp: messageSentTimestamp, using: dependencies ) @@ -170,7 +159,7 @@ extension MessageReceiver { /// if the sender deletes and re-accepts message requests from the current user) /// - This will always appear in the un-blinded thread if !senderHadAlreadyApprovedMe { - _ = try Interaction( + let interaction: Interaction = try Interaction( serverHash: message.serverHash, threadId: unblindedThread.id, threadVariant: unblindedThread.variant, @@ -182,11 +171,17 @@ extension MessageReceiver { ), using: dependencies ).inserted(db) + + return interaction.id.map { + (unblindedThread.id, unblindedThread.variant, $0, .infoMessageRequestAccepted, true, 0) + } } + + return nil } internal static func updateContactApprovalStatusIfNeeded( - _ db: Database, + _ db: ObservingDatabase, senderSessionId: String, threadId: String?, using dependencies: Dependencies @@ -200,7 +195,7 @@ extension MessageReceiver { guard let threadId: String = threadId, let thread: SessionThread = try? SessionThread.fetchOne(db, id: threadId), - !thread.isNoteToSelf(db, using: dependencies) + !thread.isNoteToSelf(using: dependencies) else { return } // Sending a message to someone flags them as approved so create the contact record if @@ -217,6 +212,7 @@ extension MessageReceiver { Contact.Columns.isApproved.set(to: true), using: dependencies ) + db.addContactEvent(id: threadId, change: .isApproved(true)) } else { // The message was sent to the current user so flag their 'didApproveMe' as true (can't send a message to @@ -233,6 +229,7 @@ extension MessageReceiver { Contact.Columns.didApproveMe.set(to: true), using: dependencies ) + db.addContactEvent(id: senderSessionId, change: .didApproveMe(true)) } } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ReadReceipts.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ReadReceipts.swift index f8b1a625e4..aabad64430 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ReadReceipts.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ReadReceipts.swift @@ -2,12 +2,14 @@ import Foundation import GRDB +import SessionUtilitiesKit extension MessageReceiver { internal static func handleReadReceipt( - _ db: Database, + _ db: ObservingDatabase, message: ReadReceipt, - serverExpirationTimestamp: TimeInterval? + serverExpirationTimestamp: TimeInterval?, + using dependencies: Dependencies ) throws { guard let sender: String = message.sender else { return } guard let timestampMsValues: [Int64] = message.timestamps?.map({ Int64($0) }) else { return } @@ -17,7 +19,8 @@ extension MessageReceiver { db, threadId: sender, timestampMsValues: timestampMsValues, - readTimestampMs: readTimestampMs + readTimestampMs: readTimestampMs, + using: dependencies ) guard !pendingTimestampMs.isEmpty else { return } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+TypingIndicators.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+TypingIndicators.swift index 6bf23974e3..f9a3bb578b 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+TypingIndicators.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+TypingIndicators.swift @@ -6,7 +6,7 @@ import SessionUtilitiesKit extension MessageReceiver { internal static func handleTypingIndicator( - _ db: Database, + _ db: ObservingDatabase, threadId: String, threadVariant: SessionThread.Variant, message: TypingIndicator, @@ -16,35 +16,22 @@ extension MessageReceiver { switch message.kind { case .started: - let currentUserSessionId: SessionId = dependencies[cache: .general].sessionId - let threadIsBlocked: Bool = ( - threadVariant == .contact && - (try? Contact - .filter(id: threadId) - .select(.isBlocked) - .asRequest(of: Bool.self) - .fetchOne(db)) - .defaulting(to: false) - ) - let threadIsMessageRequest: Bool = (try? SessionThread - .filter(id: threadId) - .filter(SessionThread.isMessageRequest( - userSessionId: currentUserSessionId, - includeNonVisible: true - )) - .isEmpty(db)) - .defaulting(to: false) - dependencies[singleton: .typingIndicators].startIfNeeded( - threadId: threadId, - threadVariant: threadVariant, - threadIsBlocked: threadIsBlocked, - threadIsMessageRequest: threadIsMessageRequest, - direction: .incoming, - timestampMs: message.sentTimestampMs.map { Int64($0) } - ) + Task { + await dependencies[singleton: .typingIndicators].startIfNeeded( + threadId: threadId, + threadVariant: threadVariant, + direction: .incoming, + timestampMs: message.sentTimestampMs.map { Int64($0) } + ) + } case .stopped: - dependencies[singleton: .typingIndicators].didStopTyping(db, threadId: threadId, direction: .incoming) + Task { + await dependencies[singleton: .typingIndicators].didStopTyping( + threadId: threadId, + direction: .incoming + ) + } default: Log.warn(.messageReceiver, "Unknown TypingIndicator Kind ignored") diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift index 86fd17a1cb..ae75d068a5 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift @@ -7,7 +7,7 @@ import SessionUtilitiesKit extension MessageReceiver { public static func handleUnsendRequest( - _ db: Database, + _ db: ObservingDatabase, threadId: String, threadVariant: SessionThread.Variant, message: UnsendRequest, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 75d3bee423..29d3680f46 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -6,15 +6,25 @@ import SessionSnodeKit import SessionUtilitiesKit extension MessageReceiver { - @discardableResult public static func handleVisibleMessage( - _ db: Database, + public typealias InsertedInteractionInfo = ( + threadId: String, + threadVariant: SessionThread.Variant, + interactionId: Int64, + interactionVariant: Interaction.Variant?, + wasRead: Bool, + numPreviousInteractionsForMessageRequest: Int + ) + + internal static func handleVisibleMessage( + _ db: ObservingDatabase, threadId: String, threadVariant: SessionThread.Variant, message: VisibleMessage, serverExpirationTimestamp: TimeInterval?, associatedWithProto proto: SNProtoContent, + suppressNotifications: Bool, using dependencies: Dependencies - ) throws -> Int64 { + ) throws -> InsertedInteractionInfo { guard let sender: String = message.sender, let dataMessage = proto.dataMessage else { throw MessageReceiverError.invalidMessage } @@ -31,18 +41,7 @@ extension MessageReceiver { db, publicKey: sender, displayNameUpdate: .contactUpdate(profile.displayName), - displayPictureUpdate: { - guard - let profilePictureUrl: String = profile.profilePictureUrl, - let profileKey: Data = profile.profileKey - else { return .contactRemove } - - return .contactUpdateTo( - url: profilePictureUrl, - key: profileKey, - fileName: nil - ) - }(), + displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, sentTimestamp: messageSentTimestamp, using: dependencies @@ -77,15 +76,15 @@ extension MessageReceiver { ), using: dependencies ) - let maybeOpenGroup: OpenGroup? = { + let openGroupUrlInfo: LibSession.OpenGroupUrlInfo? = { guard threadVariant == .community else { return nil } - return try? OpenGroup.fetchOne(db, id: threadId) + return try? LibSession.OpenGroupUrlInfo.fetchOne(db, id: threadId) }() let variant: Interaction.Variant = try { guard let senderSessionId: SessionId = try? SessionId(from: sender), - let openGroup: OpenGroup = maybeOpenGroup + let openGroupUrlInfo: LibSession.OpenGroupUrlInfo = openGroupUrlInfo else { return (sender == userSessionId.hexString ? .standardOutgoing : @@ -101,7 +100,7 @@ extension MessageReceiver { .sessionId( userSessionId.hexString, matchesBlindedId: sender, - serverPublicKey: openGroup.publicKey + serverPublicKey: openGroupUrlInfo.publicKey ) ) else { return .standardIncoming } @@ -119,6 +118,30 @@ extension MessageReceiver { throw MessageReceiverError.invalidSender } }() + let generateCurrentUserSessionIds: () -> Set = { + guard threadVariant == .community else { return [userSessionId.hexString] } + + let openGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo + .fetchOne(db, id: threadId) + + return Set([ + userSessionId, + SessionThread.getCurrentUserBlindedSessionId( + threadId: threadId, + threadVariant: threadVariant, + blindingPrefix: .blinded15, + openGroupCapabilityInfo: openGroupCapabilityInfo, + using: dependencies + ), + SessionThread.getCurrentUserBlindedSessionId( + threadId: threadId, + threadVariant: threadVariant, + blindingPrefix: .blinded25, + openGroupCapabilityInfo: openGroupCapabilityInfo, + using: dependencies + ) + ].compactMap { $0 }.map { $0.hexString }) + } // Handle emoji reacts first (otherwise it's essentially an invalid message) if let interactionId: Int64 = try handleEmojiReactIfNeeded( @@ -128,10 +151,12 @@ extension MessageReceiver { associatedWithProto: proto, sender: sender, messageSentTimestamp: messageSentTimestamp, - openGroup: maybeOpenGroup, + openGroupUrlInfo: openGroupUrlInfo, + currentUserSessionIds: generateCurrentUserSessionIds(), + suppressNotifications: suppressNotifications, using: dependencies ) { - return interactionId + return (threadId, threadVariant, interactionId, nil, true, 0) } // Try to insert the interaction // @@ -148,8 +173,7 @@ extension MessageReceiver { threadId: thread.id, threadVariant: thread.variant, timestampMs: Int64(messageSentTimestamp * 1000), - userSessionId: userSessionId, - openGroup: maybeOpenGroup + openGroupUrlInfo: openGroupUrlInfo ) } ) @@ -298,7 +322,6 @@ extension MessageReceiver { // Persist quote if needed let quote: Quote? = try? Quote( - db, proto: dataMessage, interactionId: interactionId, thread: thread @@ -332,7 +355,6 @@ extension MessageReceiver { if isContactTrusted || thread.variant != .contact { attachments .map { $0.id } - .appending(quote?.attachmentId) .appending(linkPreview?.attachmentId) .forEach { attachmentId in dependencies[singleton: .jobRunner].add( @@ -352,7 +374,12 @@ extension MessageReceiver { // Cancel any typing indicators if needed if isMainAppActive { - dependencies[singleton: .typingIndicators].didStopTyping(db, threadId: thread.id, direction: .incoming) + Task { + await dependencies[singleton: .typingIndicators].didStopTyping( + threadId: thread.id, + direction: .incoming + ) + } } // Update the contact's approval status of the current user if needed (if we are getting messages from @@ -383,27 +410,100 @@ extension MessageReceiver { } // Notify the user if needed - guard variant == .standardIncoming && !interaction.wasRead else { return interactionId } + guard + !suppressNotifications && + variant == .standardIncoming && + !interaction.wasRead + else { return (threadId, threadVariant, interactionId, variant, interaction.wasRead, 0) } - // Use the same identifier for notifications when in backgroud polling to prevent spam - dependencies[singleton: .notificationsManager].notifyUser( - db, - for: interaction, - in: thread, - applicationState: (isMainAppActive ? .active : .background) + let isMessageRequest: Bool = dependencies.mutate(cache: .libSession) { cache in + cache.isMessageRequest( + threadId: threadId, + threadVariant: threadVariant + ) + } + let numPreviousInteractionsForMessageRequest: Int = { + guard isMessageRequest else { return 0 } + + switch interaction.serverHash { + case .some(let serverHash): + return (try? Interaction + .filter(Interaction.Columns.threadId == threadId) + .filter(Interaction.Columns.serverHash != serverHash) + .fetchCount(db)) + .defaulting(to: 0) + + case .none: + return (try? Interaction + .filter(Interaction.Columns.threadId == threadId) + .filter(Interaction.Columns.timestampMs != interaction.timestampMs) + .fetchCount(db)) + .defaulting(to: 0) + } + }() + + try? dependencies[singleton: .notificationsManager].notifyUser( + message: message, + threadId: threadId, + threadVariant: threadVariant, + interactionIdentifier: (interaction.serverHash ?? "\(interactionId)"), + interactionVariant: interaction.variant, + attachmentDescriptionInfo: attachments.map { $0.descriptionInfo }, + openGroupUrlInfo: openGroupUrlInfo, + applicationState: (isMainAppActive ? .active : .background), + extensionBaseUnreadCount: nil, + currentUserSessionIds: generateCurrentUserSessionIds(), + displayNameRetriever: { sessionId in + Profile.displayNameNoFallback( + db, + id: sessionId, + threadVariant: threadVariant + ) + }, + groupNameRetriever: { threadId, threadVariant in + switch threadVariant { + case .group: + let groupId: SessionId = SessionId(.group, hex: threadId) + return dependencies.mutate(cache: .libSession) { cache in + cache.groupName(groupSessionId: groupId) + } + + case .community: + return try? OpenGroup + .select(.name) + .filter(id: threadId) + .asRequest(of: String.self) + .fetchOne(db) + + default: return nil + } + }, + shouldShowForMessageRequest: { + // We only want to show a notification for the first interaction in the thread + return (numPreviousInteractionsForMessageRequest == 0) + } ) - return interactionId + return ( + threadId, + threadVariant, + interactionId, + variant, + interaction.wasRead, + numPreviousInteractionsForMessageRequest + ) } private static func handleEmojiReactIfNeeded( - _ db: Database, + _ db: ObservingDatabase, thread: SessionThread, message: VisibleMessage, associatedWithProto proto: SNProtoContent, sender: String, messageSentTimestamp: TimeInterval, - openGroup: OpenGroup?, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo?, + currentUserSessionIds: Set, + suppressNotifications: Bool, using dependencies: Dependencies ) throws -> Int64? { guard @@ -411,6 +511,8 @@ extension MessageReceiver { proto.dataMessage?.reaction != nil else { return nil } + // Since we have database access here make sure the original message for this reaction exists + // before handling it or showing a notification let maybeInteractionId: Int64? = try? Interaction .select(.id) .filter(Interaction.Columns.threadId == thread.id) @@ -438,7 +540,7 @@ extension MessageReceiver { let isMainAppActive: Bool = dependencies[defaults: .appGroup, key: .isMainAppActive] let timestampMs: Int64 = Int64(messageSentTimestamp * 1000) let userSessionId: SessionId = dependencies[cache: .general].sessionId - let reaction: Reaction = try Reaction( + _ = try Reaction( interactionId: interactionId, serverHash: message.serverHash, timestampMs: timestampMs, @@ -452,19 +554,54 @@ extension MessageReceiver { threadId: thread.id, threadVariant: thread.variant, timestampMs: timestampMs, - userSessionId: userSessionId, - openGroup: openGroup + openGroupUrlInfo: openGroupUrlInfo ) } // Don't notify if the reaction was added before the lastest read timestamp for // the conversation - if sender != userSessionId.hexString && !timestampAlreadyRead { - dependencies[singleton: .notificationsManager].notifyUser( - db, - forReaction: reaction, - in: thread, - applicationState: (isMainAppActive ? .active : .background) + if + !suppressNotifications && + sender != userSessionId.hexString && + !timestampAlreadyRead + { + try? dependencies[singleton: .notificationsManager].notifyUser( + message: message, + threadId: thread.id, + threadVariant: thread.variant, + interactionIdentifier: (message.serverHash ?? "\(interactionId)"), + interactionVariant: .standardIncoming, + attachmentDescriptionInfo: nil, + openGroupUrlInfo: openGroupUrlInfo, + applicationState: (isMainAppActive ? .active : .background), + extensionBaseUnreadCount: nil, + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: { sessionId in + Profile.displayNameNoFallback( + db, + id: sessionId, + threadVariant: thread.variant + ) + }, + groupNameRetriever: { threadId, threadVariant in + switch threadVariant { + case .group: + let groupId: SessionId = SessionId(.group, hex: threadId) + return dependencies.mutate(cache: .libSession) { cache in + cache.groupName(groupSessionId: groupId) + } + + case .community: + return try? OpenGroup + .select(.name) + .filter(id: threadId) + .asRequest(of: String.self) + .fetchOne(db) + + default: return nil + } + }, + shouldShowForMessageRequest: { false } ) } @@ -480,7 +617,7 @@ extension MessageReceiver { } private static func updateRecipientAndReadStatesForOutgoingInteraction( - _ db: Database, + _ db: ObservingDatabase, thread: SessionThread, interactionId: Int64, messageSentTimestamp: TimeInterval, @@ -492,14 +629,16 @@ extension MessageReceiver { // Immediately update any existing outgoing message 'State' records to be 'sent' (can // also remove the failure text as it's redundant if the message is in the sent state) - _ = try? Interaction - .filter(id: interactionId) - .filter(Interaction.Columns.state != Interaction.State.sent) - .updateAll( - db, - Interaction.Columns.state.set(to: Interaction.State.sent), - Interaction.Columns.mostRecentFailureText.set(to: nil) - ) + if (try? Interaction.select(.state).filter(id: interactionId).asRequest(of: Interaction.State.self).fetchOne(db)) != .sent { + _ = try? Interaction + .filter(id: interactionId) + .updateAll( + db, + Interaction.Columns.state.set(to: Interaction.State.sent), + Interaction.Columns.mostRecentFailureText.set(to: nil) + ) + db.addMessageEvent(id: interactionId, threadId: thread.id, type: .updated(.state(.sent))) + } // For outgoing messages mark all older interactions as read (the user should have seen // them if they send a message - also avoids a situation where the user has "phantom" @@ -525,7 +664,8 @@ extension MessageReceiver { db, threadId: thread.id, timestampMsValues: [pendingReadReceipt.interactionTimestampMs], - readTimestampMs: pendingReadReceipt.readTimestampMs + readTimestampMs: pendingReadReceipt.readTimestampMs, + using: dependencies ) _ = try pendingReadReceipt.delete(db) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift index f9217e2f3a..c86315ef62 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift @@ -51,7 +51,6 @@ extension MessageSender { name: name, description: description, displayPictureUrl: displayPictureInfo?.downloadUrl, - displayPictureFilename: displayPictureInfo?.fileName, displayPictureEncryptionKey: displayPictureInfo?.encryptionKey, members: members, using: dependencies @@ -202,7 +201,7 @@ extension MessageSender { .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) .sinkUntilComplete() - dependencies[singleton: .storage].write { db in + dependencies[singleton: .storage].writeAsync { db in // Save jobs for sending group member invitations groupMembers .filter { $0.profileId != userSessionId.hexString } @@ -269,9 +268,16 @@ extension MessageSender { try cache.withCustomBehaviour(.skipAutomaticConfigSync, for: sessionId) { var groupChanges: [ConfigColumnAssignment] = [] - if name != closedGroup.name { groupChanges.append(ClosedGroup.Columns.name.set(to: name)) } + if name != closedGroup.name { + groupChanges.append(ClosedGroup.Columns.name.set(to: name)) + db.addConversationEvent(id: groupSessionId, type: .updated(.displayName(name))) + } if groupDescription != closedGroup.groupDescription { groupChanges.append(ClosedGroup.Columns.groupDescription.set(to: groupDescription)) + db.addConversationEvent( + id: groupSessionId, + type: .updated(.description(groupDescription)) + ) } /// Update the group (this will be propagated to libSession configs automatically) @@ -375,8 +381,6 @@ extension MessageSender { db, ClosedGroup.Columns.displayPictureUrl.set(to: nil), ClosedGroup.Columns.displayPictureEncryptionKey.set(to: nil), - ClosedGroup.Columns.displayPictureFilename.set(to: nil), - ClosedGroup.Columns.lastDisplayPictureUpdate.set(to: dependencies.dateNow), using: dependencies ) @@ -387,8 +391,6 @@ extension MessageSender { db, ClosedGroup.Columns.displayPictureUrl.set(to: url), ClosedGroup.Columns.displayPictureEncryptionKey.set(to: key), - ClosedGroup.Columns.displayPictureFilename.set(to: fileName), - ClosedGroup.Columns.lastDisplayPictureUpdate.set(to: dependencies.dateNow), using: dependencies ) @@ -594,7 +596,7 @@ extension MessageSender { maybeSupplementalKeyRequest = try SnodeAPI.preparedSendMessage( message: SnodeMessage( recipient: sessionId.hexString, - data: supplementData.base64EncodedString(), + data: supplementData, ttl: ConfigDump.Variant.groupKeys.ttl, timestampMs: UInt64(changeTimestampMs) ), @@ -857,7 +859,7 @@ extension MessageSender { maybeSupplementalKeyRequest = try SnodeAPI.preparedSendMessage( message: SnodeMessage( recipient: sessionId.hexString, - data: supplementData.base64EncodedString(), + data: supplementData, ttl: ConfigDump.Variant.groupKeys.ttl, timestampMs: UInt64(changeTimestampMs) ), @@ -1252,7 +1254,7 @@ extension MessageSender { /// This function also removes all encryption key pairs associated with the closed group and the group's public key, and /// unregisters from push notifications. public static func leave( - _ db: Database, + _ db: ObservingDatabase, threadId: String, threadVariant: SessionThread.Variant, using dependencies: Dependencies diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index d82f970694..eae1e8f65a 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -18,12 +18,12 @@ public enum MessageReceiver { private static var lastEncryptionKeyPairRequest: [String: Date] = [:] public static func parse( - _ db: Database, data: Data, origin: Message.Origin, using dependencies: Dependencies ) throws -> ProcessedMessage { let userSessionId: SessionId = dependencies[cache: .general].sessionId + let uniqueIdentifier: String var plaintext: Data var customProto: SNProtoContent? = nil var customMessage: Message? = nil @@ -45,10 +45,12 @@ public enum MessageReceiver { namespace: namespace, serverHash: serverHash, serverTimestampMs: serverTimestampMs, - data: data + data: data, + uniqueIdentifier: serverHash ) case (_, .community(let openGroupId, let messageSender, let timestamp, let messageServerId, let messageWhisper, let messageWhisperMods, let messageWhisperTo)): + uniqueIdentifier = "\(messageServerId)" plaintext = data.removePadding() // Remove the padding sender = messageSender sentTimestampMs = UInt64(floor(timestamp * 1000)) // Convert to ms for database consistency @@ -68,15 +70,14 @@ public enum MessageReceiver { case (_, .openGroupInbox(let timestamp, let messageServerId, let serverPublicKey, let senderId, let recipientId)): (plaintext, sender) = try dependencies[singleton: .crypto].tryGenerate( .plaintextWithSessionBlindingProtocol( - db, ciphertext: data, senderId: senderId, recipientId: recipientId, - serverPublicKey: serverPublicKey, - using: dependencies + serverPublicKey: serverPublicKey ) ) + uniqueIdentifier = "\(messageServerId)" plaintext = plaintext.removePadding() // Remove the padding sentTimestampMs = UInt64(floor(timestamp * 1000)) // Convert to ms for database consistency serverHash = nil @@ -88,6 +89,9 @@ public enum MessageReceiver { threadIdGenerator = { _ in sender } case (_, .swarm(let publicKey, let namespace, let swarmServerHash, _, _)): + uniqueIdentifier = swarmServerHash + serverHash = swarmServerHash + switch namespace { case .default: guard @@ -99,15 +103,10 @@ public enum MessageReceiver { } (plaintext, sender) = try dependencies[singleton: .crypto].tryGenerate( - .plaintextWithSessionProtocol( - db, - ciphertext: ciphertext, - using: dependencies - ) + .plaintextWithSessionProtocol(ciphertext: ciphertext) ) plaintext = plaintext.removePadding() // Remove the padding sentTimestampMs = envelope.timestamp - serverHash = swarmServerHash openGroupServerMessageId = nil openGroupWhisper = false openGroupWhisperMods = false @@ -138,7 +137,6 @@ public enum MessageReceiver { } plaintext = envelopeContent // Padding already removed for updated groups sentTimestampMs = envelope.timestamp - serverHash = swarmServerHash openGroupServerMessageId = nil openGroupWhisper = false openGroupWhisperMods = false @@ -155,7 +153,6 @@ public enum MessageReceiver { customMessage = LibSessionMessage(ciphertext: data) sender = publicKey // The "group" sends these messages sentTimestampMs = 0 - serverHash = swarmServerHash openGroupServerMessageId = nil openGroupWhisper = false openGroupWhisperMods = false @@ -170,7 +167,7 @@ public enum MessageReceiver { throw MessageReceiverError.invalidConfigMessageHandling case .legacyClosedGroup: throw MessageReceiverError.deprecatedMessage - case .all, .unknown: + case .configLocal, .all, .unknown: Log.warn(.messageReceiver, "Couldn't process message due to invalid namespace.") throw MessageReceiverError.unknownMessage(nil) } @@ -196,9 +193,12 @@ public enum MessageReceiver { } // Don't process the envelope any further if the sender is blocked - guard (try? Contact.fetchOne(db, id: sender))?.isBlocked != true || message.processWithBlockedSender else { - throw MessageReceiverError.senderBlocked - } + guard + dependencies.mutate(cache: .libSession, { cache in + !cache.isContactBlocked(contactId: sender) + }) || + message.processWithBlockedSender + else { throw MessageReceiverError.senderBlocked } // Ignore self sends if needed guard message.isSelfSendValid || sender != userSessionId.hexString else { @@ -221,31 +221,38 @@ public enum MessageReceiver { proto: proto, messageInfo: try MessageReceiveJob.Details.MessageInfo( message: message, - variant: try Message.Variant(from: message) ?? { throw MessageReceiverError.invalidMessage }(), + variant: try Message.Variant(from: message) ?? { + throw MessageReceiverError.invalidMessage + }(), threadVariant: threadVariant, serverExpirationTimestamp: origin.serverExpirationTimestamp, proto: proto - ) + ), + uniqueIdentifier: uniqueIdentifier ) } // MARK: - Handling public static func handle( - _ db: Database, + _ db: ObservingDatabase, threadId: String, threadVariant: SessionThread.Variant, message: Message, serverExpirationTimestamp: TimeInterval?, associatedWithProto proto: SNProtoContent, + suppressNotifications: Bool, using dependencies: Dependencies - ) throws { - // Throw if the message is outdated and shouldn't be processed + ) throws -> InsertedInteractionInfo? { + /// Throw if the message is outdated and shouldn't be processed (this is based on pretty flaky logic which checks if the config + /// has been updated since the message was sent - this should be reworked to be less edge-case prone in the future) try throwIfMessageOutdated( - db, message: message, threadId: threadId, threadVariant: threadVariant, + openGroupUrlInfo: (threadVariant != .community ? nil : + try? LibSession.OpenGroupUrlInfo.fetchOne(db, id: threadId) + ), using: dependencies ) @@ -260,15 +267,19 @@ public enum MessageReceiver { using: dependencies ) + let interactionInfo: InsertedInteractionInfo? switch message { case let message as ReadReceipt: + interactionInfo = nil try MessageReceiver.handleReadReceipt( db, message: message, - serverExpirationTimestamp: serverExpirationTimestamp + serverExpirationTimestamp: serverExpirationTimestamp, + using: dependencies ) case let message as TypingIndicator: + interactionInfo = nil try MessageReceiver.handleTypingIndicator( db, threadId: threadId, @@ -281,17 +292,18 @@ public enum MessageReceiver { is GroupUpdateMemberChangeMessage, is GroupUpdatePromoteMessage, is GroupUpdateMemberLeftMessage, is GroupUpdateMemberLeftNotificationMessage, is GroupUpdateInviteResponseMessage, is GroupUpdateDeleteMemberContentMessage: - try MessageReceiver.handleGroupUpdateMessage( + interactionInfo = try MessageReceiver.handleGroupUpdateMessage( db, threadId: threadId, threadVariant: threadVariant, message: message, serverExpirationTimestamp: serverExpirationTimestamp, + suppressNotifications: suppressNotifications, using: dependencies ) case let message as DataExtractionNotification: - try MessageReceiver.handleDataExtractionNotification( + interactionInfo = try MessageReceiver.handleDataExtractionNotification( db, threadId: threadId, threadVariant: threadVariant, @@ -301,7 +313,7 @@ public enum MessageReceiver { ) case let message as ExpirationTimerUpdate: - try MessageReceiver.handleExpirationTimerUpdate( + interactionInfo = try MessageReceiver.handleExpirationTimerUpdate( db, threadId: threadId, threadVariant: threadVariant, @@ -312,6 +324,7 @@ public enum MessageReceiver { ) case let message as UnsendRequest: + interactionInfo = nil try MessageReceiver.handleUnsendRequest( db, threadId: threadId, @@ -321,33 +334,36 @@ public enum MessageReceiver { ) case let message as CallMessage: - try MessageReceiver.handleCallMessage( + interactionInfo = try MessageReceiver.handleCallMessage( db, threadId: threadId, threadVariant: threadVariant, message: message, + suppressNotifications: suppressNotifications, using: dependencies ) case let message as MessageRequestResponse: - try MessageReceiver.handleMessageRequestResponse( + interactionInfo = try MessageReceiver.handleMessageRequestResponse( db, message: message, using: dependencies ) case let message as VisibleMessage: - try MessageReceiver.handleVisibleMessage( + interactionInfo = try MessageReceiver.handleVisibleMessage( db, threadId: threadId, threadVariant: threadVariant, message: message, serverExpirationTimestamp: serverExpirationTimestamp, associatedWithProto: proto, + suppressNotifications: suppressNotifications, using: dependencies ) case let message as LibSessionMessage: + interactionInfo = nil try MessageReceiver.handleLibSessionMessage( db, threadId: threadId, @@ -365,15 +381,19 @@ public enum MessageReceiver { threadId: threadId, threadVariant: threadVariant, message: message, + insertedInteractionInfo: interactionInfo, using: dependencies ) + + return interactionInfo } public static func postHandleMessage( - _ db: Database, + _ db: ObservingDatabase, threadId: String, threadVariant: SessionThread.Variant, message: Message, + insertedInteractionInfo: InsertedInteractionInfo?, using dependencies: Dependencies ) throws { // When handling any message type which has related UI we want to make sure the thread becomes @@ -406,17 +426,15 @@ public enum MessageReceiver { // Start the disappearing messages timer if needed // For disappear after send, this is necessary so the message will disappear even if it is not read if threadVariant != .community { - db.afterNextTransactionNestedOnce( - dedupeId: "PostInsertDisappearingMessagesJob", // stringlint:ignore - using: dependencies, - onCommit: { db in + db.afterCommit(dedupeId: "PostInsertDisappearingMessagesJob") { // stringlint:ignore + dependencies[singleton: .storage].writeAsync { db in dependencies[singleton: .jobRunner].upsert( db, job: DisappearingMessagesJob.updateNextRunIfNeeded(db, using: dependencies), canStartJob: true ) } - ) + } } // Only check the current visibility state if we should become visible for this message type @@ -433,19 +451,17 @@ public enum MessageReceiver { guard !isCurrentlyVisible else { return } - try SessionThread - .filter(id: threadId) - .updateAllAndConfig( - db, - SessionThread.Columns.shouldBeVisible.set(to: true), - SessionThread.Columns.pinnedPriority.set(to: LibSession.visiblePriority), - SessionThread.Columns.isDraft.set(to: false), - using: dependencies - ) + try SessionThread.updateVisibility( + db, + threadId: threadId, + isVisible: true, + additionalChanges: [SessionThread.Columns.isDraft.set(to: false)], + using: dependencies + ) } public static func handleOpenGroupReactions( - _ db: Database, + _ db: ObservingDatabase, threadId: String, openGroupMessageServerId: Int64, openGroupReactions: [Reaction] @@ -476,92 +492,109 @@ public enum MessageReceiver { } public static func throwIfMessageOutdated( - _ db: Database, message: Message, threadId: String, threadVariant: SessionThread.Variant, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo?, using dependencies: Dependencies ) throws { - let userSessionId: SessionId = dependencies[cache: .general].sessionId - - switch message { - case is ReadReceipt: return // No visible artifact created so better to keep for more reliable read states - case is UnsendRequest: return // We should always process the removal of messages just in case - default: break + // TODO: [Database Relocation] Need the "deleted_contacts" logic to handle the 'throwIfMessageOutdated' case + // TODO: [Database Relocation] Need a way to detect _when_ the NTS conversation was hidden (so an old message won't re-show it) + switch (threadVariant, message) { + case (_, is ReadReceipt): return /// No visible artifact created so better to keep for more reliable read states + case (_, is UnsendRequest): return /// We should always process the removal of messages just in case + + /// These group update messages update the group state so should be processed even if they were old + case (.group, is GroupUpdateInviteResponseMessage): return + case (.group, is GroupUpdateDeleteMemberContentMessage): return + case (.group, is GroupUpdateMemberLeftMessage): return + + /// No special logic for these, just make sure that either the conversation is already visible, or we are allowed to + /// make a config change + case (.contact, _), (.community, _), (.legacyGroup, _): break + + /// If the destination is a group then ensure: + /// • We have credentials + /// • The group hasn't been destroyed + /// • The user wasn't kicked from the group + /// • The message wasn't sent before all messages/attachments were deleted + case (.group, _): + let messageSentTimestamp: TimeInterval = TimeInterval((message.sentTimestampMs ?? 0) / 1000) + let groupSessionId: SessionId = SessionId(.group, hex: threadId) + + /// Ensure the group is able to receive messages + try dependencies.mutate(cache: .libSession) { cache in + guard + cache.hasCredentials(groupSessionId: groupSessionId), + !cache.groupIsDestroyed(groupSessionId: groupSessionId), + !cache.wasKickedFromGroup(groupSessionId: groupSessionId) + else { throw MessageReceiverError.outdatedMessage } + + return + } + + /// Ensure the message shouldn't have been deleted + try dependencies.mutate(cache: .libSession) { cache in + let deleteBefore: TimeInterval = (cache.groupDeleteBefore(groupSessionId: groupSessionId) ?? 0) + let deleteAttachmentsBefore: TimeInterval = (cache.groupDeleteAttachmentsBefore(groupSessionId: groupSessionId) ?? 0) + + guard + messageSentTimestamp > deleteBefore && ( + (message as? VisibleMessage)?.dataMessageHasAttachments == false || + messageSentTimestamp > deleteAttachmentsBefore + ) + else { throw MessageReceiverError.outdatedMessage } + + return + } } - // If the destination is a group conversation that has been destroyed then the message is outdated - guard - threadVariant != .group || - !LibSession.groupIsDestroyed( - groupSessionId: SessionId(.group, hex: threadId), - using: dependencies + /// If the conversation is not visible in the config and the message was sent before the last config update (minus a buffer period) + /// then we can assume that the user has hidden/deleted the conversation and it shouldn't be reshown by this (old) message + try dependencies.mutate(cache: .libSession) { cache in + let conversationInConfig: Bool? = cache.conversationInConfig( + threadId: threadId, + threadVariant: threadVariant, + visibleOnly: true, + openGroupUrlInfo: openGroupUrlInfo + ) + let canPerformConfigChange: Bool? = cache.canPerformChange( + threadId: threadId, + threadVariant: threadVariant, + changeTimestampMs: message.sentTimestampMs + .map { Int64($0) } + .defaulting(to: dependencies[cache: .snodeAPI].currentOffsetTimestampMs()) ) - else { throw MessageReceiverError.outdatedMessage } - - // Determine if it's a group conversation that received a deletion instruction after this - // message was sent (if so then it's outdated) - let deletionInstructionSentAfterThisMessage: Bool = { - guard threadVariant == .group else { return false } - // These group update messages update the group state so should be processed even - // if they were old - switch message { - case is GroupUpdateInviteResponseMessage: return false - case is GroupUpdateDeleteMemberContentMessage: return false - case is GroupUpdateMemberLeftMessage: return false + switch (conversationInConfig, canPerformConfigChange) { + case (false, false): throw MessageReceiverError.outdatedMessage default: break } - - // Note: 'sentTimestamp' is in milliseconds so convert it - let messageSentTimestamp: TimeInterval = TimeInterval((message.sentTimestampMs ?? 0) / 1000) - let deletionInfo: (deleteBefore: TimeInterval, deleteAttachmentsBefore: TimeInterval) = dependencies.mutate(cache: .libSession) { cache in - let config: LibSession.Config? = cache.config(for: .groupInfo, sessionId: SessionId(.group, hex: threadId)) - - return ( - ((try? LibSession.groupDeleteBefore(in: config)) ?? 0), - ((try? LibSession.groupAttachmentDeleteBefore(in: config)) ?? 0) - ) - } - - return ( - deletionInfo.deleteBefore > messageSentTimestamp || ( - (message as? VisibleMessage)?.dataMessageHasAttachments == true && - deletionInfo.deleteAttachmentsBefore > messageSentTimestamp - ) - ) - }() + } - guard !deletionInstructionSentAfterThisMessage else { throw MessageReceiverError.outdatedMessage } + /// If we made it here then the message is not outdated + } + + /// Notify any observers of newly received messages + public static func prepareNotificationsForInsertedInteractions( + _ db: ObservingDatabase, + insertedInteractionInfo: InsertedInteractionInfo?, + isMessageRequest: Bool, + using dependencies: Dependencies + ) { + guard let info: InsertedInteractionInfo = insertedInteractionInfo else { return } - // If the conversation is not visible in the config and the message was sent before the last config - // update (minus a buffer period) then we can assume that the user has hidden/deleted the conversation - // and it shouldn't be reshown by this (old) message - let conversationVisibleInConfig: Bool = LibSession.conversationInConfig( - db, - threadId: threadId, - threadVariant: threadVariant, - visibleOnly: true, - using: dependencies - ) - let canPerformChange: Bool = LibSession.canPerformChange( - db, - threadId: threadId, - targetConfig: { - switch threadVariant { - case .contact: return (threadId == userSessionId.hexString ? .userProfile : .contacts) - default: return .userGroups - } - }(), - changeTimestampMs: message.sentTimestampMs - .map { Int64($0) } - .defaulting(to: dependencies[cache: .snodeAPI].currentOffsetTimestampMs()), - using: dependencies - ) + /// This allows observing for an event where a message request receives an unread message + if isMessageRequest && !info.wasRead { + db.addEvent( + MessageEvent(id: info.interactionId, threadId: info.threadId, change: nil), + forKey: .unreadMessageRequestMessageReceived + ) + } - switch (conversationVisibleInConfig, canPerformChange) { - case (false, false): throw MessageReceiverError.outdatedMessage - default: break // Message not outdated + /// Need to re-show the message requests section if we received a new message request + if isMessageRequest && info.numPreviousInteractionsForMessageRequest == 0 { + dependencies.setAsync(.hasHiddenMessageRequests, false) } } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index 330bb843b7..13c86b46b3 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -10,7 +10,7 @@ extension MessageSender { // MARK: - Durable public static func send( - _ db: Database, + _ db: ObservingDatabase, interaction: Interaction, threadId: String, threadVariant: SessionThread.Variant, @@ -33,7 +33,7 @@ extension MessageSender { } public static func send( - _ db: Database, + _ db: ObservingDatabase, message: Message, interactionId: Int64?, threadId: String, @@ -54,7 +54,7 @@ extension MessageSender { } public static func send( - _ db: Database, + _ db: ObservingDatabase, message: Message, threadId: String?, interactionId: Int64?, @@ -90,31 +90,359 @@ extension MessageSender { canStartJob: true ) } +} + +// MARK: - Success & Failure Handling - // MARK: - Non-Durable +extension MessageSender { + public static func standardEventHandling(using dependencies: Dependencies) -> ((Event) -> Void) { + return { event in + let threadId: String = Message.threadId( + forMessage: event.message, + destination: event.destination, + using: dependencies + ) + + dependencies[singleton: .storage].writeAsync { db in + switch event { + case .willSend(let message, let destination, let interactionId): + handleMessageWillSend( + db, + threadId: threadId, + message: message, + destination: destination, + interactionId: interactionId, + using: dependencies + ) + + case .success(let message, let destination, let interactionId, let serverTimestampMs, let serverExpirationMs): + try handleSuccessfulMessageSend( + db, + threadId: threadId, + message: message, + to: destination, + interactionId: interactionId, + serverTimestampMs: serverTimestampMs, + serverExpirationTimestampMs: serverExpirationMs, + using: dependencies + ) + + case .failure(let message, let destination, let interactionId, let error): + let threadId: String = Message.threadId(forMessage: message, destination: destination, using: dependencies) + + handleFailedMessageSend( + db, + threadId: threadId, + message: message, + destination: destination, + error: error, + interactionId: interactionId, + using: dependencies + ) + } + } + } + } - public static func preparedSend( - _ db: Database, - interaction: Interaction, - fileIds: [String], + internal static func handleMessageWillSend( + _ db: ObservingDatabase, threadId: String, - threadVariant: SessionThread.Variant, + message: Message, + destination: Message.Destination, + interactionId: Int64?, using dependencies: Dependencies - ) throws -> Network.PreparedRequest { - // Only 'VisibleMessage' types can be sent via this method - guard interaction.variant == .standardOutgoing else { throw MessageSenderError.invalidMessage } - guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved } + ) { + // If the message was a reaction then we don't want to do anything to the original + // interaction (which the 'interactionId' is pointing to + guard (message as? VisibleMessage)?.reaction == nil else { return } + + // Mark messages as "sending"/"syncing" if needed (this is for retries) + switch destination { + case .syncMessage: + _ = try? Interaction + .filter(id: interactionId) + .filter(Interaction.Columns.state == Interaction.State.failedToSync) + .updateAll(db, Interaction.Columns.state.set(to: Interaction.State.syncing)) + db.addMessageEvent(id: interactionId, threadId: threadId, type: .updated(.state(.syncing))) + + default: + _ = try? Interaction + .filter(id: interactionId) + .filter(Interaction.Columns.state == Interaction.State.failed) + .updateAll(db, Interaction.Columns.state.set(to: Interaction.State.sending)) + db.addMessageEvent(id: interactionId, threadId: threadId, type: .updated(.state(.sending))) + } + } + + private static func handleSuccessfulMessageSend( + _ db: ObservingDatabase, + threadId: String, + message: Message, + to destination: Message.Destination, + interactionId: Int64?, + serverTimestampMs: Int64? = nil, + serverExpirationTimestampMs: Int64? = nil, + using dependencies: Dependencies + ) throws { + // If the message was a reaction then we want to update the reaction instead of the original + // interaction (which the 'interactionId' is pointing to + if let visibleMessage: VisibleMessage = message as? VisibleMessage, let reaction: VisibleMessage.VMReaction = visibleMessage.reaction { + try Reaction + .filter(Reaction.Columns.interactionId == interactionId) + .filter(Reaction.Columns.authorId == reaction.publicKey) + .filter(Reaction.Columns.emoji == reaction.emoji) + .updateAll(db, Reaction.Columns.serverHash.set(to: message.serverHash)) + } + else { + // Otherwise we do want to try and update the referenced interaction + let interaction: Interaction? = try interaction(db, for: message, interactionId: interactionId) + + // Get the visible message if possible + if let interaction: Interaction = interaction { + // Only store the server hash of a sync message if the message is self send valid + switch (message.isSelfSendValid, destination) { + case (false, .syncMessage): + try interaction.with(state: .sent).update(db) + + case (true, .syncMessage), (_, .contact), (_, .closedGroup), (_, .openGroup), (_, .openGroupInbox): + try interaction.with( + serverHash: message.serverHash, + // Track the open group server message ID and update server timestamp (use server + // timestamp for open group messages otherwise the quote messages may not be able + // to be found by the timestamp on other devices + timestampMs: (message.openGroupServerMessageId == nil ? + nil : + serverTimestampMs.map { Int64($0) } + ), + openGroupServerMessageId: message.openGroupServerMessageId.map { Int64($0) }, + state: .sent + ).update(db) + + if interaction.isExpiringMessage { + // Start disappearing messages job after a message is successfully sent. + // For DAR and DAS outgoing messages, the expiration start time are the + // same as message sentTimestamp. So do this once, DAR and DAS messages + // should all be covered. + dependencies[singleton: .jobRunner].upsert( + db, + job: DisappearingMessagesJob.updateNextRunIfNeeded( + db, + interaction: interaction, + startedAtMs: Double(interaction.timestampMs), + using: dependencies + ), + canStartJob: true + ) + + if + case .syncMessage = destination, + let startedAtMs: Double = interaction.expiresStartedAtMs, + let expiresInSeconds: TimeInterval = interaction.expiresInSeconds, + let serverHash: String = message.serverHash + { + let expirationTimestampMs: Int64 = Int64(startedAtMs + expiresInSeconds * 1000) + dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .expirationUpdate, + behaviour: .runOnce, + threadId: interaction.threadId, + details: ExpirationUpdateJob.Details( + serverHashes: [serverHash], + expirationTimestampMs: expirationTimestampMs + ) + ), + canStartJob: true + ) + } + } + } + } + } + + // Notify of the state change + db.addMessageEvent(id: interactionId, threadId: threadId, type: .updated(.state(.sent))) + + // Insert a `MessageDeduplication` record so we don't handle this message when it's received + // in the next poll + try MessageDeduplication.insert( + db, + threadId: threadId, + threadVariant: destination.threadVariant, + uniqueIdentifier: { + if let serverHash: String = message.serverHash { return serverHash } + if let openGroupServerMessageId: UInt64 = message.openGroupServerMessageId { + return "\(openGroupServerMessageId)" + } + + let variantString: String = Message.Variant(from: message) + .map { "\($0)" } + .defaulting(to: "Unknown Variant") // stringlint:ignore + Log.warn(.messageSender, "Unable to store deduplication unique identifier for outgoing message of type: \(variantString).") + return nil + }(), + message: message, + serverExpirationTimestamp: serverExpirationTimestampMs.map { (TimeInterval($0) / 1000) }, + ignoreDedupeFiles: false, + using: dependencies + ) - return try MessageSender.preparedSend( + // Sync the message if needed + scheduleSyncMessageIfNeeded( db, - message: VisibleMessage.from(db, interaction: interaction), - to: try Message.Destination.from(db, threadId: threadId, threadVariant: threadVariant), - namespace: try Message.Destination - .from(db, threadId: threadId, threadVariant: threadVariant) - .defaultNamespace, + message: message, + destination: destination, + threadId: threadId, interactionId: interactionId, - fileIds: fileIds, using: dependencies ) } + + @discardableResult internal static func handleFailedMessageSend( + _ db: ObservingDatabase, + threadId: String, + message: Message, + destination: Message.Destination?, + error: MessageSenderError, + interactionId: Int64?, + using dependencies: Dependencies + ) -> Error { + // Log a message for any 'other' errors + switch error { + case .other(let cat, let description, let error): + Log.error([.messageSender, cat].compactMap { $0 }, "\(description) due to error: \(error).") + default: break + } + + // Only 'VisibleMessage' messages can show a status so don't bother updating + // the other cases (if the VisibleMessage was a reaction then we also don't + // want to do anything as the `interactionId` points to the original message + // which has it's own status) + switch message { + case let message as VisibleMessage where message.reaction != nil: return error + case is VisibleMessage: break + default: return error + } + + /// Check if we need to mark any "sending" recipients as "failed" and update their errors + switch destination { + case .syncMessage: + _ = try? Interaction + .filter(id: interactionId) + .filter( + Interaction.Columns.state == Interaction.State.syncing || + Interaction.Columns.state == Interaction.State.sent + ) + .updateAll( + db, + Interaction.Columns.state.set(to: Interaction.State.failedToSync), + Interaction.Columns.mostRecentFailureText.set(to: "\(error)") + ) + db.addMessageEvent(id: interactionId, threadId: threadId, type: .updated(.state(.failedToSync))) + + default: + _ = try? Interaction + .filter(id: interactionId) + .filter(Interaction.Columns.state == Interaction.State.sending) + .updateAll( + db, + Interaction.Columns.state.set(to: Interaction.State.failed), + Interaction.Columns.mostRecentFailureText.set(to: "\(error)") + ) + db.addMessageEvent(id: interactionId, threadId: threadId, type: .updated(.state(.failed))) + } + + return error + } + + private static func interaction(_ db: ObservingDatabase, for message: Message, interactionId: Int64?) throws -> Interaction? { + if let interactionId: Int64 = interactionId { + return try Interaction.fetchOne(db, id: interactionId) + } + + if let sentTimestampMs: Double = message.sentTimestampMs.map({ Double($0) }) { + return try Interaction + .filter(Interaction.Columns.timestampMs == sentTimestampMs) + .fetchOne(db) + } + + return nil + } + + private static func scheduleSyncMessageIfNeeded( + _ db: ObservingDatabase, + message: Message, + destination: Message.Destination, + threadId: String?, + interactionId: Int64?, + using dependencies: Dependencies + ) { + // Sync the message if it's not a sync message, wasn't already sent to the current user and + // it's a message type which should be synced + let userSessionId = dependencies[cache: .general].sessionId + + if + case .contact(let publicKey) = destination, + publicKey != userSessionId.hexString, + Message.shouldSync(message: message) + { + if let message = message as? VisibleMessage { message.syncTarget = publicKey } + if let message = message as? ExpirationTimerUpdate { message.syncTarget = publicKey } + + dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .messageSend, + threadId: threadId, + interactionId: interactionId, + details: MessageSendJob.Details( + destination: .syncMessage(originalRecipientPublicKey: publicKey), + message: message + ) + ), + canStartJob: true + ) + } + } +} + +// MARK: - Database Type Conversion + +public extension VisibleMessage { + static func from(_ db: ObservingDatabase, interaction: Interaction) -> VisibleMessage { + let linkPreview: LinkPreview? = try? interaction.linkPreview.fetchOne(db) + + let visibleMessage: VisibleMessage = VisibleMessage( + sender: interaction.authorId, + sentTimestampMs: UInt64(interaction.timestampMs), + syncTarget: nil, + text: interaction.body, + attachmentIds: ((try? interaction.attachments.fetchAll(db)) ?? []) + .map { $0.id }, + quote: (try? interaction.quote.fetchOne(db)) + .map { VMQuote.from(quote: $0) }, + linkPreview: linkPreview + .map { linkPreview in + guard linkPreview.variant == .standard else { return nil } + + return VMLinkPreview.from(linkPreview: linkPreview) + }, + profile: nil, // Don't attach the profile to avoid sending a legacy version (set in MessageSender) + openGroupInvitation: linkPreview.map { linkPreview in + guard linkPreview.variant == .openGroupInvitation else { return nil } + + return VMOpenGroupInvitation.from(linkPreview: linkPreview) + }, + reaction: nil // Reactions are custom messages sent separately + ) + .with( + expiresInSeconds: interaction.expiresInSeconds, + expiresStartedAtMs: interaction.expiresStartedAtMs + ) + + visibleMessage.expiresInSeconds = interaction.expiresInSeconds + visibleMessage.expiresStartedAtMs = interaction.expiresStartedAtMs + + return visibleMessage + } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 63679df8ca..510819e3f4 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -3,8 +3,6 @@ // stringlint:disable import Foundation -import Combine -import GRDB import SessionSnodeKit import SessionUtilitiesKit @@ -17,19 +15,42 @@ public extension Log.Category { // MARK: - MessageSender public final class MessageSender { + private typealias SendResponse = (message: Message, serverTimestampMs: Int64?, serverExpirationMs: Int64?) + public enum Event { + case willSend(Message, Message.Destination, interactionId: Int64?) + case success(Message, Message.Destination, interactionId: Int64?, serverTimestampMs: Int64?, serverExpirationMs: Int64?) + case failure(Message, Message.Destination, interactionId: Int64?, error: MessageSenderError) + + var message: Message { + switch self { + case .willSend(let message, _, _), .success(let message, _, _, _, _), + .failure(let message, _, _, _): + return message + } + } + + var destination: Message.Destination { + switch self { + case .willSend(_, let destination, _), .success(_, let destination, _, _, _), + .failure(_, let destination, _, _): + return destination + } + } + } + // MARK: - Message Preparation public static func preparedSend( - _ db: Database, message: Message, to destination: Message.Destination, namespace: SnodeAPI.Namespace?, interactionId: Int64?, - fileIds: [String], + attachments: [(attachment: Attachment, fileId: String)]?, + authMethod: AuthenticationMethod, + onEvent: ((Event) -> Void)?, using dependencies: Dependencies - ) throws -> Network.PreparedRequest { + ) throws -> Network.PreparedRequest { // Common logic for all destinations - let userSessionId: SessionId = dependencies[cache: .general].sessionId let messageSendTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() let updatedMessage: Message = message @@ -41,206 +62,120 @@ public final class MessageSender { updatedMessage.sigTimestampMs = updatedMessage.sentTimestampMs do { + let preparedRequest: Network.PreparedRequest + switch destination { case .contact, .syncMessage, .closedGroup: - return try preparedSendToSnodeDestination( - db, + preparedRequest = try preparedSendToSnodeDestination( message: updatedMessage, to: destination, namespace: namespace, interactionId: interactionId, - fileIds: fileIds, - userSessionId: userSessionId, + attachments: attachments, messageSendTimestampMs: messageSendTimestampMs, + authMethod: authMethod, + onEvent: onEvent, using: dependencies ) - .map { _, _ in () } case .openGroup: - return try preparedSendToOpenGroupDestination( - db, + preparedRequest = try preparedSendToOpenGroupDestination( message: updatedMessage, to: destination, interactionId: interactionId, - fileIds: fileIds, + attachments: attachments, messageSendTimestampMs: messageSendTimestampMs, + authMethod: authMethod, + onEvent: onEvent, using: dependencies ) case .openGroupInbox: - return try preparedSendToOpenGroupInboxDestination( - db, + preparedRequest = try preparedSendToOpenGroupInboxDestination( message: message, to: destination, interactionId: interactionId, - fileIds: fileIds, - userSessionId: userSessionId, + attachments: attachments, messageSendTimestampMs: messageSendTimestampMs, + authMethod: authMethod, + onEvent: onEvent, using: dependencies ) } + + return preparedRequest + .handleEvents( + receiveOutput: { _, response in + onEvent?(.success( + response.message, + destination, + interactionId: interactionId, + serverTimestampMs: response.serverTimestampMs, + serverExpirationMs: response.serverExpirationMs + )) + }, + receiveCompletion: { result in + switch result { + case .finished: break + case .failure(let error): + onEvent?(.failure( + message, + destination, + interactionId: interactionId, + error: .other(nil, "Couldn't send message", error) + )) + } + } + ) + .map { _, response in response.message } } catch let error as MessageSenderError { - throw MessageSender.handleFailedMessageSend( - db, - message: message, - destination: destination, - error: error, - interactionId: interactionId, - using: dependencies - ) + onEvent?(.failure(message, destination, interactionId: interactionId, error: error)) + throw error } } - internal static func preparedSendToSnodeDestination( - _ db: Database, + private static func preparedSendToSnodeDestination( message: Message, to destination: Message.Destination, namespace: SnodeAPI.Namespace?, interactionId: Int64?, - fileIds: [String], - userSessionId: SessionId, + attachments: [(attachment: Attachment, fileId: String)]?, messageSendTimestampMs: Int64, + authMethod: AuthenticationMethod, + onEvent: ((Event) -> Void)?, using dependencies: Dependencies - ) throws -> Network.PreparedRequest { + ) throws -> Network.PreparedRequest { guard let namespace: SnodeAPI.Namespace = namespace else { throw MessageSenderError.invalidMessage } /// Set the sender/recipient info (needed to be valid) /// /// **Note:** The `sentTimestamp` will differ from the `messageSendTimestampMs` as it's the time the user originally /// sent the message whereas the `messageSendTimestamp` is the time it will be uploaded to the swarm + let userSessionId: SessionId = dependencies[cache: .general].sessionId let sentTimestampMs: UInt64 = (message.sentTimestampMs ?? UInt64(messageSendTimestampMs)) message.sender = userSessionId.hexString message.sentTimestampMs = sentTimestampMs message.sigTimestampMs = sentTimestampMs - // Ensure the message is valid - try MessageSender.ensureValidMessage(message, destination: destination, fileIds: fileIds, using: dependencies) - // Attach the user's profile if needed (no need to do so for 'Note to Self' or sync // messages as they will be managed by the user config handling switch (destination, message as? MessageWithProfile) { case (.syncMessage, _), (_, .none): break case (.contact(let publicKey), _) where publicKey == userSessionId.hexString: break case (_, .some(var messageWithProfile)): - let profile: Profile = Profile.fetchOrCreateCurrentUser(db, using: dependencies) - - if let profileKey: Data = profile.profileEncryptionKey, let profilePictureUrl: String = profile.profilePictureUrl { - messageWithProfile.profile = VisibleMessage.VMProfile( - displayName: profile.name, - profileKey: profileKey, - profilePictureUrl: profilePictureUrl, - blocksCommunityMessageRequests: !db[.checkForCommunityMessageRequests] - ) - } - else { - messageWithProfile.profile = VisibleMessage.VMProfile( - displayName: profile.name, - blocksCommunityMessageRequests: !db[.checkForCommunityMessageRequests] - ) - } + messageWithProfile.profile = dependencies + .mutate(cache: .libSession) { $0.profile(contactId: userSessionId.hexString) } + .map { profile in + VisibleMessage.VMProfile( + displayName: profile.name, + profileKey: profile.displayPictureEncryptionKey, + profilePictureUrl: profile.displayPictureUrl + ) + } } - // Perform any pre-send actions - handleMessageWillSend(db, message: message, destination: destination, interactionId: interactionId) - // Convert and prepare the data for sending - let threadId: String = Message.threadId(forMessage: message, destination: destination, using: dependencies) - let plaintext: Data = try { - switch namespace { - case .revokedRetrievableGroupMessages: - return try BencodeEncoder(using: dependencies).encode(message) - - default: - guard let proto = message.toProto(db, threadId: threadId) else { - throw MessageSenderError.protoConversionFailed - } - - return try Result(proto.serializedData()) - .map { serialisedData -> Data in - switch destination { - case .closedGroup(let groupId) where (try? SessionId.Prefix(from: groupId)) == .group: - return serialisedData - - default: return serialisedData.paddedMessageBody() - } - } - .mapError { MessageSenderError.other(nil, "Couldn't serialize proto", $0) } - .successOrThrow() - } - }() - let base64EncodedData: String = try { - switch (destination, namespace) { - // Updated group messages should be wrapped _before_ encrypting - case (.closedGroup(let groupId), .groupMessages) where (try? SessionId.Prefix(from: groupId)) == .group: - let messageData: Data = try Result( - MessageWrapper.wrap( - type: .closedGroupMessage, - timestampMs: sentTimestampMs, - content: plaintext, - wrapInWebSocketMessage: false - ) - ) - .mapError { MessageSenderError.other(nil, "Couldn't wrap message", $0) } - .successOrThrow() - - let ciphertext: Data = try dependencies[singleton: .crypto].tryGenerate( - .ciphertextForGroupMessage( - groupSessionId: SessionId(.group, hex: groupId), - message: Array(messageData) - ) - ) - return ciphertext.base64EncodedString() - - // revokedRetrievableGroupMessages should be sent in plaintext (their content has custom encryption) - case (.closedGroup(let groupId), .revokedRetrievableGroupMessages) where (try? SessionId.Prefix(from: groupId)) == .group: - return plaintext.base64EncodedString() - - // Config messages should be sent directly rather than via this method - case (.closedGroup(let groupId), _) where (try? SessionId.Prefix(from: groupId)) == .group: - throw MessageSenderError.invalidConfigMessageHandling - - // Standard one-to-one messages and legacy groups (which used a `05` prefix) - case (.contact(let publicKey), .default), (.syncMessage(let publicKey), _), (.closedGroup(let publicKey), _): - let ciphertext: Data = try dependencies[singleton: .crypto].tryGenerate( - .ciphertextWithSessionProtocol( - db, - plaintext: plaintext, - destination: destination, - using: dependencies - ) - ) - - return try Result( - try MessageWrapper.wrap( - type: try { - switch destination { - case .contact, .syncMessage: return .sessionMessage - case .closedGroup: return .closedGroupMessage - default: throw MessageSenderError.invalidMessage - } - }(), - timestampMs: sentTimestampMs, - senderPublicKey: { - switch destination { - case .closedGroup: return publicKey // Needed for Android - default: return "" // Empty for all other cases - } - }(), - content: ciphertext - ) - ) - .mapError { MessageSenderError.other(nil, "Couldn't wrap message", $0) } - .successOrThrow() - .base64EncodedString() - - // Config messages should be sent directly rather than via this method - case (.contact, _): throw MessageSenderError.invalidConfigMessageHandling - case (.openGroup, _), (.openGroupInbox, _): preconditionFailure() - } - }() - - // Send the result let swarmPublicKey: String = { switch destination { case .contact(let publicKey): return publicKey @@ -251,568 +186,317 @@ public final class MessageSender { }() let snodeMessage = SnodeMessage( recipient: swarmPublicKey, - data: base64EncodedData, + data: try MessageSender.encodeMessageForSending( + namespace: namespace, + destination: destination, + message: message, + attachments: attachments, + authMethod: authMethod, + using: dependencies + ), ttl: Message.getSpecifiedTTL(message: message, destination: destination, using: dependencies), timestampMs: UInt64(messageSendTimestampMs) ) + // Perform any pre-send actions + onEvent?(.willSend(message, destination, interactionId: interactionId)) + return try SnodeAPI .preparedSendMessage( message: snodeMessage, in: namespace, - authMethod: try Authentication.with(db, swarmPublicKey: swarmPublicKey, using: dependencies), + authMethod: authMethod, using: dependencies ) - .handleEvents( - receiveOutput: { _, response in - let updatedMessage: Message = message - updatedMessage.serverHash = response.hash - - // Save the updated message info and send a PN if needed - dependencies[singleton: .storage].write { db in - try MessageSender.handleSuccessfulMessageSend( - db, - message: updatedMessage, - to: destination, - interactionId: interactionId, - using: dependencies - ) - } - }, - receiveCompletion: { result in - switch result { - case .finished: break - case .failure(let error): - dependencies[singleton: .storage].read { db in - MessageSender.handleFailedMessageSend( - db, - message: message, - destination: destination, - error: .other(nil, "Couldn't send message", error), - interactionId: interactionId, - using: dependencies - ) - } - } - } - ) + .map { _, response in + let expirationTimestampMs: Int64 = (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + SnodeReceivedMessage.defaultExpirationMs) + let updatedMessage: Message = message + updatedMessage.serverHash = response.hash + + return (updatedMessage, nil, expirationTimestampMs) + } } private static func preparedSendToOpenGroupDestination( - _ db: Database, message: Message, to destination: Message.Destination, interactionId: Int64?, - fileIds: [String], + attachments: [(attachment: Attachment, fileId: String)]?, messageSendTimestampMs: Int64, + authMethod: AuthenticationMethod, + onEvent: ((Event) -> Void)?, using dependencies: Dependencies - ) throws -> Network.PreparedRequest { + ) throws -> Network.PreparedRequest { // Note: It's possible to send a message and then delete the open group you sent the message to // which would go into this case, so rather than handling it as an invalid state we just want to // error in a non-retryable way guard let message: VisibleMessage = message as? VisibleMessage, - case .openGroup(let roomToken, let server, let whisperTo, let whisperMods) = destination, - let openGroup: OpenGroup = try? OpenGroup.fetchOne( - db, - id: OpenGroup.idFor(roomToken: roomToken, server: server) - ), - let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) + case .community(let server, let publicKey, let hasCapabilities, let supportsBlinding, _) = authMethod.info, + case .openGroup(let roomToken, let destinationServer, let whisperTo, let whisperMods) = destination, + server == destinationServer, + let userEdKeyPair: KeyPair = dependencies[singleton: .crypto].generate( + .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) + ) else { throw MessageSenderError.invalidMessage } // Set the sender/recipient info (needed to be valid) - let threadId: String = OpenGroup.idFor(roomToken: roomToken, server: server) + let userSessionId: SessionId = dependencies[cache: .general].sessionId message.sender = try { - let capabilities: [Capability.Variant] = (try? Capability - .select(.variant) - .filter(Capability.Columns.openGroupServer == server) - .filter(Capability.Columns.isMissing == false) - .asRequest(of: Capability.Variant.self) - .fetchAll(db)) - .defaulting(to: []) - // If the server doesn't support blinding then go with an unblinded id - guard capabilities.isEmpty || capabilities.contains(.blind) else { + guard !hasCapabilities || supportsBlinding else { return SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString } guard let blinded15KeyPair: KeyPair = dependencies[singleton: .crypto].generate( - .blinded15KeyPair(serverPublicKey: openGroup.publicKey, ed25519SecretKey: userEdKeyPair.secretKey) + .blinded15KeyPair( + serverPublicKey: publicKey, + ed25519SecretKey: userEdKeyPair.secretKey + ) ) else { throw MessageSenderError.signingFailed } return SessionId(.blinded15, publicKey: blinded15KeyPair.publicKey).hexString }() - - // Ensure the message is valid - try MessageSender.ensureValidMessage(message, destination: destination, fileIds: fileIds, using: dependencies) - - // Attach the user's profile - message.profile = VisibleMessage.VMProfile( - profile: Profile.fetchOrCreateCurrentUser(db, using: dependencies), - blocksCommunityMessageRequests: !db[.checkForCommunityMessageRequests] - ) + message.profile = dependencies + .mutate(cache: .libSession) { cache in + cache.profile(contactId: userSessionId.hexString).map { + ($0, cache.get(.checkForCommunityMessageRequests)) + } + } + .map { profile, checkForCommunityMessageRequests in + VisibleMessage.VMProfile( + displayName: profile.name, + profileKey: profile.displayPictureEncryptionKey, + profilePictureUrl: profile.displayPictureUrl, + blocksCommunityMessageRequests: !checkForCommunityMessageRequests + ) + } guard !(message.profile?.displayName ?? "").isEmpty else { throw MessageSenderError.noUsername } - // Perform any pre-send actions - handleMessageWillSend(db, message: message, destination: destination, interactionId: interactionId) - - // Convert it to protobuf - guard let proto = message.toProto(db, threadId: threadId) else { - throw MessageSenderError.protoConversionFailed - } + let plaintext: Data = try MessageSender.encodeMessageForSending( + namespace: .default, + destination: destination, + message: message, + attachments: attachments, + authMethod: authMethod, + using: dependencies + ) - // Serialize the protobuf - let plaintext: Data = try Result(proto.serializedData().paddedMessageBody()) - .mapError { MessageSenderError.other(nil, "Couldn't serialize proto", $0) } - .successOrThrow() + // Perform any pre-send actions + onEvent?(.willSend(message, destination, interactionId: interactionId)) return try OpenGroupAPI .preparedSend( - db, plaintext: plaintext, - to: roomToken, - on: server, + roomToken: roomToken, whisperTo: whisperTo, whisperMods: whisperMods, - fileIds: fileIds, + fileIds: attachments?.map { $0.fileId }, + authMethod: authMethod, using: dependencies ) - .handleEvents( - receiveOutput: { _, response in - let serverTimestampMs: UInt64? = response.posted.map { UInt64(floor($0 * 1000)) } - let updatedMessage: Message = message - updatedMessage.openGroupServerMessageId = UInt64(response.id) - - dependencies[singleton: .storage].write { db in - // The `posted` value is in seconds but we sent it in ms so need that for de-duping - try MessageSender.handleSuccessfulMessageSend( - db, - message: updatedMessage, - to: destination, - interactionId: interactionId, - serverTimestampMs: serverTimestampMs, - using: dependencies - ) - } - }, - receiveCompletion: { result in - switch result { - case .finished: break - case .failure(let error): - dependencies[singleton: .storage].read { db in - MessageSender.handleFailedMessageSend( - db, - message: message, - destination: destination, - error: .other(nil, "Couldn't send message", error), - interactionId: interactionId, - using: dependencies - ) - } - } - } - ) - .map { _, _ in () } + .map { _, response in + let updatedMessage: Message = message + updatedMessage.openGroupServerMessageId = UInt64(response.id) + updatedMessage.sentTimestampMs = UInt64(floor(response.posted * 1000)) + + return (updatedMessage, Int64(floor(response.posted * 1000)), nil) + } } private static func preparedSendToOpenGroupInboxDestination( - _ db: Database, message: Message, to destination: Message.Destination, interactionId: Int64?, - fileIds: [String], - userSessionId: SessionId, + attachments: [(attachment: Attachment, fileId: String)]?, messageSendTimestampMs: Int64, + authMethod: AuthenticationMethod, + onEvent: ((Event) -> Void)?, using dependencies: Dependencies - ) throws -> Network.PreparedRequest { + ) throws -> Network.PreparedRequest { // The `openGroupInbox` destination does not support attachments guard - fileIds.isEmpty, - case .openGroupInbox(let server, let openGroupPublicKey, let recipientBlindedPublicKey) = destination + (attachments ?? []).isEmpty, + case .openGroupInbox(_, _, let recipientBlindedPublicKey) = destination else { throw MessageSenderError.invalidMessage } + let userSessionId: SessionId = dependencies[cache: .general].sessionId message.sender = userSessionId.hexString // Attach the user's profile if needed - if let message: VisibleMessage = message as? VisibleMessage { - let profile: Profile = Profile.fetchOrCreateCurrentUser(db, using: dependencies) + switch (message as? MessageWithProfile) { + case .some(var messageWithProfile): + messageWithProfile.profile = dependencies + .mutate(cache: .libSession) { $0.profile(contactId: userSessionId.hexString) } + .map { profile in + VisibleMessage.VMProfile( + displayName: profile.name, + profileKey: profile.displayPictureEncryptionKey, + profilePictureUrl: profile.displayPictureUrl + ) + } - if let profileKey: Data = profile.profileEncryptionKey, let profilePictureUrl: String = profile.profilePictureUrl { - message.profile = VisibleMessage.VMProfile( - displayName: profile.name, - profileKey: profileKey, - profilePictureUrl: profilePictureUrl, - blocksCommunityMessageRequests: !db[.checkForCommunityMessageRequests] - ) - } - else { - message.profile = VisibleMessage.VMProfile( - displayName: profile.name, - blocksCommunityMessageRequests: !db[.checkForCommunityMessageRequests] - ) - } + default: break } + let ciphertext: Data = try MessageSender.encodeMessageForSending( + namespace: .default, + destination: destination, + message: message, + attachments: nil, + authMethod: authMethod, + using: dependencies + ) // Perform any pre-send actions - handleMessageWillSend(db, message: message, destination: destination, interactionId: interactionId) - - // Convert it to protobuf - guard let proto = message.toProto(db, threadId: recipientBlindedPublicKey) else { - throw MessageSenderError.protoConversionFailed - } - - // Serialize the protobuf - let plaintext: Data = try Result(proto.serializedData().paddedMessageBody()) - .mapError { MessageSenderError.other(nil, "Couldn't serialize proto", $0) } - .successOrThrow() - - // Encrypt the serialized protobuf - let ciphertext: Data = try dependencies[singleton: .crypto].generateResult( - .ciphertextWithSessionBlindingProtocol( - db, - plaintext: plaintext, - recipientBlindedId: recipientBlindedPublicKey, - serverPublicKey: openGroupPublicKey, - using: dependencies - ) - ) - .mapError { MessageSenderError.other(nil, "Couldn't encrypt message for destination: \(destination)", $0) } - .successOrThrow() + onEvent?(.willSend(message, destination, interactionId: interactionId)) return try OpenGroupAPI .preparedSend( - db, ciphertext: ciphertext, toInboxFor: recipientBlindedPublicKey, - on: server, + authMethod: authMethod, using: dependencies ) - .handleEvents( - receiveOutput: { _, response in - let updatedMessage: Message = message - updatedMessage.openGroupServerMessageId = UInt64(response.id) - - dependencies[singleton: .storage].write { db in - // The `posted` value is in seconds but we sent it in ms so need that for de-duping - try MessageSender.handleSuccessfulMessageSend( - db, - message: updatedMessage, - to: destination, - interactionId: interactionId, - serverTimestampMs: UInt64(floor(response.posted * 1000)), - using: dependencies - ) - } - }, - receiveCompletion: { result in - switch result { - case .finished: break - case .failure(let error): - dependencies[singleton: .storage].read { db in - MessageSender.handleFailedMessageSend( - db, - message: message, - destination: destination, - error: .other(nil, "Couldn't send message", error), - interactionId: interactionId, - using: dependencies - ) - } - } - } - ) - .map { _, _ in () } - } - - // MARK: - Success & Failure Handling - - private static func ensureValidMessage( - _ message: Message, - destination: Message.Destination, - fileIds: [String], - using dependencies: Dependencies - ) throws { - /// Check the message itself is valid - guard message.isValid(isSending: true) else { throw MessageSenderError.invalidMessage } - - /// We now allow the creation of message data without validating it's attachments have finished uploading first, this is here to - /// ensure we don't send a message which should have uploaded files - /// - /// If you see this error then you need to upload the associated attachments prior to sending the message - if let visibleMessage: VisibleMessage = message as? VisibleMessage { - let expectedAttachmentUploadCount: Int = ( - visibleMessage.attachmentIds.count + - (visibleMessage.linkPreview?.attachmentId != nil ? 1 : 0) + - (visibleMessage.quote?.attachmentId != nil ? 1 : 0) - ) - - guard expectedAttachmentUploadCount == fileIds.count else { - throw MessageSenderError.attachmentsNotUploaded + .map { _, response in + let updatedMessage: Message = message + updatedMessage.openGroupServerMessageId = UInt64(response.id) + updatedMessage.sentTimestampMs = UInt64(floor(response.posted * 1000)) + + return (updatedMessage, Int64(floor(response.posted * 1000)), Int64(floor(response.expires * 1000))) } - } } - public static func handleMessageWillSend( - _ db: Database, - message: Message, - destination: Message.Destination, - interactionId: Int64? - ) { - // If the message was a reaction then we don't want to do anything to the original - // interaction (which the 'interactionId' is pointing to - guard (message as? VisibleMessage)?.reaction == nil else { return } - - // Mark messages as "sending"/"syncing" if needed (this is for retries) - switch destination { - case .syncMessage: - _ = try? Interaction - .filter(id: interactionId) - .filter(Interaction.Columns.state == Interaction.State.failedToSync) - .updateAll(db, Interaction.Columns.state.set(to: Interaction.State.syncing)) - - default: - _ = try? Interaction - .filter(id: interactionId) - .filter(Interaction.Columns.state == Interaction.State.failed) - .updateAll(db, Interaction.Columns.state.set(to: Interaction.State.sending)) - } - } + // MARK: - Message Wrapping - private static func handleSuccessfulMessageSend( - _ db: Database, - message: Message, - to destination: Message.Destination, - interactionId: Int64?, - serverTimestampMs: UInt64? = nil, - using dependencies: Dependencies - ) throws { - // If the message was a reaction then we want to update the reaction instead of the original - // interaction (which the 'interactionId' is pointing to - if let visibleMessage: VisibleMessage = message as? VisibleMessage, let reaction: VisibleMessage.VMReaction = visibleMessage.reaction { - try Reaction - .filter(Reaction.Columns.interactionId == interactionId) - .filter(Reaction.Columns.authorId == reaction.publicKey) - .filter(Reaction.Columns.emoji == reaction.emoji) - .updateAll(db, Reaction.Columns.serverHash.set(to: message.serverHash)) - } - else { - // Otherwise we do want to try and update the referenced interaction - let interaction: Interaction? = try interaction(db, for: message, interactionId: interactionId) - - // Get the visible message if possible - if let interaction: Interaction = interaction { - // Only store the server hash of a sync message if the message is self send valid - switch (message.isSelfSendValid, destination) { - case (false, .syncMessage): - try interaction.with(state: .sent).update(db) - - case (true, .syncMessage), (_, .contact), (_, .closedGroup), (_, .openGroup), (_, .openGroupInbox): - try interaction.with( - serverHash: message.serverHash, - // Track the open group server message ID and update server timestamp (use server - // timestamp for open group messages otherwise the quote messages may not be able - // to be found by the timestamp on other devices - timestampMs: (message.openGroupServerMessageId == nil ? - nil : - serverTimestampMs.map { Int64($0) } - ), - openGroupServerMessageId: message.openGroupServerMessageId.map { Int64($0) }, - state: .sent - ).update(db) - - if interaction.isExpiringMessage { - // Start disappearing messages job after a message is successfully sent. - // For DAR and DAS outgoing messages, the expiration start time are the - // same as message sentTimestamp. So do this once, DAR and DAS messages - // should all be covered. - dependencies[singleton: .jobRunner].upsert( - db, - job: DisappearingMessagesJob.updateNextRunIfNeeded( - db, - interaction: interaction, - startedAtMs: Double(interaction.timestampMs), - using: dependencies - ), - canStartJob: true - ) - - if - case .syncMessage = destination, - let startedAtMs: Double = interaction.expiresStartedAtMs, - let expiresInSeconds: TimeInterval = interaction.expiresInSeconds, - let serverHash: String = message.serverHash - { - let expirationTimestampMs: Int64 = Int64(startedAtMs + expiresInSeconds * 1000) - dependencies[singleton: .jobRunner].add( - db, - job: Job( - variant: .expirationUpdate, - behaviour: .runOnce, - threadId: interaction.threadId, - details: ExpirationUpdateJob.Details( - serverHashes: [serverHash], - expirationTimestampMs: expirationTimestampMs - ) - ), - canStartJob: true - ) - } - } - } - } - } - - // Extract the threadId from the message - let threadId: String = Message.threadId(forMessage: message, destination: destination, using: dependencies) - - // Prevent ControlMessages from being handled multiple times if not supported - try? ControlMessageProcessRecord( - threadId: threadId, - message: message, - serverExpirationTimestamp: ( - TimeInterval(dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + - ControlMessageProcessRecord.defaultExpirationSeconds - ) - )?.insert(db) - - // Sync the message if needed - scheduleSyncMessageIfNeeded( - db, - message: message, - destination: destination, - threadId: threadId, - interactionId: interactionId, - using: dependencies - ) - } - - @discardableResult internal static func handleFailedMessageSend( - _ db: Database, + public static func encodeMessageForSending( + namespace: SnodeAPI.Namespace, + destination: Message.Destination, message: Message, - destination: Message.Destination?, - error: MessageSenderError, - interactionId: Int64?, + attachments: [(attachment: Attachment, fileId: String)]?, + authMethod: AuthenticationMethod, using dependencies: Dependencies - ) -> Error { - // Log a message for any 'other' errors - switch error { - case .other(let cat, let description, let error): - Log.error([.messageSender, cat].compactMap { $0 }, "\(description) due to error: \(error).") - default: break - } - - // Only 'VisibleMessage' messages can show a status so don't bother updating - // the other cases (if the VisibleMessage was a reaction then we also don't - // want to do anything as the `interactionId` points to the original message - // which has it's own status) - switch message { - case let message as VisibleMessage where message.reaction != nil: return error - case is VisibleMessage: break - default: return error - } + ) throws -> Data { + /// Check the message itself is valid + guard + message.isValid(isSending: true), + let sentTimestampMs: UInt64 = message.sentTimestampMs + else { throw MessageSenderError.invalidMessage } - // Check if we need to mark any "sending" recipients as "failed" - // - // Note: The 'db' could be either read-only or writeable so we determine - // if a change is required, and if so dispatch to a separate queue for the - // actual write - let rowIds: [Int64] = (try? { - switch destination { - case .syncMessage: - return Interaction - .select(Column.rowID) - .filter(id: interactionId) - .filter( - Interaction.Columns.state == Interaction.State.syncing || - Interaction.Columns.state == Interaction.State.sent - ) + let plaintext: Data = try { + switch (namespace, destination) { + case (.revokedRetrievableGroupMessages, _): + return try BencodeEncoder(using: dependencies).encode(message) + + case (_, .openGroup), (_, .openGroupInbox): + guard + let proto: SNProtoContent = try message.toProto()? + .addingAttachmentsIfNeeded(message, attachments?.map { $0.attachment }) + else { throw MessageSenderError.protoConversionFailed } + + return try Result(proto.serializedData().paddedMessageBody()) + .mapError { MessageSenderError.other(nil, "Couldn't serialize proto", $0) } + .successOrThrow() default: - return Interaction - .select(Column.rowID) - .filter(id: interactionId) - .filter(Interaction.Columns.state == Interaction.State.sending) + guard + let proto: SNProtoContent = try message.toProto()? + .addingAttachmentsIfNeeded(message, attachments?.map { $0.attachment }) + else { throw MessageSenderError.protoConversionFailed } + + return try Result(proto.serializedData()) + .map { serialisedData -> Data in + switch destination { + case .closedGroup(let groupId) where (try? SessionId.Prefix(from: groupId)) == .group: + return serialisedData + + default: return serialisedData.paddedMessageBody() + } + } + .mapError { MessageSenderError.other(nil, "Couldn't serialize proto", $0) } + .successOrThrow() } }() - .asRequest(of: Int64.self) - .fetchAll(db)) - .defaulting(to: []) - - guard !rowIds.isEmpty else { return error } - /// If we have affected rows then we should update them with the latest error text - /// - /// **Note:** We `writeAsync` here as performing a syncronous `write` results in a reentrancy assertion - dependencies[singleton: .storage].writeAsync { db in - let targetState: Interaction.State - switch destination { - case .syncMessage: targetState = .failedToSync - default: targetState = .failed - } - - _ = try? Interaction - .filter(rowIds.contains(Column.rowID)) - .updateAll( - db, - Interaction.Columns.state.set(to: targetState), - Interaction.Columns.mostRecentFailureText.set(to: "\(error)") + switch (destination, namespace) { + /// Updated group messages should be wrapped _before_ encrypting + case (.closedGroup(let groupId), .groupMessages) where (try? SessionId.Prefix(from: groupId)) == .group: + let messageData: Data = try Result( + MessageWrapper.wrap( + type: .closedGroupMessage, + timestampMs: sentTimestampMs, + content: plaintext, + wrapInWebSocketMessage: false + ) ) - } - - return error - } - - // MARK: - Convenience - - private static func interaction(_ db: Database, for message: Message, interactionId: Int64?) throws -> Interaction? { - if let interactionId: Int64 = interactionId { - return try Interaction.fetchOne(db, id: interactionId) - } - - if let sentTimestampMs: Double = message.sentTimestampMs.map({ Double($0) }) { - return try Interaction - .filter(Interaction.Columns.timestampMs == sentTimestampMs) - .fetchOne(db) - } - - return nil - } - - public static func scheduleSyncMessageIfNeeded( - _ db: Database, - message: Message, - destination: Message.Destination, - threadId: String?, - interactionId: Int64?, - using dependencies: Dependencies - ) { - // Sync the message if it's not a sync message, wasn't already sent to the current user and - // it's a message type which should be synced - let userSessionId = dependencies[cache: .general].sessionId - - if - case .contact(let publicKey) = destination, - publicKey != userSessionId.hexString, - Message.shouldSync(message: message) - { - if let message = message as? VisibleMessage { message.syncTarget = publicKey } - if let message = message as? ExpirationTimerUpdate { message.syncTarget = publicKey } + .mapError { MessageSenderError.other(nil, "Couldn't wrap message", $0) } + .successOrThrow() + + let ciphertext: Data = try dependencies[singleton: .crypto].tryGenerate( + .ciphertextForGroupMessage( + groupSessionId: SessionId(.group, hex: groupId), + message: Array(messageData) + ) + ) + return ciphertext + + /// `revokedRetrievableGroupMessages` should be sent in plaintext (their content has custom encryption) + case (.closedGroup(let groupId), .revokedRetrievableGroupMessages) where (try? SessionId.Prefix(from: groupId)) == .group: + return plaintext + + // Standard one-to-one messages and legacy groups (which used a `05` prefix) + case (.contact, .default), (.syncMessage, _), (.closedGroup, _): + let ciphertext: Data = try dependencies[singleton: .crypto].tryGenerate( + .ciphertextWithSessionProtocol( + plaintext: plaintext, + destination: destination + ) + ) + + return try Result( + try MessageWrapper.wrap( + type: try { + switch destination { + case .contact, .syncMessage: return .sessionMessage + case .closedGroup: return .closedGroupMessage + default: throw MessageSenderError.invalidMessage + } + }(), + timestampMs: sentTimestampMs, + senderPublicKey: { + switch destination { + case .closedGroup: return try authMethod.swarmPublicKey // Needed for Android + default: return "" // Empty for all other cases + } + }(), + content: ciphertext + ) + ) + .mapError { MessageSenderError.other(nil, "Couldn't wrap message", $0) } + .successOrThrow() + + /// Community messages should be sent in plaintext + case (.openGroup, _): return plaintext - dependencies[singleton: .jobRunner].add( - db, - job: Job( - variant: .messageSend, - threadId: threadId, - interactionId: interactionId, - details: MessageSendJob.Details( - destination: .syncMessage(originalRecipientPublicKey: publicKey), - message: message + /// Blinded community messages have their own special encryption + case (.openGroupInbox(_, let serverPublicKey, let recipientBlindedPublicKey), _): + return try dependencies[singleton: .crypto].generateResult( + .ciphertextWithSessionBlindingProtocol( + plaintext: plaintext, + recipientBlindedId: recipientBlindedPublicKey, + serverPublicKey: serverPublicKey ) - ), - canStartJob: true - ) + ) + .mapError { MessageSenderError.other(nil, "Couldn't encrypt message for destination: \(destination)", $0) } + .successOrThrow() + + /// Config messages should be sent directly rather than via this method + case (.closedGroup(let groupId), _) where (try? SessionId.Prefix(from: groupId)) == .group: + throw MessageSenderError.invalidConfigMessageHandling + + /// Config messages should be sent directly rather than via this method + case (.contact, _): throw MessageSenderError.invalidConfigMessageHandling } } } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/AuthenticatedRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/AuthenticatedRequest.swift index 0ec86569e0..9e7d30447b 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Models/AuthenticatedRequest.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/AuthenticatedRequest.swift @@ -20,7 +20,9 @@ extension PushNotificationAPI { /// The signature unix timestamp (seconds, not ms) internal let timestamp: Int64 - var verificationBytes: [UInt8] { preconditionFailure("abstract class - override in subclass") } + var verificationBytes: [UInt8] { + get throws { preconditionFailure("abstract class - override in subclass") } + } // MARK: - Initialization @@ -39,21 +41,23 @@ extension PushNotificationAPI { // Generate the signature for the request for encoding let signature: Authentication.Signature = try authMethod.generateSignature( - with: verificationBytes, + with: try verificationBytes, using: try encoder.dependencies ?? { throw DependenciesError.missingDependencies }() ) try container.encode(timestamp, forKey: .timestamp) switch authMethod.info { - case .standard(let sessionId, let ed25519KeyPair): + case .standard(let sessionId, let ed25519PublicKey): try container.encode(sessionId.hexString, forKey: .pubkey) - try container.encode(ed25519KeyPair.publicKey.toHexString(), forKey: .ed25519PublicKey) + 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 { diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift index 212484a16f..369cdcfba9 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift @@ -47,9 +47,12 @@ extension PushNotificationAPI.NotificationMetadata { public init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - let namespace: SnodeAPI.Namespace = SnodeAPI.Namespace( - rawValue: try container.decode(Int.self, forKey: .namespace) - ).defaulting(to: .unknown) + /// 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: SnodeAPI.Namespace = try container.decodeIfPresent(Int.self, forKey: .namespace) + .map { SnodeAPI.Namespace(rawValue: $0) } + .defaulting(to: .unknown) self = PushNotificationAPI.NotificationMetadata( accountId: try container.decode(String.self, forKey: .accountId), @@ -65,7 +68,7 @@ extension PushNotificationAPI.NotificationMetadata { // MARK: - Convenience -extension PushNotificationAPI.NotificationMetadata { +public extension PushNotificationAPI.NotificationMetadata { static var invalid: PushNotificationAPI.NotificationMetadata { PushNotificationAPI.NotificationMetadata( accountId: "", diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeRequest.swift index 1da3c69284..5138d5d8f5 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeRequest.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeRequest.swift @@ -33,34 +33,36 @@ extension PushNotificationAPI { private let notificationsEncryptionKey: Data override var verificationBytes: [UInt8] { - /// The signature data collected and stored here is used by the PN server to subscribe to the swarms - /// for the given account; the specific rules are governed by the storage server, but in general: - /// - /// A signature must have been produced (via the timestamp) within the past 14 days. It is - /// recommended that clients generate a new signature whenever they re-subscribe, and that - /// re-subscriptions happen more frequently than once every 14 days. - /// - /// A signature is signed using the account's Ed25519 private key (or Ed25519 subaccount, if using - /// subaccount authentication with a `subaccount_token`, for future closed group subscriptions), - /// and signs the value: - /// `"MONITOR" || HEX(ACCOUNT) || SIG_TS || DATA01 || NS[0] || "," || ... || "," || NS[n]` - /// - /// Where `SIG_TS` is the `sig_ts` value as a base-10 string; `DATA01` is either "0" or "1" depending - /// on whether the subscription wants message data included; and the trailing `NS[i]` values are a - /// comma-delimited list of namespaces that should be subscribed to, in the same sorted order as - /// the `namespaces` parameter. - "MONITOR".bytes - .appending(contentsOf: authMethod.swarmPublicKey.bytes) - .appending(contentsOf: "\(timestamp)".bytes) - .appending(contentsOf: (includeMessageData ? "1" : "0").bytes) - .appending( - contentsOf: namespaces - .map { $0.rawValue } // Intentionally not using `verificationString` here - .sorted() - .map { "\($0)" } - .joined(separator: ",") - .bytes - ) + get throws { + /// The signature data collected and stored here is used by the PN server to subscribe to the swarms + /// for the given account; the specific rules are governed by the storage server, but in general: + /// + /// A signature must have been produced (via the timestamp) within the past 14 days. It is + /// recommended that clients generate a new signature whenever they re-subscribe, and that + /// re-subscriptions happen more frequently than once every 14 days. + /// + /// A signature is signed using the account's Ed25519 private key (or Ed25519 subaccount, if using + /// subaccount authentication with a `subaccount_token`, for future closed group subscriptions), + /// and signs the value: + /// `"MONITOR" || HEX(ACCOUNT) || SIG_TS || DATA01 || NS[0] || "," || ... || "," || NS[n]` + /// + /// Where `SIG_TS` is the `sig_ts` value as a base-10 string; `DATA01` is either "0" or "1" depending + /// on whether the subscription wants message data included; and the trailing `NS[i]` values are a + /// comma-delimited list of namespaces that should be subscribed to, in the same sorted order as + /// the `namespaces` parameter. + "MONITOR".bytes + .appending(contentsOf: try authMethod.swarmPublicKey.bytes) + .appending(contentsOf: "\(timestamp)".bytes) + .appending(contentsOf: (includeMessageData ? "1" : "0").bytes) + .appending( + contentsOf: namespaces + .map { $0.rawValue } // Intentionally not using `verificationString` here + .sorted() + .map { "\($0)" } + .joined(separator: ",") + .bytes + ) + } } // MARK: - Initialization diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeRequest.swift index 53c4591b7f..1d29c882d8 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeRequest.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeRequest.swift @@ -19,15 +19,17 @@ extension PushNotificationAPI { private let serviceInfo: ServiceInfo override var verificationBytes: [UInt8] { - /// A signature is signed using the account's Ed25519 private key (or Ed25519 subaccount, if using - /// subaccount authentication with a `subaccount_token`, for future closed group subscriptions), - /// and signs the value: - /// `"UNSUBSCRIBE" || HEX(ACCOUNT) || SIG_TS` - /// - /// Where `SIG_TS` is the `sig_ts` value as a base-10 string and must be within 24 hours of the current time. - "UNSUBSCRIBE".bytes - .appending(contentsOf: authMethod.swarmPublicKey.bytes) - .appending(contentsOf: "\(timestamp)".data(using: .ascii)?.bytes) + get throws { + /// A signature is signed using the account's Ed25519 private key (or Ed25519 subaccount, if using + /// subaccount authentication with a `subaccount_token`, for future closed group subscriptions), + /// and signs the value: + /// `"UNSUBSCRIBE" || HEX(ACCOUNT) || SIG_TS` + /// + /// Where `SIG_TS` is the `sig_ts` value as a base-10 string and must be within 24 hours of the current time. + "UNSUBSCRIBE".bytes + .appending(contentsOf: try authMethod.swarmPublicKey.bytes) + .appending(contentsOf: "\(timestamp)".data(using: .ascii)?.bytes) + } } // MARK: - Initialization diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift new file mode 100644 index 0000000000..391801376a --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift @@ -0,0 +1,417 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import SessionUIKit +import SessionUtilitiesKit + +// MARK: - Singleton + +public extension Singleton { + static let notificationsManager: SingletonConfig = Dependencies.create( + identifier: "notificationsManager", + createInstance: { dependencies in NoopNotificationsManager(using: dependencies) } + ) +} + +// MARK: - NotificationsManagerType + +public protocol NotificationsManagerType { + var dependencies: Dependencies { get } + + init(using dependencies: Dependencies) + + func setDelegate(_ delegate: (any UNUserNotificationCenterDelegate)?) + func registerSystemNotificationSettings() -> AnyPublisher + + func settings(threadId: String?, threadVariant: SessionThread.Variant) -> Preferences.NotificationSettings + func updateSettings( + threadId: String, + threadVariant: SessionThread.Variant, + mentionsOnly: Bool, + mutedUntil: TimeInterval? + ) + func notificationUserInfo(threadId: String, threadVariant: SessionThread.Variant) -> [String: Any] + func notificationShouldPlaySound(applicationState: UIApplication.State) -> Bool + + func notifyForFailedSend( + threadId: String, + threadVariant: SessionThread.Variant, + applicationState: UIApplication.State + ) + func scheduleSessionNetworkPageLocalNotifcation(force: Bool) + func addNotificationRequest( + content: NotificationContent, + notificationSettings: Preferences.NotificationSettings, + extensionBaseUnreadCount: Int? + ) + + func cancelNotifications(identifiers: [String]) + func clearAllNotifications() +} + +public extension NotificationsManagerType { + func settings(threadVariant: SessionThread.Variant) -> Preferences.NotificationSettings { + return settings(threadId: nil, threadVariant: threadVariant) + } +} + +public extension NotificationsManagerType { + func ensureWeShouldShowNotification( + message: Message, + threadId: String, + threadVariant: SessionThread.Variant, + interactionVariant: Interaction.Variant?, + isMessageRequest: Bool, + notificationSettings: Preferences.NotificationSettings, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo?, + currentUserSessionIds: Set, + shouldShowForMessageRequest: () -> Bool, + using dependencies: Dependencies + ) throws { + guard let sender: String = message.sender else { throw MessageReceiverError.invalidSender } + + /// Don't show notifications for the `Note to Self` thread or messages sent from the current user + guard !currentUserSessionIds.contains(threadId) && !currentUserSessionIds.contains(sender) else { + throw MessageReceiverError.selfSend + } + + /// Ensure that the thread isn't muted + guard dependencies.dateNow.timeIntervalSince1970 > (notificationSettings.mutedUntil ?? 0) else { + throw MessageReceiverError.ignorableMessage + } + + switch message { + /// For a `VisibleMessage` we should only notify if the notification mode is `all` or if `mentionsOnly` and the + /// user was actually mentioned + case let visibleMessage as VisibleMessage: + guard interactionVariant == .standardIncoming else { throw MessageReceiverError.ignorableMessage } + guard + !notificationSettings.mentionsOnly || + Interaction.isUserMentioned( + publicKeysToCheck: currentUserSessionIds, + body: visibleMessage.text, + quoteAuthorId: visibleMessage.quote?.authorId + ) + else { throw MessageReceiverError.ignorableMessage } + + /// If the message is a reaction then we only want to show notifications for `contact` conversations + if visibleMessage.reaction != nil { + switch threadVariant { + case .contact: break + case .legacyGroup, .group, .community: throw MessageReceiverError.ignorableMessage + } + } + break + + /// Calls are only supported in `contact` conversations and we only want to notify for missed calls + case let callMessage as CallMessage: + guard threadVariant == .contact else { throw MessageReceiverError.invalidMessage } + guard case .preOffer = callMessage.kind else { throw MessageReceiverError.ignorableMessage } + + switch callMessage.state { + case .missed, .permissionDenied, .permissionDeniedMicrophone: break + default: throw MessageReceiverError.ignorableMessage + } + + /// Group invitations and promotions may show notifications in some cases + case is GroupUpdateInviteMessage, is GroupUpdatePromoteMessage: break + + /// No other messages should have notifications + default: throw MessageReceiverError.ignorableMessage + } + + /// Ensure the sender isn't blocked (this should be checked when parsing the message but we should also check here in case + /// that logic ever changes) + guard + dependencies.mutate(cache: .libSession, { cache in + !cache.isContactBlocked(contactId: sender) + }) + else { throw MessageReceiverError.senderBlocked } + + /// Ensure the message hasn't already been maked as read (don't want to show notification in that case) + guard + dependencies.mutate(cache: .libSession, { cache in + !cache.timestampAlreadyRead( + threadId: threadId, + threadVariant: threadVariant, + timestampMs: (message.sentTimestampMs.map { Int64($0) } ?? 0), /// Default to unread + openGroupUrlInfo: openGroupUrlInfo + ) + }) + else { throw MessageReceiverError.ignorableMessage } + + /// If the thread is a message request then we only want to show a notification for the first message + switch (threadVariant, isMessageRequest) { + case (.community, _), (.legacyGroup, _), (.contact, false), (.group, false): break + case (.contact, true), (.group, true): + guard shouldShowForMessageRequest() else { + throw MessageReceiverError.ignorableMessageRequestMessage(threadId) + } + break + } + + /// If we made it here then we should show the notification + } + + func notificationTitle( + message: Message, + threadId: String, + threadVariant: SessionThread.Variant, + isMessageRequest: Bool, + notificationSettings: Preferences.NotificationSettings, + displayNameRetriever: (String) -> String?, + groupNameRetriever: (String, SessionThread.Variant) -> String?, + using dependencies: Dependencies + ) throws -> String { + switch (notificationSettings.previewType, message.sender, isMessageRequest, threadVariant) { + /// If it's a message request or shouldn't have a title then use something generic + case (.noNameNoPreview, _, _, _), (_, .none, _, _), (_, _, true, _): + return Constants.app_name + + case (.nameNoPreview, .some(let sender), _, .contact), (.nameAndPreview, .some(let sender), _, .contact): + return displayNameRetriever(sender) + .defaulting(to: Profile.truncated(id: sender, threadVariant: threadVariant)) + + case (.nameNoPreview, .some(let sender), _, .group), (.nameAndPreview, .some(let sender), _, .group), + (.nameNoPreview, .some(let sender), _, .community), (.nameAndPreview, .some(let sender), _, .community): + let senderName: String = displayNameRetriever(sender) + .defaulting(to: Profile.truncated(id: sender, threadVariant: threadVariant)) + let groupName: String = groupNameRetriever(threadId, threadVariant) + .defaulting(to: "groupUnknown".localized()) + + return "notificationsIosGroup" + .put(key: "name", value: senderName) + .put(key: "conversation_name", value: groupName) + .localized() + + case (_, _, _, .legacyGroup): throw MessageReceiverError.ignorableMessage + } + } + + func notificationBody( + message: Message, + threadVariant: SessionThread.Variant, + isMessageRequest: Bool, + notificationSettings: Preferences.NotificationSettings, + interactionVariant: Interaction.Variant?, + attachmentDescriptionInfo: [Attachment.DescriptionInfo]?, + currentUserSessionIds: Set, + displayNameRetriever: (String) -> String?, + using dependencies: Dependencies + ) -> String { + /// If it's a message request then use something generic + guard !isMessageRequest else { return "messageRequestsNew".localized() } + + /// If it shouldn't have the content or has no sender then use something generic + guard + let sender: String = message.sender, + notificationSettings.previewType == .nameAndPreview + else { + return "messageNewYouveGot" + .putNumber(1) + .localized() + } + + switch message { + case let visibleMessage as VisibleMessage where visibleMessage.reaction != nil: + return "emojiReactsNotification" + .put(key: "emoji", value: (visibleMessage.reaction?.emoji ?? "")) + .localized() + + case let visibleMessage as VisibleMessage: + return (interactionVariant + .map { variant -> String in + Interaction.previewText( + variant: variant, + body: visibleMessage.text, + authorDisplayName: displayNameRetriever(sender) + .defaulting(to: Profile.truncated(id: sender, threadVariant: threadVariant)), + attachmentDescriptionInfo: attachmentDescriptionInfo?.first, + attachmentCount: (attachmentDescriptionInfo?.count ?? 0), + isOpenGroupInvitation: (visibleMessage.openGroupInvitation != nil), + using: dependencies + ) + }? + .filteredForDisplay + .filteredForNotification + .nullIfEmpty? + .replacingMentions( + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: displayNameRetriever + )) + .defaulting(to: "messageNewYouveGot" + .putNumber(1) + .localized() + ) + + case let callMessage as CallMessage where callMessage.state == .permissionDenied: + let senderName: String = displayNameRetriever(sender) + .defaulting(to: Profile.truncated(id: sender, threadVariant: threadVariant)) + + return "callsYouMissedCallPermissions" + .put(key: "name", value: senderName) + .localizedDeformatted() + + case is CallMessage: + let senderName: String = displayNameRetriever(sender) + .defaulting(to: Profile.truncated(id: sender, threadVariant: threadVariant)) + + return "callsMissedCallFrom" + .put(key: "name", value: senderName) + .localizedDeformatted() + + /// Fallback to soemthing generic + default: + return "messageNewYouveGot" + .putNumber(1) + .localized() + } + } + + func notifyUser( + message: Message, + threadId: String, + threadVariant: SessionThread.Variant, + interactionIdentifier: String, + interactionVariant: Interaction.Variant?, + attachmentDescriptionInfo: [Attachment.DescriptionInfo]?, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo?, + applicationState: UIApplication.State, + extensionBaseUnreadCount: Int?, + currentUserSessionIds: Set, + displayNameRetriever: (String) -> String?, + groupNameRetriever: (String, SessionThread.Variant) -> String?, + shouldShowForMessageRequest: () -> Bool + ) throws { + let isMessageRequest: Bool = dependencies.mutate(cache: .libSession) { cache in + cache.isMessageRequest( + threadId: threadId, + threadVariant: threadVariant + ) + } + let settings: Preferences.NotificationSettings = settings( + threadId: threadId, + threadVariant: threadVariant + ) + + /// Ensure we should be showing a notification for the thread + try ensureWeShouldShowNotification( + message: message, + threadId: threadId, + threadVariant: threadVariant, + interactionVariant: interactionVariant, + isMessageRequest: isMessageRequest, + notificationSettings: settings, + openGroupUrlInfo: openGroupUrlInfo, + currentUserSessionIds: currentUserSessionIds, + shouldShowForMessageRequest: shouldShowForMessageRequest, + using: dependencies + ) + + /// Actually add the notification + addNotificationRequest( + content: NotificationContent( + threadId: threadId, + threadVariant: threadVariant, + identifier: { + switch (message as? VisibleMessage)?.reaction { + case .some: return dependencies.randomUUID().uuidString + default: + return Interaction.notificationIdentifier( + for: interactionIdentifier, + threadId: threadId, + shouldGroupMessagesForThread: (threadVariant == .community) + ) + } + }(), + category: .incomingMessage, + title: try notificationTitle( + message: message, + threadId: threadId, + threadVariant: threadVariant, + isMessageRequest: isMessageRequest, + notificationSettings: settings, + displayNameRetriever: displayNameRetriever, + groupNameRetriever: groupNameRetriever, + using: dependencies + ), + body: notificationBody( + message: message, + threadVariant: threadVariant, + isMessageRequest: isMessageRequest, + notificationSettings: settings, + interactionVariant: interactionVariant, + attachmentDescriptionInfo: attachmentDescriptionInfo, + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: displayNameRetriever, + using: dependencies + ), + sound: settings.sound, + userInfo: notificationUserInfo(threadId: threadId, threadVariant: threadVariant), + applicationState: applicationState + ), + notificationSettings: settings, + extensionBaseUnreadCount: extensionBaseUnreadCount + ) + } +} + +// MARK: - NoopNotificationsManager + +public struct NoopNotificationsManager: NotificationsManagerType { + public let dependencies: Dependencies + + public init(using dependencies: Dependencies) { + self.dependencies = dependencies + } + + public func setDelegate(_ delegate: (any UNUserNotificationCenterDelegate)?) {} + + public func registerSystemNotificationSettings() -> AnyPublisher { + return Just(()).eraseToAnyPublisher() + } + + public func settings(threadId: String?, threadVariant: SessionThread.Variant) -> Preferences.NotificationSettings { + return Preferences.NotificationSettings( + previewType: .defaultPreviewType, + sound: .defaultNotificationSound, + mentionsOnly: false, + mutedUntil: nil + ) + } + + public func updateSettings(threadId: String, threadVariant: SessionThread.Variant, mentionsOnly: Bool, mutedUntil: TimeInterval?) { + } + + public func notificationUserInfo(threadId: String, threadVariant: SessionThread.Variant) -> [String: Any] { + return [:] + } + + public func notificationShouldPlaySound(applicationState: UIApplication.State) -> Bool { + return false + } + + public func notifyForFailedSend( + threadId: String, + threadVariant: SessionThread.Variant, + applicationState: UIApplication.State + ) {} + public func scheduleSessionNetworkPageLocalNotifcation(force: Bool) {} + + public func addNotificationRequest( + content: NotificationContent, + notificationSettings: Preferences.NotificationSettings, + extensionBaseUnreadCount: Int? + ) {} + public func cancelNotifications(identifiers: [String]) {} + public func clearAllNotifications() {} +} + +// MARK: - Notifications + +public enum Notifications { + /// Delay notification of incoming messages when we want to group them (eg. during background polling) to avoid + /// firing too many notifications at the same time + public static let delayForGroupedNotifications: TimeInterval = 5 +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift deleted file mode 100644 index 2dab980912..0000000000 --- a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Combine -import GRDB -import SessionUtilitiesKit - -// MARK: - Singleton - -public extension Singleton { - static let notificationsManager: SingletonConfig = Dependencies.create( - identifier: "notificationsManager", - createInstance: { dependencies in NoopNotificationsManager(using: dependencies) } - ) -} - -// MARK: - NotificationsManagerType - -public protocol NotificationsManagerType { - init(using dependencies: Dependencies) - - func setDelegate(_ delegate: (any UNUserNotificationCenterDelegate)?) - func registerNotificationSettings() -> AnyPublisher - - func notifyUser( - _ db: Database, - for interaction: Interaction, - in thread: SessionThread, - applicationState: UIApplication.State - ) - - func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) - func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread, applicationState: UIApplication.State) - func notifyForFailedSend(_ db: Database, in thread: SessionThread, applicationState: UIApplication.State) - - func scheduleSessionNetworkPageLocalNotifcation(force: Bool) - - func cancelNotifications(identifiers: [String]) - func clearAllNotifications() -} - -// MARK: - NoopNotificationsManager - -public struct NoopNotificationsManager: NotificationsManagerType { - public init(using dependencies: Dependencies) {} - - public func setDelegate(_ delegate: (any UNUserNotificationCenterDelegate)?) {} - - public func registerNotificationSettings() -> AnyPublisher { - return Just(()).eraseToAnyPublisher() - } - - public func notifyUser( - _ db: Database, - for interaction: Interaction, - in thread: SessionThread, - applicationState: UIApplication.State - ) {} - public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) {} - public func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread, applicationState: UIApplication.State) {} - public func notifyForFailedSend(_ db: Database, in thread: SessionThread, applicationState: UIApplication.State) {} - - public func scheduleSessionNetworkPageLocalNotifcation(force: Bool) {} - - public func cancelNotifications(identifiers: [String]) {} - public func clearAllNotifications() {} -} - -// MARK: - Notifications - -public enum Notifications { - /// Delay notification of incoming messages when we want to group them (eg. during background polling) to avoid - /// firing too many notifications at the same time - public static let delayForGroupedNotifications: TimeInterval = 5 -} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 8c949371f2..754c1340d5 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -76,6 +76,7 @@ public enum PushNotificationAPI { dependencies[defaults: .standard, key: .deviceToken] = hexEncodedToken dependencies[defaults: .standard, key: .lastDeviceTokenUpload] = now dependencies[defaults: .standard, key: .isUsingFullAPNs] = true + dependencies.notifyAsync(.isUsingFullAPNs, value: true) } ) } @@ -125,7 +126,7 @@ public enum PushNotificationAPI { // MARK: - Prepared Requests public static func preparedSubscribe( - _ db: Database, + _ db: ObservingDatabase, token: Data, sessionIds: [SessionId], using dependencies: Dependencies @@ -134,9 +135,15 @@ public enum PushNotificationAPI { throw NetworkError.invalidPreparedRequest } - guard let notificationsEncryptionKey: Data = try? getOrGenerateEncryptionKey(using: dependencies) else { + guard let notificationsEncryptionKey: Data = try? dependencies[singleton: .keychain].getOrGenerateEncryptionKey( + forKey: .pushNotificationEncryptionKey, + length: encryptionKeyLength, + cat: .cat, + legacyKey: "PNEncryptionKeyKey", + legacyService: "PNKeyChainService" + ) else { Log.error(.cat, "Unable to retrieve PN encryption key.") - throw StorageError.invalidKeySpec + throw KeychainStorageError.keySpecInvalid } return try Network.PreparedRequest( @@ -155,7 +162,13 @@ public enum PushNotificationAPI { .configGroupMembers, .revokedRetrievableGroupMessages ] - default: return [.default, .configConvoInfoVolatile] + default: return [ + .default, + .configUserProfile, + .configContacts, + .configConvoInfoVolatile, + .configUserGroups + ] } }(), /// Note: Unfortunately we always need the message content because without the content @@ -191,14 +204,14 @@ public enum PushNotificationAPI { receiveCompletion: { result in switch result { case .finished: break - case .failure: Log.error(.cat, "Couldn't subscribe for push notifications.") + case .failure(let error): Log.error(.cat, "Couldn't subscribe for push notifications due to error: \(error).") } } ) } public static func preparedUnsubscribe( - _ db: Database, + _ db: ObservingDatabase, token: Data, sessionIds: [SessionId], using dependencies: Dependencies @@ -238,7 +251,7 @@ public enum PushNotificationAPI { receiveCompletion: { result in switch result { case .finished: break - case .failure: Log.error(.cat, "Couldn't unsubscribe for push notifications.") + case .failure(let error): Log.error(.cat, "Couldn't unsubscribe for push notifications due to error: \(error).") } } ) @@ -262,7 +275,13 @@ public enum PushNotificationAPI { // Decrypt and decode the payload guard let encryptedData: Data = Data(base64Encoded: base64EncodedEncString), - let notificationsEncryptionKey: Data = try? getOrGenerateEncryptionKey(using: dependencies), + let notificationsEncryptionKey: Data = try? dependencies[singleton: .keychain].getOrGenerateEncryptionKey( + forKey: .pushNotificationEncryptionKey, + length: encryptionKeyLength, + cat: .cat, + legacyKey: "PNEncryptionKeyKey", + legacyService: "PNKeyChainService" + ), let decryptedData: Data = dependencies[singleton: .crypto].generate( .plaintextWithPushNotificationPayload( payload: encryptedData, @@ -293,54 +312,4 @@ public enum PushNotificationAPI { // Success, we have the notification content return (notificationData, notification.info, .success) } - - // MARK: - Security - - @discardableResult private static func getOrGenerateEncryptionKey(using dependencies: Dependencies) throws -> Data { - do { - try dependencies[singleton: .keychain].migrateLegacyKeyIfNeeded( - legacyKey: "PNEncryptionKeyKey", - legacyService: "PNKeyChainService", - toKey: .pushNotificationEncryptionKey - ) - var encryptionKey: Data = try dependencies[singleton: .keychain].data(forKey: .pushNotificationEncryptionKey) - defer { encryptionKey.resetBytes(in: 0.. NotificationContent { + return NotificationContent( + threadId: threadId, + threadVariant: threadVariant, + identifier: identifier, + category: category, + title: (title ?? self.title), + body: (body ?? self.body), + delay: self.delay, + sound: (sound ?? self.sound), + userInfo: userInfo, + applicationState: applicationState + ) + } + + public func toMutableContent(shouldPlaySound: Bool) -> UNMutableNotificationContent { + let content: UNMutableNotificationContent = UNMutableNotificationContent() + content.categoryIdentifier = category.identifier + content.userInfo = userInfo + + if let threadId: String = threadId { content.threadIdentifier = threadId } + if let title: String = title { content.title = title } + if let body: String = body { content.body = body } + + if shouldPlaySound { + content.sound = sound.notificationSound(isQuiet: (applicationState == .active)) + } + + return content + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationUserInfoKey.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationUserInfoKey.swift new file mode 100644 index 0000000000..9df3f253e1 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationUserInfoKey.swift @@ -0,0 +1,14 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation + +public struct NotificationUserInfoKey { + public static let isFromRemote = "remote" + public static let threadId = "Signal.AppNotificationsUserInfoKey.threadId" + public static let threadVariantRaw = "Signal.AppNotificationsUserInfoKey.threadVariantRaw" + public static let callBackNumber = "Signal.AppNotificationsUserInfoKey.callBackNumber" + public static let localCallId = "Signal.AppNotificationsUserInfoKey.localCallId" + public static let threadNotificationCounter = "Session.AppNotificationsUserInfoKey.threadNotificationCounter" +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Types/ProcessResult.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Types/ProcessResult.swift index f059dc2ee5..07496de265 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Types/ProcessResult.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Types/ProcessResult.swift @@ -8,8 +8,6 @@ public extension PushNotificationAPI { case successTooLong case failure case failureNoContent - case legacySuccess case legacyFailure - case legacyForceSilent } } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Types/VoipPayloadKey.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Types/VoipPayloadKey.swift new file mode 100644 index 0000000000..d836396e39 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Types/VoipPayloadKey.swift @@ -0,0 +1,10 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum VoipPayloadKey: String { + case uuid + case caller + case timestamp + case contactName +} diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift index ccfbad27a5..835329ec9c 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift @@ -93,9 +93,11 @@ public final class CommunityPoller: CommunityPollerType & PollerType { // MARK: - Abstract Methods - public func nextPollDelay() -> TimeInterval { + public func nextPollDelay() -> AnyPublisher { // Arbitrary backoff factor... - return min(CommunityPoller.maxPollInterval, CommunityPoller.minPollInterval + pow(2, Double(failureCount))) + return Just(min(CommunityPoller.maxPollInterval, CommunityPoller.minPollInterval + pow(2, Double(failureCount)))) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } public func handlePollError(_ error: Error, _ lastError: Error?) -> PollerErrorResponse { @@ -108,7 +110,7 @@ public final class CommunityPoller: CommunityPollerType & PollerType { case (true, .none), (true, false): break default: /// Save the updated failure count to the database - dependencies[singleton: .storage].write { [pollerDestination, failureCount] db in + dependencies[singleton: .storage].writeAsync { [pollerDestination, failureCount] db in try OpenGroup .filter(OpenGroup.Columns.server == pollerDestination.target) .updateAll( @@ -118,13 +120,94 @@ public final class CommunityPoller: CommunityPollerType & PollerType { } return .continuePolling } + //[pollerName, pollerDestination, failureCount, dependencies] + func handleError(_ error: Error) throws -> AnyPublisher { + /// 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 { + /// 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) + ) + } + + throw error + } + + 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 + ) + } + + return Array(hiddenRoomIds) + } + .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 - let fallbackPollDelay: TimeInterval = self.nextPollDelay() - cancellable = dependencies[singleton: .storage] - .readPublisher { [pollerDestination, dependencies] db in - try OpenGroupAPI.preparedCapabilities( + .readPublisher { [pollerDestination, dependencies] db -> AuthenticationMethod in + try Authentication.with( db, server: pollerDestination.target, forceBlinded: true, @@ -133,105 +216,34 @@ public final class CommunityPoller: CommunityPollerType & PollerType { } .subscribe(on: pollerQueue, using: dependencies) .receive(on: pollerQueue, using: dependencies) - .flatMap { [dependencies] request in request.send(using: dependencies) } - .flatMapStorageWritePublisher(using: dependencies) { [pollerDestination] (db: Database, response: (info: ResponseInfoType, data: OpenGroupAPI.Capabilities)) in + .tryMap { [dependencies] authMethod in + try OpenGroupAPI.preparedCapabilities( + authMethod: authMethod, + using: dependencies + ) + } + .flatMap { [dependencies] in $0.send(using: dependencies) } + .flatMapStorageWritePublisher(using: dependencies) { [pollerDestination] (db: ObservingDatabase, response: (info: ResponseInfoType, data: OpenGroupAPI.Capabilities)) in OpenGroupManager.handleCapabilities( db, capabilities: response.data, on: pollerDestination.target ) } - .tryCatch { [pollerName, pollerDestination, failureCount, dependencies] error -> AnyPublisher in - /// 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 { - /// Save the updated failure count to the database - dependencies[singleton: .storage].writeAsync { db in - try OpenGroup - .filter(OpenGroup.Columns.server == pollerDestination.target) - .updateAll( - db, - OpenGroup.Columns.pollFailureCount.set(to: failureCount + 1) - ) - } + .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) - throw error - } - - return dependencies[singleton: .storage] - .writePublisher { 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 - ) - } - - return Array(hiddenRoomIds) + // Schedule the next poll + pollerQueue.asyncAfter(deadline: .now() + .milliseconds(Int(nextPollInterval.timeInterval * 1000)), qos: .default, using: dependencies) { + self?.pollRecursively(error) } - .handleEvents( - receiveOutput: { 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() - } - .asResult() - .sink(receiveValue: { [weak self, pollerQueue, dependencies] _ in - let nextPollInterval: TimeUnit = .seconds((self?.nextPollDelay()).defaulting(to: fallbackPollDelay)) - - // 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 @@ -247,6 +259,12 @@ public final class CommunityPoller: CommunityPollerType & PollerType { /// **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 { + typealias PollInfo = ( + roomInfo: [OpenGroupAPI.RoomInfo], + lastInboxMessageId: Int64, + lastOutboxMessageId: Int64, + authMethod: AuthenticationMethod + ) let lastSuccessfulPollTimestamp: TimeInterval = (self.lastPollStart > 0 ? lastPollStart : dependencies.mutate(cache: .openGroupManager) { cache in @@ -255,16 +273,49 @@ public final class CommunityPoller: CommunityPollerType & PollerType { ) return dependencies[singleton: .storage] - .readPublisher { [pollerDestination, pollCount, dependencies] db -> Network.PreparedRequest> in - try OpenGroupAPI.preparedPoll( - db, - server: pollerDestination.target, - hasPerformedInitialPoll: (pollCount > 0), - timeSinceLastPoll: (dependencies.dateNow.timeIntervalSince1970 - lastSuccessfulPollTimestamp), - using: dependencies + .readPublisher { [pollerDestination, dependencies] db -> PollInfo in + /// **Note:** The `OpenGroup` type converts to lowercase in init + let server: String = pollerDestination.target.lowercased() + let roomInfo: [OpenGroupAPI.RoomInfo] = try OpenGroup + .select(.roomToken, .infoUpdates, .sequenceNumber) + .filter(OpenGroup.Columns.server == server) + .filter(OpenGroup.Columns.isActive == true) + .filter(OpenGroup.Columns.roomToken != "") + .asRequest(of: OpenGroupAPI.RoomInfo.self) + .fetchAll(db) + + guard !roomInfo.isEmpty else { throw OpenGroupAPIError.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) ) } - .flatMap { [dependencies] request in request.send(using: dependencies) } + .tryFlatMap { [pollCount, dependencies] pollInfo -> AnyPublisher<(ResponseInfoType, Network.BatchResponseMap), Error> in + try OpenGroupAPI + .preparedPoll( + roomInfo: pollInfo.roomInfo, + lastInboxMessageId: pollInfo.lastInboxMessageId, + lastOutboxMessageId: pollInfo.lastOutboxMessageId, + 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, @@ -443,14 +494,15 @@ public final class CommunityPoller: CommunityPollerType & PollerType { } return dependencies[singleton: .storage] - .writePublisher { db in + .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)) } - + + var interactionInfo: [MessageReceiver.InsertedInteractionInfo?] = [] try changedResponses.forEach { endpoint, data in switch endpoint { case .capabilities: @@ -486,12 +538,14 @@ public final class CommunityPoller: CommunityPollerType & PollerType { let responseBody: [Failable] = responseData.body else { return } - OpenGroupManager.handleMessages( - db, - messages: responseBody.compactMap { $0.value }, - for: roomToken, - on: pollerDestination.target, - using: dependencies + interactionInfo.append( + contentsOf: OpenGroupManager.handleMessages( + db, + messages: responseBody.compactMap { $0.value }, + for: roomToken, + on: pollerDestination.target, + using: dependencies + ) ) case .inbox, .inboxSince, .outbox, .outboxSince: @@ -509,19 +563,33 @@ public final class CommunityPoller: CommunityPollerType & PollerType { } }() - OpenGroupManager.handleDirectMessages( - db, - messages: messages, - fromOutbox: fromOutbox, - on: pollerDestination.target, - using: dependencies + interactionInfo.append( + contentsOf: OpenGroupManager.handleDirectMessages( + db, + messages: messages, + fromOutbox: fromOutbox, + on: pollerDestination.target, + using: dependencies + ) ) 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 + ) + } + + /// Assume all messages were handled + return ((info, response), rawMessageCount, rawMessageCount, true) } - .map { _ in ((info, response), rawMessageCount, rawMessageCount, true) } // Assume all messages were handled .eraseToAnyPublisher() } .eraseToAnyPublisher() @@ -579,9 +647,9 @@ public extension CommunityPoller { public func startAllPollers() { // On the communityPollerQueue fetch all SOGS and start the pollers - Threading.communityPollerQueue.async(using: dependencies) { [dependencies] in - dependencies[singleton: .storage] - .read { db -> [Info] in + 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 @@ -594,8 +662,19 @@ public extension CommunityPoller { .group(OpenGroup.Columns.server) .asRequest(of: Info.self) .fetchAll(db) - }? - .forEach { [weak self] info in self?.getOrCreatePoller(for: info).startIfNeeded() } + }, + 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() + } + } + } + } + ) } } @@ -652,3 +731,7 @@ public extension CommunityPollerCacheType { return getOrCreatePoller(for: CommunityPoller.Info(server: server, pollFailureCount: 0)) } } + +// MARK: - Conformance + +extension OpenGroupAPI.RoomInfo: FetchableRecord {} diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift index 234084dbf7..e9290f1800 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift @@ -42,14 +42,20 @@ public final class CurrentUserPoller: SwarmPoller { // MARK: - Abstract Methods - override public func nextPollDelay() -> TimeInterval { + override public func nextPollDelay() -> AnyPublisher { // If there have been no failures then just use the 'minPollInterval' - guard failureCount > 0 else { return pollInterval } + guard failureCount > 0 else { + return Just(pollInterval) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } // Otherwise use a simple back-off with the 'retryInterval' let nextDelay: TimeInterval = TimeInterval(retryInterval * (Double(failureCount) * 1.2)) - return min(maxRetryInterval, nextDelay) + return Just(min(maxRetryInterval, nextDelay)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } // stringlint:ignore_contents diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift index d91a79cf1f..667ec2909c 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift @@ -82,31 +82,13 @@ public final class GroupPoller: SwarmPoller { // MARK: - Abstract Methods - override public func nextPollDelay() -> TimeInterval { - // Get the received date of the last message in the thread. If we don't have - // any messages yet, pick some reasonable fake time interval to use instead - // FIXME: Update this to be based on `active_at` once it gets added to libSession - let lastMessageDate: Date = dependencies[singleton: .storage] - .read { [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 { receivedAtTimestampMs -> Date? in - guard receivedAtTimestampMs > 0 else { return nil } - - return Date(timeIntervalSince1970: TimeInterval(Double(receivedAtTimestampMs) / 1000)) - } - .defaulting(to: dependencies.dateNow.addingTimeInterval(-5 * 60)) + override public func nextPollDelay() -> AnyPublisher { let lastReadDate: Date = dependencies .mutate(cache: .libSession) { cache in cache.conversationLastRead( threadId: pollerDestination.target, threadVariant: .group, - openGroup: nil + openGroupUrlInfo: nil ) } .map { lastReadTimestampMs in @@ -116,13 +98,35 @@ public final class GroupPoller: SwarmPoller { } .defaulting(to: dependencies.dateNow.addingTimeInterval(-5 * 60)) - 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 + // 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 { @@ -152,9 +156,9 @@ public extension GroupPoller { public func startAllPollers() { // On the group poller queue fetch all closed groups which should poll and start the pollers - Threading.groupPollerQueue.async(using: dependencies) { [dependencies] in - dependencies[singleton: .storage] - .read { db -> Set in + 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) @@ -164,10 +168,19 @@ public extension GroupPoller { ) .asRequest(of: String.self) .fetchSet(db) - }? - .forEach { [weak self] swarmPublicKey in - self?.getOrCreatePoller(for: swarmPublicKey).startIfNeeded() + }, + 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() + } + } + } } + ) } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/PollerType.swift b/SessionMessagingKit/Sending & Receiving/Pollers/PollerType.swift index 581f03fab2..42b34aa91c 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/PollerType.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/PollerType.swift @@ -77,7 +77,7 @@ public protocol PollerType: AnyObject { func pollerDidStart() func poll(forceSynchronousProcessing: Bool) -> AnyPublisher - func nextPollDelay() -> TimeInterval + func nextPollDelay() -> AnyPublisher func handlePollError(_ error: Error, _ lastError: Error?) -> PollerErrorResponse } @@ -139,21 +139,21 @@ public extension PollerType { } self.lastPollStart = dependencies.dateNow.timeIntervalSince1970 - let fallbackPollDelay: TimeInterval = self.nextPollDelay() 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 in + 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((self?.nextPollDelay()).defaulting(to: fallbackPollDelay)) + let nextPollInterval: TimeUnit = .seconds(nextPollDelay) var errorFromPoll: Error? // Log information about the poll diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift index 04eb19cfd9..b632d6d87d 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift @@ -20,6 +20,11 @@ public protocol SwarmPollerType { // MARK: - SwarmPoller 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 @@ -70,7 +75,7 @@ public class SwarmPoller: SwarmPollerType & PollerType { // MARK: - Abstract Methods /// Calculate the delay which should occur before the next poll - public func nextPollDelay() -> TimeInterval { + public func nextPollDelay() -> AnyPublisher { preconditionFailure("abstract class - override in subclass") } @@ -128,245 +133,36 @@ public class SwarmPoller: SwarmPollerType & PollerType { request.send(using: dependencies) .map { _, response in (snode, response) } } - .flatMap { [pollerDestination, shouldStoreMessages, dependencies] (snode: LibSession.Snode, namespacedResults: SnodeAPI.PollResponse) -> AnyPublisher<(configMessageJobs: [Job], standardMessageJobs: [Job], pollResult: PollResult), Error> in - // Get all of the messages and sort them by their required 'processingOrder' - let sortedMessages: [(namespace: SnodeAPI.Namespace, messages: [SnodeReceivedMessage])] = namespacedResults - .compactMap { namespace, result in (result.data?.messages).map { (namespace, $0) } } + .flatMapStorageWritePublisher(using: dependencies, updates: { [pollerDestination, shouldStoreMessages, forceSynchronousProcessing, dependencies] db, info -> ([Job], [Job], PollResult) in + let (snode, namespacedResults): (LibSession.Snode, SnodeAPI.PollResponse) = info + + /// Get all of the messages and sort them by their required `processingOrder` + typealias MessageData = (namespace: 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 + /// No need to do anything if there are no messages guard rawMessageCount > 0 else { - return Just(([], [], ([], 0, 0, false))) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return ([], [], ([], 0, 0, false)) } - // Otherwise process the messages and add them to the queue for handling - let lastHashes: [String] = namespacedResults - .compactMap { $0.value.data?.lastHash } - let otherKnownHashes: [String] = namespacedResults - .filter { $0.key.shouldFetchSinceLastHash } - .compactMap { $0.value.data?.messages.map { $0.info.hash } } - .reduce([], +) - var messageCount: Int = 0 - var finalProcessedMessages: [ProcessedMessage] = [] - var hadValidHashUpdate: Bool = false - - return dependencies[singleton: .storage].writePublisher { db -> (configMessageJobs: [Job], standardMessageJobs: [Job], pollResult: PollResult) in - // If the poll was successful we need to retrieve the `lastHash` values - // direct from the database again to ensure they still line up (if they - // have been reset in the database then we want to ignore the poll as it - // would invalidate whatever change modified the `lastHash` values potentially - // resulting in us not polling again from scratch even if we want to) - let lastHashesAfterFetch: Set = try Set(namespacedResults - .compactMap { namespace, _ in - try SnodeReceivedMessageInfo - .fetchLastNotExpired( - db, - for: snode, - namespace: namespace, - swarmPublicKey: pollerDestination.target, - using: dependencies - )? - .hash - }) - - guard lastHashes.isEmpty || Set(lastHashes) == lastHashesAfterFetch else { - return ([], [], ([], 0, 0, false)) - } - - // Since the hashes are still accurate we can now process the messages - let allProcessedMessages: [ProcessedMessage] = sortedMessages - .compactMap { namespace, messages -> [ProcessedMessage]? in - let processedMessages: [ProcessedMessage] = messages - .compactMap { message -> ProcessedMessage? in - do { - return try Message.processRawReceivedMessage( - db, - rawMessage: message, - swarmPublicKey: pollerDestination.target, - shouldStoreMessages: shouldStoreMessages, - using: dependencies - ) - } - catch { - switch error { - /// Ignore duplicate & selfSend message errors (and don't bother logging them as there - /// will be a lot since we each service node duplicates messages) - case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, - DatabaseError.SQLITE_CONSTRAINT, /// Sometimes thrown for UNIQUE - MessageReceiverError.duplicateMessage, - MessageReceiverError.duplicateControlMessage, - MessageReceiverError.selfSend: - break - - case MessageReceiverError.duplicateMessageNewSnode: - hadValidHashUpdate = true - break - - case DatabaseError.SQLITE_ABORT: - Log.warn(.poller, "Failed to the database being suspended (running in background with no background task).") - - default: Log.error(.poller, "Failed to deserialize envelope due to error: \(error).") - } - - return nil - } - } - - /// If this message should be handled by this poller and should be handled synchronously then do so here before - /// processing the next namespace - guard shouldStoreMessages && namespace.shouldHandleSynchronously else { - return processedMessages - } - - if namespace.isConfigNamespace { - do { - /// Process config messages all at once in case they are multi-part messages - try dependencies.mutate(cache: .libSession) { - try $0.handleConfigMessages( - db, - swarmPublicKey: pollerDestination.target, - messages: ConfigMessageReceiveJob - .Details(messages: processedMessages) - .messages - ) - } - } - catch { Log.error(.poller, "Failed to handle processed config message due to error: \(error).") } - } - else { - /// Individually process non-config messages - processedMessages.forEach { processedMessage in - guard case .standard(let threadId, let threadVariant, let proto, let messageInfo) = processedMessage else { - return - } - - do { - try MessageReceiver.handle( - db, - threadId: threadId, - threadVariant: threadVariant, - message: messageInfo.message, - serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, - associatedWithProto: proto, - using: dependencies - ) - } - catch { Log.error(.poller, "Failed to handle processed message due to error: \(error).") } - } - } - - /// Make sure to add any synchronously processed messages to the `finalProcessedMessages` - /// as otherwise they wouldn't be emitted by the `receivedPollResponseSubject` - finalProcessedMessages += processedMessages - return nil - } - .flatMap { $0 } - - // 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 else { - messageCount += allProcessedMessages.count - finalProcessedMessages += allProcessedMessages - return ([], [], (finalProcessedMessages, rawMessageCount, messageCount, hadValidHashUpdate)) - } - - // Add a job to process the config messages first - let configMessageJobs: [Job] = allProcessedMessages - .filter { $0.isConfigMessage && !$0.namespace.shouldHandleSynchronously } - .grouped { $0.threadId } - .compactMap { threadId, threadMessages in - messageCount += threadMessages.count - finalProcessedMessages += threadMessages - - let job: Job? = Job( - variant: .configMessageReceive, - behaviour: .runOnce, - threadId: threadId, - details: ConfigMessageReceiveJob.Details(messages: threadMessages) - ) - - // If we are force-polling then add to the JobRunner so they are - // persistent and will retry on the next app run if they fail but - // don't let them auto-start - return dependencies[singleton: .jobRunner].add( - db, - job: job, - canStartJob: ( - !forceSynchronousProcessing && - !dependencies[singleton: .appContext].isInBackground - ) - ) - } - let configJobIds: [Int64] = configMessageJobs.compactMap { $0.id } - - // Add jobs for processing non-config messages which are dependant on the config message - // processing jobs - let standardMessageJobs: [Job] = allProcessedMessages - .filter { !$0.isConfigMessage && !$0.namespace.shouldHandleSynchronously } - .grouped { $0.threadId } - .compactMap { threadId, threadMessages in - messageCount += threadMessages.count - finalProcessedMessages += threadMessages - - let job: Job? = Job( - variant: .messageReceive, - behaviour: .runOnce, - threadId: threadId, - details: MessageReceiveJob.Details(messages: threadMessages) - ) - - // If we are force-polling then add to the JobRunner so they are - // persistent and will retry on the next app run if they fail but - // don't let them auto-start - let updatedJob: Job? = dependencies[singleton: .jobRunner].add( - db, - job: job, - canStartJob: ( - !forceSynchronousProcessing && ( - !dependencies[singleton: .appContext].isInBackground || - // FIXME: Better seperate the call messages handling, since we need to handle them all the time - dependencies[singleton: .callManager].currentCall != nil - ) - ) - ) - - // Create the dependency between the jobs (config processing should happen before - // standard message processing) - if let updatedJobId: Int64 = updatedJob?.id { - do { - try configJobIds.forEach { configJobId in - try JobDependencies( - jobId: updatedJobId, - dependantId: configJobId - ) - .insert(db) - } - } - catch { - Log.warn(.poller, "Failed to add dependency between config processing and non-config processing messageReceive jobs.") - } - } - - return updatedJob - } - - // Update the cached validity of the messages - try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash( - db, - potentiallyInvalidHashes: (sortedMessages.isEmpty && !hadValidHashUpdate ? - lastHashes : - [] - ), - otherKnownValidHashes: otherKnownHashes - ) - - return (configMessageJobs, standardMessageJobs, (finalProcessedMessages, rawMessageCount, messageCount, hadValidHashUpdate)) - } - } - .flatMap { [dependencies] (configMessageJobs: [Job], standardMessageJobs: [Job], pollResult: PollResult) -> AnyPublisher in + 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) @@ -428,4 +224,272 @@ public class SwarmPoller: SwarmPollerType & PollerType { ) .eraseToAnyPublisher() } + + @discardableResult public static func processPollResponse( + _ db: ObservingDatabase, + cat: Log.Category, + source: PollSource, + swarmPublicKey: String, + shouldStoreMessages: Bool, + ignoreDedupeFiles: Bool, + forceSynchronousProcessing: Bool, + sortedMessages: [(namespace: SnodeAPI.Namespace, messages: [SnodeReceivedMessage], lastHash: String?)], + using dependencies: Dependencies + ) -> ([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)) + } + + /// Otherwise process the messages and add them to the queue for handling + let lastHashes: [String] = sortedMessages.compactMap { $0.lastHash } + let otherKnownHashes: [String] = sortedMessages + .filter { $0.namespace.shouldFetchSinceLastHash } + .compactMap { $0.messages.map { $0.hash } } + .reduce([], +) + var messageCount: Int = 0 + var finalProcessedMessages: [ProcessedMessage] = [] + var hadValidHashUpdate: Bool = false + + /// If the poll was successful we need to retrieve the `lastHash` values direct from the database again to ensure they + /// still line up (if they have been reset in the database then we want to ignore the poll as it would invalidate whatever + /// change modified the `lastHash` values potentially resulting in us not polling again from scratch even if we want to) + let lastHashesAfterFetch: Set = { + switch source { + case .pushNotification: return [] + case .snode(let snode): + return Set(sortedMessages.compactMap { namespace, _, _ in + try? SnodeReceivedMessageInfo + .fetchLastNotExpired( + db, + for: snode, + namespace: namespace, + swarmPublicKey: swarmPublicKey, + using: dependencies + )? + .hash + }) + } + }() + + guard lastHashes.isEmpty || Set(lastHashes) == lastHashesAfterFetch else { + return ([], [], ([], 0, 0, false)) + } + + /// Since the hashes are still accurate we can now process the messages + let allProcessedMessages: [ProcessedMessage] = sortedMessages + .compactMap { namespace, messages, _ -> [ProcessedMessage]? in + let processedMessages: [ProcessedMessage] = messages.compactMap { message -> ProcessedMessage? in + do { + let processedMessage: ProcessedMessage = try MessageReceiver.parse( + data: message.data, + origin: .swarm( + publicKey: swarmPublicKey, + namespace: message.namespace, + serverHash: message.hash, + serverTimestampMs: message.timestampMs, + serverExpirationTimestamp: TimeInterval(Double(message.expirationTimestampMs) / 1000) + ), + using: dependencies + ) + hadValidHashUpdate = (message.info?.storeUpdatedLastHash(db) == true) + + /// Insert the standard dedupe record ignoring dedupe files if needed + try MessageDeduplication.insert( + db, + processedMessage: processedMessage, + ignoreDedupeFiles: ignoreDedupeFiles, + using: dependencies + ) + + return processedMessage + } + catch { + /// For some error cases we want to update the last hash so do so + if (error as? MessageReceiverError)?.shouldUpdateLastHash == true { + hadValidHashUpdate = (message.info?.storeUpdatedLastHash(db) == true) + } + + switch error { + /// Ignore duplicate & selfSend message errors (and don't bother logging them as there + /// will be a lot since we each service node duplicates messages) + case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, + DatabaseError.SQLITE_CONSTRAINT, /// Sometimes thrown for UNIQUE + MessageReceiverError.duplicateMessage, + MessageReceiverError.selfSend: + break + + case DatabaseError.SQLITE_ABORT: + Log.warn(cat, "Failed to the database being suspended (running in background with no background task).") + + default: Log.error(cat, "Failed to deserialize envelope due to error: \(error).") + } + + return nil + } + } + + /// If this message should be stored and should be handled synchronously then do so here before processing the next namespace + guard + shouldStoreMessages && + !processedMessages.isEmpty && + (namespace.shouldHandleSynchronously || forceSynchronousProcessing) + else { return processedMessages } + + if namespace.isConfigNamespace { + do { + /// Process config messages all at once in case they are multi-part messages + try dependencies.mutate(cache: .libSession) { + try $0.handleConfigMessages( + db, + swarmPublicKey: swarmPublicKey, + messages: ConfigMessageReceiveJob + .Details(messages: processedMessages) + .messages + ) + } + } + catch { Log.error(cat, "Failed to handle processed config message in \(swarmPublicKey) due to error: \(error).") } + } + else { + /// Individually process non-config messages + processedMessages.forEach { processedMessage in + guard case .standard(let threadId, let threadVariant, let proto, let messageInfo, _) = processedMessage else { + return + } + + do { + let info: MessageReceiver.InsertedInteractionInfo? = try MessageReceiver.handle( + db, + threadId: threadId, + threadVariant: threadVariant, + message: messageInfo.message, + serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, + associatedWithProto: proto, + suppressNotifications: (source == .pushNotification), /// Have already shown + using: dependencies + ) + + /// Notify about the received message + MessageReceiver.prepareNotificationsForInsertedInteractions( + db, + insertedInteractionInfo: info, + isMessageRequest: dependencies.mutate(cache: .libSession) { cache in + cache.isMessageRequest(threadId: threadId, threadVariant: messageInfo.threadVariant) + }, + using: dependencies + ) + } + catch { Log.error(cat, "Failed to handle processed message in \(threadId) due to error: \(error).") } + } + } + + /// Make sure to add any synchronously processed messages to the `finalProcessedMessages` + /// as otherwise they wouldn't be emitted by the `receivedPollResponseSubject` + finalProcessedMessages += processedMessages + return nil + } + .flatMap { $0 } + + /// 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 { + messageCount += allProcessedMessages.count + finalProcessedMessages += allProcessedMessages + return ([], [], (finalProcessedMessages, rawMessageCount, messageCount, hadValidHashUpdate)) + } + + /// Add a job to process the config messages first + let configMessageJobs: [Job] = allProcessedMessages + .filter { $0.isConfigMessage && !$0.namespace.shouldHandleSynchronously } + .grouped { $0.threadId } + .compactMap { threadId, threadMessages in + messageCount += threadMessages.count + finalProcessedMessages += threadMessages + + let job: Job? = Job( + variant: .configMessageReceive, + behaviour: .runOnce, + threadId: threadId, + details: ConfigMessageReceiveJob.Details(messages: threadMessages) + ) + + /// If we are force-polling then add to the `JobRunner` so they are persistent and will retry on the next app + /// run if they fail but don't let them auto-start + return dependencies[singleton: .jobRunner].add( + db, + job: job, + canStartJob: !dependencies[singleton: .appContext].isInBackground + ) + } + let configJobIds: [Int64] = configMessageJobs.compactMap { $0.id } + + /// Add jobs for processing non-config messages which are dependant on the config message processing jobs + let standardMessageJobs: [Job] = allProcessedMessages + .filter { !$0.isConfigMessage && !$0.namespace.shouldHandleSynchronously } + .grouped { $0.threadId } + .compactMap { threadId, threadMessages in + messageCount += threadMessages.count + finalProcessedMessages += threadMessages + + let job: Job? = Job( + variant: .messageReceive, + behaviour: .runOnce, + threadId: threadId, + details: MessageReceiveJob.Details(messages: threadMessages) + ) + + /// If we are force-polling then add to the `JobRunner` so they are persistent and will retry on the next app + /// run if they fail but don't let them auto-start + let updatedJob: Job? = dependencies[singleton: .jobRunner].add( + db, + job: job, + canStartJob: ( + !dependencies[singleton: .appContext].isInBackground || + // FIXME: Better seperate the call messages handling, since we need to handle them all the time + dependencies[singleton: .callManager].currentCall != nil + ) + ) + + /// Create the dependency between the jobs (config processing should happen before standard message processing) + if let updatedJobId: Int64 = updatedJob?.id { + do { + try configJobIds.forEach { configJobId in + try JobDependencies( + jobId: updatedJobId, + dependantId: configJobId + ) + .insert(db) + } + } + catch { + Log.warn(cat, "Failed to add dependency between config processing and non-config processing messageReceive jobs.") + } + } + + return updatedJob + } + + /// If the source was a snode then update the cached validity of the messages (for messages received via push notifications + /// we want to receive them in a subsequent poll to ensure we have the correct `lastHash` value as they can be received + /// out of order) + switch source { + case .pushNotification: break + case .snode: + do { + try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash( + db, + potentiallyInvalidHashes: (sortedMessages.isEmpty && !hadValidHashUpdate ? + lastHashes : + [] + ), + otherKnownValidHashes: otherKnownHashes + ) + } + catch { Log.error(cat, "Failed to handle potential invalid/deleted hashes due to error: \(error).") } + } + + return (configMessageJobs, standardMessageJobs, (finalProcessedMessages, rawMessageCount, messageCount, hadValidHashUpdate)) + } } diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift index 6401e7d82f..b7b8b8adfc 100644 --- a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift +++ b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift @@ -12,9 +12,7 @@ public struct QuotedReplyModel { public let contentType: String? public let sourceFileName: String? public let thumbnailDownloadFailed: Bool - public let currentUserSessionId: String? - public let currentUserBlinded15SessionId: String? - public let currentUserBlinded25SessionId: String? + public let currentUserSessionIds: Set // MARK: - Initialization @@ -27,9 +25,7 @@ public struct QuotedReplyModel { contentType: String?, sourceFileName: String?, thumbnailDownloadFailed: Bool, - currentUserSessionId: String?, - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String? + currentUserSessionIds: Set ) { self.attachment = attachment self.threadId = threadId @@ -39,9 +35,7 @@ public struct QuotedReplyModel { self.contentType = contentType self.sourceFileName = sourceFileName self.thumbnailDownloadFailed = thumbnailDownloadFailed - self.currentUserSessionId = currentUserSessionId - self.currentUserBlinded15SessionId = currentUserBlinded15SessionId - self.currentUserBlinded25SessionId = currentUserBlinded25SessionId + self.currentUserSessionIds = currentUserSessionIds } public static func quotedReplyForSending( @@ -52,9 +46,7 @@ public struct QuotedReplyModel { timestampMs: Int64, attachments: [Attachment]?, linkPreviewAttachment: Attachment?, - currentUserSessionId: String?, - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String? + currentUserSessionIds: Set ) -> QuotedReplyModel? { guard variant == .standardOutgoing || variant == .standardIncoming else { return nil } guard (body != nil && body?.isEmpty == false) || attachments?.isEmpty == false else { return nil } @@ -70,9 +62,7 @@ public struct QuotedReplyModel { contentType: targetAttachment?.contentType, sourceFileName: targetAttachment?.sourceFilename, thumbnailDownloadFailed: false, - currentUserSessionId: currentUserSessionId, - currentUserBlinded15SessionId: currentUserBlinded15SessionId, - currentUserBlinded25SessionId: currentUserBlinded25SessionId + currentUserSessionIds: currentUserSessionIds ) } } diff --git a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift index 32845af47f..09dad96688 100644 --- a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift +++ b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift @@ -16,16 +16,12 @@ public extension Singleton { // MARK: - ThumbnailService -public class TypingIndicators { +public actor TypingIndicators { // MARK: - Variables private let dependencies: Dependencies - @ThreadSafeObject private var timerQueue: DispatchQueue = DispatchQueue( - label: "org.getsession.typingIndicatorQueue", // stringlint:ignore - qos: .userInteractive - ) - @ThreadSafeObject private var outgoing: [String: Indicator] = [:] - @ThreadSafeObject private var incoming: [String: Indicator] = [:] + private var outgoing: [String: Indicator] = [:] + private var incoming: [String: Indicator] = [:] // MARK: - Initialization @@ -38,16 +34,14 @@ public class TypingIndicators { public func startIfNeeded( threadId: String, threadVariant: SessionThread.Variant, - threadIsBlocked: Bool, - threadIsMessageRequest: Bool, direction: Direction, timestampMs: Int64? - ) { + ) async { let targetIndicators: [String: Indicator] = (direction == .outgoing ? outgoing : incoming) /// If we already have an existing typing indicator for this thread then just refresh it's timeout (no need to do anything else) if let existingIndicator: Indicator = targetIndicators[threadId] { - existingIndicator.refreshTimeout(timerQueue: timerQueue, using: dependencies) + await existingIndicator.refreshTimeout(sentTimestampMs: timestampMs, using: dependencies) return } @@ -57,49 +51,47 @@ public class TypingIndicators { /// /// The `typingIndicatorsEnabled` flag reflects the user-facing setting in the app preferences, if it's disabled we don't /// want to emit "typing indicator" messages or show typing indicators for other users - /// - /// **Note:** We do this check on a background thread because, while it's just checking a setting, we are still accessing the - /// database to check `typingIndicatorsEnabled` so want to avoid doing it on the main thread - timerQueue.async { [weak self, dependencies] in - guard - threadVariant == .contact && - !threadIsBlocked && - !threadIsMessageRequest && - dependencies[singleton: .storage, key: .typingIndicatorsEnabled], - let timerQueue: DispatchQueue = self?.timerQueue - else { return } - - let newIndicator: Indicator = Indicator( - threadId: threadId, - threadVariant: threadVariant, - direction: direction, - timestampMs: (timestampMs ?? dependencies[cache: .snodeAPI].currentOffsetTimestampMs()) - ) - - switch direction { - case .outgoing: self?._outgoing.performUpdate { $0.setting(threadId, newIndicator) } - case .incoming: self?._incoming.performUpdate { $0.setting(threadId, newIndicator) } - } - - dependencies[singleton: .storage].writeAsync { db in - newIndicator.start(db, timerQueue: timerQueue, using: dependencies) - } + guard + threadVariant == .contact && + dependencies.mutate(cache: .libSession, { libSession in + libSession.get(.typingIndicatorsEnabled) && + !libSession.isContactBlocked(contactId: threadId) && + !libSession.isMessageRequest(threadId: threadId, threadVariant: threadVariant) + }) + else { return } + + let newIndicator: Indicator = Indicator( + threadId: threadId, + threadVariant: threadVariant, + direction: direction, + timestampMs: (timestampMs ?? dependencies[cache: .snodeAPI].currentOffsetTimestampMs()) + ) + + switch direction { + case .outgoing: self.outgoing[threadId] = newIndicator + case .incoming: self.incoming[threadId] = newIndicator } + + await newIndicator.start(using: dependencies) } - public func didStopTyping(_ db: Database, threadId: String, direction: Direction) { + public func didStopTyping(threadId: String, direction: Direction) async { switch direction { - case .outgoing: - if let indicator: Indicator = outgoing[threadId] { - indicator.stop(db, using: dependencies) - _outgoing.performUpdate { $0.removingValue(forKey: threadId) } - } - - case .incoming: - if let indicator: Indicator = incoming[threadId] { - indicator.stop(db, using: dependencies) - _incoming.performUpdate { $0.removingValue(forKey: threadId) } - } + case .outgoing: await self.outgoing.removeValue(forKey: threadId)?.stop(using: dependencies) + case .incoming: await self.incoming.removeValue(forKey: threadId)?.stop(using: dependencies) + } + } + + fileprivate func handleRefresh(threadId: String, threadVariant: SessionThread.Variant) async { + try? await dependencies[singleton: .storage].writeAsync { db in + try? MessageSender.send( + db, + message: TypingIndicator(kind: .started), + interactionId: nil, + threadId: threadId, + threadVariant: threadVariant, + using: self.dependencies + ) } } } @@ -115,12 +107,12 @@ public extension TypingIndicators { // MARK: - Indicator class Indicator { - fileprivate let threadId: String - fileprivate let threadVariant: SessionThread.Variant - fileprivate let direction: Direction - fileprivate let timestampMs: Int64 - fileprivate var refreshTimer: DispatchSourceTimer? - fileprivate var stopTimer: DispatchSourceTimer? + let threadId: String + let threadVariant: SessionThread.Variant + let direction: Direction + let initialTimestampMs: Int64 + private var stopTask: Task? + private var refreshTask: Task? init( threadId: String, @@ -131,89 +123,89 @@ public extension TypingIndicators { self.threadId = threadId self.threadVariant = threadVariant self.direction = direction - self.timestampMs = timestampMs + self.initialTimestampMs = timestampMs + } + + deinit { + stopTask?.cancel() + refreshTask?.cancel() } - fileprivate func start(_ db: Database, timerQueue: DispatchQueue, using dependencies: Dependencies) { - // Start the typing indicator + fileprivate func start(using dependencies: Dependencies) async { switch direction { - case .outgoing: scheduleRefreshCallback(timerQueue: timerQueue, using: dependencies) + case .outgoing: scheduleRefreshCallback(using: dependencies) case .incoming: - try? ThreadTypingIndicator( - threadId: threadId, - timestampMs: timestampMs - ) - .upsert(db) + try? await dependencies[singleton: .storage].writeAsync { [threadId, initialTimestampMs] db in + try ThreadTypingIndicator(threadId: threadId, timestampMs: initialTimestampMs).upsert(db) + db.addTypingIndicatorEvent(threadId: threadId, change: .started) + } } - // Refresh the timeout since we just started - refreshTimeout(timerQueue: timerQueue, using: dependencies) + await refreshTimeout(sentTimestampMs: initialTimestampMs, using: dependencies) } - fileprivate func stop(_ db: Database, using dependencies: Dependencies) { - self.refreshTimer?.cancel() - self.refreshTimer = nil - self.stopTimer?.cancel() - self.stopTimer = nil + func stop(using dependencies: Dependencies) async { + stopTask?.cancel() + refreshTask?.cancel() - switch direction { - case .outgoing: - try? MessageSender.send( - db, - message: TypingIndicator(kind: .stopped), - interactionId: nil, - threadId: threadId, - threadVariant: threadVariant, - using: dependencies - ) - - case .incoming: - _ = try? ThreadTypingIndicator - .filter(ThreadTypingIndicator.Columns.threadId == self.threadId) - .deleteAll(db) + try? await dependencies[singleton: .storage].writeAsync { [threadId, threadVariant, direction] db in + switch direction { + case .outgoing: + try MessageSender.send( + db, + message: TypingIndicator(kind: .stopped), + interactionId: nil, + threadId: threadId, + threadVariant: threadVariant, + using: dependencies + ) + + case .incoming: + _ = try ThreadTypingIndicator + .filter(ThreadTypingIndicator.Columns.threadId == threadId) + .deleteAll(db) + db.addTypingIndicatorEvent(threadId: threadId, change: .stopped) + } } } - fileprivate func refreshTimeout(timerQueue: DispatchQueue, using dependencies: Dependencies) { - let threadId: String = self.threadId - let direction: Direction = self.direction + func refreshTimeout(sentTimestampMs: Int64?, using dependencies: Dependencies) async { + stopTask?.cancel() - // Schedule the 'stopCallback' to cancel the typing indicator - stopTimer?.cancel() - stopTimer = DispatchSource.makeTimerSource(queue: timerQueue) - stopTimer?.schedule(deadline: .now() + .seconds(direction == .outgoing ? 3 : 15)) - stopTimer?.setEventHandler { - dependencies[singleton: .storage].writeAsync { db in - dependencies[singleton: .typingIndicators].didStopTyping( - db, - threadId: threadId, - direction: direction - ) + let baseTimestamp: TimeInterval = ( + sentTimestampMs.map { TimeInterval(Double($0) / 1000) } ?? + dependencies.dateNow.timeIntervalSince1970 + ) + let delay: TimeInterval = TimeInterval(direction == .outgoing ? 3 : 15) + + stopTask = Task { [threadId, direction] in + /// If the delay is in the future then we want to wait until then + if baseTimestamp + delay > dependencies.dateNow.timeIntervalSince1970 { + let nanoseconds: UInt64 = UInt64((baseTimestamp + delay) * 1_000_000_000) + try await Task.sleep(nanoseconds: nanoseconds) } + + try Task.checkCancellation() + + await dependencies[singleton: .typingIndicators].didStopTyping( + threadId: threadId, + direction: direction + ) } - stopTimer?.resume() } - private func scheduleRefreshCallback( - timerQueue: DispatchQueue, - using dependencies: Dependencies - ) { - refreshTimer?.cancel() - refreshTimer = DispatchSource.makeTimerSource(queue: timerQueue) - refreshTimer?.schedule(deadline: .now(), repeating: .seconds(10)) - refreshTimer?.setEventHandler { [threadId = self.threadId, threadVariant = self.threadVariant] in - dependencies[singleton: .storage].writeAsync { db in - try? MessageSender.send( - db, - message: TypingIndicator(kind: .started), - interactionId: nil, + private func scheduleRefreshCallback(using dependencies: Dependencies) { + refreshTask?.cancel() + + refreshTask = Task { [threadId, threadVariant] in + while !Task.isCancelled { + await dependencies[singleton: .typingIndicators].handleRefresh( threadId: threadId, - threadVariant: threadVariant, - using: dependencies + threadVariant: threadVariant ) + try await Task.sleep(for: .seconds(10)) } } - refreshTimer?.resume() } } } diff --git a/SessionMessagingKit/Shared Models/MentionInfo.swift b/SessionMessagingKit/Shared Models/MentionInfo.swift index 49525e4659..3e78fb6ba8 100644 --- a/SessionMessagingKit/Shared Models/MentionInfo.swift +++ b/SessionMessagingKit/Shared Models/MentionInfo.swift @@ -23,12 +23,10 @@ public struct MentionInfo: FetchableRecord, Decodable, ColumnExpressible { public extension MentionInfo { // stringlint:ignore_contents static func query( - userPublicKey: String, threadId: String, threadVariant: SessionThread.Variant, targetPrefixes: [SessionId.Prefix], - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String?, + currentUserSessionIds: Set, pattern: FTS5Pattern? ) -> AdaptedFetchRequest>? { let profile: TypedTableAlias = TypedTableAlias() @@ -47,11 +45,6 @@ public extension MentionInfo { } .joined(operator: .or) let profileFullTextSearch: SQL = SQL(stringLiteral: Profile.fullTextSearchTableName) - let currentUserIds: Set = [ - userPublicKey, - currentUserBlinded15SessionId, - currentUserBlinded25SessionId - ].compactMap { $0 }.asSet() /// The query needs to differ depending on the thread variant because the behaviour should be different: /// @@ -98,9 +91,9 @@ public extension MentionInfo { \(targetJoin) \(targetWhere) AND ( \(SQL("\(profile[.id]) = \(threadId)")) OR - \(SQL("\(profile[.id]) IN \(currentUserIds)")) + \(SQL("\(profile[.id]) IN \(currentUserSessionIds)")) ) - ORDER BY \(SQL("\(profile[.id]) IN \(currentUserIds)")) DESC + ORDER BY \(SQL("\(profile[.id]) IN \(currentUserSessionIds)")) DESC """) case .legacyGroup, .group: @@ -118,7 +111,7 @@ public extension MentionInfo { \(targetWhere) GROUP BY \(profile[.id]) ORDER BY - \(SQL("\(profile[.id]) IN \(currentUserIds)")) DESC, + \(SQL("\(profile[.id]) IN \(currentUserSessionIds)")) DESC, IFNULL(\(profile[.nickname]), \(profile[.name])) ASC """) @@ -140,7 +133,7 @@ public extension MentionInfo { \(targetWhere) GROUP BY \(profile[.id]) ORDER BY - \(SQL("\(profile[.id]) IN \(currentUserIds)")) DESC, + \(SQL("\(profile[.id]) IN \(currentUserSessionIds)")) DESC, \(interaction[.timestampMs].desc) LIMIT 20 """) diff --git a/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift b/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift index 17c561a40e..05553088ed 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift @@ -11,7 +11,7 @@ public extension MessageViewModel { struct DeletionBehaviours { public enum Behaviour { case markAsDeleted(ids: [Int64], options: Interaction.DeletionOption, threadId: String, threadVariant: SessionThread.Variant) - case deleteFromDatabase([Int64]) + case deleteFromDatabase(ids: [Int64], threadId: String) case cancelPendingSendJobs([Int64]) case preparedRequest(Network.PreparedRequest) } @@ -97,11 +97,14 @@ public extension MessageViewModel { ) } - case .deleteFromDatabase(let ids): + case .deleteFromDatabase(let ids, let threadId): result = result.flatMapStorageWritePublisher(using: dependencies) { db, _ in _ = try Interaction .filter(ids: ids) .deleteAll(db) + ids.forEach { id in + db.addMessageEvent(id: id, threadId: threadId, type: .deleted) + } } case .preparedRequest(let preparedRequest): @@ -146,210 +149,225 @@ public extension MessageViewModel.DeletionBehaviours { }() /// The remaining deletion options are more complicated to determine - return dependencies[singleton: .storage].read { [dependencies] db -> MessageViewModel.DeletionBehaviours? in - let isAdmin: Bool = { - switch threadData.threadVariant { - case .contact: return false - case .group, .legacyGroup: return (threadData.currentUserIsClosedGroupAdmin == true) - case .community: - guard - let server: String = threadData.openGroupServer, - let roomToken: String = threadData.openGroupRoomToken - else { return false } - - return dependencies[singleton: .openGroupManager].isUserModeratorOrAdmin( - db, - publicKey: threadData.currentUserSessionId, - for: roomToken, - on: server - ) - } - }() - - switch (state, isAdmin) { - /// User selects messages including a control message or “deleted” message - case (.containsDeletedOrControlMessages, _): - return MessageViewModel.DeletionBehaviours( - title: "deleteMessage" - .putNumber(cellViewModels.count) - .localized(), - warning: (threadData.threadIsNoteToSelf ? - "deleteMessageNoteToSelfWarning" - .putNumber(cellViewModels.count) - .localized() : - "deleteMessageWarning" - .putNumber(cellViewModels.count) - .localized() - ), - body: "deleteMessageConfirm" - .putNumber(cellViewModels.count) - .localized(), - actions: [ - NamedAction( - title: "deleteMessageDeviceOnly".localized(), - state: .enabledAndDefaultSelected, - accessibility: Accessibility(identifier: "Delete for me"), - behaviours: [ - .cancelPendingSendJobs(cellViewModels.map { $0.id }), - - /// Control messages and deleted messages should be immediately deleted from the database - .deleteFromDatabase( - cellViewModels - .filter { viewModel in - viewModel.variant.isInfoMessage || - viewModel.variant.isDeletedMessage - } - .map { $0.id } - ), - - /// Other message types should only be marked as deleted - .markAsDeleted( - ids: cellViewModels - .filter { viewModel in - !viewModel.variant.isInfoMessage && - !viewModel.variant.isDeletedMessage - } - .map { $0.id }, - options: .local, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant - ) - ] - ), - NamedAction( - title: (threadData.threadIsNoteToSelf ? - "deleteMessageDevicesAll".localized() : - "deleteMessageEveryone".localized() - ), - state: .disabled, - accessibility: Accessibility(identifier: "Delete for everyone") + // FIXME: [Database Relocation] Remove this database usage + var deletionBehaviours: MessageViewModel.DeletionBehaviours? + let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + + dependencies[singleton: .storage].readAsync( + retrieve: { [dependencies] db -> MessageViewModel.DeletionBehaviours? in + let isAdmin: Bool = { + switch threadData.threadVariant { + case .contact: return false + case .group, .legacyGroup: return (threadData.currentUserIsClosedGroupAdmin == true) + case .community: + guard + let server: String = threadData.openGroupServer, + let roomToken: String = threadData.openGroupRoomToken + else { return false } + + return dependencies[singleton: .openGroupManager].isUserModeratorOrAdmin( + db, + publicKey: threadData.currentUserSessionId, + for: roomToken, + on: server, + currentUserSessionIds: (threadData.currentUserSessionIds ?? []) ) - ] - ) + } + }() - /// User selects messages including only their own messages - case (.outgoingOnly, _): - return MessageViewModel.DeletionBehaviours( - title: "deleteMessage" - .putNumber(cellViewModels.count) - .localized(), - warning: nil, - body: "deleteMessageConfirm" - .putNumber(cellViewModels.count) - .localized(), - actions: [ - NamedAction( - title: "deleteMessageDeviceOnly".localized(), - state: .enabledAndDefaultSelected, - accessibility: Accessibility(identifier: "Delete for me"), - behaviours: [ - .cancelPendingSendJobs(cellViewModels.map { $0.id }), - .markAsDeleted( - ids: cellViewModels.map { $0.id }, - options: .local, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant - ) - ] + switch (state, isAdmin) { + /// User selects messages including a control message or “deleted” message + case (.containsDeletedOrControlMessages, _): + return MessageViewModel.DeletionBehaviours( + title: "deleteMessage" + .putNumber(cellViewModels.count) + .localized(), + warning: (threadData.threadIsNoteToSelf ? + "deleteMessageNoteToSelfWarning" + .putNumber(cellViewModels.count) + .localized() : + "deleteMessageWarning" + .putNumber(cellViewModels.count) + .localized() ), - NamedAction( - title: (threadData.threadIsNoteToSelf ? - "deleteMessageDevicesAll".localized() : - "deleteMessageEveryone".localized() + body: "deleteMessageConfirm" + .putNumber(cellViewModels.count) + .localized(), + actions: [ + NamedAction( + title: "deleteMessageDeviceOnly".localized(), + state: .enabledAndDefaultSelected, + accessibility: Accessibility(identifier: "Delete for me"), + behaviours: [ + .cancelPendingSendJobs(cellViewModels.map { $0.id }), + + /// Control messages and deleted messages should be immediately deleted from the database + .deleteFromDatabase( + ids: cellViewModels + .filter { viewModel in + viewModel.variant.isInfoMessage || + viewModel.variant.isDeletedMessage + } + .map { $0.id }, + threadId: threadData.threadId + ), + + /// Other message types should only be marked as deleted + .markAsDeleted( + ids: cellViewModels + .filter { viewModel in + !viewModel.variant.isInfoMessage && + !viewModel.variant.isDeletedMessage + } + .map { $0.id }, + options: .local, + threadId: threadData.threadId, + threadVariant: threadData.threadVariant + ) + ] ), - state: .enabled, - accessibility: Accessibility(identifier: "Delete for everyone"), - behaviours: try deleteForEveryoneBehaviours( - db, - isAdmin: isAdmin, - threadData: threadData, - cellViewModels: cellViewModels, - using: dependencies + NamedAction( + title: (threadData.threadIsNoteToSelf ? + "deleteMessageDevicesAll".localized() : + "deleteMessageEveryone".localized() + ), + state: .disabled, + accessibility: Accessibility(identifier: "Delete for everyone") ) - ) - ] - ) + ] + ) - /// User selects messages including ones from other users - case (.containsIncoming, false): - return MessageViewModel.DeletionBehaviours( - title: "deleteMessage" - .putNumber(cellViewModels.count) - .localized(), - warning: "deleteMessageWarning" - .putNumber(cellViewModels.count) - .localized(), - body: "deleteMessageDescriptionDevice" - .putNumber(cellViewModels.count) - .localized(), - actions: [ - NamedAction( - title: "deleteMessageDeviceOnly".localized(), - state: .enabledAndDefaultSelected, - accessibility: Accessibility(identifier: "Delete for me"), - behaviours: [ - .cancelPendingSendJobs(cellViewModels.map { $0.id }), - .markAsDeleted( - ids: cellViewModels.map { $0.id }, - options: .local, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant + /// User selects messages including only their own messages + case (.outgoingOnly, _): + return MessageViewModel.DeletionBehaviours( + title: "deleteMessage" + .putNumber(cellViewModels.count) + .localized(), + warning: nil, + body: "deleteMessageConfirm" + .putNumber(cellViewModels.count) + .localized(), + actions: [ + NamedAction( + title: "deleteMessageDeviceOnly".localized(), + state: .enabledAndDefaultSelected, + accessibility: Accessibility(identifier: "Delete for me"), + behaviours: [ + .cancelPendingSendJobs(cellViewModels.map { $0.id }), + .markAsDeleted( + ids: cellViewModels.map { $0.id }, + options: .local, + threadId: threadData.threadId, + threadVariant: threadData.threadVariant + ) + ] + ), + NamedAction( + title: (threadData.threadIsNoteToSelf ? + "deleteMessageDevicesAll".localized() : + "deleteMessageEveryone".localized() + ), + state: .enabled, + accessibility: Accessibility(identifier: "Delete for everyone"), + behaviours: try deleteForEveryoneBehaviours( + db, + isAdmin: isAdmin, + threadData: threadData, + cellViewModels: cellViewModels, + using: dependencies ) - ] - ), - NamedAction( - title: "deleteMessageEveryone".localized(), - state: .disabled, - accessibility: Accessibility(identifier: "Delete for everyone") - ) - ] - ) - - /// Admin can multi-select their own messages and messages from other users - case (.containsIncoming, true): - return MessageViewModel.DeletionBehaviours( - title: "deleteMessage" - .putNumber(cellViewModels.count) - .localized(), - warning: nil, - body: "deleteMessageConfirm" - .putNumber(cellViewModels.count) - .localized(), - actions: [ - NamedAction( - title: "deleteMessageDeviceOnly".localized(), - state: .enabled, - accessibility: Accessibility(identifier: "Delete for me"), - behaviours: [ - .cancelPendingSendJobs(cellViewModels.map { $0.id }), - .markAsDeleted( - ids: cellViewModels.map { $0.id }, - options: .local, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant + ) + ] + ) + + /// User selects messages including ones from other users + case (.containsIncoming, false): + return MessageViewModel.DeletionBehaviours( + title: "deleteMessage" + .putNumber(cellViewModels.count) + .localized(), + warning: "deleteMessageWarning" + .putNumber(cellViewModels.count) + .localized(), + body: "deleteMessageDescriptionDevice" + .putNumber(cellViewModels.count) + .localized(), + actions: [ + NamedAction( + title: "deleteMessageDeviceOnly".localized(), + state: .enabledAndDefaultSelected, + accessibility: Accessibility(identifier: "Delete for me"), + behaviours: [ + .cancelPendingSendJobs(cellViewModels.map { $0.id }), + .markAsDeleted( + ids: cellViewModels.map { $0.id }, + options: .local, + threadId: threadData.threadId, + threadVariant: threadData.threadVariant + ) + ] + ), + NamedAction( + title: "deleteMessageEveryone".localized(), + state: .disabled, + accessibility: Accessibility(identifier: "Delete for everyone") + ) + ] + ) + + /// Admin can multi-select their own messages and messages from other users + case (.containsIncoming, true): + return MessageViewModel.DeletionBehaviours( + title: "deleteMessage" + .putNumber(cellViewModels.count) + .localized(), + warning: nil, + body: "deleteMessageConfirm" + .putNumber(cellViewModels.count) + .localized(), + actions: [ + NamedAction( + title: "deleteMessageDeviceOnly".localized(), + state: .enabled, + accessibility: Accessibility(identifier: "Delete for me"), + behaviours: [ + .cancelPendingSendJobs(cellViewModels.map { $0.id }), + .markAsDeleted( + ids: cellViewModels.map { $0.id }, + options: .local, + threadId: threadData.threadId, + threadVariant: threadData.threadVariant + ) + ] + ), + NamedAction( + title: "deleteMessageEveryone".localized(), + state: .enabledAndDefaultSelected, + accessibility: Accessibility(identifier: "Delete for everyone"), + behaviours: try deleteForEveryoneBehaviours( + db, + isAdmin: isAdmin, + threadData: threadData, + cellViewModels: cellViewModels, + using: dependencies ) - ] - ), - NamedAction( - title: "deleteMessageEveryone".localized(), - state: .enabledAndDefaultSelected, - accessibility: Accessibility(identifier: "Delete for everyone"), - behaviours: try deleteForEveryoneBehaviours( - db, - isAdmin: isAdmin, - threadData: threadData, - cellViewModels: cellViewModels, - using: dependencies ) - ) - ] - ) + ] + ) + } + }, + completion: { result in + deletionBehaviours = try? result.successOrThrow() + semaphore.signal() } - } + ) + semaphore.wait() + + return deletionBehaviours } private static func deleteForEveryoneBehaviours( - _ db: Database, + _ db: ObservingDatabase, isAdmin: Bool, threadData: SessionThreadViewModel, cellViewModels: [MessageViewModel], @@ -364,14 +382,13 @@ public extension MessageViewModel.DeletionBehaviours { case (.contact, _): /// Only include messages sent by the current user (can't delete incoming messages in contact conversations) let targetViewModels: [MessageViewModel] = cellViewModels - .filter { $0.authorId == threadData.currentUserSessionId } - let serverHashes: Set = try Interaction.serverHashesForDeletion( - db, - interactionIds: targetViewModels.map { $0.id }.asSet() - ) + .filter { threadData.currentUserSessionId.contains($0.authorId) } + let serverHashes: Set = targetViewModels.compactMap { $0.serverHash }.asSet() + .inserting(contentsOf: Set(targetViewModels.flatMap { message in + (message.reactionInfo ?? []).compactMap { $0.reaction.serverHash } + })) let unsendRequests: [Network.PreparedRequest] = try targetViewModels.map { model in try MessageSender.preparedSend( - db, message: UnsendRequest( timestamp: UInt64(model.timestampMs), author: threadData.currentUserSessionId @@ -383,9 +400,17 @@ public extension MessageViewModel.DeletionBehaviours { to: .contact(publicKey: model.threadId), namespace: .default, interactionId: nil, - fileIds: [], + attachments: nil, + authMethod: try Authentication.with( + db, + threadId: threadData.threadId, + threadVariant: threadData.threadVariant, + using: dependencies + ), + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) + .map { _, _ in () } } /// Batch requests have a limited number of subrequests so make sure to chunk @@ -422,7 +447,10 @@ public extension MessageViewModel.DeletionBehaviours { ) .appending(threadData.threadIsNoteToSelf ? /// If it's the `Note to Self`conversation then we want to just delete the interaction - .deleteFromDatabase(cellViewModels.map { $0.id }) : + .deleteFromDatabase( + ids: cellViewModels.map { $0.id }, + threadId: threadData.threadId + ) : .markAsDeleted( ids: targetViewModels.map { $0.id }, options: [.local, .network], @@ -439,10 +467,9 @@ public extension MessageViewModel.DeletionBehaviours { case (.legacyGroup, _): /// Only try to delete messages send by other users if the current user is an admin let targetViewModels: [MessageViewModel] = cellViewModels - .filter { isAdmin || $0.authorId == threadData.currentUserSessionId } + .filter { isAdmin || (threadData.currentUserSessionIds ?? []).contains($0.authorId) } let unsendRequests: [Network.PreparedRequest] = try targetViewModels.map { model in try MessageSender.preparedSend( - db, message: UnsendRequest( timestamp: UInt64(model.timestampMs), author: (model.variant == .standardOutgoing ? @@ -457,9 +484,17 @@ public extension MessageViewModel.DeletionBehaviours { to: .closedGroup(groupPublicKey: model.threadId), namespace: .legacyClosedGroup, interactionId: nil, - fileIds: [], + attachments: nil, + authMethod: try Authentication.with( + db, + threadId: threadData.threadId, + threadVariant: threadData.threadVariant, + using: dependencies + ), + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) + .map { _, _ in () } } /// Batch requests have a limited number of subrequests so make sure to chunk @@ -496,18 +531,17 @@ public extension MessageViewModel.DeletionBehaviours { case (.group, false): /// Only include messages sent by the current user (non-admins can't delete incoming messages in group conversations) let targetViewModels: [MessageViewModel] = cellViewModels - .filter { $0.authorId == threadData.currentUserSessionId } - let serverHashes: Set = try Interaction.serverHashesForDeletion( - db, - interactionIds: targetViewModels.map { $0.id }.asSet() - ) + .filter { (threadData.currentUserSessionIds ?? []).contains($0.authorId) } + let serverHashes: Set = targetViewModels.compactMap { $0.serverHash }.asSet() + .inserting(contentsOf: Set(targetViewModels.flatMap { message in + (message.reactionInfo ?? []).compactMap { $0.reaction.serverHash } + })) return [.cancelPendingSendJobs(targetViewModels.map { $0.id })] /// **Note:** No signature for member delete content .appending(serverHashes.isEmpty ? nil : .preparedRequest(try MessageSender .preparedSend( - db, message: GroupUpdateDeleteMemberContentMessage( memberSessionIds: [], messageHashes: Array(serverHashes), @@ -518,9 +552,18 @@ public extension MessageViewModel.DeletionBehaviours { to: .closedGroup(groupPublicKey: threadData.threadId), namespace: .groupMessages, interactionId: nil, - fileIds: [], + attachments: nil, + authMethod: try Authentication.with( + db, + threadId: threadData.threadId, + threadVariant: threadData.threadVariant, + using: dependencies + ), + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies - )) + ) + .map { _, _ in () } + ) ) .appending( .markAsDeleted( @@ -537,43 +580,49 @@ public extension MessageViewModel.DeletionBehaviours { /// Mark as deleted case (.group, true): guard - let ed25519SecretKey: Data = try? ClosedGroup - .filter(id: threadData.threadId) - .select(.groupIdentityPrivateKey) - .asRequest(of: Data.self) - .fetchOne(db) + let ed25519SecretKey: [UInt8] = dependencies.mutate(cache: .libSession, { cache in + cache.secretKey(groupSessionId: SessionId(.group, hex: threadData.threadId)) + }) else { Log.error("[ConversationViewModel] Failed to retrieve groupIdentityPrivateKey when trying to delete messages from group.") throw StorageError.objectNotFound } /// Only try to delete messages with server hashes (can't delete them otherwise) - let serverHashes: Set = try Interaction.serverHashesForDeletion( - db, - interactionIds: cellViewModels.map { $0.id }.asSet() - ) + let serverHashes: Set = cellViewModels.compactMap { $0.serverHash }.asSet() + .inserting(contentsOf: Set(cellViewModels.flatMap { message in + (message.reactionInfo ?? []).compactMap { $0.reaction.serverHash } + })) return [.cancelPendingSendJobs(cellViewModels.map { $0.id })] .appending(serverHashes.isEmpty ? nil : .preparedRequest(try MessageSender .preparedSend( - db, message: GroupUpdateDeleteMemberContentMessage( memberSessionIds: [], messageHashes: Array(serverHashes), sentTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs(), authMethod: Authentication.groupAdmin( groupSessionId: SessionId(.group, hex: threadData.threadId), - ed25519SecretKey: Array(ed25519SecretKey) + ed25519SecretKey: ed25519SecretKey ), using: dependencies ), to: .closedGroup(groupPublicKey: threadData.threadId), namespace: .groupMessages, interactionId: nil, - fileIds: [], + attachments: nil, + authMethod: try Authentication.with( + db, + threadId: threadData.threadId, + threadVariant: threadData.threadVariant, + using: dependencies + ), + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies - )) + ) + .map { _, _ in () } + ) ) .appending(serverHashes.isEmpty ? nil : .preparedRequest(try SnodeAPI @@ -604,22 +653,24 @@ public extension MessageViewModel.DeletionBehaviours { /// **Note:** To simplify the logic (since the sender is a blinded id) we don't bother doing admin/sender checks here /// and just rely on the UI state or the SOGS server (if the UI allows an invalid case) to prevent invalid behaviours case (.community, _): - guard - let server: String = threadData.openGroupServer, - let roomToken: String = threadData.openGroupRoomToken - else { + guard let roomToken: String = threadData.openGroupRoomToken else { Log.error("[ConversationViewModel] Failed to retrieve community info when trying to delete messages.") throw StorageError.objectNotFound } + let authMethod: AuthenticationMethod = try Authentication.with( + db, + threadId: threadData.threadId, + threadVariant: threadData.threadVariant, + using: dependencies + ) let deleteRequests: [Network.PreparedRequest] = try cellViewModels .compactMap { $0.openGroupServerMessageId } .map { messageId in try OpenGroupAPI.preparedMessageDelete( - db, id: messageId, - in: roomToken, - on: server, + roomToken: roomToken, + authMethod: authMethod, using: dependencies ) } @@ -633,16 +684,20 @@ public extension MessageViewModel.DeletionBehaviours { .map { deleteRequestsChunk in .preparedRequest( try OpenGroupAPI.preparedBatch( - db, - server: server, requests: deleteRequestsChunk, + authMethod: authMethod, using: dependencies ) .map { _, _ in () } ) } ) - .appending(.deleteFromDatabase(cellViewModels.map { $0.id })) + .appending( + .deleteFromDatabase( + ids: cellViewModels.map { $0.id }, + threadId: threadData.threadId + ) + ) } } } diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index c28f7cb59b..90b9b5ef7f 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -14,6 +14,7 @@ fileprivate typealias ReactionInfo = MessageViewModel.ReactionInfo fileprivate typealias TypingIndicatorInfo = MessageViewModel.TypingIndicatorInfo fileprivate typealias QuotedInfo = MessageViewModel.QuotedInfo +// TODO: [Database Relocation] Refactor this to split database data from no-database data (to avoid unneeded nullables) public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable, ColumnExpressible { public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { @@ -72,8 +73,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, case isOnlyMessageInCluster case isLast case isLastOutgoing - case currentUserBlinded15SessionId - case currentUserBlinded25SessionId + case currentUserSessionIds case optimisticMessageId } @@ -196,11 +196,8 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, public let isLastOutgoing: Bool - /// This is the users blinded15 sessionId hex string (will only be set for messages within open groups) - public let currentUserBlinded15SessionId: String? - - /// This is the users blinded25 sessionId hex string (will only be set for messages within open groups) - public let currentUserBlinded25SessionId: String? + /// This contains all sessionId values for the current user (standard and any blinded variants) + public let currentUserSessionIds: Set? /// This is a temporary id used before an outgoing message is persisted into the database public let optimisticMessageId: UUID? @@ -263,8 +260,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, isOnlyMessageInCluster: self.isOnlyMessageInCluster, isLast: self.isLast, isLastOutgoing: self.isLastOutgoing, - currentUserBlinded15SessionId: self.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: self.currentUserBlinded25SessionId, + currentUserSessionIds: self.currentUserSessionIds, optimisticMessageId: self.optimisticMessageId ) } @@ -325,8 +321,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, isOnlyMessageInCluster: self.isOnlyMessageInCluster, isLast: self.isLast, isLastOutgoing: self.isLastOutgoing, - currentUserBlinded15SessionId: self.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: self.currentUserBlinded25SessionId, + currentUserSessionIds: self.currentUserSessionIds, optimisticMessageId: self.optimisticMessageId ) } @@ -336,8 +331,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, nextModel: MessageViewModel?, isLast: Bool, isLastOutgoing: Bool, - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String?, + currentUserSessionIds: Set, using dependencies: Dependencies ) -> MessageViewModel { let cellType: CellType = { @@ -545,8 +539,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, isOnlyMessageInCluster: isOnlyMessageInCluster, isLast: isLast, isLastOutgoing: isLastOutgoing, - currentUserBlinded15SessionId: currentUserBlinded15SessionId, - currentUserBlinded25SessionId: currentUserBlinded25SessionId, + currentUserSessionIds: currentUserSessionIds, optimisticMessageId: self.optimisticMessageId ) } @@ -766,8 +759,7 @@ public extension MessageViewModel { self.isOnlyMessageInCluster = true self.isLast = isLast self.isLastOutgoing = isLastOutgoing - self.currentUserBlinded15SessionId = nil - self.currentUserBlinded25SessionId = nil + self.currentUserSessionIds = [currentUserSessionId] self.optimisticMessageId = nil } @@ -851,8 +843,7 @@ public extension MessageViewModel { self.isOnlyMessageInCluster = true self.isLast = false self.isLastOutgoing = false - self.currentUserBlinded15SessionId = nil - self.currentUserBlinded25SessionId = nil + self.currentUserSessionIds = [currentUserProfile.id] self.optimisticMessageId = optimisticMessageId } } @@ -920,8 +911,7 @@ public extension MessageViewModel { static func baseQuery( userSessionId: SessionId, - blinded15SessionId: SessionId?, - blinded25SessionId: SessionId?, + currentUserSessionIds: Set, orderSQL: SQL, groupSQL: SQL? ) -> (([Int64]) -> AdaptedFetchRequest>) { @@ -1022,10 +1012,7 @@ public extension MessageViewModel { -- A users outgoing message is stored in some cases using their standard id -- but the quote will use their blinded id so handle that case \(quoteInteraction[.authorId]) = \(userSessionId.hexString) AND - ( - \(quote[.authorId]) = \(blinded15SessionId?.hexString ?? "''") OR - \(quote[.authorId]) = \(blinded25SessionId?.hexString ?? "''") - ) + \(quote[.authorId]) IN \(currentUserSessionIds) ) ) ) @@ -1042,8 +1029,7 @@ public extension MessageViewModel { ) LEFT JOIN \(quoteAttachment) ON ( \(quoteAttachment[.id]) = \(quoteInteractionAttachment[.attachmentId]) OR - \(quoteAttachment[.id]) = \(quoteLinkPreview[.attachmentId]) OR - \(quoteAttachment[.id]) = \(quote[.attachmentId]) + \(quoteAttachment[.id]) = \(quoteLinkPreview[.attachmentId]) ) LEFT JOIN \(LinkPreview.self) ON ( @@ -1299,8 +1285,7 @@ public extension MessageViewModel.TypingIndicatorInfo { public extension MessageViewModel.QuotedInfo { static func baseQuery( userSessionId: SessionId, - blinded15SessionId: SessionId?, - blinded25SessionId: SessionId? + currentUserSessionIds: Set ) -> ((SQL?) -> AdaptedFetchRequest>) { return { additionalFilters -> AdaptedFetchRequest> in let quote: TypedTableAlias = TypedTableAlias() @@ -1335,10 +1320,7 @@ public extension MessageViewModel.QuotedInfo { -- A users outgoing message is stored in some cases using their standard id -- but the quote will use their blinded id so handle that case \(quoteInteraction[.authorId]) = \(userSessionId.hexString) AND - ( - \(quote[.authorId]) = \(blinded15SessionId?.hexString ?? "''") OR - \(quote[.authorId]) = \(blinded25SessionId?.hexString ?? "''") - ) + \(quote[.authorId]) IN \(currentUserSessionIds) ) ) ) @@ -1346,10 +1328,7 @@ public extension MessageViewModel.QuotedInfo { \(quoteInteractionAttachment[.interactionId]) = \(quoteInteraction[.id]) AND \(quoteInteractionAttachment[.albumIndex]) = 0 ) - LEFT JOIN \(Attachment.self) ON ( - \(attachment[.id]) = \(quoteInteractionAttachment[.attachmentId]) OR - \(attachment[.id]) = \(quote[.attachmentId]) - ) + LEFT JOIN \(Attachment.self) ON \(attachment[.id]) = \(quoteInteractionAttachment[.attachmentId]) \(finalFilterSQL) """ @@ -1368,11 +1347,7 @@ public extension MessageViewModel.QuotedInfo { } } - static func joinToViewModelQuerySQL( - userSessionId: SessionId, - blinded15SessionId: SessionId?, - blinded25SessionId: SessionId? - ) -> SQL { + static func joinToViewModelQuerySQL() -> SQL { let quote: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() @@ -1421,7 +1396,6 @@ public extension MessageViewModel.QuotedInfo { guard ( dataToUpdate.quote?.body != nil || - dataToUpdate.quote?.attachmentId != nil || dataToUpdate.quoteAttachment != nil ) else { return } diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 71ea6de6ee..21a25284b6 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -16,7 +16,9 @@ fileprivate typealias ViewModel = SessionThreadViewModel /// /// **Note:** When updating the UI make sure to check the actual queries being run as some fields will have incorrect default values /// in order to optimise their queries to only include the required data -public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable, ColumnExpressible, ThreadSafeType { +// TODO: [Database Relocation] Refactor this to split database data from no-database data (to avoid unneeded nullables) +public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable, ColumnExpressible, ThreadSafeType { + public typealias PagedDataType = SessionThread public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { case rowId @@ -49,7 +51,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat case disappearingMessagesConfiguration case contactLastKnownClientVersion - case displayPictureFilename + case threadDisplayPictureUrl case contactProfile case closedGroupProfileFront case closedGroupProfileBack @@ -68,6 +70,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat case openGroupPublicKey case openGroupUserCount case openGroupPermissions + case openGroupCapabilities // Interaction display info @@ -85,8 +88,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat case threadContactNameInternal case authorNameInternal case currentUserSessionId - case currentUserBlinded15SessionId - case currentUserBlinded25SessionId + case currentUserSessionIds case recentReactionEmoji case wasKickedFromGroup case groupIsDestroyed @@ -152,11 +154,11 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public let disappearingMessagesConfiguration: DisappearingMessagesConfiguration? public let contactLastKnownClientVersion: FeatureVersion? - public let displayPictureFilename: String? - private let contactProfile: Profile? - private let closedGroupProfileFront: Profile? - private let closedGroupProfileBack: Profile? - private let closedGroupProfileBackFallback: Profile? + public let threadDisplayPictureUrl: String? + internal let contactProfile: Profile? + internal let closedGroupProfileFront: Profile? + internal let closedGroupProfileBack: Profile? + internal let closedGroupProfileBackFallback: Profile? public let closedGroupAdminProfile: Profile? public let closedGroupName: String? private let closedGroupDescription: String? @@ -171,6 +173,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public let openGroupPublicKey: String? private let openGroupUserCount: Int? private let openGroupPermissions: OpenGroup.Permissions? + public let openGroupCapabilities: Set? // Interaction display info @@ -188,8 +191,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat private let threadContactNameInternal: String? private let authorNameInternal: String? public let currentUserSessionId: String - public let currentUserBlinded15SessionId: String? - public let currentUserBlinded25SessionId: String? + public let currentUserSessionIds: Set? public let recentReactionEmoji: [String]? public let wasKickedFromGroup: Bool? public let groupIsDestroyed: Bool? @@ -203,6 +205,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat closedGroupName: closedGroupName, openGroupName: openGroupName, isNoteToSelf: threadIsNoteToSelf, + ignoringNickname: false, profile: profile ) } @@ -227,6 +230,13 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat } } + public var allProfileIds: Set { + Set([ + profile?.id, contactProfile?.id, closedGroupProfileFront?.id, + closedGroupProfileBackFallback?.id, closedGroupAdminProfile?.id + ].compactMap { $0 }) + } + public var profile: Profile? { switch threadVariant { case .contact: return contactProfile @@ -351,6 +361,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat SessionThread.Columns.markedAsUnread.set(to: false), using: dependencies ) + db.addConversationEvent(id: threadId, type: .updated(.markedAsUnread(false))) } } @@ -412,6 +423,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat SessionThread.Columns.markedAsUnread.set(to: true), using: dependencies ) + db.addConversationEvent(id: threadId, type: .updated(.markedAsUnread(true))) } } @@ -438,17 +450,12 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat guard wasKickedFromGroup != true else { return false } guard threadIsMessageRequest == false else { return true } - // Double check LibSession directly just in case we the view model hasn't been - // updated since they were changed + /// Double check `libSession` directly just in case we the view model hasn't been updated since they were changed guard - !LibSession.wasKickedFromGroup( - groupSessionId: SessionId(.group, hex: threadId), - using: dependencies - ) && - !LibSession.groupIsDestroyed( - groupSessionId: SessionId(.group, hex: threadId), - using: dependencies - ) + dependencies.mutate(cache: .libSession, { cache in + !cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadId)) && + !cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadId)) + }) else { return false } return interactionVariant?.isGroupLeavingStatus != true @@ -515,7 +522,7 @@ public extension SessionThreadViewModel { self.disappearingMessagesConfiguration = disappearingMessagesConfiguration self.contactLastKnownClientVersion = nil - self.displayPictureFilename = nil + self.threadDisplayPictureUrl = nil self.contactProfile = contactProfile self.closedGroupProfileFront = nil self.closedGroupProfileBack = nil @@ -534,6 +541,7 @@ public extension SessionThreadViewModel { self.openGroupPublicKey = nil self.openGroupUserCount = nil self.openGroupPermissions = openGroupPermissions + self.openGroupCapabilities = nil // Interaction display info @@ -551,8 +559,7 @@ public extension SessionThreadViewModel { self.threadContactNameInternal = nil self.authorNameInternal = nil self.currentUserSessionId = dependencies[cache: .general].sessionId.hexString - self.currentUserBlinded15SessionId = nil - self.currentUserBlinded25SessionId = nil + self.currentUserSessionIds = [dependencies[cache: .general].sessionId.hexString] self.recentReactionEmoji = nil self.wasKickedFromGroup = false self.groupIsDestroyed = false @@ -562,82 +569,13 @@ public extension SessionThreadViewModel { // MARK: - Mutation public extension SessionThreadViewModel { - func with( - recentReactionEmoji: [String]? = nil - ) -> SessionThreadViewModel { - return SessionThreadViewModel( - rowId: self.rowId, - threadId: self.threadId, - threadVariant: self.threadVariant, - threadCreationDateTimestamp: self.threadCreationDateTimestamp, - threadMemberNames: self.threadMemberNames, - threadIsNoteToSelf: self.threadIsNoteToSelf, - outdatedMemberId: self.outdatedMemberId, - threadIsMessageRequest: self.threadIsMessageRequest, - threadRequiresApproval: self.threadRequiresApproval, - threadShouldBeVisible: self.threadShouldBeVisible, - threadPinnedPriority: self.threadPinnedPriority, - threadIsBlocked: self.threadIsBlocked, - threadMutedUntilTimestamp: self.threadMutedUntilTimestamp, - threadOnlyNotifyForMentions: self.threadOnlyNotifyForMentions, - threadMessageDraft: self.threadMessageDraft, - threadIsDraft: self.threadIsDraft, - threadContactIsTyping: self.threadContactIsTyping, - threadWasMarkedUnread: self.threadWasMarkedUnread, - threadUnreadCount: self.threadUnreadCount, - threadUnreadMentionCount: self.threadUnreadMentionCount, - threadHasUnreadMessagesOfAnyKind: self.threadHasUnreadMessagesOfAnyKind, - threadCanWrite: self.threadCanWrite, - disappearingMessagesConfiguration: self.disappearingMessagesConfiguration, - contactLastKnownClientVersion: self.contactLastKnownClientVersion, - displayPictureFilename: self.displayPictureFilename, - contactProfile: self.contactProfile, - closedGroupProfileFront: self.closedGroupProfileFront, - closedGroupProfileBack: self.closedGroupProfileBack, - closedGroupProfileBackFallback: self.closedGroupProfileBackFallback, - closedGroupAdminProfile: self.closedGroupAdminProfile, - closedGroupName: self.closedGroupName, - closedGroupDescription: self.closedGroupDescription, - closedGroupUserCount: self.closedGroupUserCount, - closedGroupExpired: self.closedGroupExpired, - currentUserIsClosedGroupMember: self.currentUserIsClosedGroupMember, - currentUserIsClosedGroupAdmin: self.currentUserIsClosedGroupAdmin, - openGroupName: self.openGroupName, - openGroupDescription: self.openGroupDescription, - openGroupServer: self.openGroupServer, - openGroupRoomToken: self.openGroupRoomToken, - openGroupPublicKey: self.openGroupPublicKey, - openGroupUserCount: self.openGroupUserCount, - openGroupPermissions: self.openGroupPermissions, - interactionId: self.interactionId, - interactionVariant: self.interactionVariant, - interactionTimestampMs: self.interactionTimestampMs, - interactionBody: self.interactionBody, - interactionState: self.interactionState, - interactionHasBeenReadByRecipient: self.interactionHasBeenReadByRecipient, - interactionIsOpenGroupInvitation: self.interactionIsOpenGroupInvitation, - interactionAttachmentDescriptionInfo: self.interactionAttachmentDescriptionInfo, - interactionAttachmentCount: self.interactionAttachmentCount, - authorId: self.authorId, - threadContactNameInternal: self.threadContactNameInternal, - authorNameInternal: self.authorNameInternal, - currentUserSessionId: self.currentUserSessionId, - currentUserBlinded15SessionId: self.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: self.currentUserBlinded25SessionId, - recentReactionEmoji: (recentReactionEmoji ?? self.recentReactionEmoji), - wasKickedFromGroup: self.wasKickedFromGroup, - groupIsDestroyed: self.groupIsDestroyed - ) - } - func populatingPostQueryData( - _ db: Database? = nil, - currentUserBlinded15SessionIdForThisThread: String?, - currentUserBlinded25SessionIdForThisThread: String?, + recentReactionEmoji: [String]?, + openGroupCapabilities: Set?, + currentUserSessionIds: Set, wasKickedFromGroup: Bool, groupIsDestroyed: Bool, - threadCanWrite: Bool, - using dependencies: Dependencies + threadCanWrite: Bool ) -> SessionThreadViewModel { return SessionThreadViewModel( rowId: self.rowId, @@ -664,7 +602,7 @@ public extension SessionThreadViewModel { threadCanWrite: threadCanWrite, disappearingMessagesConfiguration: self.disappearingMessagesConfiguration, contactLastKnownClientVersion: self.contactLastKnownClientVersion, - displayPictureFilename: self.displayPictureFilename, + threadDisplayPictureUrl: self.threadDisplayPictureUrl, contactProfile: self.contactProfile, closedGroupProfileFront: self.closedGroupProfileFront, closedGroupProfileBack: self.closedGroupProfileBack, @@ -683,6 +621,7 @@ public extension SessionThreadViewModel { openGroupPublicKey: self.openGroupPublicKey, openGroupUserCount: self.openGroupUserCount, openGroupPermissions: self.openGroupPermissions, + openGroupCapabilities: openGroupCapabilities, interactionId: self.interactionId, interactionVariant: self.interactionVariant, interactionTimestampMs: self.interactionTimestampMs, @@ -696,27 +635,8 @@ public extension SessionThreadViewModel { threadContactNameInternal: self.threadContactNameInternal, authorNameInternal: self.authorNameInternal, currentUserSessionId: self.currentUserSessionId, - currentUserBlinded15SessionId: ( - currentUserBlinded15SessionIdForThisThread ?? - SessionThread.getCurrentUserBlindedSessionId( - db, - threadId: self.threadId, - threadVariant: self.threadVariant, - blindingPrefix: .blinded15, - using: dependencies - )?.hexString - ), - currentUserBlinded25SessionId: ( - currentUserBlinded25SessionIdForThisThread ?? - SessionThread.getCurrentUserBlindedSessionId( - db, - threadId: self.threadId, - threadVariant: self.threadVariant, - blindingPrefix: .blinded25, - using: dependencies - )?.hexString - ), - recentReactionEmoji: self.recentReactionEmoji, + currentUserSessionIds: currentUserSessionIds, + recentReactionEmoji: recentReactionEmoji, wasKickedFromGroup: wasKickedFromGroup, groupIsDestroyed: groupIsDestroyed ) @@ -775,239 +695,253 @@ private struct GroupMemberInfo: Decodable, ColumnExpressible { // MARK: --SessionThreadViewModel public extension SessionThreadViewModel { - /// **Note:** This query **will not** include deleted incoming messages in it's unread count (they should never be marked as unread - /// but including this warning just in case there is a discrepancy) - static func baseQuery( + static func query( userSessionId: SessionId, groupSQL: SQL, - orderSQL: SQL - ) -> (([Int64]) -> AdaptedFetchRequest>) { - return { rowIds -> AdaptedFetchRequest> in - let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let typingIndicator: TypedTableAlias = TypedTableAlias() - let aggregateInteraction: TypedTableAlias = TypedTableAlias(name: "aggregateInteraction") - let interaction: TypedTableAlias = TypedTableAlias() - let linkPreview: TypedTableAlias = TypedTableAlias() - let firstInteractionAttachment: TypedTableAlias = TypedTableAlias(name: "firstInteractionAttachment") - let attachment: TypedTableAlias = TypedTableAlias() - let interactionAttachment: TypedTableAlias = TypedTableAlias() - let profile: TypedTableAlias = TypedTableAlias() - let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) - let closedGroup: TypedTableAlias = TypedTableAlias() - let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) - let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) - let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) - let closedGroupAdminProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupAdminProfile) - let groupMember: TypedTableAlias = TypedTableAlias() - let openGroup: TypedTableAlias = TypedTableAlias() - - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `contactProfile` entry below otherwise the query will fail to parse and might throw - /// - /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 15 - let numColumnsBetweenProfilesAndAttachmentInfo: Int = 13 // The attachment info columns will be combined - let request: SQLRequest = """ - SELECT - \(thread[.rowId]) AS \(ViewModel.Columns.rowId), - \(thread[.id]) AS \(ViewModel.Columns.threadId), - \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), - \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), + orderSQL: SQL, + rowIds: [Int64] + ) -> AdaptedFetchRequest> { + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let typingIndicator: TypedTableAlias = TypedTableAlias() + let aggregateInteraction: TypedTableAlias = TypedTableAlias(name: "aggregateInteraction") + let interaction: TypedTableAlias = TypedTableAlias() + let linkPreview: TypedTableAlias = TypedTableAlias() + let firstInteractionAttachment: TypedTableAlias = TypedTableAlias(name: "firstInteractionAttachment") + let attachment: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + let profile: TypedTableAlias = TypedTableAlias() + let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) + let closedGroup: TypedTableAlias = TypedTableAlias() + let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) + let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) + let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) + let closedGroupAdminProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupAdminProfile) + let groupMember: TypedTableAlias = TypedTableAlias() + let openGroup: TypedTableAlias = TypedTableAlias() + + /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before + /// the `contactProfile` entry below otherwise the query will fail to parse and might throw + /// + /// Explicitly set default values for the fields ignored for search results + let numColumnsBeforeProfiles: Int = 15 + let numColumnsBetweenProfilesAndAttachmentInfo: Int = 13 // The attachment info columns will be combined + let request: SQLRequest = """ + SELECT + \(thread[.rowId]) AS \(ViewModel.Columns.rowId), + \(thread[.id]) AS \(ViewModel.Columns.threadId), + \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), + \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - (\(SQL("\(thread[.id]) = \(userSessionId.hexString)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - \(contact[.isBlocked]) AS \(ViewModel.Columns.threadIsBlocked), - \(thread[.mutedUntilTimestamp]) AS \(ViewModel.Columns.threadMutedUntilTimestamp), - \(thread[.onlyNotifyForMentions]) AS \(ViewModel.Columns.threadOnlyNotifyForMentions), - ( - COALESCE(\(closedGroup[.invited]), false) = true OR ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) AND - IFNULL(\(contact[.isApproved]), false) = false - ) - ) AS \(ViewModel.Columns.threadIsMessageRequest), - - (\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.Columns.threadContactIsTyping), - \(thread[.markedAsUnread]) AS \(ViewModel.Columns.threadWasMarkedUnread), - \(aggregateInteraction[.threadUnreadCount]), - \(aggregateInteraction[.threadUnreadMentionCount]), - \(aggregateInteraction[.threadHasUnreadMessagesOfAnyKind]), + (\(SQL("\(thread[.id]) = \(userSessionId.hexString)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), + \(contact[.isBlocked]) AS \(ViewModel.Columns.threadIsBlocked), + \(thread[.mutedUntilTimestamp]) AS \(ViewModel.Columns.threadMutedUntilTimestamp), + \(thread[.onlyNotifyForMentions]) AS \(ViewModel.Columns.threadOnlyNotifyForMentions), + ( + COALESCE(\(closedGroup[.invited]), false) = true OR ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) AND + IFNULL(\(contact[.isApproved]), false) = false + ) + ) AS \(ViewModel.Columns.threadIsMessageRequest), + + (\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.Columns.threadContactIsTyping), + \(thread[.markedAsUnread]) AS \(ViewModel.Columns.threadWasMarkedUnread), + \(aggregateInteraction[.threadUnreadCount]), + \(aggregateInteraction[.threadUnreadMentionCount]), + \(aggregateInteraction[.threadHasUnreadMessagesOfAnyKind]), - \(contactProfile.allColumns), - \(closedGroupProfileFront.allColumns), - \(closedGroupProfileBack.allColumns), - \(closedGroupProfileBackFallback.allColumns), - \(closedGroupAdminProfile.allColumns), - \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), - \(closedGroup[.expired]) AS \(ViewModel.Columns.closedGroupExpired), + \(contactProfile.allColumns), + \(closedGroupProfileFront.allColumns), + \(closedGroupProfileBack.allColumns), + \(closedGroupProfileBackFallback.allColumns), + \(closedGroupAdminProfile.allColumns), + \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), + \(closedGroup[.expired]) AS \(ViewModel.Columns.closedGroupExpired), - EXISTS ( - SELECT 1 - FROM \(GroupMember.self) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) AND - \(SQL("\(groupMember[.profileId]) = \(userSessionId.hexString)")) - ) - ) AS \(ViewModel.Columns.currentUserIsClosedGroupMember), + EXISTS ( + SELECT 1 + FROM \(GroupMember.self) + WHERE ( + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) AND + \(SQL("\(groupMember[.profileId]) = \(userSessionId.hexString)")) + ) + ) AS \(ViewModel.Columns.currentUserIsClosedGroupMember), - EXISTS ( - SELECT 1 - FROM \(GroupMember.self) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) AND - \(SQL("\(groupMember[.profileId]) = \(userSessionId.hexString)")) AND ( - ( - -- Legacy groups don't have a 'roleStatus' so just let those through - -- based solely on the 'role' - \(groupMember[.groupId]) > \(SessionId.Prefix.standard.rawValue) AND - \(groupMember[.groupId]) < \(SessionId.Prefix.standard.endOfRangeString) - ) OR - \(SQL("\(groupMember[.roleStatus]) = \(GroupMember.RoleStatus.accepted)")) - ) + EXISTS ( + SELECT 1 + FROM \(GroupMember.self) + WHERE ( + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) AND + \(SQL("\(groupMember[.profileId]) = \(userSessionId.hexString)")) AND ( + ( + -- Legacy groups don't have a 'roleStatus' so just let those through + -- based solely on the 'role' + \(groupMember[.groupId]) > \(SessionId.Prefix.standard.rawValue) AND + \(groupMember[.groupId]) < \(SessionId.Prefix.standard.endOfRangeString) + ) OR + \(SQL("\(groupMember[.roleStatus]) = \(GroupMember.RoleStatus.accepted)")) ) - ) AS \(ViewModel.Columns.currentUserIsClosedGroupAdmin), + ) + ) AS \(ViewModel.Columns.currentUserIsClosedGroupAdmin), - \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), - - COALESCE( - \(openGroup[.displayPictureFilename]), - \(closedGroup[.displayPictureFilename]), - \(contactProfile[.profilePictureFileName]) - ) AS \(ViewModel.Columns.displayPictureFilename), + \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), + + COALESCE( + \(openGroup[.displayPictureOriginalUrl]), + \(closedGroup[.displayPictureUrl]), + \(contactProfile[.displayPictureUrl]) + ) AS \(ViewModel.Columns.threadDisplayPictureUrl), - \(interaction[.id]) AS \(ViewModel.Columns.interactionId), - \(interaction[.variant]) AS \(ViewModel.Columns.interactionVariant), - \(interaction[.timestampMs]) AS \(ViewModel.Columns.interactionTimestampMs), - \(interaction[.body]) AS \(ViewModel.Columns.interactionBody), - \(interaction[.state]) AS \(ViewModel.Columns.interactionState), - (\(interaction[.recipientReadTimestampMs]) IS NOT NULL) AS \(ViewModel.Columns.interactionHasBeenReadByRecipient), - (\(linkPreview[.url]) IS NOT NULL) AS \(ViewModel.Columns.interactionIsOpenGroupInvitation), + \(interaction[.id]) AS \(ViewModel.Columns.interactionId), + \(interaction[.variant]) AS \(ViewModel.Columns.interactionVariant), + \(interaction[.timestampMs]) AS \(ViewModel.Columns.interactionTimestampMs), + \(interaction[.body]) AS \(ViewModel.Columns.interactionBody), + \(interaction[.state]) AS \(ViewModel.Columns.interactionState), + (\(interaction[.recipientReadTimestampMs]) IS NOT NULL) AS \(ViewModel.Columns.interactionHasBeenReadByRecipient), + (\(linkPreview[.url]) IS NOT NULL) AS \(ViewModel.Columns.interactionIsOpenGroupInvitation), - -- These 4 properties will be combined into 'Attachment.DescriptionInfo' - \(attachment[.id]), - \(attachment[.variant]), - \(attachment[.contentType]), - \(attachment[.sourceFilename]), - COUNT(\(interactionAttachment[.interactionId])) AS \(ViewModel.Columns.interactionAttachmentCount), + -- These 4 properties will be combined into 'Attachment.DescriptionInfo' + \(attachment[.id]), + \(attachment[.variant]), + \(attachment[.contentType]), + \(attachment[.sourceFilename]), + COUNT(\(interactionAttachment[.interactionId])) AS \(ViewModel.Columns.interactionAttachmentCount), - \(interaction[.authorId]), - IFNULL(\(contactProfile[.nickname]), \(contactProfile[.name])) AS \(ViewModel.Columns.threadContactNameInternal), - IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.Columns.authorNameInternal), - \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) + \(interaction[.authorId]), + IFNULL(\(contactProfile[.nickname]), \(contactProfile[.name])) AS \(ViewModel.Columns.threadContactNameInternal), + IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.Columns.authorNameInternal), + \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) - FROM \(SessionThread.self) - LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) - LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id]) + FROM \(SessionThread.self) + LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) + LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id]) - LEFT JOIN ( - SELECT - \(interaction[.id]) AS \(AggregateInteraction.Columns.interactionId), - \(interaction[.threadId]) AS \(AggregateInteraction.Columns.threadId), - MAX(\(interaction[.timestampMs])) AS \(AggregateInteraction.Columns.interactionTimestampMs), - SUM(\(interaction[.wasRead]) = false) AS \(AggregateInteraction.Columns.threadUnreadCount), - SUM(\(interaction[.wasRead]) = false AND \(interaction[.hasMention]) = true) AS \(AggregateInteraction.Columns.threadUnreadMentionCount), - (SUM(\(interaction[.wasRead]) = false) > 0) AS \(AggregateInteraction.Columns.threadHasUnreadMessagesOfAnyKind) + LEFT JOIN ( + SELECT + \(interaction[.id]) AS \(AggregateInteraction.Columns.interactionId), + \(interaction[.threadId]) AS \(AggregateInteraction.Columns.threadId), + MAX(\(interaction[.timestampMs])) AS \(AggregateInteraction.Columns.interactionTimestampMs), + SUM(\(interaction[.wasRead]) = false) AS \(AggregateInteraction.Columns.threadUnreadCount), + SUM(\(interaction[.wasRead]) = false AND \(interaction[.hasMention]) = true) AS \(AggregateInteraction.Columns.threadUnreadMentionCount), + (SUM(\(interaction[.wasRead]) = false) > 0) AS \(AggregateInteraction.Columns.threadHasUnreadMessagesOfAnyKind) + + FROM \(Interaction.self) + WHERE \(SQL("\(interaction[.variant]) IN \(Interaction.Variant.variantsToShowConversationSnippet)")) + GROUP BY \(interaction[.threadId]) + ) AS \(aggregateInteraction) ON \(aggregateInteraction[.threadId]) = \(thread[.id]) - FROM \(Interaction.self) - WHERE \(SQL("\(interaction[.variant]) IN \(Interaction.Variant.variantsToShowConversationSnippet)")) - GROUP BY \(interaction[.threadId]) - ) AS \(aggregateInteraction) ON \(aggregateInteraction[.threadId]) = \(thread[.id]) - - LEFT JOIN \(Interaction.self) ON ( - \(interaction[.threadId]) = \(thread[.id]) AND - \(interaction[.id]) = \(aggregateInteraction[.interactionId]) - ) + LEFT JOIN \(Interaction.self) ON ( + \(interaction[.threadId]) = \(thread[.id]) AND + \(interaction[.id]) = \(aggregateInteraction[.interactionId]) + ) - LEFT JOIN \(LinkPreview.self) ON ( - \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND - \(Interaction.linkPreviewFilterLiteral()) AND - \(SQL("\(linkPreview[.variant]) = \(LinkPreview.Variant.openGroupInvitation)")) - ) - LEFT JOIN \(firstInteractionAttachment) ON ( - \(firstInteractionAttachment[.interactionId]) = \(interaction[.id]) AND - \(firstInteractionAttachment[.albumIndex]) = 0 - ) - LEFT JOIN \(Attachment.self) ON \(attachment[.id]) = \(firstInteractionAttachment[.attachmentId]) - LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.interactionId]) = \(interaction[.id]) - LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) + LEFT JOIN \(LinkPreview.self) ON ( + \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND + \(Interaction.linkPreviewFilterLiteral()) AND + \(SQL("\(linkPreview[.variant]) = \(LinkPreview.Variant.openGroupInvitation)")) + ) + LEFT JOIN \(firstInteractionAttachment) ON ( + \(firstInteractionAttachment[.interactionId]) = \(interaction[.id]) AND + \(firstInteractionAttachment[.albumIndex]) = 0 + ) + LEFT JOIN \(Attachment.self) ON \(attachment[.id]) = \(firstInteractionAttachment[.attachmentId]) + LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.interactionId]) = \(interaction[.id]) + LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) - -- Thread naming & avatar content + -- Thread naming & avatar content - LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) - LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) + LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) + LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) + LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(closedGroupProfileFront) ON ( - \(closedGroupProfileFront[.id]) = ( - SELECT MIN(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND - \(SQL("\(groupMember[.profileId]) != \(userSessionId.hexString)")) - ) + LEFT JOIN \(closedGroupProfileFront) ON ( + \(closedGroupProfileFront[.id]) = ( + SELECT MIN(\(groupMember[.profileId])) + FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + WHERE ( + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(SQL("\(groupMember[.profileId]) != \(userSessionId.hexString)")) ) ) - LEFT JOIN \(closedGroupProfileBack) ON ( - \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND - \(closedGroupProfileBack[.id]) = ( - SELECT MAX(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND - \(SQL("\(groupMember[.profileId]) != \(userSessionId.hexString)")) - ) + ) + LEFT JOIN \(closedGroupProfileBack) ON ( + \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND + \(closedGroupProfileBack[.id]) = ( + SELECT MAX(\(groupMember[.profileId])) + FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + WHERE ( + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(SQL("\(groupMember[.profileId]) != \(userSessionId.hexString)")) ) ) - LEFT JOIN \(closedGroupProfileBackFallback) ON ( - \(closedGroup[.threadId]) IS NOT NULL AND - \(closedGroupProfileBack[.id]) IS NULL AND - \(closedGroupProfileBackFallback[.id]) = \(SQL("\(userSessionId.hexString)")) - ) - LEFT JOIN \(closedGroupAdminProfile) ON ( - \(closedGroupAdminProfile[.id]) = ( - SELECT MIN(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) - ) + ) + LEFT JOIN \(closedGroupProfileBackFallback) ON ( + \(closedGroup[.threadId]) IS NOT NULL AND + \(closedGroupProfileBack[.id]) IS NULL AND + \(closedGroupProfileBackFallback[.id]) = \(SQL("\(userSessionId.hexString)")) + ) + LEFT JOIN \(closedGroupAdminProfile) ON ( + \(closedGroupAdminProfile[.id]) = ( + SELECT MIN(\(groupMember[.profileId])) + FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + WHERE ( + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) ) ) + ) - WHERE \(thread[.rowId]) IN \(rowIds) - \(groupSQL) - ORDER BY \(orderSQL) - """ + WHERE \(thread[.rowId]) IN \(rowIds) + \(groupSQL) + ORDER BY \(orderSQL) + """ + + return request.adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeProfiles, + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db), + Profile.numberOfSelectedColumns(db), + numColumnsBetweenProfilesAndAttachmentInfo, + Attachment.DescriptionInfo.numberOfSelectedColumns() + ]) - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeProfiles, - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - numColumnsBetweenProfilesAndAttachmentInfo, - Attachment.DescriptionInfo.numberOfSelectedColumns() - ]) - - return ScopeAdapter.with(ViewModel.self, [ - .contactProfile: adapters[1], - .closedGroupProfileFront: adapters[2], - .closedGroupProfileBack: adapters[3], - .closedGroupProfileBackFallback: adapters[4], - .closedGroupAdminProfile: adapters[5], - .interactionAttachmentDescriptionInfo: adapters[7] - ]) - } + return ScopeAdapter.with(ViewModel.self, [ + .contactProfile: adapters[1], + .closedGroupProfileFront: adapters[2], + .closedGroupProfileBack: adapters[3], + .closedGroupProfileBackFallback: adapters[4], + .closedGroupAdminProfile: adapters[5], + .interactionAttachmentDescriptionInfo: adapters[7] + ]) + } + } + + /// **Note:** This query **will not** include deleted incoming messages in it's unread count (they should never be marked as unread + /// but including this warning just in case there is a discrepancy) + static func baseQuery( + userSessionId: SessionId, + groupSQL: SQL, + orderSQL: SQL + ) -> (([Int64]) -> AdaptedFetchRequest>) { + return { rowIds -> AdaptedFetchRequest> in + SessionThreadViewModel.query( + userSessionId: userSessionId, + groupSQL: groupSQL, + orderSQL: orderSQL, + rowIds: rowIds + ) } } @@ -1211,10 +1145,10 @@ public extension SessionThreadViewModel { \(openGroup[.permissions]) AS \(ViewModel.Columns.openGroupPermissions), COALESCE( - \(openGroup[.displayPictureFilename]), - \(closedGroup[.displayPictureFilename]), - \(contactProfile[.profilePictureFileName]) - ) AS \(ViewModel.Columns.displayPictureFilename), + \(openGroup[.displayPictureOriginalUrl]), + \(closedGroup[.displayPictureUrl]), + \(contactProfile[.displayPictureUrl]) + ) AS \(ViewModel.Columns.threadDisplayPictureUrl), \(aggregateInteraction[.interactionId]), \(aggregateInteraction[.interactionTimestampMs]), @@ -1383,10 +1317,10 @@ public extension SessionThreadViewModel { \(openGroup[.publicKey]) AS \(ViewModel.Columns.openGroupPublicKey), COALESCE( - \(openGroup[.displayPictureFilename]), - \(closedGroup[.displayPictureFilename]), - \(contactProfile[.profilePictureFileName]) - ) AS \(ViewModel.Columns.displayPictureFilename), + \(openGroup[.displayPictureOriginalUrl]), + \(closedGroup[.displayPictureUrl]), + \(contactProfile[.displayPictureUrl]) + ) AS \(ViewModel.Columns.threadDisplayPictureUrl), \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) @@ -1496,11 +1430,11 @@ public extension SessionThreadViewModel { .replacingOccurrences(of: "“", with: "\"") } - static func pattern(_ db: Database, searchTerm: String) throws -> FTS5Pattern { + static func pattern(_ db: ObservingDatabase, searchTerm: String) throws -> FTS5Pattern { return try pattern(db, searchTerm: searchTerm, forTable: Interaction.self) } - static func pattern(_ db: Database, searchTerm: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible { + static func pattern(_ db: ObservingDatabase, searchTerm: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible { // Note: FTS doesn't support both prefix/suffix wild cards so don't bother trying to // add a prefix one let rawPattern: String = { @@ -1569,10 +1503,10 @@ public extension SessionThreadViewModel { \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), COALESCE( - \(openGroup[.displayPictureFilename]), - \(closedGroup[.displayPictureFilename]), - \(contactProfile[.profilePictureFileName]) - ) AS \(ViewModel.Columns.displayPictureFilename), + \(openGroup[.displayPictureOriginalUrl]), + \(closedGroup[.displayPictureUrl]), + \(contactProfile[.displayPictureUrl]) + ) AS \(ViewModel.Columns.threadDisplayPictureUrl), \(interaction[.id]) AS \(ViewModel.Columns.interactionId), \(interaction[.variant]) AS \(ViewModel.Columns.interactionVariant), @@ -1717,10 +1651,10 @@ public extension SessionThreadViewModel { \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), COALESCE( - \(openGroup[.displayPictureFilename]), - \(closedGroup[.displayPictureFilename]), - \(contactProfile[.profilePictureFileName]) - ) AS \(ViewModel.Columns.displayPictureFilename), + \(openGroup[.displayPictureOriginalUrl]), + \(closedGroup[.displayPictureUrl]), + \(contactProfile[.displayPictureUrl]) + ) AS \(ViewModel.Columns.threadDisplayPictureUrl), \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) @@ -1997,10 +1931,10 @@ public extension SessionThreadViewModel { \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), COALESCE( - \(openGroup[.displayPictureFilename]), - \(closedGroup[.displayPictureFilename]), - \(contactProfile[.profilePictureFileName]) - ) AS \(ViewModel.Columns.displayPictureFilename), + \(openGroup[.displayPictureOriginalUrl]), + \(closedGroup[.displayPictureUrl]), + \(contactProfile[.displayPictureUrl]) + ) AS \(ViewModel.Columns.threadDisplayPictureUrl), \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) @@ -2268,10 +2202,10 @@ public extension SessionThreadViewModel { \(openGroup[.permissions]) AS \(ViewModel.Columns.openGroupPermissions), COALESCE( - \(openGroup[.displayPictureFilename]), - \(closedGroup[.displayPictureFilename]), - \(contactProfile[.profilePictureFileName]) - ) AS \(ViewModel.Columns.displayPictureFilename), + \(openGroup[.displayPictureOriginalUrl]), + \(closedGroup[.displayPictureUrl]), + \(contactProfile[.displayPictureUrl]) + ) AS \(ViewModel.Columns.threadDisplayPictureUrl), \(interaction[.id]) AS \(ViewModel.Columns.interactionId), \(interaction[.variant]) AS \(ViewModel.Columns.interactionVariant), diff --git a/SessionMessagingKit/Utilities/AsyncAccessible.swift b/SessionMessagingKit/Utilities/AsyncAccessible.swift new file mode 100644 index 0000000000..18370d0e3f --- /dev/null +++ b/SessionMessagingKit/Utilities/AsyncAccessible.swift @@ -0,0 +1,28 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +private let unsafeSyncQueue: DispatchQueue = DispatchQueue(label: "com.session.unsafeSyncQueue") + +public protocol AsyncAccessible {} + +public extension AsyncAccessible { + + /// This function blocks the current thread and waits for the result of the closure, use async/await functionality directly where possible + /// as this approach could result in deadlocks + nonisolated func unsafeSync(_ closure: @escaping (Self) async -> T) -> T { + let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + var result: T! /// Intentionally implicitly unwrapped as we will wait undefinitely for it to return otherwise + + /// Run the task on a specific queue, not the global pool to try to force any unsafe execution to run serially + unsafeSyncQueue.async { [self] in + Task { [self] in + result = await closure(self) + semaphore.signal() + } + } + semaphore.wait() + + return result + } +} diff --git a/SessionMessagingKit/Utilities/AttachmentManager.swift b/SessionMessagingKit/Utilities/AttachmentManager.swift new file mode 100644 index 0000000000..cd25cb6f1f --- /dev/null +++ b/SessionMessagingKit/Utilities/AttachmentManager.swift @@ -0,0 +1,246 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import AVFAudio +import AVFoundation +import Combine +import UniformTypeIdentifiers +import GRDB +import SessionUIKit +import SessionSnodeKit +import SessionUtilitiesKit + +// MARK: - Singleton + +public extension Singleton { + static let attachmentManager: SingletonConfig = Dependencies.create( + identifier: "attachmentManager", + createInstance: { dependencies in AttachmentManager(using: dependencies) } + ) +} + +// MARK: - Log.Category + +public extension Log.Category { + static let attachmentManager: Log.Category = .create("AttachmentManager", defaultLevel: .info) +} + +// MARK: - AttachmentManager + +public final class AttachmentManager: Sendable, ThumbnailManager { + private let dependencies: Dependencies + + // MARK: - Initalization + + init(using dependencies: Dependencies) { + self.dependencies = dependencies + } + + // MARK: - General + + public func sharedDataAttachmentsDirPath() -> String { + let path: String = URL(fileURLWithPath: SessionFileManager.nonInjectedAppSharedDataDirectoryPath) + .appendingPathComponent("Attachments") // stringlint:ignore + .path + try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: path) + + return path + } + + // MARK: - File Paths + + /// **Note:** Generally the url we get won't have an extension and we don't want to make assumptions until we have the actual + /// image data so generate a name for the file and then determine the extension separately + public func path(for urlString: String?) throws -> String { + guard + let urlString: String = urlString, + !urlString.isEmpty + else { throw DisplayPictureError.invalidCall } + + let urlHash = try dependencies[singleton: .crypto] + .tryGenerate(.hash(message: Array(urlString.utf8))) + .toHexString() + + return URL(fileURLWithPath: sharedDataAttachmentsDirPath()) + .appendingPathComponent(urlHash) + .path + } + + private func placeholderUrlPath() -> String { + return URL(fileURLWithPath: sharedDataAttachmentsDirPath()) + .appendingPathComponent("uploadPlaceholderUrl") // stringlint:ignore + .path + } + + public func uploadPathAndUrl(for id: String) throws -> (url: String, path: String) { + let fakeLocalUrlPath: String = URL(fileURLWithPath: placeholderUrlPath()) + .appendingPathComponent(URL(fileURLWithPath: id).path) + .path + + return (fakeLocalUrlPath, try path(for: fakeLocalUrlPath)) + } + + public func isPlaceholderUploadUrl(_ url: String?) -> Bool { + return (url?.hasPrefix(placeholderUrlPath()) == true) + } + + public func temporaryPathForOpening(downloadUrl: String?, mimeType: String?, sourceFilename: String?) throws -> String { + guard let downloadUrl: String = downloadUrl else { throw AttachmentError.invalidData } + + /// Since `mimeType` and/or `sourceFilename` can be null we need to try to resolve them both to values + let finalExtension: String + let targetFilenameNoExtension: String + + switch (mimeType, sourceFilename) { + case (.none, .none): throw AttachmentError.invalidData + case (.none, .some(let sourceFilename)): + guard + let type: UTType = UTType( + sessionFileExtension: URL(fileURLWithPath: sourceFilename).pathExtension + ), + let fileExtension: String = type.sessionFileExtension(sourceFilename: sourceFilename) + else { throw AttachmentError.invalidData } + + finalExtension = fileExtension + targetFilenameNoExtension = String(sourceFilename.prefix(sourceFilename.count - (1 + fileExtension.count))) + + case (.some(let mimeType), let sourceFilename): + guard + let fileExtension: String = UTType(sessionMimeType: mimeType)? + .sessionFileExtension(sourceFilename: sourceFilename) + else { throw AttachmentError.invalidData } + + finalExtension = fileExtension + targetFilenameNoExtension = try { + guard let sourceFilename: String = sourceFilename else { + return URL(fileURLWithPath: try path(for: downloadUrl)).lastPathComponent + } + + return (sourceFilename.hasSuffix(".\(fileExtension)") ? // stringlint:ignore + String(sourceFilename.prefix(sourceFilename.count - (1 + fileExtension.count))) : + sourceFilename + ) + }() + } + + return URL(fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectory) + .appendingPathComponent(targetFilenameNoExtension) + .appendingPathExtension(finalExtension) + .path + } + + public func createTemporaryFileForOpening(downloadUrl: String?, mimeType: String?, sourceFilename: String?) throws -> String { + let path: String = try path(for: downloadUrl) + let tmpPath: String = try temporaryPathForOpening( + downloadUrl: downloadUrl, + mimeType: mimeType, + sourceFilename: sourceFilename + ) + + /// If the file already exists (since it's deterministically generated) then no need to copy it again + if !dependencies[singleton: .fileManager].fileExists(atPath: tmpPath) { + try dependencies[singleton: .fileManager].copyItem(atPath: path, toPath: tmpPath) + } + + return tmpPath + } + + public func resetStorage() { + try? dependencies[singleton: .fileManager].removeItem( + atPath: sharedDataAttachmentsDirPath() + ) + } + + // MARK: - ThumbnailManager + + private func thumbnailUrl(for url: URL, size: ImageDataManager.ThumbnailSize) throws -> URL { + guard !url.lastPathComponent.isEmpty else { throw DisplayPictureError.invalidCall } + + /// Thumbnails are written to the caches directory, so that iOS can remove them if necessary + return URL(fileURLWithPath: SessionFileManager.cachesDirectoryPath) + .appendingPathComponent(url.lastPathComponent) + .appendingPathComponent("thumbnail-\(size).jpg") // stringlint:ignore + } + + 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) + } + + 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) + } + + // MARK: - Validity + + public func determineValidityAndDuration( + contentType: String, + downloadUrl: String?, + sourceFilename: String? + ) -> (isValid: Bool, duration: TimeInterval?) { + guard let path: String = try? path(for: downloadUrl) else { return (false, nil) } + + // Process audio attachments + if UTType.isAudio(contentType) { + do { + let audioPlayer: AVAudioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path)) + + return ((audioPlayer.duration > 0), audioPlayer.duration) + } + catch { + switch (error as NSError).code { + case Int(kAudioFileInvalidFileError), Int(kAudioFileStreamError_InvalidFile): + // Ignore "invalid audio file" errors + return (false, nil) + + default: return (false, nil) + } + } + } + + // Process image attachments + if UTType.isImage(contentType) || UTType.isAnimated(contentType) { + return ( + Data.isValidImage(at: path, type: UTType(sessionMimeType: contentType), using: dependencies), + nil + ) + } + + // Process video attachments + if UTType.isVideo(contentType) { + let assetInfo: (asset: AVURLAsset, cleanup: () -> Void)? = AVURLAsset.asset( + for: path, + mimeType: contentType, + sourceFilename: sourceFilename, + using: dependencies + ) + + guard + let asset: AVURLAsset = assetInfo?.asset, + MediaUtils.isVideoOfValidContentTypeAndSize( + path: path, + type: contentType, + using: dependencies + ), + MediaUtils.isValidVideo(asset: asset) + else { + assetInfo?.cleanup() + return (false, nil) + } + + let durationSeconds: TimeInterval = ( + // According to the CMTime docs "value/timescale = seconds" + TimeInterval(asset.duration.value) / TimeInterval(asset.duration.timescale) + ) + assetInfo?.cleanup() + + return (true, durationSeconds) + } + + // Any other attachment types are valid and have no duration + return (true, nil) + } +} diff --git a/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift index 4483d49e32..4a1069c1ca 100644 --- a/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift @@ -8,28 +8,30 @@ import SessionUtilitiesKit // MARK: - Authentication Types public extension Authentication { - /// Used for when interacting as the current user + /// Used when interacting as the current user struct standard: AuthenticationMethod { public let sessionId: SessionId - public let ed25519KeyPair: KeyPair + public let ed25519PublicKey: [UInt8] + public let ed25519SecretKey: [UInt8] - public var info: Info { .standard(sessionId: sessionId, ed25519KeyPair: ed25519KeyPair) } + public var info: Info { .standard(sessionId: sessionId, ed25519PublicKey: ed25519PublicKey) } - public init(sessionId: SessionId, ed25519KeyPair: KeyPair) { + public init(sessionId: SessionId, ed25519PublicKey: [UInt8], ed25519SecretKey: [UInt8]) { self.sessionId = sessionId - self.ed25519KeyPair = ed25519KeyPair + self.ed25519PublicKey = ed25519PublicKey + self.ed25519SecretKey = ed25519SecretKey } // MARK: - SignatureGenerator public func generateSignature(with verificationBytes: [UInt8], using dependencies: Dependencies) throws -> Authentication.Signature { return try dependencies[singleton: .crypto].tryGenerate( - .signature(message: verificationBytes, ed25519SecretKey: ed25519KeyPair.secretKey) + .signature(message: verificationBytes, ed25519SecretKey: ed25519SecretKey) ) } } - /// Used for when interacting as a group admin + /// Used when interacting as a group admin struct groupAdmin: AuthenticationMethod { public let groupSessionId: SessionId public let ed25519SecretKey: [UInt8] @@ -50,7 +52,7 @@ public extension Authentication { } } - /// Used for when interacting as a group member + /// Used when interacting as a group member struct groupMember: AuthenticationMethod { public let groupSessionId: SessionId public let authData: Data @@ -76,6 +78,38 @@ public extension Authentication { } } } + + /// Used when interacting with a community + struct community: AuthenticationMethod { + public let openGroupCapabilityInfo: LibSession.OpenGroupCapabilityInfo + public let forceBlinded: Bool + + public var server: String { openGroupCapabilityInfo.server } + public var publicKey: String { openGroupCapabilityInfo.publicKey } + public var hasCapabilities: Bool { !openGroupCapabilityInfo.capabilities.isEmpty } + public var supportsBlinding: Bool { openGroupCapabilityInfo.capabilities.contains(.blind) } + + public var info: Info { + .community( + server: server, + publicKey: publicKey, + hasCapabilities: hasCapabilities, + supportsBlinding: supportsBlinding, + forceBlinded: forceBlinded + ) + } + + public init(info: LibSession.OpenGroupCapabilityInfo, forceBlinded: Bool = false) { + self.openGroupCapabilityInfo = info + self.forceBlinded = forceBlinded + } + + // MARK: - SignatureGenerator + + public func generateSignature(with verificationBytes: [UInt8], using dependencies: Dependencies) throws -> Authentication.Signature { + throw CryptoError.signatureGenerationFailed + } + } } // MARK: - Convenience @@ -87,17 +121,69 @@ fileprivate struct GroupAuthData: Codable, FetchableRecord { public extension Authentication { static func with( - _ db: Database, + _ db: ObservingDatabase, + server: String, + activeOnly: Bool = true, + forceBlinded: Bool = false, + using dependencies: Dependencies + ) throws -> AuthenticationMethod { + guard + // TODO: [Database Relocation] Store capability info locally in libSession so we don't need the db here + let info: LibSession.OpenGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo + .fetchOne(db, server: server, activeOnly: activeOnly) + else { throw CryptoError.invalidAuthentication } + + return Authentication.community(info: info, forceBlinded: forceBlinded) + } + + static func with( + _ db: ObservingDatabase, + threadId: String, + threadVariant: SessionThread.Variant, + forceBlinded: Bool = false, + using dependencies: Dependencies + ) throws -> AuthenticationMethod { + switch (threadVariant, try? SessionId.Prefix(from: threadId)) { + case (.community, _): + guard + // TODO: [Database Relocation] Store capability info locally in libSession so we don't need the db here + let info: LibSession.OpenGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo + .fetchOne(db, id: threadId) + else { throw CryptoError.invalidAuthentication } + + return Authentication.community(info: info, forceBlinded: forceBlinded) + + case (.contact, .blinded15), (.contact, .blinded25): + guard + let lookup: BlindedIdLookup = try? BlindedIdLookup.fetchOne(db, id: threadId), + let info: LibSession.OpenGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo + .fetchOne(db, server: lookup.openGroupServer) + else { throw CryptoError.invalidAuthentication } + + return Authentication.community(info: info, forceBlinded: forceBlinded) + + default: return try Authentication.with(db, swarmPublicKey: threadId, using: dependencies) + } + } + + static func with( + _ db: ObservingDatabase, swarmPublicKey: String, using dependencies: Dependencies ) throws -> AuthenticationMethod { switch try? SessionId(from: swarmPublicKey) { case .some(let sessionId) where sessionId.prefix == .standard: - guard let keyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { - throw SnodeAPIError.noKeyPair - } + guard + let userEdKeyPair: KeyPair = dependencies[singleton: .crypto].generate( + .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) + ) + else { throw SnodeAPIError.noKeyPair } - return Authentication.standard(sessionId: sessionId, ed25519KeyPair: keyPair) + return Authentication.standard( + sessionId: sessionId, + ed25519PublicKey: userEdKeyPair.publicKey, + ed25519SecretKey: userEdKeyPair.secretKey + ) case .some(let sessionId) where sessionId.prefix == .group: let authData: GroupAuthData? = try? ClosedGroup @@ -119,10 +205,10 @@ public extension Authentication { authData: authData ) - default: throw SnodeAPIError.invalidAuthentication + default: throw CryptoError.invalidAuthentication } - default: throw SnodeAPIError.invalidAuthentication + default: throw CryptoError.invalidAuthentication } } } diff --git a/SessionMessagingKit/Utilities/Dependencies+Observation.swift b/SessionMessagingKit/Utilities/Dependencies+Observation.swift new file mode 100644 index 0000000000..62297c12e1 --- /dev/null +++ b/SessionMessagingKit/Utilities/Dependencies+Observation.swift @@ -0,0 +1,12 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +public extension Dependencies { + func notifyAsync(_ key: ObservableKey, value: AnyHashable?) { + Task(priority: .userInitiated) { [dependencies = self] in + await dependencies[singleton: .observationManager].notify(key, value: value) + } + } +} diff --git a/SessionMessagingKit/Utilities/DisplayPictureError.swift b/SessionMessagingKit/Utilities/DisplayPictureError.swift index d871791635..82fa50c841 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureError.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureError.swift @@ -4,26 +4,32 @@ import Foundation -public enum DisplayPictureError: LocalizedError { +public enum DisplayPictureError: Error, Equatable, CustomStringConvertible { case imageTooLarge case writeFailed + case loadFailed case databaseChangesFailed case encryptionFailed case uploadFailed case uploadMaxFileSizeExceeded case invalidCall - case invalidFilename + case invalidPath + case alreadyDownloaded(URL?) + case updateNoLongerValid - var localizedDescription: String { + public var description: String { switch self { case .imageTooLarge: return "Display picture too large." case .writeFailed: return "Display picture write failed." + case .loadFailed: return "Display picture load failed." case .databaseChangesFailed: return "Failed to save display picture to database." case .encryptionFailed: return "Display picture encryption failed." case .uploadFailed: return "Display picture upload failed." case .uploadMaxFileSizeExceeded: return "Maximum file size exceeded." case .invalidCall: return "Attempted to remove display picture using the wrong method." - case .invalidFilename: return "Filename would have resulted in an invalid path." + case .invalidPath: return "Failed to generate a valid path." + case .alreadyDownloaded: return "Display picture already downloaded." + case .updateNoLongerValid: return "Display picture update no longer valid." } } } diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index c3849ca80f..6b6c1a605f 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -25,21 +25,39 @@ public extension Log.Category { // MARK: - DisplayPictureManager public class DisplayPictureManager { - public typealias UploadResult = (downloadUrl: String, fileName: String, encryptionKey: Data) + public typealias UploadResult = (downloadUrl: String, filePath: String, encryptionKey: Data) public enum Update { case none case contactRemove - case contactUpdateTo(url: String, key: Data, fileName: String?) + case contactUpdateTo(url: String, key: Data, filePath: String) case currentUserRemove case currentUserUploadImageData(Data) - case currentUserUpdateTo(url: String, key: Data, fileName: String?) + case currentUserUpdateTo(url: String, key: Data, filePath: String) case groupRemove case groupUploadImageData(Data) - case groupUpdateTo(url: String, key: Data, fileName: String?) + case groupUpdateTo(url: String, key: Data, filePath: String) + + static func from(_ profile: VisibleMessage.VMProfile, fallback: Update, using dependencies: Dependencies) -> Update { + return from(profile.profilePictureUrl, key: profile.profileKey, fallback: fallback, using: dependencies) + } + + static func from(_ profile: Profile, fallback: Update, using dependencies: Dependencies) -> Update { + return from(profile.displayPictureUrl, key: profile.displayPictureEncryptionKey, fallback: fallback, using: dependencies) + } + + static func from(_ url: String?, key: Data?, fallback: Update, using dependencies: Dependencies) -> Update { + guard + let url: String = url, + let key: Data = key, + let filePath: String = try? dependencies[singleton: .displayPictureManager].path(for: url) + else { return fallback } + + return .contactUpdateTo(url: url, key: key, filePath: filePath) + } } public static let maxBytes: UInt = (5 * 1000 * 1000) @@ -52,6 +70,17 @@ public class DisplayPictureManager { private let scheduleDownloads: PassthroughSubject<(), Never> = PassthroughSubject() private var scheduleDownloadsCancellable: AnyCancellable? + /// `NSCache` has more nuanced memory management systems than just listening for `didReceiveMemoryWarningNotification` + /// and can clear out values gradually, it can also remove items based on their "cost" so is better suited than our custom `LRUCache` + /// + /// Additionally `NSCache` is thread safe so we don't need to do any custom `ThreadSafeObject` work to interact with it + private var cache: NSCache = { + let result: NSCache = NSCache() + result.totalCostLimit = 5 * 1024 * 1024 /// Max 5MB of url to hash data (approx. 20,000 records) + + return result + }() + // MARK: - Initalization init(using dependencies: Dependencies) { @@ -72,45 +101,35 @@ public class DisplayPictureManager { public func sharedDataDisplayPictureDirPath() -> String { let path: String = URL(fileURLWithPath: dependencies[singleton: .fileManager].appSharedDataDirectoryPath) - .appendingPathComponent("ProfileAvatars") // stringlint:ignore + .appendingPathComponent("DisplayPictures") // stringlint:ignore .path try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: path) return path } - // MARK: - Loading - - public func loadDisplayPictureFromDisk(for fileName: String) -> Data? { - guard let filePath: String = try? filepath(for: fileName) else { return nil } - - return try? Data(contentsOf: URL(fileURLWithPath: filePath)) - } - // MARK: - File Paths /// **Note:** Generally the url we get won't have an extension and we don't want to make assumptions until we have the actual /// image data so generate a name for the file and then determine the extension separately - public func generateFilenameWithoutExtension(for url: String) -> String { - return (dependencies[singleton: .crypto] - .generate(.hash(message: url.bytes))? - .toHexString()) - .defaulting(to: UUID().uuidString) - } - - public func generateFilename(format: ImageFormat = .jpeg) -> String { - return dependencies[singleton: .crypto] - .generate(.uuid()) - .defaulting(to: UUID()) - .uuidString - .appendingFileExtension(format.fileExtension) - } - - public func filepath(for filename: String) throws -> String { - guard !filename.isEmpty else { throw DisplayPictureError.invalidCall } + public func path(for urlString: String?) throws -> String { + guard + let urlString: String = urlString, + !urlString.isEmpty + else { throw DisplayPictureError.invalidCall } + + let urlHash = try { + guard let cachedHash: String = cache.object(forKey: urlString as NSString) as? String else { + return try dependencies[singleton: .crypto] + .tryGenerate(.hash(message: Array(urlString.utf8))) + .toHexString() + } + + return cachedHash + }() return URL(fileURLWithPath: sharedDataDisplayPictureDirPath()) - .appendingPathComponent(filename) + .appendingPathComponent(urlHash) .path } @@ -129,26 +148,20 @@ public class DisplayPictureManager { .throttle(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .userInitiated), latest: true) .sink( receiveValue: { [dependencies] _ in - let pendingInfo: Set = dependencies.mutate(cache: .displayPicture) { cache in - let result: Set = cache.downloadsToSchedule + let pendingInfo: Set = dependencies.mutate(cache: .displayPicture) { cache in + let result: Set = cache.downloadsToSchedule cache.downloadsToSchedule.removeAll() return result } dependencies[singleton: .storage].writeAsync { db in - pendingInfo.forEach { info in - // If the current file is invalid then clear out the 'profilePictureFileName' - // and try to re-download the file - if info.currentFileInvalid { - info.owner.clearCurrentFile(db) - } - + pendingInfo.forEach { owner in dependencies[singleton: .jobRunner].add( db, job: Job( variant: .displayPictureDownload, shouldBeUnique: true, - details: DisplayPictureDownloadJob.Details(owner: info.owner) + details: DisplayPictureDownloadJob.Details(owner: owner) ), canStartJob: true ) @@ -158,11 +171,11 @@ public class DisplayPictureManager { ) } - public func scheduleDownload(for owner: Owner, currentFileInvalid invalid: Bool = false) { + public func scheduleDownload(for owner: Owner) { guard owner.canDownloadImage else { return } dependencies.mutate(cache: .displayPicture) { cache in - cache.downloadsToSchedule.insert(DownloadInfo(owner: owner, currentFileInvalid: invalid)) + cache.downloadsToSchedule.insert(owner) } scheduleDownloads.send(()) } @@ -172,7 +185,7 @@ public class DisplayPictureManager { public func prepareAndUploadDisplayPicture(imageData: Data) -> AnyPublisher { return Just(()) .setFailureType(to: DisplayPictureError.self) - .tryMap { [weak self, dependencies] _ -> (Network.PreparedRequest, String, Data, Data) in + .tryMap { [dependencies] _ -> (Network.PreparedRequest, String, Data) in // If the profile avatar was updated or removed then encrypt with a new profile key // to ensure that other users know that our profile picture was updated let newEncryptionKey: Data @@ -245,17 +258,10 @@ public class DisplayPictureManager { // * Send asset service info to Signal Service Log.verbose(.displayPictureManager, "Updating local profile on service with new avatar.") - let fileName: String = dependencies[singleton: .crypto].generate(.uuid()) - .defaulting(to: UUID()) - .uuidString - .appendingFileExtension(fileExtension) - - guard let filePath: String = try? self?.filepath(for: fileName) else { - throw DisplayPictureError.invalidFilename - } + let temporaryFilePath: String = dependencies[singleton: .fileManager].temporaryFilePath(fileExtension: fileExtension) // Write the avatar to disk - do { try finalImageData.write(to: URL(fileURLWithPath: filePath), options: [.atomic]) } + do { try finalImageData.write(to: URL(fileURLWithPath: temporaryFilePath), options: [.atomic]) } catch { Log.error(.displayPictureManager, "Updating service with profile failed.") throw DisplayPictureError.writeFailed @@ -264,7 +270,7 @@ public class DisplayPictureManager { // Encrypt the avatar for upload guard let encryptedData: Data = dependencies[singleton: .crypto].generate( - .encryptedDataDisplayPicture(data: finalImageData, key: newEncryptionKey, using: dependencies) + .encryptedDataDisplayPicture(data: finalImageData, key: newEncryptionKey) ) else { Log.error(.displayPictureManager, "Updating service with profile failed.") @@ -283,15 +289,22 @@ public class DisplayPictureManager { throw DisplayPictureError.uploadFailed } - return (preparedUpload, fileName, newEncryptionKey, finalImageData) + return (preparedUpload, temporaryFilePath, newEncryptionKey) } - .flatMap { [dependencies] preparedUpload, fileName, newEncryptionKey, finalImageData -> AnyPublisher<(FileUploadResponse, String, Data, Data), Error> in + .flatMap { [dependencies] preparedUpload, temporaryFilePath, newEncryptionKey -> AnyPublisher<(FileUploadResponse, String, Data), Error> in preparedUpload.send(using: dependencies) - .map { _, response -> (FileUploadResponse, String, Data, Data) in - (response, fileName, newEncryptionKey, finalImageData) + .map { _, response -> (FileUploadResponse, String, Data) in + (response, temporaryFilePath, newEncryptionKey) } .eraseToAnyPublisher() } + .tryMap { [dependencies] fileUploadResponse, temporaryFilePath, newEncryptionKey -> (String, String, Data) in + let downloadUrl: String = Network.FileServer.downloadUrlString(for: fileUploadResponse.id) + let finalFilePath: String = try dependencies[singleton: .displayPictureManager].path(for: downloadUrl) + try dependencies[singleton: .fileManager].moveItem(atPath: temporaryFilePath, toPath: finalFilePath) + + return (downloadUrl, finalFilePath, newEncryptionKey) + } .mapError { error in Log.error(.displayPictureManager, "Updating service with profile failed with error: \(error).") @@ -301,22 +314,16 @@ public class DisplayPictureManager { default: return DisplayPictureError.uploadFailed } } - .map { [dependencies] fileUploadResponse, fileName, newEncryptionKey, finalImageData -> UploadResult in - let downloadUrl: String = Network.FileServer.downloadUrlString(for: fileUploadResponse.id) - + .map { [dependencies] downloadUrl, finalFilePath, newEncryptionKey -> UploadResult in /// Load the data into the `imageDataManager` (assuming we will use it elsewhere in the UI) - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - Task { - await dependencies[singleton: .imageDataManager].loadImageData( - identifier: fileName, - source: .data(finalImageData) + Task(priority: .userInitiated) { + await dependencies[singleton: .imageDataManager].load( + .url(URL(fileURLWithPath: finalFilePath)) ) - semaphore.signal() } - semaphore.wait() Log.verbose(.displayPictureManager, "Successfully uploaded avatar image.") - return (downloadUrl, fileName, newEncryptionKey) + return (downloadUrl, finalFilePath, newEncryptionKey) } .eraseToAnyPublisher() } @@ -337,53 +344,14 @@ public extension DisplayPictureManager { case community(OpenGroup) case file(String) - var fileName: String? { - switch self { - case .user(let profile): return profile.profilePictureFileName - case .group(let group): return group.displayPictureFilename - case .community(let openGroup): return openGroup.displayPictureFilename - case .file(let name): return name - } - } - var canDownloadImage: Bool { switch self { - case .user(let profile): return (profile.profilePictureUrl?.isEmpty == false) + case .user(let profile): return (profile.displayPictureUrl?.isEmpty == false) case .group(let group): return (group.displayPictureUrl?.isEmpty == false) case .community(let openGroup): return (openGroup.imageId?.isEmpty == false) case .file: return false } } - - fileprivate func clearCurrentFile(_ db: Database) { - switch self { - case .user(let profile): - _ = try? Profile - .filter(id: profile.id) - .updateAll(db, Profile.Columns.profilePictureFileName.set(to: nil)) - - case .group(let group): - _ = try? ClosedGroup - .filter(id: group.id) - .updateAll(db, ClosedGroup.Columns.displayPictureFilename.set(to: nil)) - - case .community(let openGroup): - _ = try? OpenGroup - .filter(id: openGroup.id) - .updateAll(db, OpenGroup.Columns.displayPictureFilename.set(to: nil)) - - case .file: return - } - } - } -} - -// MARK: - DisplayPictureManager.DownloadInfo - -public extension DisplayPictureManager { - struct DownloadInfo: Hashable { - let owner: Owner - let currentFileInvalid: Bool } } @@ -391,7 +359,7 @@ public extension DisplayPictureManager { public extension DisplayPictureManager { class Cache: DisplayPictureCacheType { - public var downloadsToSchedule: Set = [] + public var downloadsToSchedule: Set = [] } } @@ -408,9 +376,9 @@ public extension Cache { /// This is a read-only version of the Cache designed to avoid unintentionally mutating the instance in a non-thread-safe way public protocol DisplayPictureImmutableCacheType: ImmutableCacheType { - var downloadsToSchedule: Set { get } + var downloadsToSchedule: Set { get } } public protocol DisplayPictureCacheType: DisplayPictureImmutableCacheType, MutableCacheType { - var downloadsToSchedule: Set { get set } + var downloadsToSchedule: Set { get set } } diff --git a/SessionMessagingKit/Utilities/ExtensionHelper.swift b/SessionMessagingKit/Utilities/ExtensionHelper.swift new file mode 100644 index 0000000000..b42398044e --- /dev/null +++ b/SessionMessagingKit/Utilities/ExtensionHelper.swift @@ -0,0 +1,819 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionSnodeKit +import SessionUtilitiesKit + +// MARK: - Singleton + +public extension Singleton { + static let extensionHelper: SingletonConfig = Dependencies.create( + identifier: "extensionHelper", + createInstance: { dependencies in ExtensionHelper(using: dependencies) } + ) +} + +// MARK: - KeychainStorage + +// stringlint:ignore_contents +public extension KeychainStorage.DataKey { + static let extensionEncryptionKey: Self = "ExtensionEncryptionKeyKey" +} + +// MARK: - Log.Category + +private extension Log.Category { + static let cat: Log.Category = .create("ExtensionHelper", defaultLevel: .info) +} + +// MARK: - ExtensionHelper + +public class ExtensionHelper: ExtensionHelperType { + // stringlint:ignore_start + private lazy var cacheDirectoryPath: String = "\(dependencies[singleton: .fileManager].appSharedDataDirectoryPath)/extensionCache" + private lazy var metadataPath: String = "\(cacheDirectoryPath)/metadata" + private lazy var conversationsPath: String = "\(cacheDirectoryPath)/conversations" + private lazy var notificationSettingsPath: String = "\(cacheDirectoryPath)/notificationSettings" + private let conversationConfigDir: String = "config" + private let conversationReadDir: String = "read" + private let conversationUnreadDir: String = "unread" + private let conversationDedupeDir: String = "dedupe" + private let encryptionKeyLength: Int = 32 + // stringlint:ignore_stop + + private let dependencies: Dependencies + private lazy var messagesLoadedStream: CurrentValueAsyncStream = CurrentValueAsyncStream(false) + + // MARK: - Initialization + + init(using dependencies: Dependencies) { + self.dependencies = dependencies + } + + // MARK: - File Management + + // stringlint:ignore_contents + private func conversationPath(_ threadId: String) -> String? { + guard + let hash: [UInt8] = dependencies[singleton: .crypto].generate( + .hash(message: Array("ConvoIdSalt-\(threadId)".utf8)) + ) + else { return nil } + + return URL(fileURLWithPath: conversationsPath) + .appendingPathComponent(hash.toHexString()) + .path + } + + private func write(data: Data, to path: String) throws { + /// Load in the data and `encKey` and reset the `encKey` as soon as the function ends + guard + var encKey: [UInt8] = (try? dependencies[singleton: .keychain] + .getOrGenerateEncryptionKey( + forKey: .extensionEncryptionKey, + length: encryptionKeyLength, + cat: .cat + )).map({ Array($0) }) + else { throw ExtensionHelperError.noEncryptionKey } + defer { encKey.resetBytes(in: 0.. Data { + /// Load in the data and `encKey` and reset the `encKey` as soon as the function ends + guard + var encKey: [UInt8] = (try? dependencies[singleton: .keychain] + .getOrGenerateEncryptionKey( + forKey: .extensionEncryptionKey, + length: encryptionKeyLength, + cat: .cat + )).map({ Array($0) }) + else { throw ExtensionHelperError.noEncryptionKey } + defer { encKey.resetBytes(in: 0.. UserMetadata? { + guard let plaintext: Data = try? read(from: metadataPath) else { return nil } + + return try? JSONDecoder(using: dependencies) + .decode(UserMetadata.self, from: plaintext) + } + + // MARK: - Deduping + + // stringlint:ignore_contents + private func dedupeRecordPath(_ threadId: String, _ uniqueIdentifier: String) -> String? { + guard + let conversationPath: String = conversationPath(threadId), + let hash: [UInt8] = dependencies[singleton: .crypto].generate( + .hash(message: Array("DedupeRecordSalt-\(uniqueIdentifier)".utf8)) + ) + else { return nil } + + return URL(fileURLWithPath: conversationPath) + .appendingPathComponent(conversationDedupeDir) + .appendingPathComponent(hash.toHexString()) + .path + } + + public func hasAtLeastOneDedupeRecord(threadId: String) -> Bool { + guard let conversationPath: String = conversationPath(threadId) else { return false } + + return !dependencies[singleton: .fileManager].isDirectoryEmpty( + atPath: URL(fileURLWithPath: conversationPath) + .appendingPathComponent(conversationDedupeDir) + .path + ) + } + + public func dedupeRecordExists(threadId: String, uniqueIdentifier: String) -> Bool { + guard let path: String = dedupeRecordPath(threadId, uniqueIdentifier) else { return false } + + return dependencies[singleton: .fileManager].fileExists(atPath: path) + } + + public func createDedupeRecord(threadId: String, uniqueIdentifier: String) throws { + guard let path: String = dedupeRecordPath(threadId, uniqueIdentifier) else { + throw ExtensionHelperError.failedToStoreDedupeRecord + } + + try write(data: Data(), to: path) + } + + public func removeDedupeRecord(threadId: String, uniqueIdentifier: String) throws { + guard let path: String = dedupeRecordPath(threadId, uniqueIdentifier) else { + throw ExtensionHelperError.failedToRemoveDedupeRecord + } + + try dependencies[singleton: .fileManager].removeItem(atPath: path) + + /// Also remove the directory if it's empty + let parentDirectory: String = URL(fileURLWithPath: path) + .deletingLastPathComponent() + .path + + if dependencies[singleton: .fileManager].isDirectoryEmpty(atPath: parentDirectory) { + try? dependencies[singleton: .fileManager].removeItem(atPath: parentDirectory) + } + } + + // MARK: - Config Dumps + + // stringlint:ignore_contents + private func dumpFilePath(for sessionId: SessionId, variant: ConfigDump.Variant) -> String? { + guard + let conversationPath: String = conversationPath(sessionId.hexString), + let hash: [UInt8] = dependencies[singleton: .crypto].generate( + .hash(message: Array("DumpSalt-\(variant)".utf8)) + ) + else { return nil } + + return URL(fileURLWithPath: conversationPath) + .appendingPathComponent("dumps") + .appendingPathComponent(hash.toHexString()) + .path + } + + public func lastUpdatedTimestamp( + for sessionId: SessionId, + variant: ConfigDump.Variant + ) -> TimeInterval { + guard let path: String = dumpFilePath(for: sessionId, variant: variant) else { return 0 } + + return ((try? dependencies[singleton: .fileManager] + .attributesOfItem(atPath: path) + .getting(.modificationDate) as? Date)? + .timeIntervalSince1970) + .defaulting(to: 0) + } + + public func replicate(dump: ConfigDump?, replaceExisting: Bool) { + guard + let dump: ConfigDump = dump, + let path: String = dumpFilePath(for: dump.sessionId, variant: dump.variant) + else { return } + + /// Only continue if we want to replace an existing dump, or one doesn't exist + guard + replaceExisting || + !dependencies[singleton: .fileManager].fileExists(atPath: path) + else { return } + + /// Write the dump data to disk + do { try write(data: dump.data, to: path) } + catch { Log.error(.cat, "Failed to replicate \(dump.variant) dump for \(dump.sessionId.hexString).") } + } + + public func replicateAllConfigDumpsIfNeeded(userSessionId: SessionId) { + /// We can be reasonably sure that if the `userProfile` config dump is missing then we probably haven't replicated any + /// config dumps yet and should do so, if the `userProfile` config dump is there but we can't read it for some reason then + /// we should also replicate + guard + let path: String = dumpFilePath(for: userSessionId, variant: .userProfile), + ((try? read(from: path)) == nil) + else { return } + + /// Load the config dumps from the database + let fetchTimestamp: TimeInterval = dependencies.dateNow.timeIntervalSince1970 + dependencies[singleton: .storage].readAsync( + retrieve: { db in try ConfigDump.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) + ) + } + } + ) + } + + public func refreshDumpModifiedDate(sessionId: SessionId, variant: ConfigDump.Variant) { + guard let path: String = dumpFilePath(for: sessionId, variant: variant) else { return } + + try? refreshModifiedDate(at: path) + } + + public func loadUserConfigState( + into cache: LibSessionCacheType, + userSessionId: SessionId, + userEd25519SecretKey: [UInt8] + ) { + ConfigDump.Variant.userVariants + .sorted { $0.loadOrder < $1.loadOrder } + .forEach { variant in + guard + let path: String = dumpFilePath(for: userSessionId, variant: variant), + let dump: Data = try? read(from: path), + let config: LibSession.Config = try? cache.loadState( + for: variant, + sessionId: userSessionId, + userEd25519SecretKey: userEd25519SecretKey, + groupEd25519SecretKey: nil, + cachedData: dump + ) + else { + /// If a file doesn't exist at the path then assume we don't have a config dump and just load in a default one + return cache.loadDefaultStateFor( + variant: variant, + sessionId: userSessionId, + userEd25519SecretKey: userEd25519SecretKey, + groupEd25519SecretKey: nil + ) + } + + cache.setConfig(for: variant, sessionId: userSessionId, to: config) + } + } + + public func loadGroupConfigStateIfNeeded( + into cache: LibSessionCacheType, + swarmPublicKey: String, + userEd25519SecretKey: [UInt8] + ) throws -> [ConfigDump.Variant: Bool] { + guard + let groupSessionId: SessionId = try? SessionId(from: swarmPublicKey), + groupSessionId.prefix == .group + else { return [:] } + + let groupEd25519SecretKey: [UInt8]? = cache.secretKey(groupSessionId: groupSessionId) + var results: [ConfigDump.Variant: Bool] = [:] + + try ConfigDump.Variant.groupVariants + .sorted { $0.loadOrder < $1.loadOrder } + .forEach { variant in + /// If a file doesn't exist at the path then assume we don't have a config dump and don't do anything (we wouldn't + /// be able to handle a notification without a valid config anyway) + guard + let path: String = dumpFilePath(for: groupSessionId, variant: variant), + let dump: Data = try? read(from: path) + else { return results[variant] = false } + + cache.setConfig( + for: variant, + sessionId: groupSessionId, + to: try cache.loadState( + for: variant, + sessionId: groupSessionId, + userEd25519SecretKey: userEd25519SecretKey, + groupEd25519SecretKey: groupEd25519SecretKey, + cachedData: dump + ) + ) + results[variant] = true + } + + return results + } + + // MARK: - Notification Settings + + private struct NotificationSettings: Codable { + let threadId: String + let mentionsOnly: Bool + let mutedUntil: TimeInterval? + } + + public func replicate(settings: [String: Preferences.NotificationSettings], replaceExisting: Bool) throws { + /// Only continue if we want to replace an existing file, or one doesn't exist + guard + replaceExisting || + !dependencies[singleton: .fileManager].fileExists(atPath: notificationSettingsPath) + else { return } + + /// Generate the data (we can exclude anything which has default settings as that would just be redudant data) + let allSettings: [NotificationSettings] = settings + .filter { _, value in + value.mentionsOnly || + value.mutedUntil != nil + } + .map { key, value in + NotificationSettings( + threadId: key, + mentionsOnly: value.mentionsOnly, + mutedUntil: value.mutedUntil + ) + } + + guard let settingsAsData: Data = try? JSONEncoder(using: dependencies).encode(allSettings) else { + return + } + + try write(data: settingsAsData, to: notificationSettingsPath) + } + + public func loadNotificationSettings( + previewType: Preferences.NotificationPreviewType, + sound: Preferences.Sound + ) -> [String: Preferences.NotificationSettings]? { + guard + let plaintext: Data = try? read(from: notificationSettingsPath), + let allSettings: [NotificationSettings] = try? JSONDecoder(using: dependencies) + .decode([NotificationSettings].self, from: plaintext) + else { return nil } + + return allSettings.reduce(into: [:]) { result, settings in + result[settings.threadId] = Preferences.NotificationSettings( + previewType: previewType, + sound: sound, + mentionsOnly: settings.mentionsOnly, + mutedUntil: settings.mutedUntil + ) + } + } + + // MARK: - Messages + + // stringlint:ignore_contents + private func configMessagePath(_ threadId: String, _ uniqueIdentifier: String) -> String? { + guard + let conversationPath: String = conversationPath(threadId), + let hash: [UInt8] = dependencies[singleton: .crypto].generate( + .hash(message: Array("ConfigMessageSalt-\(uniqueIdentifier)".utf8)) + ) + else { return nil } + + return URL(fileURLWithPath: conversationPath) + .appendingPathComponent(conversationConfigDir) + .appendingPathComponent(hash.toHexString()) + .path + } + + // stringlint:ignore_contents + private func readMessagePath(_ threadId: String, _ uniqueIdentifier: String) -> String? { + guard + let conversationPath: String = conversationPath(threadId), + let hash: [UInt8] = dependencies[singleton: .crypto].generate( + .hash(message: Array("ReadMessageSalt-\(uniqueIdentifier)".utf8)) + ) + else { return nil } + + return URL(fileURLWithPath: conversationPath) + .appendingPathComponent(conversationReadDir) + .appendingPathComponent(hash.toHexString()) + .path + } + + // stringlint:ignore_contents + private func unreadMessagePath(_ threadId: String, _ uniqueIdentifier: String) -> String? { + guard + let conversationPath: String = conversationPath(threadId), + let hash: [UInt8] = dependencies[singleton: .crypto].generate( + .hash(message: Array("UnreadMessageSalt-\(uniqueIdentifier)".utf8)) + ) + else { return nil } + + return URL(fileURLWithPath: conversationPath) + .appendingPathComponent(conversationUnreadDir) + .appendingPathComponent(hash.toHexString()) + .path + } + + public func unreadMessageCount() -> Int? { + do { + let conversationHashes: [String] = try dependencies[singleton: .fileManager] + .contentsOfDirectory(atPath: conversationsPath) + .filter({ !$0.starts(with: ".") }) // stringlint:ignore + + return try conversationHashes.reduce(0) { result, conversationHash in + let unreadMessagePath: String = URL(fileURLWithPath: conversationsPath) + .appendingPathComponent(conversationHash) + .appendingPathComponent(conversationUnreadDir) + .path + + /// Ensure the `unreadMessagePath` exists before trying to count it's contents (if it doesn't then `contentsOfDirectory` + /// will throw, but that case is actually a valid `0` result + guard dependencies[singleton: .fileManager].fileExists(atPath: unreadMessagePath) else { + return result + } + + let unreadMessageHashes: [String] = try dependencies[singleton: .fileManager] + .contentsOfDirectory(atPath: unreadMessagePath) + .filter { !$0.starts(with: ".") } // stringlint:ignore + + return (result + unreadMessageHashes.count) + } + } + catch { return nil } + } + + public func saveMessage(_ message: SnodeReceivedMessage?, isUnread: Bool) throws { + guard + let message: SnodeReceivedMessage = message, + let messageAsData: Data = try? JSONEncoder(using: dependencies).encode(message), + let targetPath: String = { + switch (message.namespace.isConfigNamespace, isUnread) { + case (true, _): return configMessagePath(message.swarmPublicKey, message.hash) + case (false, true): return unreadMessagePath(message.swarmPublicKey, message.hash) + case (false, false): return readMessagePath(message.swarmPublicKey, message.hash) + } + }() + else { return } + + 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: SnodeAPI.Namespace, messages: [SnodeReceivedMessage], lastHash: String?) + + /// Retrieve all conversation file paths + /// + /// This will ignore any hidden files (just in case) and will also insert the current users conversation (ie. `Note to Self`) at + /// the first position as that's where user config messages will be sotred + let userSessionId: SessionId = dependencies[cache: .general].sessionId + let currentUserConversationHash: String? = conversationPath(userSessionId.hexString) + .map { URL(fileURLWithPath: $0).lastPathComponent } + let conversationHashes: [String] = (try? dependencies[singleton: .fileManager] + .contentsOfDirectory(atPath: conversationsPath) + .filter { hash in + !hash.starts(with: ".") && // stringlint:ignore + hash != currentUserConversationHash + }) + .defaulting(to: []) + .inserting(currentUserConversationHash, at: 0) + var successConfigCount: Int = 0 + var failureConfigCount: Int = 0 + var successStandardCount: Int = 0 + var failureStandardCount: Int = 0 + + try await dependencies[singleton: .storage].writeAsync { [weak self, dependencies] db in + guard let this = self else { return } + + /// Process each conversation individually + conversationHashes.forEach { conversationHash in + /// 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 + /// to ensure that we don't miss any changes and ensure they are processed in the order they were received, if an + /// error occurs then we want to just discard all of the config changes as otherwise we could end up in a weird state + let configsPath: String = URL(fileURLWithPath: this.conversationsPath) + .appendingPathComponent(conversationHash) + .appendingPathComponent(this.conversationConfigDir) + .path + let configMessageHashes: [String] = (try? dependencies[singleton: .fileManager] + .contentsOfDirectory(atPath: configsPath) + .filter { !$0.starts(with: ".") }) // stringlint:ignore + .defaulting(to: []) + + do { + let sortedMessages: [MessageData] = try configMessageHashes + .reduce([SnodeAPI.Namespace: [SnodeReceivedMessage]]()) { (result: [SnodeAPI.Namespace: [SnodeReceivedMessage]], 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) + + return result.appending(message, toArrayOn: message.namespace) + } + .map { namespace, messages -> MessageData in (namespace, messages, nil) } + .sorted { lhs, rhs in lhs.namespace.processingOrder < rhs.namespace.processingOrder } + + /// Process the message (inserting into the database if needed (messages are processed per conversaiton so + /// all have the same `swarmPublicKey`) + switch sortedMessages.first?.messages.first?.swarmPublicKey { + case .none: break + case .some(let swarmPublicKey): + SwarmPoller.processPollResponse( + db, + cat: .cat, + source: .pushNotification, + swarmPublicKey: swarmPublicKey, + shouldStoreMessages: true, + ignoreDedupeFiles: true, + forceSynchronousProcessing: true, + sortedMessages: sortedMessages, + using: dependencies + ) + } + + successConfigCount += configMessageHashes.count + } + catch { + failureConfigCount += configMessageHashes.count + Log.error(.cat, "Discarding some config message changes due to error: \(error)") + } + + /// Remove the config message files now that they are processed + try? dependencies[singleton: .fileManager].removeItem(atPath: configsPath) + + /// Retrieve and process any standard messages + /// + /// Since there is no guarantee that we will have received a push notification for every message, or even that push + /// notifications will be received in the correct order, we can just process standard messages individually + let readMessagePath: String = URL(fileURLWithPath: this.conversationsPath) + .appendingPathComponent(conversationHash) + .appendingPathComponent(this.conversationReadDir) + .path + let unreadMessagePath: String = URL(fileURLWithPath: this.conversationsPath) + .appendingPathComponent(conversationHash) + .appendingPathComponent(this.conversationUnreadDir) + .path + let readMessageHashes: [String] = (try? dependencies[singleton: .fileManager] + .contentsOfDirectory(atPath: readMessagePath) + .filter { !$0.starts(with: ".") }) // stringlint:ignore + .defaulting(to: []) + let unreadMessageHashes: [String] = (try? dependencies[singleton: .fileManager] + .contentsOfDirectory(atPath: unreadMessagePath) + .filter { !$0.starts(with: ".") }) // stringlint:ignore + .defaulting(to: []) + let allMessagePaths: [String] = ( + readMessageHashes.map { hash in + URL(fileURLWithPath: this.conversationsPath) + .appendingPathComponent(conversationHash) + .appendingPathComponent(this.conversationReadDir) + .appendingPathComponent(hash) + .path + } + + unreadMessageHashes.map { hash in + URL(fileURLWithPath: this.conversationsPath) + .appendingPathComponent(conversationHash) + .appendingPathComponent(this.conversationUnreadDir) + .appendingPathComponent(hash) + .path + } + ) + + allMessagePaths.forEach { path in + do { + let plaintext: Data = try this.read(from: path) + let message: SnodeReceivedMessage = try JSONDecoder(using: dependencies) + .decode(SnodeReceivedMessage.self, from: plaintext) + + SwarmPoller.processPollResponse( + db, + cat: .cat, + source: .pushNotification, + swarmPublicKey: message.swarmPublicKey, + shouldStoreMessages: true, + ignoreDedupeFiles: true, + forceSynchronousProcessing: true, + sortedMessages: [(message.namespace, [message], nil)], + using: dependencies + ) + successStandardCount += 1 + } + catch { + failureStandardCount += 1 + Log.error(.cat, "Discarding standard message due to error: \(error)") + } + } + + /// Remove the standard message files now that they are processed + try? dependencies[singleton: .fileManager].removeItem(atPath: readMessagePath) + try? dependencies[singleton: .fileManager].removeItem(atPath: unreadMessagePath) + } + } + + 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 } + return true + } + group.addTask { + try await Task.sleep(for: timeout) + return false + } + + let result = await group.nextResult() + group.cancelAll() + + switch result { + case .success(true): return true + default: return false + } + } + } +} + +// MARK: - ExtensionHelper.UserMetadata + +public extension ExtensionHelper { + struct UserMetadata: Codable { + public let sessionId: SessionId + public let ed25519SecretKey: [UInt8] + public let unreadCount: Int + } +} + +// MARK: - ExtensionHelperError + +public enum ExtensionHelperError: Error, CustomStringConvertible { + case noEncryptionKey + case failedToWriteToFile + case failedToReadFromFile + case failedToStoreDedupeRecord + case failedToRemoveDedupeRecord + + // stringlint:ignore_contents + public var description: String { + switch self { + case .noEncryptionKey: return "No encryption key available." + case .failedToWriteToFile: return "Failed to write to file." + case .failedToReadFromFile: return "Failed to read from file." + case .failedToStoreDedupeRecord: return "Failed to store a record for message deduplication." + case .failedToRemoveDedupeRecord: return "Failed to remove a record for message deduplication." + } + } +} + +// MARK: - ExtensionHelperType + +public protocol ExtensionHelperType { + func deleteCache() + + // MARK: - User Metadata + + func saveUserMetadata( + sessionId: SessionId, + ed25519SecretKey: [UInt8], + unreadCount: Int? + ) throws + func loadUserMetadata() -> ExtensionHelper.UserMetadata? + + // MARK: - Deduping + + func hasAtLeastOneDedupeRecord(threadId: String) -> Bool + func dedupeRecordExists(threadId: String, uniqueIdentifier: String) -> Bool + func createDedupeRecord(threadId: String, uniqueIdentifier: String) throws + func removeDedupeRecord(threadId: String, uniqueIdentifier: String) throws + + // MARK: - Config Dumps + + func lastUpdatedTimestamp(for sessionId: SessionId, variant: ConfigDump.Variant) -> TimeInterval + func replicate(dump: ConfigDump?, replaceExisting: Bool) + func replicateAllConfigDumpsIfNeeded(userSessionId: SessionId) + func refreshDumpModifiedDate(sessionId: SessionId, variant: ConfigDump.Variant) + func loadUserConfigState( + into cache: LibSessionCacheType, + userSessionId: SessionId, + userEd25519SecretKey: [UInt8] + ) + func loadGroupConfigStateIfNeeded( + into cache: LibSessionCacheType, + swarmPublicKey: String, + userEd25519SecretKey: [UInt8] + ) throws -> [ConfigDump.Variant: Bool] + + // MARK: - Notification Settings + + func replicate(settings: [String: Preferences.NotificationSettings], replaceExisting: Bool) throws + func loadNotificationSettings( + previewType: Preferences.NotificationPreviewType, + sound: Preferences.Sound + ) -> [String: Preferences.NotificationSettings]? + + // MARK: - Messages + + func unreadMessageCount() -> Int? + func saveMessage(_ message: SnodeReceivedMessage?, isUnread: Bool) throws + func willLoadMessages() + func loadMessages() async throws + @discardableResult func waitUntilMessagesAreLoaded(timeout: DispatchTimeInterval) async -> Bool +} + +public extension ExtensionHelperType { + func replicate(dump: ConfigDump?) { replicate(dump: dump, replaceExisting: true) } +} diff --git a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift new file mode 100644 index 0000000000..835e7c1d1a --- /dev/null +++ b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift @@ -0,0 +1,254 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import SessionUtilitiesKit + +public extension ObservableKey { + static let isUsingFullAPNs: ObservableKey = "isUsingFullAPNs" + + static func setting(_ key: any Setting.Key) -> ObservableKey { + ObservableKey(key.rawValue) + } + + static func loadPage(_ observationName: String) -> ObservableKey { + ObservableKey("loadPage-\(observationName)", .loadPage) + } + + static func typingIndicator(_ threadId: String) -> ObservableKey { + ObservableKey("typingIndicator-\(threadId)", .typingIndicator) + } + + // MARK: - Contacts + + static func profile(_ id: String) -> ObservableKey { + ObservableKey("profile-\(id)", .profile) + } + static func contact(_ id: String) -> ObservableKey { + ObservableKey("contact-\(id)", .contact) + } + + // MARK: - Conversations + + static let conversationCreated: ObservableKey = "conversationCreated" + static func conversationUpdated(_ id: String) -> ObservableKey { + ObservableKey("conversationUpdated-\(id)", .conversationUpdated) + } + static func conversationDeleted(_ id: String) -> ObservableKey { + ObservableKey("conversationDeleted-\(id)", .conversationDeleted) + } + + // MARK: - Messages + + static func messageCreated(threadId: String) -> ObservableKey { + ObservableKey("messageCreated-\(threadId)", .messageCreated) + } + static func messageUpdated(id: Int64?, threadId: String) -> ObservableKey { + ObservableKey("messageUpdated-\(threadId)-\(id.map { "\($0)" } ?? "NULL")", .messageUpdated) + } + static func messageDeleted(id: Int64?, threadId: String) -> ObservableKey { + ObservableKey("messageDeleted-\(threadId)-\(id.map { "\($0)" } ?? "NULL")", .messageDeleted) + } + + static func attachmentCreated(messageId: Int64?) -> ObservableKey { + ObservableKey("attachmentUpdated-\(messageId.map { "\($0)" } ?? "NULL")", .attachmentCreated) + } + static func attachmentUpdated(id: String, messageId: Int64?) -> ObservableKey { + ObservableKey("attachmentUpdated-\(id)-\(messageId.map { "\($0)" } ?? "NULL")", .attachmentUpdated) + } + static func attachmentDeleted(id: String, messageId: Int64?) -> ObservableKey { + ObservableKey("attachmentDeleted-\(id)-\(messageId.map { "\($0)" } ?? "NULL")", .attachmentDeleted) + } + + // MARK: - Message Requests + + static let messageRequestAccepted: ObservableKey = "messageRequestAccepted" + static let unreadMessageRequestMessageReceived: ObservableKey = "unreadMessageRequestMessageReceived" +} + +public extension GenericObservableKey { + static let setting: GenericObservableKey = "setting" + static let loadPage: GenericObservableKey = "loadPage" + static let typingIndicator: GenericObservableKey = "typingIndicator" + static let profile: GenericObservableKey = "profile" + static let contact: GenericObservableKey = "contact" + + static let conversationUpdated: GenericObservableKey = "conversationUpdated" + static let conversationDeleted: GenericObservableKey = "conversationDeleted" + static let messageCreated: GenericObservableKey = "messageCreated" + static let messageUpdated: GenericObservableKey = "messageUpdated" + static let messageDeleted: GenericObservableKey = "messageDeleted" + static let attachmentCreated: GenericObservableKey = "attachmentCreated" + static let attachmentUpdated: GenericObservableKey = "attachmentUpdated" + static let attachmentDeleted: GenericObservableKey = "attachmentDeleted" +} + +// MARK: - Event Payloads - General + +public enum CRUDEvent { + case created + case updated(T) + case deleted + + var change: T? { + switch self { + case .updated(let value): return value + case .created, .deleted: return nil + } + } +} + +public struct LoadPageEvent: Hashable { + public let target: Target + + public enum Target: Hashable { + case initial + case previousPage(Int) + case nextPage(Int) + } + + public static var initial: LoadPageEvent { LoadPageEvent(target: .initial) } + + public static func previousPage(firstIndex: Int) -> LoadPageEvent { + LoadPageEvent(target: .previousPage(firstIndex)) + } + + public static func nextPage(lastIndex: Int) -> LoadPageEvent { + LoadPageEvent(target: .nextPage(lastIndex)) + } +} + +public struct TypingIndicatorEvent: Hashable { + public let threadId: String + public let change: Change + + public enum Change: Hashable { + case started + case stopped + } +} + +public extension ObservingDatabase { + func addTypingIndicatorEvent(threadId: String, change: TypingIndicatorEvent.Change) { + self.addEvent(ObservedEvent( + key: .typingIndicator(threadId), + value: TypingIndicatorEvent(threadId: threadId, change: change) + )) + } +} + +// MARK: - Event Payloads - Contacts + +public struct ProfileEvent: Hashable { + public let id: String + public let change: Change + + public enum Change: Hashable { + case name(String) + case nickname(String?) + case displayPictureUrl(String?) + } +} + +public extension ObservingDatabase { + func addProfileEvent(id: String, change: ProfileEvent.Change) { + self.addEvent(ObservedEvent(key: .profile(id), value: ProfileEvent(id: id, change: change))) + } +} + +public struct ContactEvent: Hashable { + public let id: String + public let change: Change + + public enum Change: Hashable { + case isTrusted(Bool) + case isApproved(Bool) + case isBlocked(Bool) + case didApproveMe(Bool) + } +} + +public extension ObservingDatabase { + func addContactEvent(id: String, change: ContactEvent.Change) { + self.addEvent(ObservedEvent(key: .contact(id), value: ContactEvent(id: id, change: change))) + } +} + +// MARK: - Event Payloads - Conversations + +public struct ConversationEvent: Hashable { + public let id: String + public let change: Change? + + public enum Change: Hashable { + case displayName(String) + case description(String?) + case displayPictureUrl(String?) + case pinnedPriority(Int32) + case shouldBeVisible(Bool) + case mutedUntilTimestamp(TimeInterval?) + case onlyNotifyForMentions(Bool) + case markedAsUnread(Bool) + } +} + +public extension ObservingDatabase { + func addConversationEvent(id: String, type: CRUDEvent) { + let event: ConversationEvent = ConversationEvent(id: id, change: type.change) + + 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)) + } + } +} + +// MARK: - Event Payloads - Messages + +public struct MessageEvent: Hashable { + public let id: Int64? + public let threadId: String + public let change: Change? + + public enum Change: Hashable { + case wasRead(Bool) + case state(Interaction.State) + case recipientReadTimestampMs(Int64) + } +} + +public extension ObservingDatabase { + func addMessageEvent(id: Int64?, threadId: String, type: CRUDEvent) { + let event: MessageEvent = MessageEvent(id: id, threadId: threadId, change: type.change) + + switch type { + case .created: addEvent(ObservedEvent(key: .messageCreated(threadId: threadId), value: event)) + case .updated: addEvent(ObservedEvent(key: .messageUpdated(id: id, threadId: threadId), value: event)) + case .deleted: addEvent(ObservedEvent(key: .messageDeleted(id: id, threadId: threadId), value: event)) + } + } +} + +public struct AttachmentEvent: Hashable { + public let id: String + public let messageId: Int64? + public let change: Change? + + public enum Change: Hashable { + case state(Attachment.State) + } +} + +public extension ObservingDatabase { + func addAttachmentEvent(id: String, messageId msgId: Int64?, type: CRUDEvent) { + let event: AttachmentEvent = AttachmentEvent(id: id, messageId: msgId, change: type.change) + + switch type { + case .created: addEvent(ObservedEvent(key: .attachmentCreated(messageId: msgId), value: event)) + case .updated: addEvent(ObservedEvent(key: .attachmentUpdated(id: id, messageId: msgId), value: event)) + case .deleted: addEvent(ObservedEvent(key: .attachmentDeleted(id: id, messageId: msgId), value: event)) + } + } +} diff --git a/SessionMessagingKit/Utilities/ObservableKeyEvent+Utilities.swift b/SessionMessagingKit/Utilities/ObservableKeyEvent+Utilities.swift new file mode 100644 index 0000000000..9099cfe529 --- /dev/null +++ b/SessionMessagingKit/Utilities/ObservableKeyEvent+Utilities.swift @@ -0,0 +1,21 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +public extension LoadPageEvent { + func target(with current: PagedData.LoadResult) -> PagedData.Target? { + switch target { + case .initial: return .initial + case .nextPage(let lastIndex): + guard lastIndex == current.info.lastIndex else { return nil } + + return .pageAfter + + case .previousPage(let firstIndex): + guard firstIndex == current.info.firstPageOffset else { return nil } + + return .pageBefore + } + } +} diff --git a/SessionMessagingKit/Utilities/Preferences+NotificationPreviewType.swift b/SessionMessagingKit/Utilities/Preferences+NotificationPreviewType.swift index 5c07828082..d0bda5cae7 100644 --- a/SessionMessagingKit/Utilities/Preferences+NotificationPreviewType.swift +++ b/SessionMessagingKit/Utilities/Preferences+NotificationPreviewType.swift @@ -5,7 +5,7 @@ import DifferenceKit import SessionUtilitiesKit public extension Preferences { - enum NotificationPreviewType: Int, CaseIterable, EnumIntSetting, Differentiable { + enum NotificationPreviewType: Int, CaseIterable, Differentiable, ThreadSafeType { public static var defaultPreviewType: NotificationPreviewType = .nameAndPreview /// Notifications should include both the sender name and a preview of the message content diff --git a/SessionMessagingKit/Utilities/Preferences+Sound.swift b/SessionMessagingKit/Utilities/Preferences+Sound.swift index 8812f2fa1a..6d735f756f 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, Codable, DatabaseValueConvertible, EnumIntSetting, Differentiable { + enum Sound: Int, Codable, Differentiable, ThreadSafeType { public static var defaultiOSIncomingRingtone: Sound = .opening public static var defaultNotificationSound: Sound = .note @@ -41,7 +41,6 @@ public extension Preferences { case popcorn case pulse case synth - case signalClassic // Ringtone Sounds case opening = 2000 @@ -94,7 +93,6 @@ public extension Preferences { case .popcorn: return "Popcorn" case .pulse: return "Pulse" case .synth: return "Synth" - case .signalClassic: return "Signal Classic" // Ringtone Sounds case .opening: return "Opening" @@ -131,7 +129,6 @@ public extension Preferences { case .popcorn: return (quiet ? "popcorn-quiet.aifc" : "popcorn.aifc") case .pulse: return (quiet ? "pulse-quiet.aifc" : "pulse.aifc") case .synth: return (quiet ? "synth-quiet.aifc" : "synth.aifc") - case .signalClassic: return (quiet ? "classic-quiet.aifc" : "classic.aifc") // Ringtone Sounds case .opening: return "Opening.m4r" @@ -159,7 +156,8 @@ public extension Preferences { ) } - public func notificationSound(isQuiet: Bool) -> UNNotificationSound { + public func notificationSound(isQuiet: Bool) -> UNNotificationSound? { + guard self != .none else { return nil } guard let filename: String = filename(quiet: isQuiet) else { Log.warn(.cat, "Filename was unexpectedly nil") return UNNotificationSound.default diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 99b55ed520..704bb139f0 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -63,9 +63,6 @@ public extension Setting.BoolKey { /// A flag indicating whether the user has ever send a message static let hasSentAMessage: Setting.BoolKey = "hasSentAMessageKey" - /// A flag indicating whether the app is ready for app extensions to run - static let isReadyForAppExtensions: Setting.BoolKey = "isReadyForAppExtensions" - /// Controls whether concurrent audio messages should automatically be played after the one the user starts /// playing finishes static let shouldAutoPlayConsecutiveAudioMessages: Setting.BoolKey = "shouldAutoPlayConsecutiveAudioMessages" @@ -83,37 +80,49 @@ public extension Setting.BoolKey { } // stringlint:ignore_contents -public extension Setting.StringKey { +public extension KeyValueStore.StringKey { /// This is the most recently recorded Push Notifications token - static let lastRecordedPushToken: Setting.StringKey = "lastRecordedPushToken" + static let lastRecordedPushToken: KeyValueStore.StringKey = "lastRecordedPushToken" /// This is the most recently recorded Voip token - static let lastRecordedVoipToken: Setting.StringKey = "lastRecordedVoipToken" + static let lastRecordedVoipToken: KeyValueStore.StringKey = "lastRecordedVoipToken" /// This is the last six emoji used by the user - static let recentReactionEmoji: Setting.StringKey = "recentReactionEmoji" + static let recentReactionEmoji: KeyValueStore.StringKey = "recentReactionEmoji" /// This is the preferred skin tones preference for the given emoji - static func emojiPreferredSkinTones(emoji: String) -> Setting.StringKey { - return Setting.StringKey("preferredSkinTones-\(emoji)") + static func emojiPreferredSkinTones(emoji: String) -> KeyValueStore.StringKey { + return KeyValueStore.StringKey("preferredSkinTones-\(emoji)") } } // stringlint:ignore_contents -public extension Setting.DoubleKey { - /// The duration of the timeout for screen lock in seconds - @available(*, unavailable, message: "Screen Lock should always be instant now") - static let screenLockTimeoutSeconds: Setting.DoubleKey = "screenLockTimeoutSeconds" -} - -// stringlint:ignore_contents -public extension Setting.IntKey { +public extension KeyValueStore.IntKey { /// This is the number of times the app has successfully become active, it's not actually used for anything but allows us to make /// a database change on launch so the database will output an error if it fails to write - static let activeCounter: Setting.IntKey = "activeCounter" + static let activeCounter: KeyValueStore.IntKey = "activeCounter" } public enum Preferences { + public struct NotificationSettings { + public let previewType: Preferences.NotificationPreviewType + public let sound: Preferences.Sound + public let mentionsOnly: Bool + public let mutedUntil: TimeInterval? + + public init( + previewType: Preferences.NotificationPreviewType, + sound: Preferences.Sound, + mentionsOnly: Bool, + mutedUntil: TimeInterval? + ) { + self.previewType = previewType + self.sound = sound + self.mentionsOnly = mentionsOnly + self.mutedUntil = mutedUntil + } + } + // stringlint:ignore_contents public static var isCallKitSupported: Bool { #if targetEnvironment(simulator) diff --git a/SessionMessagingKit/Utilities/Profile+CurrentUser.swift b/SessionMessagingKit/Utilities/Profile+CurrentUser.swift index 23d8c15486..5cde4d5f62 100644 --- a/SessionMessagingKit/Utilities/Profile+CurrentUser.swift +++ b/SessionMessagingKit/Utilities/Profile+CurrentUser.swift @@ -1,6 +1,6 @@ // Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. -import UIKit.UIImage +import Foundation import Combine import GRDB import SessionUtilitiesKit @@ -54,21 +54,21 @@ public extension Profile { if isRemovingAvatar { let existingProfileUrl: String? = try Profile .filter(id: userSessionId.hexString) - .select(.profilePictureUrl) - .asRequest(of: String.self) - .fetchOne(db) - let existingProfileFileName: String? = try Profile - .filter(id: userSessionId.hexString) - .select(.profilePictureFileName) + .select(.displayPictureUrl) .asRequest(of: String.self) .fetchOne(db) - // Remove any cached avatar image value - if let fileName: String = existingProfileFileName { - Task { + /// Remove any cached avatar image data + if + let existingProfileUrl: String = existingProfileUrl, + let filePath: String = try? dependencies[singleton: .displayPictureManager] + .path(for: existingProfileUrl) + { + Task(priority: .low) { await dependencies[singleton: .imageDataManager].removeImage( - identifier: fileName + identifier: filePath ) + try? dependencies[singleton: .fileManager].removeItem(atPath: filePath) } } @@ -103,7 +103,7 @@ public extension Profile { displayPictureUpdate: .currentUserUpdateTo( url: result.downloadUrl, key: result.encryptionKey, - fileName: result.fileName + filePath: result.filePath ), sentTimestamp: dependencies.dateNow.timeIntervalSince1970, using: dependencies @@ -123,7 +123,7 @@ public extension Profile { } static func updateIfNeeded( - _ db: Database, + _ db: ObservingDatabase, publicKey: String, displayNameUpdate: DisplayNameUpdate = .none, displayPictureUpdate: DisplayPictureManager.Update, @@ -158,6 +158,7 @@ public extension Profile { profileChanges.append(Profile.Columns.name.set(to: name)) profileChanges.append(Profile.Columns.lastNameUpdate.set(to: sentTimestamp)) + db.addProfileEvent(id: publicKey, change: .name(name)) // Don't want profiles in messages to modify the current users profile info so ignore those cases default: break @@ -176,54 +177,16 @@ public extension Profile { preconditionFailure("Invalid options for this function") case (.contactRemove, false), (.currentUserRemove, true): - profileChanges.append(Profile.Columns.profilePictureUrl.set(to: nil)) - profileChanges.append(Profile.Columns.profileEncryptionKey.set(to: nil)) - profileChanges.append(Profile.Columns.profilePictureFileName.set(to: nil)) - profileChanges.append(Profile.Columns.lastProfilePictureUpdate.set(to: sentTimestamp)) + profileChanges.append(Profile.Columns.displayPictureUrl.set(to: nil)) + profileChanges.append(Profile.Columns.displayPictureEncryptionKey.set(to: nil)) + profileChanges.append(Profile.Columns.displayPictureLastUpdated.set(to: sentTimestamp)) + db.addProfileEvent(id: publicKey, change: .displayPictureUrl(nil)) - case (.contactUpdateTo(let url, let key, let fileName), false), - (.currentUserUpdateTo(let url, let key, let fileName), true): - if url != profile.profilePictureUrl { - profileChanges.append(Profile.Columns.profilePictureUrl.set(to: url)) - } - - if key != profile.profileEncryptionKey && key.count == DisplayPictureManager.aes256KeyByteLength { - profileChanges.append(Profile.Columns.profileEncryptionKey.set(to: key)) - } - - // Profile filename (this isn't synchronized between devices) - if let fileName: String = fileName { - profileChanges.append(Profile.Columns.profilePictureFileName.set(to: fileName)) - } - - // If we have already downloaded the image then no need to download it again - let fileExistsAtExpectedPath: Bool = { - switch fileName { - case .some(let fileName): - let maybeFilePath: String? = try? dependencies[singleton: .displayPictureManager] - .filepath(for: fileName) - return maybeFilePath - .map { dependencies[singleton: .fileManager].fileExists(atPath: $0) } - .defaulting(to: false) - - case .none: - // If we don't have a fileName then we want to try to check if the path that - // would be generated for the URL exists, we don't know what the file extension - // should be so need to check if there is any file type with this name - let expectedFilename: String = dependencies[singleton: .displayPictureManager] - .generateFilenameWithoutExtension(for: url) - let displayPictureFolderPath: String = dependencies[singleton: .displayPictureManager].sharedDataDisplayPictureDirPath() - let filePaths: [URL] = (try? dependencies[singleton: .fileManager] - .contentsOfDirectory(at: URL(fileURLWithPath: displayPictureFolderPath))) - .defaulting(to: []) - - return filePaths.contains(where: { url -> Bool in - url.deletingLastPathComponent().lastPathComponent == expectedFilename - }) - } - }() - - if !fileExistsAtExpectedPath { + case (.contactUpdateTo(let url, let key, let filePath), false), + (.currentUserUpdateTo(let url, let key, let filePath), true): + /// If we have already downloaded the image then no need to download it again (the database records will be updated + /// once the download completes) + if !dependencies[singleton: .fileManager].fileExists(atPath: filePath) { dependencies[singleton: .jobRunner].add( db, job: Job( @@ -237,11 +200,20 @@ public extension Profile { canStartJob: dependencies[singleton: .appContext].isMainApp ) } - - // Update the 'lastProfilePictureUpdate' timestamp for either external or local changes - profileChanges.append(Profile.Columns.lastProfilePictureUpdate.set(to: sentTimestamp)) + else { + if url != profile.displayPictureUrl { + profileChanges.append(Profile.Columns.displayPictureUrl.set(to: url)) + db.addProfileEvent(id: publicKey, change: .displayPictureUrl(url)) + } + + if key != profile.displayPictureEncryptionKey && key.count == DisplayPictureManager.aes256KeyByteLength { + profileChanges.append(Profile.Columns.displayPictureEncryptionKey.set(to: key)) + } + + profileChanges.append(Profile.Columns.displayPictureLastUpdated.set(to: sentTimestamp)) + } - // Don't want profiles in messages to modify the current users profile info so ignore those cases + /// Don't want profiles in messages to modify the current users profile info so ignore those cases default: break } diff --git a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift index 0cfaa76ef4..29ba5dd71b 100644 --- a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift +++ b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift @@ -8,7 +8,7 @@ public extension ProfilePictureView { func update( publicKey: String, threadVariant: SessionThread.Variant, - displayPictureFilename: String?, + displayPictureUrl: String?, profile: Profile?, profileIcon: ProfileIcon = .none, additionalProfile: Profile? = nil, @@ -19,7 +19,7 @@ public extension ProfilePictureView { size: self.size, publicKey: publicKey, threadVariant: threadVariant, - displayPictureFilename: displayPictureFilename, + displayPictureUrl: displayPictureUrl, profile: profile, profileIcon: profileIcon, additionalProfile: additionalProfile, @@ -36,33 +36,28 @@ public extension ProfilePictureView { size: Size, publicKey: String, threadVariant: SessionThread.Variant, - displayPictureFilename: String?, + displayPictureUrl: String?, profile: Profile?, profileIcon: ProfileIcon = .none, additionalProfile: Profile? = nil, additionalProfileIcon: ProfileIcon = .none, using dependencies: Dependencies ) -> (Info?, Info?) { - // If we are given an explicit 'displayPictureFilename' then only use that (this could be for - // either Community conversations or updated groups) - if - let displayPictureFilename: String = displayPictureFilename, - let path: String = try? dependencies[singleton: .displayPictureManager] - .filepath(for: displayPictureFilename) - { - return (Info( - identifier: displayPictureFilename, - source: .url(URL(fileURLWithPath: path)), - icon: profileIcon - ), nil) - } + let explicitPath: String? = try? dependencies[singleton: .displayPictureManager].path( + for: displayPictureUrl + ) - // Otherwise there are conversation-type-specific behaviours - switch threadVariant { - case .community: + switch (explicitPath, publicKey.isEmpty, threadVariant) { + case (.some(let path), _, _): + /// If we are given an explicit `displayPictureUrl` then only use that + return (Info( + source: .url(URL(fileURLWithPath: path)), + icon: profileIcon + ), nil) + + case (_, _, .community): return ( Info( - identifier: "\(publicKey)-placeholder", source: { switch size { case .navigation, .message: return .image("SessionWhite16", #imageLiteral(resourceName: "SessionWhite16")) @@ -81,61 +76,55 @@ public extension ProfilePictureView { ), nil ) + + case (_, true, _): return (nil, nil) - case .legacyGroup, .group: - guard !publicKey.isEmpty else { return (nil, nil) } + case (_, _, .legacyGroup), (_, _, .group): + let source: ImageDataManager.DataSource = { + guard + let path: String = try? dependencies[singleton: .displayPictureManager] + .path(for: profile?.displayPictureUrl), + dependencies[singleton: .fileManager].fileExists(atPath: path) + else { + return .placeholderIcon( + seed: (profile?.id ?? publicKey), + text: (profile?.displayName(for: threadVariant)) + .defaulting(to: publicKey), + size: (additionalProfile != nil ? + size.multiImageSize : + size.viewSize + ) + ) + } + + return ImageDataManager.DataSource.url(URL(fileURLWithPath: path)) + }() return ( - Info( - identifier: (profile?.profilePictureFileName) - .defaulting(to: "\(profile?.id ?? publicKey)-placeholder"), - source: ( - profile?.profilePictureFileName - .map { try? dependencies[singleton: .displayPictureManager].filepath(for: $0) } - .map { ImageDataManager.DataSource.url(URL(fileURLWithPath: $0)) } ?? - .image( - "\(profile?.id ?? publicKey)-placeholder", - PlaceholderIcon.generate( - seed: (profile?.id ?? publicKey), - text: (profile?.displayName(for: threadVariant)) - .defaulting(to: publicKey), - size: (additionalProfile != nil ? - size.multiImageSize : - size.viewSize - ) - ) - ) - ), - icon: profileIcon - ), + Info(source: source, icon: profileIcon), additionalProfile .map { other in - Info( - identifier: (other.profilePictureFileName) - .defaulting(to: "\(other.id)-placeholder"), - source: ( - other.profilePictureFileName - .map { fileName in - try? dependencies[singleton: .displayPictureManager] - .filepath(for: fileName) - } - .map { ImageDataManager.DataSource.url(URL(fileURLWithPath: $0)) } ?? - .image( - "\(other.id)-placeholder", - PlaceholderIcon.generate( - seed: other.id, - text: other.displayName(for: threadVariant), - size: size.multiImageSize - ) + let source: ImageDataManager.DataSource = { + guard + let path: String = try? dependencies[singleton: .displayPictureManager] + .path(for: other.displayPictureUrl), + dependencies[singleton: .fileManager].fileExists(atPath: path) + else { + return .placeholderIcon( + seed: other.id, + text: other.displayName(for: threadVariant), + size: size.multiImageSize ) - ), - icon: additionalProfileIcon - ) + } + + return ImageDataManager.DataSource.url(URL(fileURLWithPath: path)) + }() + + return Info(source: source, icon: additionalProfileIcon) } .defaulting( to: Info( - identifier: "GroupFallbackIcon", // stringlint:ignore - source: .image("person.fill", UIImage(named: "ic_user_round_fill")), + source: .image("ic_user_round_fill", UIImage(systemName: "ic_user_round_fill")), renderingMode: .alwaysTemplate, themeTintColor: .white, inset: UIEdgeInsets( @@ -149,31 +138,25 @@ public extension ProfilePictureView { ) ) - case .contact: - guard !publicKey.isEmpty else { return (nil, nil) } + case (_, _, .contact): + let source: ImageDataManager.DataSource = { + guard + let path: String = try? dependencies[singleton: .displayPictureManager] + .path(for: profile?.displayPictureUrl), + dependencies[singleton: .fileManager].fileExists(atPath: path) + else { + return .placeholderIcon( + seed: publicKey, + text: (profile?.displayName(for: threadVariant)) + .defaulting(to: publicKey), + size: size.viewSize + ) + } + + return ImageDataManager.DataSource.url(URL(fileURLWithPath: path)) + }() - return ( - Info( - identifier: (profile?.profilePictureFileName) - .defaulting(to: "\(publicKey)-placeholder"), - source: ( - profile?.profilePictureFileName - .map { try? dependencies[singleton: .displayPictureManager].filepath(for: $0) } - .map { ImageDataManager.DataSource.url(URL(fileURLWithPath: $0)) } ?? - .image( - "\(profile?.id ?? publicKey)-placeholder", - PlaceholderIcon.generate( - seed: publicKey, - text: (profile?.displayName(for: threadVariant)) - .defaulting(to: publicKey), - size: size.viewSize - ) - ) - ), - icon: profileIcon - ), - nil - ) + return (Info(source: source, icon: profileIcon), nil) } } } @@ -183,7 +166,7 @@ public extension ProfilePictureSwiftUI { size: ProfilePictureView.Size, publicKey: String, threadVariant: SessionThread.Variant, - displayPictureFilename: String?, + displayPictureUrl: String?, profile: Profile?, profileIcon: ProfilePictureView.ProfileIcon = .none, additionalProfile: Profile? = nil, @@ -194,7 +177,7 @@ public extension ProfilePictureSwiftUI { size: size, publicKey: publicKey, threadVariant: threadVariant, - displayPictureFilename: displayPictureFilename, + displayPictureUrl: displayPictureUrl, profile: profile, profileIcon: profileIcon, additionalProfile: additionalProfile, diff --git a/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift b/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift index 27bec19d55..7de5f146de 100644 --- a/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift +++ b/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift @@ -1,7 +1,6 @@ // Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit import Quick @@ -14,20 +13,13 @@ class CryptoSMKSpec: QuickSpec { // MARK: Configuration @TestState var dependencies: TestDependencies! = TestDependencies() - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( - customWriter: try! DatabaseQueue(), - migrationTargets: [ - SNUtilitiesKit.self, - SNMessagingKit.self - ], - using: dependencies, - initialData: { 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 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(singleton: .crypto, in: dependencies) var crypto: Crypto! = Crypto(using: .any) - @TestState var mockCrypto: MockCrypto! = MockCrypto() // MARK: - Crypto for SessionMessagingKit describe("Crypto for SessionMessagingKit") { @@ -78,16 +70,12 @@ class CryptoSMKSpec: QuickSpec { context("when encrypting with the session protocol") { // MARK: ---- can encrypt correctly it("can encrypt correctly") { - let result: Data? = mockStorage.read { db in - try crypto.tryGenerate( - .ciphertextWithSessionProtocol( - db, - plaintext: "TestMessage".data(using: .utf8)!, - destination: .contact(publicKey: "05\(TestConstants.publicKey)"), - using: dependencies - ) + let result: Data? = try? crypto.tryGenerate( + .ciphertextWithSessionProtocol( + plaintext: "TestMessage".data(using: .utf8)!, + destination: .contact(publicKey: "05\(TestConstants.publicKey)") ) - } + ) // Note: A Nonce is used for this so we can't compare the exact value when not mocked expect(result).toNot(beNil()) @@ -96,24 +84,17 @@ class CryptoSMKSpec: QuickSpec { // MARK: ---- throws an error if there is no ed25519 keyPair it("throws an error if there is no ed25519 keyPair") { - mockStorage.write { db in - _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } + mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) - mockStorage.read { db in - expect { - try crypto.tryGenerate( - .ciphertextWithSessionProtocol( - db, - plaintext: "TestMessage".data(using: .utf8)!, - destination: .contact(publicKey: "05\(TestConstants.publicKey)"), - using: dependencies - ) + expect { + try crypto.tryGenerate( + .ciphertextWithSessionProtocol( + plaintext: "TestMessage".data(using: .utf8)!, + destination: .contact(publicKey: "05\(TestConstants.publicKey)") ) - } - .to(throwError(MessageSenderError.noUserED25519KeyPair)) + ) } + .to(throwError(MessageSenderError.noUserED25519KeyPair)) } } @@ -121,19 +102,15 @@ class CryptoSMKSpec: QuickSpec { context("when decrypting with the session protocol") { // MARK: ---- successfully decrypts a message it("successfully decrypts a message") { - let result = mockStorage.read { db in - crypto.generate( - .plaintextWithSessionProtocol( - db, - ciphertext: Data( - base64Encoded: "SRP0eBUWh4ez6ppWjUs5/Wph5fhnPRgB5zsWWnTz+FBAw/YI3oS2pDpIfyetMTbU" + - "sFMhE5G4PbRtQFey1hsxLl221Qivc3ayaX2Mm/X89Dl8e45BC+Lb/KU9EdesxIK4pVgYXs9XrMtX3v8" + - "dt0eBaXneOBfr7qB8pHwwMZjtkOu1ED07T9nszgbWabBphUfWXe2U9K3PTRisSCI=" - )!, - using: dependencies - ) + let result = crypto.generate( + .plaintextWithSessionProtocol( + ciphertext: Data( + base64Encoded: "SRP0eBUWh4ez6ppWjUs5/Wph5fhnPRgB5zsWWnTz+FBAw/YI3oS2pDpIfyetMTbU" + + "sFMhE5G4PbRtQFey1hsxLl221Qivc3ayaX2Mm/X89Dl8e45BC+Lb/KU9EdesxIK4pVgYXs9XrMtX3v8" + + "dt0eBaXneOBfr7qB8pHwwMZjtkOu1ED07T9nszgbWabBphUfWXe2U9K3PTRisSCI=" + )! ) - } + ) expect(String(data: (result?.plaintext ?? Data()), encoding: .utf8)).to(equal("TestMessage")) expect(result?.senderSessionIdHex) @@ -142,43 +119,32 @@ class CryptoSMKSpec: QuickSpec { // MARK: ---- throws an error if there is no ed25519 keyPair it("throws an error if there is no ed25519 keyPair") { - mockStorage.write { db in - _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } + mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) - mockStorage.read { db in - expect { - try crypto.tryGenerate( - .plaintextWithSessionProtocol( - db, - ciphertext: Data( - base64Encoded: "SRP0eBUWh4ez6ppWjUs5/Wph5fhnPRgB5zsWWnTz+FBAw/YI3oS2pDpIfyetMTbU" + - "sFMhE5G4PbRtQFey1hsxLl221Qivc3ayaX2Mm/X89Dl8e45BC+Lb/KU9EdesxIK4pVgYXs9XrMtX3v8" + - "dt0eBaXneOBfr7qB8pHwwMZjtkOu1ED07T9nszgbWabBphUfWXe2U9K3PTRisSCI=" - )!, - using: dependencies - ) + expect { + try crypto.tryGenerate( + .plaintextWithSessionProtocol( + ciphertext: Data( + base64Encoded: "SRP0eBUWh4ez6ppWjUs5/Wph5fhnPRgB5zsWWnTz+FBAw/YI3oS2pDpIfyetMTbU" + + "sFMhE5G4PbRtQFey1hsxLl221Qivc3ayaX2Mm/X89Dl8e45BC+Lb/KU9EdesxIK4pVgYXs9XrMtX3v8" + + "dt0eBaXneOBfr7qB8pHwwMZjtkOu1ED07T9nszgbWabBphUfWXe2U9K3PTRisSCI=" + )! ) - } - .to(throwError(MessageSenderError.noUserED25519KeyPair)) + ) } + .to(throwError(MessageSenderError.noUserED25519KeyPair)) } // MARK: ---- throws an error if the ciphertext is too short it("throws an error if the ciphertext is too short") { - mockStorage.read { db in - expect { - try crypto.tryGenerate( - .plaintextWithSessionProtocol( - db, - ciphertext: Data([1, 2, 3]), - using: dependencies - ) + expect { + try crypto.tryGenerate( + .plaintextWithSessionProtocol( + ciphertext: Data([1, 2, 3]) ) - } - .to(throwError(MessageReceiverError.decryptionFailed)) + ) } + .to(throwError(MessageReceiverError.decryptionFailed)) } } } diff --git a/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift b/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift new file mode 100644 index 0000000000..31f0cf142b --- /dev/null +++ b/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift @@ -0,0 +1,1145 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionSnodeKit +import SessionUtilitiesKit + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class MessageDeduplicationSpec: QuickSpec { + override class func spec() { + // MARK: Configuration + + @TestState var dependencies: TestDependencies! = TestDependencies() + + @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + customWriter: try! DatabaseQueue(), + migrationTargets: [ + SNMessagingKit.self + ], + 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(()) + } + ) + @TestState var mockMessage: Message! = { + let result: ReadReceipt = ReadReceipt(timestamps: [1]) + result.sentTimestampMs = 12345678901234 + + return result + }() + + // MARK: - MessageDeduplication - Inserting + describe("MessageDeduplication") { + // MARK: -- when inserting + context("when inserting") { + // MARK: ---- inserts a record correctly + it("inserts a record correctly") { + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .contact, + uniqueIdentifier: "testId", + message: nil, + serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, + using: dependencies + ) + }.toNot(throwError()) + } + + let expectedTimestamp: Int64 = (1234567890 + ((SnodeReceivedMessage.serverClockToleranceMs * 2) / 1000)) + let records: [MessageDeduplication]? = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + expect(records?.count).to(equal(1)) + expect(records?.first?.threadId).to(equal("testThreadId")) + 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") + }) + } + + // MARK: ---- checks that it is not a duplicate record + it("checks that it is not a duplicate record") { + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .contact, + uniqueIdentifier: "testId", + message: nil, + serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, + using: dependencies + ) + }.toNot(throwError()) + } + + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + $0.dedupeRecordExists(threadId: "testThreadId", uniqueIdentifier: "testId") + }) + } + + // MARK: ---- creates a legacy record if needed + it("creates a legacy record if needed") { + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .contact, + uniqueIdentifier: "testId", + legacyIdentifier: "testLegacyId", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, + using: dependencies + ) + }.toNot(throwError()) + } + + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + $0.dedupeRecordExists(threadId: "testThreadId", uniqueIdentifier: "testLegacyId") + }) + } + + // MARK: ---- sets the shouldDeleteWhenDeletingThread flag correctly + it("sets the shouldDeleteWhenDeletingThread flag correctly") { + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .contact, + uniqueIdentifier: "testId1", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, + using: dependencies + ) + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .community, + uniqueIdentifier: "testId2", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, + using: dependencies + ) + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .legacyGroup, + uniqueIdentifier: "testId3", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, + using: dependencies + ) + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .group, + uniqueIdentifier: "testId4", + message: GroupUpdateInviteMessage( + inviteeSessionIdHexString: "TestId", + groupSessionId: SessionId(.group, hex: TestConstants.publicKey), + groupName: "TestGroup", + memberAuthData: Data([1, 2, 3]), + profile: nil, + adminSignature: .standard(signature: "TestSignature".bytes) + ), + serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, + using: dependencies + ) + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .group, + uniqueIdentifier: "testId5", + message: GroupUpdatePromoteMessage( + groupIdentitySeed: Data([1, 2, 3]), + groupName: "TestGroup", + sentTimestampMs: 1234567890000 + ), + serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, + using: dependencies + ) + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .group, + uniqueIdentifier: "testId6", + message: GroupUpdateMemberLeftMessage(), + serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, + using: dependencies + ) + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .group, + uniqueIdentifier: "testId7", + message: GroupUpdateInviteResponseMessage( + isApproved: true, + profile: nil, + sentTimestampMs: 1234567800000 + ), + serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, + using: dependencies + ) + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .group, + uniqueIdentifier: "testId8", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, + using: dependencies + ) + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: nil, + uniqueIdentifier: "testId9", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, + using: dependencies + ) + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: nil, + uniqueIdentifier: "testId10", + message: nil, + serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, + using: dependencies + ) + }.toNot(throwError()) + } + + let records: [String: MessageDeduplication] = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + .defaulting(to: []) + .reduce(into: [:]) { result, next in result[next.uniqueIdentifier] = next } + expect(records["testId1"]?.shouldDeleteWhenDeletingThread).to(beFalse()) + expect(records["testId2"]?.shouldDeleteWhenDeletingThread).to(beTrue()) + expect(records["testId3"]?.shouldDeleteWhenDeletingThread).to(beTrue()) + expect(records["testId4"]?.shouldDeleteWhenDeletingThread).to(beFalse()) + expect(records["testId5"]?.shouldDeleteWhenDeletingThread).to(beFalse()) + expect(records["testId6"]?.shouldDeleteWhenDeletingThread).to(beFalse()) + expect(records["testId7"]?.shouldDeleteWhenDeletingThread).to(beFalse()) + expect(records["testId8"]?.shouldDeleteWhenDeletingThread).to(beTrue()) + expect(records["testId9"]?.shouldDeleteWhenDeletingThread).to(beFalse()) + expect(records["testId10"]?.shouldDeleteWhenDeletingThread).to(beFalse()) + } + + // MARK: ---- does nothing if no uniqueIdentifier is provided + it("does nothing if no uniqueIdentifier is provided") { + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .contact, + uniqueIdentifier: nil, + legacyIdentifier: "testLegacyId", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, + using: dependencies + ) + }.toNot(throwError()) + } + + let records: [MessageDeduplication]? = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + expect(records).to(beEmpty()) + } + + // MARK: ---- creates a record from a ProcessedMessage + it("creates a record from a ProcessedMessage") { + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + processedMessage: .standard( + threadId: "testThreadId", + threadVariant: .contact, + proto: try! SNProtoContent.builder().build(), + messageInfo: MessageReceiveJob.Details.MessageInfo( + message: mockMessage, + variant: .readReceipt, + threadVariant: .contact, + serverExpirationTimestamp: nil, + proto: try! SNProtoContent.builder().build() + ), + uniqueIdentifier: "testId" + ), + ignoreDedupeFiles: false, + 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: "LegacyRecord-1-12345678901234" + ) + }) + } + + // MARK: ---- does not create records for config ProcessedMessages + it("does not create records for config ProcessedMessages") { + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + processedMessage: .config( + publicKey: "testThreadId", + namespace: .configContacts, + serverHash: "1234", + serverTimestampMs: 1234567890, + data: Data([1, 2, 3]), + uniqueIdentifier: "testId" + ), + ignoreDedupeFiles: false, + using: dependencies + ) + }.toNot(throwError()) + } + + 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") + }) + } + + // MARK: ---- throws if the message is a duplicate + it("throws if the message is a duplicate") { + mockExtensionHelper + .when { $0.dedupeRecordExists(threadId: .any, uniqueIdentifier: .any) } + .thenReturn(true) + + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .contact, + uniqueIdentifier: "testId", + legacyIdentifier: "testLegacyId", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, + using: dependencies + ) + }.to(throwError(MessageReceiverError.duplicateMessage)) + } + } + + // MARK: ---- throws if the message is a legacy duplicate + it("throws if the message is a legacy duplicate") { + mockExtensionHelper + .when { + $0.dedupeRecordExists( + threadId: "testThreadId", + uniqueIdentifier: "testId" + ) + } + .thenReturn(false) + mockExtensionHelper + .when { + $0.dedupeRecordExists( + threadId: "testThreadId", + uniqueIdentifier: "testLegacyId" + ) + } + .thenReturn(true) + + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .contact, + uniqueIdentifier: "testId", + legacyIdentifier: "testLegacyId", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, + using: dependencies + ) + }.to(throwError(MessageReceiverError.duplicateMessage)) + } + } + + // MARK: ---- throws if it fails to create the dedupe file + it("throws if it fails to create the dedupe file") { + mockExtensionHelper + .when { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) } + .thenThrow(TestError.mock) + + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .contact, + uniqueIdentifier: "testId", + legacyIdentifier: "testLegacyId", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, + using: dependencies + ) + }.to(throwError(TestError.mock)) + } + } + + // MARK: ---- throws if it fails to create the legacy dedupe file + it("throws if it fails to create the legacy dedupe file") { + mockExtensionHelper + .when { + try $0.createDedupeRecord( + threadId: "testThreadId", + uniqueIdentifier: "testLegacyId" + ) + } + .thenThrow(TestError.mock) + + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .contact, + uniqueIdentifier: "testId", + legacyIdentifier: "testLegacyId", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, + using: dependencies + ) + }.to(throwError(TestError.mock)) + } + } + } + + // MARK: -- when inserting a call message + context("when inserting a call message") { + // MARK: ---- inserts a preOffer record correctly + it("inserts a preOffer record correctly") { + mockStorage.write { db in + expect { + try MessageDeduplication.insertCallDedupeRecordsIfNeeded( + db, + threadId: "testThreadId", + callMessage: CallMessage( + uuid: "12345", + kind: .preOffer, + sdps: [], + sentTimestampMs: 1234567890 + ), + expirationTimestampSeconds: 1234567891, + shouldDeleteWhenDeletingThread: false, + using: dependencies + ) + }.toNot(throwError()) + } + + let records: [MessageDeduplication]? = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + expect(records?.count).to(equal(1)) + expect(records?.first?.threadId).to(equal("testThreadId")) + 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") + }) + } + + // MARK: ---- inserts a generic record correctly + it("inserts a generic record correctly") { + mockStorage.write { db in + expect { + try MessageDeduplication.insertCallDedupeRecordsIfNeeded( + db, + threadId: "testThreadId", + callMessage: CallMessage( + uuid: "12345", + kind: .endCall, + sdps: [], + sentTimestampMs: 1234567890 + ), + expirationTimestampSeconds: 1234567891, + shouldDeleteWhenDeletingThread: false, + using: dependencies + ) + }.toNot(throwError()) + } + + let records: [MessageDeduplication]? = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + expect(records?.count).to(equal(1)) + expect(records?.first?.threadId).to(equal("testThreadId")) + 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") + }) + } + + // MARK: ---- does nothing if no call message is provided + it("does nothing if no call message is provided") { + mockStorage.write { db in + expect { + try MessageDeduplication.insertCallDedupeRecordsIfNeeded( + db, + threadId: "testThreadId", + callMessage: nil, + expirationTimestampSeconds: 1234567891, + shouldDeleteWhenDeletingThread: false, + using: dependencies + ) + }.toNot(throwError()) + } + + 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) + }) + } + } + } + + // MARK: - MessageDeduplication - Deleting + describe("MessageDeduplication") { + // MARK: -- when deleting a dedupe record + context("when deleting a dedupe record") { + // MARK: ---- deletes the record successfully + it("deletes the record successfully") { + mockStorage.write { db in + try MessageDeduplication( + threadId: "testThreadId", + uniqueIdentifier: "testId", + expirationTimestampSeconds: nil, + shouldDeleteWhenDeletingThread: true + ).insert(db) + } + + mockStorage.write { db in + expect { + try MessageDeduplication.deleteIfNeeded( + db, + threadIds: ["testThreadId"], + using: dependencies + ) + }.toNot(throwError()) + } + + let records: [MessageDeduplication]? = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + expect(records).to(beEmpty()) + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.removeDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") + }) + } + + // MARK: ---- deletes multiple records + it("deletes multiple records") { + mockStorage.write { db in + try MessageDeduplication( + threadId: "testThreadId", + uniqueIdentifier: "testId", + expirationTimestampSeconds: nil, + shouldDeleteWhenDeletingThread: true + ).insert(db) + try MessageDeduplication( + threadId: "testThreadId", + uniqueIdentifier: "testId2", + expirationTimestampSeconds: nil, + shouldDeleteWhenDeletingThread: true + ).insert(db) + } + + mockStorage.write { db in + expect { + try MessageDeduplication.deleteIfNeeded( + db, + threadIds: ["testThreadId"], + using: dependencies + ) + }.toNot(throwError()) + } + + let records: [MessageDeduplication]? = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + expect(records).to(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") + }) + } + + // MARK: ---- leaves unrelated records + it("leaves unrelated records") { + mockStorage.write { db in + try MessageDeduplication( + threadId: "testThreadId", + uniqueIdentifier: "testId", + expirationTimestampSeconds: nil, + shouldDeleteWhenDeletingThread: true + ).insert(db) + try MessageDeduplication( + threadId: "testThreadId2", + uniqueIdentifier: "testId2", + expirationTimestampSeconds: nil, + shouldDeleteWhenDeletingThread: true + ).insert(db) + } + + mockStorage.write { db in + expect { + try MessageDeduplication.deleteIfNeeded( + db, + threadIds: ["testThreadId"], + using: dependencies + ) + }.toNot(throwError()) + } + + let records: [MessageDeduplication]? = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + 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") + }) + } + + // MARK: ---- leaves records which should not be deleted alongside the thread + it("leaves records which should not be deleted alongside the thread") { + mockStorage.write { db in + try MessageDeduplication( + threadId: "testThreadId", + uniqueIdentifier: "testId", + expirationTimestampSeconds: nil, + shouldDeleteWhenDeletingThread: false + ).insert(db) + } + + mockStorage.write { db in + expect { + try MessageDeduplication.deleteIfNeeded( + db, + threadIds: ["testThreadId"], + using: dependencies + ) + }.toNot(throwError()) + } + + let records: [MessageDeduplication]? = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + 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) + }) + } + + // MARK: ---- resets the expiration timestamp when failing to delete the file + it("resets the expiration timestamp when failing to delete the file") { + mockStorage.write { db in + try MessageDeduplication( + threadId: "testThreadId", + uniqueIdentifier: "testId", + expirationTimestampSeconds: 1234567890, + shouldDeleteWhenDeletingThread: true + ).insert(db) + } + mockExtensionHelper + .when { try $0.removeDedupeRecord(threadId: .any, uniqueIdentifier: .any) } + .thenThrow(TestError.mock) + + mockStorage.write { db in + expect { + try MessageDeduplication.deleteIfNeeded( + db, + threadIds: ["testThreadId"], + using: dependencies + ) + }.toNot(throwError()) + } + + let records: [MessageDeduplication]? = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + expect((records?.map { $0.threadId }).map { Set($0) }).to(equal(["testThreadId"])) + expect((records?.map { $0.uniqueIdentifier }).map { Set($0) }) + .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") + }) + } + } + } + + // MARK: - MessageDeduplication - Creating + describe("MessageDeduplication") { + // MARK: -- when creating a dedupe file + context("when creating a dedupe file") { + // MARK: ---- creates the file successfully + it("creates the file successfully") { + expect { + try MessageDeduplication.createDedupeFile( + threadId: "testThreadId", + uniqueIdentifier: "testId", + using: dependencies + ) + }.toNot(throwError()) + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") + }) + } + + // MARK: ---- creates both the main file and a legacy file when needed + it("creates both the main file and a legacy file when needed") { + expect { + try MessageDeduplication.createDedupeFile( + threadId: "testThreadId", + uniqueIdentifier: "testId", + legacyIdentifier: "testLegacyId", + 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" + ) + }) + } + + // MARK: ---- creates a file from a ProcessedMessage + it("creates a file from a ProcessedMessage") { + expect { + try MessageDeduplication.createDedupeFile( + .standard( + threadId: "testThreadId", + threadVariant: .contact, + proto: try! SNProtoContent.builder().build(), + messageInfo: MessageReceiveJob.Details.MessageInfo( + message: Message(), + variant: .visibleMessage, + threadVariant: .contact, + serverExpirationTimestamp: nil, + proto: try! SNProtoContent.builder().build() + ), + uniqueIdentifier: "testId" + ), + using: dependencies + ) + }.toNot(throwError()) + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") + }) + } + + // MARK: ---- throws when it fails to create the file + it("throws when it fails to create the file") { + mockExtensionHelper + .when { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) } + .thenThrow(TestError.mock) + + expect { + try MessageDeduplication.createDedupeFile( + threadId: "testThreadId", + uniqueIdentifier: "testId", + using: dependencies + ) + }.to(throwError(TestError.mock)) + } + + // MARK: ---- throws when it fails to create the legacy file + it("throws when it fails to create the legacy file") { + mockExtensionHelper + .when { + try $0.createDedupeRecord( + threadId: "testThreadId", + uniqueIdentifier: "testId" + ) + } + .thenReturn(()) + mockExtensionHelper + .when { + try $0.createDedupeRecord( + threadId: "testThreadId", + uniqueIdentifier: "testLegacyId" + ) + } + .thenThrow(TestError.mock) + + expect { + try MessageDeduplication.createDedupeFile( + threadId: "testThreadId", + uniqueIdentifier: "testId", + legacyIdentifier: "testLegacyId", + 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" + ) + }) + } + } + + // MARK: -- when creating a call message dedupe file + context("when creating a call message dedupe file") { + // MARK: ---- creates a preOffer file correctly + it("creates a preOffer file correctly") { + expect { + try MessageDeduplication.createCallDedupeFilesIfNeeded( + threadId: "testThreadId", + callMessage: CallMessage( + uuid: "12345", + kind: .preOffer, + sdps: [], + sentTimestampMs: 1234567890 + ), + using: dependencies + ) + }.toNot(throwError()) + + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "12345-preOffer") + }) + } + + // MARK: ---- creates a generic file correctly + it("creates a generic file correctly") { + expect { + try MessageDeduplication.createCallDedupeFilesIfNeeded( + threadId: "testThreadId", + callMessage: CallMessage( + uuid: "12345", + kind: .endCall, + sdps: [], + sentTimestampMs: 1234567890 + ), + using: dependencies + ) + }.toNot(throwError()) + + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "12345") + }) + } + + // MARK: ---- creates a files for the correct call message kinds + it("creates a files for the correct call message kinds") { + var resultIdentifiers: [String] = [] + var resultKinds: [CallMessage.Kind] = [] + + CallMessage.Kind.allCases.forEach { kind in + mockExtensionHelper + .when { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) } + .then { args in + guard let identifier: String = args[test: 1] as? String else { return } + + resultIdentifiers.append(identifier) + resultKinds.append(kind) + } + .thenReturn(()) + + expect { + try MessageDeduplication.createCallDedupeFilesIfNeeded( + threadId: "testThreadId", + callMessage: CallMessage( + uuid: "12345", + kind: kind, + sdps: [], + sentTimestampMs: 1234567890 + ), + using: dependencies + ) + }.toNot(throwError()) + } + + expect(resultIdentifiers).to(equal(["12345-preOffer", "12345"])) + expect(resultKinds).to(equal([.preOffer, .endCall])) + } + + // MARK: ---- creates files for the correct call message states + it("creates files for the correct call message states") { + var resultIdentifiers: [String] = [] + var resultStates: [CallMessage.MessageInfo.State] = [] + + CallMessage.MessageInfo.State.allCases.forEach { state in + let message: CallMessage = CallMessage( + uuid: "12345", + kind: .answer, + sdps: [], + sentTimestampMs: 1234567890 + ) + message.state = state + mockExtensionHelper + .when { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) } + .then { args in + guard let identifier: String = args[test: 1] as? String else { return } + + resultIdentifiers.append(identifier) + resultStates.append(state) + } + .thenReturn(()) + + expect { + try MessageDeduplication.createCallDedupeFilesIfNeeded( + threadId: "testThreadId", + callMessage: message, + using: dependencies + ) + }.toNot(throwError()) + } + + expect(resultIdentifiers).to(equal(["12345", "12345", "12345"])) + expect(resultStates).to(equal([.missed, .permissionDenied, .permissionDeniedMicrophone])) + } + + // MARK: ---- does nothing if no call message is provided + it("does nothing if no call message is provided") { + expect { + try MessageDeduplication.createCallDedupeFilesIfNeeded( + threadId: "testThreadId", + callMessage: nil, + using: dependencies + ) + }.toNot(throwError()) + + expect(mockExtensionHelper).toNot(call { + try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) + }) + } + } + } + + // MARK: - MessageDeduplication - Ensuring + describe("MessageDeduplication") { + // MARK: -- when ensuring a message is not a duplicate + context("when ensuring a message is not a duplicate") { + // MARK: ---- does not throw when not a duplicate + it("does not throw when not a duplicate") { + expect { + try MessageDeduplication.ensureMessageIsNotADuplicate( + threadId: "testThreadId", + uniqueIdentifier: "testId", + using: dependencies + ) + }.toNot(throwError()) + } + + // MARK: ---- when ensuring a message is not a legacy duplicate + it("does not throw when not a legacy duplicate") { + expect { + try MessageDeduplication.ensureMessageIsNotADuplicate( + threadId: "testThreadId", + uniqueIdentifier: "testId", + legacyIdentifier: "testLegacyId", + using: dependencies + ) + }.toNot(throwError()) + } + + // MARK: ---- does not throw when given a non duplicate ProcessedMessage + it("does not throw when given a non duplicate ProcessedMessage") { + expect { + try MessageDeduplication.ensureMessageIsNotADuplicate( + .standard( + threadId: "testThreadId", + threadVariant: .contact, + proto: try! SNProtoContent.builder().build(), + messageInfo: MessageReceiveJob.Details.MessageInfo( + message: Message(), + variant: .visibleMessage, + threadVariant: .contact, + serverExpirationTimestamp: nil, + proto: try! SNProtoContent.builder().build() + ), + uniqueIdentifier: "testId" + ), + using: dependencies + ) + }.toNot(throwError()) + } + + // MARK: ---- throws when the message is a duplicate + it("throws when the message is a duplicate") { + mockExtensionHelper + .when { $0.dedupeRecordExists(threadId: .any, uniqueIdentifier: .any) } + .thenReturn(true) + + expect { + try MessageDeduplication.ensureMessageIsNotADuplicate( + threadId: "testThreadId", + uniqueIdentifier: "testId", + using: dependencies + ) + }.to(throwError(MessageReceiverError.duplicateMessage)) + } + + // MARK: ---- throws when the message is a legacy duplicate + it("throws when the message is a legacy duplicate") { + mockExtensionHelper + .when { + $0.dedupeRecordExists( + threadId: "testThreadId", + uniqueIdentifier: "testId" + ) + } + .thenReturn(false) + mockExtensionHelper + .when { + $0.dedupeRecordExists( + threadId: "testThreadId", + uniqueIdentifier: "testLegacyId" + ) + } + .thenReturn(true) + + expect { + try MessageDeduplication.ensureMessageIsNotADuplicate( + threadId: "testThreadId", + uniqueIdentifier: "testId", + legacyIdentifier: "testLegacyId", + 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") + }) + } + } + + // MARK: -- when ensuring a call message is not a duplicate + context("when ensuring a call message is not a duplicate") { + // MARK: ---- does not throw when not a duplicate + it("does not throw when not a duplicate") { + expect { + try MessageDeduplication.ensureCallMessageIsNotADuplicate( + threadId: "testThreadId", + callMessage: CallMessage( + uuid: "12345", + kind: .preOffer, + sdps: [], + sentTimestampMs: nil + ), + using: dependencies + ) + }.toNot(throwError()) + } + + // MARK: ---- does nothing if no call message is provided + it("does nothing if no call message is provided") { + expect { + try MessageDeduplication.ensureCallMessageIsNotADuplicate( + threadId: "testThreadId", + callMessage: nil, + using: dependencies + ) + }.toNot(throwError()) + } + + // MARK: ---- throws when the call message is a duplicate + it("throws when the call message is a duplicate") { + mockExtensionHelper + .when { $0.dedupeRecordExists(threadId: .any, uniqueIdentifier: .any) } + .thenReturn(true) + + expect { + try MessageDeduplication.ensureCallMessageIsNotADuplicate( + threadId: "testThreadId", + callMessage: CallMessage( + uuid: "12345", + kind: .preOffer, + sdps: [], + sentTimestampMs: nil + ), + using: dependencies + ) + }.to(throwError(MessageReceiverError.duplicatedCall)) + } + } + } + } +} + +// MARK: - Convenience + +extension CallMessage.Kind: @retroactive CaseIterable { + public static var allCases: [CallMessage.Kind] = { + var result: [CallMessage.Kind] = [] + switch CallMessage.Kind.preOffer { + case .preOffer: result.append(.preOffer); fallthrough + case .offer: result.append(.offer); fallthrough + case .answer: result.append(.answer); fallthrough + case .provisionalAnswer: result.append(.provisionalAnswer); fallthrough + case .iceCandidates: result.append(.iceCandidates(sdpMLineIndexes: [], sdpMids: [])); fallthrough + case .endCall: result.append(.endCall) + } + + return result + }() +} diff --git a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift index 6b0b2adcb4..0ca3ed5345 100644 --- a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift @@ -23,18 +23,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) } @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( - initialSetup: { cache in - cache.when { $0.config(for: .any, sessionId: .any) }.thenReturn(nil) - cache - .when { try $0.performAndPushChange(.any, for: .any, sessionId: .any, change: { _ in }) } - .thenReturn(()) - cache - .when { $0.pinnedPriority(.any, threadId: .any, threadVariant: .any) } - .thenReturn(LibSession.defaultNewThreadPriority) - cache.when { $0.disappearingMessagesConfig(threadId: .any, threadVariant: .any) } - .thenReturn(nil) - cache.when { $0.isAdmin(groupSessionId: .any) }.thenReturn(false) - } + initialSetup: { $0.defaultInitialSetup() } ) @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), @@ -62,7 +51,6 @@ class DisplayPictureDownloadJobSpec: QuickSpec { "673120e153a5cb6b869380744d493068ebc418266d6596d728cfc60b30662a089376" + "f2761e3bb6ee837a26b24b5" ) - let filenameHash: String = "TestHash".bytes.toHexString() @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( initialSetup: { network in network @@ -71,29 +59,13 @@ class DisplayPictureDownloadJobSpec: QuickSpec { } ) @TestState(singleton: .fileManager, in: dependencies) var mockFileManager: MockFileManager! = MockFileManager( - initialSetup: { fileManager in - fileManager.when { $0.appSharedDataDirectoryPath }.thenReturn("/test") - fileManager - .when { try $0.ensureDirectoryExists(at: .any, fileProtectionType: .any) } - .thenReturn(()) - fileManager.when { $0.fileExists(atPath: .any) }.thenReturn(false) - fileManager - .when { $0.fileExists(atPath: .any, isDirectory: .any) } - .thenReturn(false) - - fileManager - .when { $0.createFile(atPath: .any, contents: .any, attributes: .any) } - .thenReturn(true) - fileManager - .when { try $0.createDirectory(atPath: .any, withIntermediateDirectories: .any, attributes: .any) } - .thenReturn(()) - } + 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, using: .any)) } + .when { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } .thenReturn(imageData) crypto.when { $0.generate(.hash(message: .any, length: .any)) }.thenReturn("TestHash".bytes) crypto @@ -116,10 +88,19 @@ class DisplayPictureDownloadJobSpec: QuickSpec { @TestState(singleton: .imageDataManager, in: dependencies) var mockImageDataManager: MockImageDataManager! = MockImageDataManager( initialSetup: { imageDataManager in imageDataManager - .when { await $0.loadImageData(identifier: .any, source: .any) } + .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))) + } + ) // MARK: - a DisplayPictureDownloadJob describe("a DisplayPictureDownloadJob") { @@ -217,8 +198,8 @@ class DisplayPictureDownloadJobSpec: QuickSpec { Profile( id: "1234", name: "test", - profilePictureUrl: nil, - profileEncryptionKey: encryptionKey + displayPictureUrl: nil, + displayPictureEncryptionKey: encryptionKey ) ) ) @@ -233,8 +214,8 @@ class DisplayPictureDownloadJobSpec: QuickSpec { Profile( id: "1234", name: "test", - profilePictureUrl: "http://oxen.io/1234/", - profileEncryptionKey: nil + displayPictureUrl: "http://oxen.io/1234/", + displayPictureEncryptionKey: nil ) ) ) @@ -249,8 +230,8 @@ class DisplayPictureDownloadJobSpec: QuickSpec { Profile( id: "1234", name: "test", - profilePictureUrl: "http://oxen.io/1234/", - profileEncryptionKey: encryptionKey + displayPictureUrl: "http://oxen.io/1234/", + displayPictureEncryptionKey: encryptionKey ) ) ) @@ -564,10 +545,17 @@ class DisplayPictureDownloadJobSpec: QuickSpec { ) let expectedRequest: Network.PreparedRequest = mockStorage.read { db in try OpenGroupAPI.preparedDownload( - db, fileId: "12", - from: "testRoom", - on: "testserver", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.serverPublicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) }! @@ -600,10 +588,9 @@ class DisplayPictureDownloadJobSpec: QuickSpec { profile = Profile( id: "1234", name: "test", - profilePictureUrl: nil, - profilePictureFileName: nil, - profileEncryptionKey: nil, - lastProfilePictureUpdate: nil + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + displayPictureLastUpdated: nil ) mockStorage.write { db in try profile.insert(db) } job = Job( @@ -635,7 +622,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { context("when it fails to decrypt the data") { beforeEach { mockCrypto - .when { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any, using: .any)) } + .when { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } .thenReturn(nil) } @@ -643,8 +630,8 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNot(call { - await $0.loadImageData(identifier: .any, source: .any) + expect(mockImageDataManager).toNotEventually(call { + await $0.load(.any) }) expect(mockStorage.read { db in try Profile.fetchOne(db) }).to(equal(profile)) } @@ -654,7 +641,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { context("when it decrypts invalid image data") { beforeEach { mockCrypto - .when { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any, using: .any)) } + .when { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } .thenReturn(Data([1, 2, 3])) } @@ -662,8 +649,8 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNot(call { - await $0.loadImageData(identifier: .any, source: .any) + expect(mockImageDataManager).toNotEventually(call { + await $0.load(.any) }) expect(mockStorage.read { db in try Profile.fetchOne(db) }).to(equal(profile)) } @@ -679,8 +666,8 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: ------ does not save the picture it("does not save the picture") { - expect(mockImageDataManager).toNot(call { - await $0.loadImageData(identifier: .any, source: .any) + expect(mockImageDataManager).toNotEventually(call { + await $0.load(.any) }) expect(mockStorage.read { db in try Profile.fetchOne(db) }).to(equal(profile)) } @@ -691,7 +678,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { expect(mockFileManager) .to(call(.exactly(times: 1), matchingParameters: .all) { mockFileManager in mockFileManager.createFile( - atPath: "/test/ProfileAvatars/\(filenameHash).png", + atPath: "/test/DisplayPictures/5465737448617368", contents: imageData, attributes: nil ) @@ -701,10 +688,9 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: ---- adds the image data to the displayPicture cache it("adds the image data to the displayPicture cache") { expect(mockImageDataManager) - .to(call(.exactly(times: 1), matchingParameters: .all) { - await $0.loadImageData( - identifier: "\(filenameHash).png", - source: .data(imageData) + .toEventually(call(.exactly(times: 1), matchingParameters: .all) { + await $0.load( + .url(URL(fileURLWithPath: "/test/DisplayPictures/5465737448617368")) ) }) } @@ -720,10 +706,9 @@ class DisplayPictureDownloadJobSpec: QuickSpec { profile = Profile( id: "1234", name: "test", - profilePictureUrl: "http://oxen.io/100/", - profilePictureFileName: nil, - profileEncryptionKey: encryptionKey, - lastProfilePictureUpdate: 1234567890 + displayPictureUrl: "http://oxen.io/100/", + displayPictureEncryptionKey: encryptionKey, + displayPictureLastUpdated: 1234567890 ) mockStorage.write { db in _ = try Profile.deleteAll(db) @@ -753,12 +738,12 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any, using: .any)) + $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNot(call { - await $0.loadImageData(identifier: .any, source: .any) + expect(mockImageDataManager).toNotEventually(call { + await $0.load(.any) }) expect(mockStorage.read { db in try Profile.fetchOne(db) }).to(beNil()) } @@ -771,8 +756,8 @@ class DisplayPictureDownloadJobSpec: QuickSpec { try Profile .updateAll( db, - Profile.Columns.profileEncryptionKey.set(to: Data([1, 2, 3])), - Profile.Columns.lastProfilePictureUpdate.set(to: 9999999999) + Profile.Columns.displayPictureEncryptionKey.set(to: Data([1, 2, 3])), + Profile.Columns.displayPictureLastUpdated.set(to: 9999999999) ) } } @@ -781,22 +766,21 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any, using: .any)) + $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNot(call { - await $0.loadImageData(identifier: .any, source: .any) + expect(mockImageDataManager).toNotEventually(call { + await $0.load(.any) }) expect(mockStorage.read { db in try Profile.fetchOne(db) }) .toNot(equal( Profile( id: "1234", name: "test", - profilePictureUrl: "http://oxen.io/100/", - profilePictureFileName: "\(filenameHash).jpg", - profileEncryptionKey: encryptionKey, - lastProfilePictureUpdate: 1234567891 + displayPictureUrl: "http://oxen.io/100/", + displayPictureEncryptionKey: encryptionKey, + displayPictureLastUpdated: 1234567891 ) )) } @@ -809,8 +793,8 @@ class DisplayPictureDownloadJobSpec: QuickSpec { try Profile .updateAll( db, - Profile.Columns.profilePictureUrl.set(to: "testUrl"), - Profile.Columns.lastProfilePictureUpdate.set(to: 9999999999) + Profile.Columns.displayPictureUrl.set(to: "testUrl"), + Profile.Columns.displayPictureLastUpdated.set(to: 9999999999) ) } } @@ -819,22 +803,21 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any, using: .any)) + $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNot(call { - await $0.loadImageData(identifier: .any, source: .any) + expect(mockImageDataManager).toNotEventually(call { + await $0.load(.any) }) expect(mockStorage.read { db in try Profile.fetchOne(db) }) .toNot(equal( Profile( id: "1234", name: "test", - profilePictureUrl: "http://oxen.io/100/", - profilePictureFileName: "\(filenameHash).png", - profileEncryptionKey: encryptionKey, - lastProfilePictureUpdate: 1234567891 + displayPictureUrl: "http://oxen.io/100/", + displayPictureEncryptionKey: encryptionKey, + displayPictureLastUpdated: 1234567891 ) )) } @@ -847,7 +830,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { try Profile .updateAll( db, - Profile.Columns.lastProfilePictureUpdate.set(to: 9999999999) + Profile.Columns.displayPictureLastUpdated.set(to: 9999999999) ) } } @@ -856,20 +839,20 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("saves the picture") { expect(mockCrypto) .to(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any, using: .any)) + $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { $0.createFile( - atPath: "/test/ProfileAvatars/\(filenameHash).png", + atPath: "/test/DisplayPictures/5465737448617368", contents: imageData, attributes: nil ) }) + expect(mockImageDataManager) - .to(call(.exactly(times: 1), matchingParameters: .all) { - await $0.loadImageData( - identifier: "\(filenameHash).png", - source: .data(imageData) + .toEventually(call(.exactly(times: 1), matchingParameters: .all) { + await $0.load( + .url(URL(fileURLWithPath: "/test/DisplayPictures/5465737448617368")) ) }) expect(mockStorage.read { db in try Profile.fetchOne(db) }) @@ -877,10 +860,9 @@ class DisplayPictureDownloadJobSpec: QuickSpec { Profile( id: "1234", name: "test", - profilePictureUrl: "http://oxen.io/100/", - profilePictureFileName: "\(filenameHash).png", - profileEncryptionKey: encryptionKey, - lastProfilePictureUpdate: 1234567891 + displayPictureUrl: "http://oxen.io/100/", + displayPictureEncryptionKey: encryptionKey, + displayPictureLastUpdated: 1234567891 ) )) } @@ -893,10 +875,9 @@ class DisplayPictureDownloadJobSpec: QuickSpec { Profile( id: "1234", name: "test", - profilePictureUrl: "http://oxen.io/100/", - profilePictureFileName: "\(filenameHash).png", - profileEncryptionKey: encryptionKey, - lastProfilePictureUpdate: 1234567891 + displayPictureUrl: "http://oxen.io/100/", + displayPictureEncryptionKey: encryptionKey, + displayPictureLastUpdated: 1234567891 ) )) } @@ -911,9 +892,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { groupDescription: nil, formationTimestamp: 1234567890, displayPictureUrl: "http://oxen.io/100/", - displayPictureFilename: nil, displayPictureEncryptionKey: encryptionKey, - lastDisplayPictureUpdate: 1234567890, shouldPoll: true, groupIdentityPrivateKey: nil, authData: Data([1, 2, 3]), @@ -957,12 +936,12 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any, using: .any)) + $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNot(call { - await $0.loadImageData(identifier: .any, source: .any) + expect(mockImageDataManager).toNotEventually(call { + await $0.load(.any) }) expect(mockStorage.read { db in try ClosedGroup.fetchOne(db) }).to(beNil()) } @@ -975,8 +954,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { try ClosedGroup .updateAll( db, - ClosedGroup.Columns.displayPictureEncryptionKey.set(to: Data([1, 2, 3])), - ClosedGroup.Columns.lastDisplayPictureUpdate.set(to: 9999999999) + ClosedGroup.Columns.displayPictureEncryptionKey.set(to: Data([1, 2, 3])) ) } } @@ -985,12 +963,12 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any, using: .any)) + $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNot(call { - await $0.loadImageData(identifier: .any, source: .any) + expect(mockImageDataManager).toNotEventually(call { + await $0.load(.any) }) expect(mockStorage.read { db in try ClosedGroup.fetchOne(db) }) .toNot(equal( @@ -1000,9 +978,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { groupDescription: nil, formationTimestamp: 1234567890, displayPictureUrl: "http://oxen.io/100/", - displayPictureFilename: "\(filenameHash).jpg", displayPictureEncryptionKey: encryptionKey, - lastDisplayPictureUpdate: 1234567891, shouldPoll: true, groupIdentityPrivateKey: nil, authData: Data([1, 2, 3]), @@ -1019,8 +995,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { try ClosedGroup .updateAll( db, - ClosedGroup.Columns.displayPictureUrl.set(to: "testUrl"), - ClosedGroup.Columns.lastDisplayPictureUpdate.set(to: 9999999999) + ClosedGroup.Columns.displayPictureUrl.set(to: "testUrl") ) } } @@ -1029,12 +1004,12 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any, using: .any)) + $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNot(call { - await $0.loadImageData(identifier: .any, source: .any) + expect(mockImageDataManager).toNotEventually(call { + await $0.load(.any) }) expect(mockStorage.read { db in try ClosedGroup.fetchOne(db) }) .toNot(equal( @@ -1044,61 +1019,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { groupDescription: nil, formationTimestamp: 1234567890, displayPictureUrl: "http://oxen.io/100/", - displayPictureFilename: "\(filenameHash).jpg", displayPictureEncryptionKey: encryptionKey, - lastDisplayPictureUpdate: 1234567891, - shouldPoll: true, - groupIdentityPrivateKey: nil, - authData: Data([1, 2, 3]), - invited: false - ) - )) - } - } - - // MARK: ------ that has a more recent update but the same url and encryption key - context("that has a more recent update but the same url and encryption key") { - beforeEach { - mockStorage.write { db in - try ClosedGroup - .updateAll( - db, - ClosedGroup.Columns.lastDisplayPictureUpdate.set(to: 9999999999) - ) - } - } - - // MARK: -------- saves the picture - it("saves the picture") { - expect(mockCrypto) - .to(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any, using: .any)) - }) - expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { - $0.createFile( - atPath: "/test/ProfileAvatars/\(filenameHash).png", - contents: imageData, - attributes: nil - ) - }) - expect(mockImageDataManager) - .to(call(.exactly(times: 1), matchingParameters: .all) { - await $0.loadImageData( - identifier: "\(filenameHash).png", - source: .data(imageData) - ) - }) - expect(mockStorage.read { db in try ClosedGroup.fetchOne(db) }) - .to(equal( - ClosedGroup( - threadId: "03cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece", - name: "TestGroup", - groupDescription: nil, - formationTimestamp: 1234567890, - displayPictureUrl: "http://oxen.io/100/", - displayPictureFilename: "\(filenameHash).png", - displayPictureEncryptionKey: encryptionKey, - lastDisplayPictureUpdate: 1234567891, shouldPoll: true, groupIdentityPrivateKey: nil, authData: Data([1, 2, 3]), @@ -1118,9 +1039,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { groupDescription: nil, formationTimestamp: 1234567890, displayPictureUrl: "http://oxen.io/100/", - displayPictureFilename: "\(filenameHash).png", displayPictureEncryptionKey: encryptionKey, - lastDisplayPictureUpdate: 1234567891, shouldPoll: true, groupIdentityPrivateKey: nil, authData: Data([1, 2, 3]), @@ -1142,8 +1061,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { imageId: "100", userCount: 1, infoUpdates: 1, - displayPictureFilename: nil, - lastDisplayPictureUpdate: 1234567890 + displayPictureOriginalUrl: nil ) mockStorage.write { db in _ = try OpenGroup.deleteAll(db) @@ -1188,22 +1106,19 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNot(call { - await $0.loadImageData(identifier: .any, source: .any) - }) + expect(mockImageDataManager).toNotEventually(call { await $0.load(.any) }) expect(mockStorage.read { db in try OpenGroup.fetchOne(db) }).to(beNil()) } } - // MARK: ------ that has a different imageId and more recent update - context("that has a different imageId and more recent update") { + // MARK: ------ that has a different imageId + context("that has a different imageId") { beforeEach { mockStorage.write { db in try OpenGroup .updateAll( db, - OpenGroup.Columns.imageId.set(to: "101"), - OpenGroup.Columns.lastDisplayPictureUpdate.set(to: 9999999999) + OpenGroup.Columns.imageId.set(to: "101") ) } } @@ -1212,9 +1127,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNot(call { - await $0.loadImageData(identifier: .any, source: .any) - }) + expect(mockImageDataManager).toNotEventually(call { await $0.load(.any) }) expect(mockStorage.read { db in try OpenGroup.fetchOne(db) }) .toNot(equal( OpenGroup( @@ -1225,23 +1138,20 @@ class DisplayPictureDownloadJobSpec: QuickSpec { name: "name", imageId: "100", userCount: 1, - infoUpdates: 1, - displayPictureFilename: "\(filenameHash).png", - lastDisplayPictureUpdate: 1234567891 + infoUpdates: 1 ) )) } } - // MARK: ------ that has a more recent update but the same imageId - context("that has a more recent update but the same imageId") { + // MARK: ------ that has the same imageId + context("that has the same imageId") { beforeEach { mockStorage.write { db in try OpenGroup .updateAll( db, - OpenGroup.Columns.imageId.set(to: "100"), - OpenGroup.Columns.lastDisplayPictureUpdate.set(to: 9999999999) + OpenGroup.Columns.imageId.set(to: "100") ) } } @@ -1250,16 +1160,15 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("saves the picture") { expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { $0.createFile( - atPath: "/test/ProfileAvatars/\(filenameHash).png", + atPath: "/test/DisplayPictures/5465737448617368", contents: imageData, attributes: nil ) }) expect(mockImageDataManager) - .to(call(.exactly(times: 1), matchingParameters: .all) { - await $0.loadImageData( - identifier: "\(filenameHash).png", - source: .data(imageData) + .toEventually(call(.exactly(times: 1), matchingParameters: .all) { + await $0.load( + .url(URL(fileURLWithPath: "/test/DisplayPictures/5465737448617368")) ) }) expect(mockStorage.read { db in try OpenGroup.fetchOne(db) }) @@ -1273,8 +1182,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { imageId: "100", userCount: 1, infoUpdates: 1, - displayPictureFilename: "\(filenameHash).png", - lastDisplayPictureUpdate: 1234567891 + displayPictureOriginalUrl: "testserver/room/testRoom/file/100" ) )) } @@ -1293,8 +1201,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { imageId: "100", userCount: 1, infoUpdates: 1, - displayPictureFilename: "\(filenameHash).png", - lastDisplayPictureUpdate: 1234567891 + displayPictureOriginalUrl: "testserver/room/testRoom/file/100" ) )) } diff --git a/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift b/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift index 9607f39ee8..3817c8f40d 100644 --- a/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift @@ -31,14 +31,7 @@ class MessageSendJobSpec: QuickSpec { dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) } @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( - initialSetup: { cache in - cache.when { $0.config(for: .any, sessionId: .any) }.thenReturn(nil) - cache - .when { $0.pinnedPriority(.any, threadId: .any, threadVariant: .any) } - .thenReturn(LibSession.defaultNewThreadPriority) - cache.when { $0.disappearingMessagesConfig(threadId: .any, threadVariant: .any) } - .thenReturn(nil) - } + initialSetup: { $0.defaultInitialSetup() } ) @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), @@ -75,7 +68,7 @@ class MessageSendJobSpec: QuickSpec { jobRunner .when { $0.insert(.any, job: .any, before: .any) } .then { args, untrackedArgs in - let db: Database = untrackedArgs[0] as! Database + let db: ObservingDatabase = untrackedArgs[0] as! ObservingDatabase var job: Job = args[0] as! Job job.id = 1000 @@ -110,10 +103,43 @@ class MessageSendJobSpec: QuickSpec { expect(permanentFailure).to(beTrue()) } + // MARK: -- fails when not give a thread id + it("fails when not give a thread id") { + job = Job( + variant: .messageSend, + threadId: nil, + details: MessageSendJob.Details( + destination: .contact(publicKey: "Test"), + message: VisibleMessage( + text: "Test" + ) + ) + ) + + var error: Error? = nil + var permanentFailure: Bool = false + + MessageSendJob.run( + job, + scheduler: DispatchQueue.main, + success: { _, _ in }, + failure: { _, runError, runPermanentFailure in + error = runError + permanentFailure = runPermanentFailure + }, + deferred: { _ in }, + using: dependencies + ) + + expect(error).to(matchError(JobRunnerError.missingRequiredDetails)) + expect(permanentFailure).to(beTrue()) + } + // MARK: -- fails when given incorrect details it("fails when given incorrect details") { job = Job( variant: .messageSend, + threadId: "Test", details: MessageReceiveJob.Details( messages: [MessageReceiveJob.Details.MessageInfo]() ) @@ -162,11 +188,11 @@ class MessageSendJobSpec: QuickSpec { openGroupWhisperTo: nil, state: .sending, recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - transientDependencies: nil + mostRecentFailureText: nil ) job = Job( variant: .messageSend, + threadId: "Test1", interactionId: interaction.id!, details: MessageSendJob.Details( destination: .contact(publicKey: "Test"), @@ -186,6 +212,7 @@ class MessageSendJobSpec: QuickSpec { it("fails when there is no job id") { job = Job( variant: .messageSend, + threadId: "Test1", interactionId: interaction.id!, details: MessageSendJob.Details( destination: .contact(publicKey: "Test"), @@ -218,6 +245,7 @@ class MessageSendJobSpec: QuickSpec { it("fails when there is no interaction id") { job = Job( variant: .messageSend, + threadId: "Test1", details: MessageSendJob.Details( destination: .contact(publicKey: "Test"), message: VisibleMessage( @@ -249,6 +277,7 @@ class MessageSendJobSpec: QuickSpec { it("fails when there is no interaction for the provided interaction id") { job = Job( variant: .messageSend, + threadId: "Test1", interactionId: 12345, details: MessageSendJob.Details( destination: .contact(publicKey: "Test"), @@ -402,6 +431,7 @@ class MessageSendJobSpec: QuickSpec { behaviour: .runOnce, shouldBlock: false, shouldSkipLaunchBecomeActive: false, + threadId: "Test1", interactionId: 100, details: AttachmentUploadJob.Details( messageSendJobId: 54321, diff --git a/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift index 121819e34c..39d2bff89d 100644 --- a/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift @@ -86,6 +86,15 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { 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 @@ -245,8 +254,15 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { } let expectedRequest: Network.PreparedRequest! = mockStorage.read { db in try OpenGroupAPI.preparedCapabilitiesAndRooms( - db, - on: OpenGroupAPI.defaultServer, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: OpenGroupAPI.defaultServer, + publicKey: OpenGroupAPI.defaultServerPublicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) } @@ -444,8 +460,7 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { name: "TestExisting", imageId: "10", userCount: 0, - infoUpdates: 10, - displayPictureFilename: "TestFilename" + infoUpdates: 10 ) .insert(db) } @@ -534,7 +549,7 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { imageId: "12", userCount: 0, infoUpdates: 12, - displayPictureFilename: "TestFilename" + displayPictureOriginalUrl: "TestUrl" ) .insert(db) } @@ -553,7 +568,7 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { } // MARK: -- updates the cache with the default rooms - it("does not schedule a display picture download if the imageId matches and the image has already been downloaded") { + it("updates the cache with the default rooms") { RetrieveDefaultOpenGroupRoomsJob.run( job, scheduler: DispatchQueue.main, @@ -596,8 +611,7 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { name: "TestRoomName2", imageId: "12", userCount: 0, - infoUpdates: 12, - displayPictureFilename: nil + infoUpdates: 12 ) ) ]) diff --git a/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift index 1fca27cccc..dbf516efbb 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift @@ -23,6 +23,7 @@ class LibSessionGroupInfoSpec: QuickSpec { @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( @@ -67,7 +68,6 @@ class LibSessionGroupInfoSpec: QuickSpec { name: "TestGroup", description: nil, displayPictureUrl: nil, - displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [], using: dependencies @@ -80,25 +80,15 @@ class LibSessionGroupInfoSpec: QuickSpec { var secretKey: [UInt8] = Array(Data(hex: TestConstants.edSecretKey)) _ = user_groups_init(&conf, &secretKey, nil, 0, nil) - cache.when { $0.setConfig(for: .any, sessionId: .any, to: .any) }.thenReturn(()) - cache.when { $0.removeConfigs(for: .any) }.thenReturn(()) - cache.when { $0.config(for: .userGroups, sessionId: .any) } - .thenReturn(.userGroups(conf)) - cache.when { $0.config(for: .groupInfo, sessionId: .any) } - .thenReturn(createGroupOutput.groupState[.groupInfo]) - cache.when { $0.config(for: .groupMembers, sessionId: .any) } - .thenReturn(createGroupOutput.groupState[.groupMembers]) - cache.when { $0.config(for: .groupKeys, sessionId: .any) } - .thenReturn(createGroupOutput.groupState[.groupKeys]) + 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) - cache.when { try $0.createDump(config: .any, for: .any, sessionId: .any, timestampMs: .any) }.thenReturn(nil) - cache.when { try $0.performAndPushChange(.any, for: .any, sessionId: .any, change: { _ in }) }.thenReturn(nil) - cache - .when { $0.pinnedPriority(.any, threadId: .any, threadVariant: .any) } - .thenReturn(LibSession.defaultNewThreadPriority) - cache.when { $0.disappearingMessagesConfig(threadId: .any, threadVariant: .any) } - .thenReturn(nil) - cache.when { $0.isAdmin(groupSessionId: .any) }.thenReturn(true) } ) @@ -269,8 +259,7 @@ class LibSessionGroupInfoSpec: QuickSpec { .updateAll( db, ClosedGroup.Columns.displayPictureUrl.set(to: "TestUrl"), - ClosedGroup.Columns.displayPictureEncryptionKey.set(to: Data([1, 2, 3])), - ClosedGroup.Columns.displayPictureFilename.set(to: "TestFilename") + ClosedGroup.Columns.displayPictureEncryptionKey.set(to: Data([1, 2, 3])) ) } @@ -288,8 +277,6 @@ class LibSessionGroupInfoSpec: QuickSpec { } expect(latestGroup?.displayPictureUrl).to(beNil()) expect(latestGroup?.displayPictureEncryptionKey).to(beNil()) - expect(latestGroup?.displayPictureFilename).to(beNil()) - expect(latestGroup?.lastDisplayPictureUpdate).to(equal(1234567891)) } // MARK: ------ schedules a display picture download job if there is a new one @@ -397,8 +384,7 @@ class LibSessionGroupInfoSpec: QuickSpec { openGroupWhisperTo: nil, state: .sent, recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - transientDependencies: nil + mostRecentFailureText: nil ).inserted(db) } @@ -453,8 +439,7 @@ class LibSessionGroupInfoSpec: QuickSpec { openGroupWhisperTo: nil, state: .sent, recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - transientDependencies: nil + mostRecentFailureText: nil ).inserted(db) _ = try Interaction( serverHash: "1235", @@ -476,8 +461,7 @@ class LibSessionGroupInfoSpec: QuickSpec { openGroupWhisperTo: nil, state: .sent, recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - transientDependencies: nil + mostRecentFailureText: nil ).inserted(db) } @@ -537,8 +521,7 @@ class LibSessionGroupInfoSpec: QuickSpec { openGroupWhisperTo: nil, state: .sent, recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - transientDependencies: nil + mostRecentFailureText: nil ).inserted(db) _ = try Attachment( id: "AttachmentId", @@ -606,8 +589,7 @@ class LibSessionGroupInfoSpec: QuickSpec { openGroupWhisperTo: nil, state: .sent, recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - transientDependencies: nil + mostRecentFailureText: nil ).inserted(db) _ = try Attachment( id: "AttachmentId", @@ -687,8 +669,7 @@ class LibSessionGroupInfoSpec: QuickSpec { openGroupWhisperTo: nil, state: .sent, recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - transientDependencies: nil + mostRecentFailureText: nil ).inserted(db) let interaction2: Interaction = try Interaction( serverHash: "1235", @@ -710,8 +691,7 @@ class LibSessionGroupInfoSpec: QuickSpec { openGroupWhisperTo: nil, state: .sent, recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - transientDependencies: nil + mostRecentFailureText: nil ).inserted(db) _ = try Attachment( id: "AttachmentId", @@ -790,8 +770,7 @@ class LibSessionGroupInfoSpec: QuickSpec { openGroupWhisperTo: nil, state: .sent, recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - transientDependencies: nil + mostRecentFailureText: nil ).inserted(db) _ = try Interaction( serverHash: "1235", @@ -813,8 +792,7 @@ class LibSessionGroupInfoSpec: QuickSpec { openGroupWhisperTo: nil, state: .sent, recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - transientDependencies: nil + mostRecentFailureText: nil ).inserted(db) _ = try Attachment( id: "AttachmentId", @@ -883,8 +861,7 @@ class LibSessionGroupInfoSpec: QuickSpec { openGroupWhisperTo: nil, state: .sent, recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - transientDependencies: nil + mostRecentFailureText: nil ).inserted(db) } @@ -952,8 +929,7 @@ class LibSessionGroupInfoSpec: QuickSpec { openGroupWhisperTo: nil, state: .sent, recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - transientDependencies: nil + mostRecentFailureText: nil ).inserted(db) } diff --git a/SessionMessagingKitTests/LibSession/LibSessionGroupMembersSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionGroupMembersSpec.swift index 6b87e202a8..5e2884cd3e 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionGroupMembersSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionGroupMembersSpec.swift @@ -22,6 +22,7 @@ class LibSessionGroupMembersSpec: QuickSpec { @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( @@ -65,7 +66,6 @@ class LibSessionGroupMembersSpec: QuickSpec { name: "TestGroup", description: nil, displayPictureUrl: nil, - displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [], using: dependencies @@ -78,21 +78,14 @@ class LibSessionGroupMembersSpec: QuickSpec { var secretKey: [UInt8] = Array(Data(hex: TestConstants.edSecretKey)) _ = user_groups_init(&conf, &secretKey, nil, 0, nil) - cache.when { $0.setConfig(for: .any, sessionId: .any, to: .any) }.thenReturn(()) - cache.when { $0.config(for: .userGroups, sessionId: .any) } - .thenReturn(.userGroups(conf)) - cache.when { $0.config(for: .groupInfo, sessionId: .any) } - .thenReturn(createGroupOutput.groupState[.groupInfo]) - cache.when { $0.config(for: .groupMembers, sessionId: .any) } - .thenReturn(createGroupOutput.groupState[.groupMembers]) - cache.when { $0.config(for: .groupKeys, sessionId: .any) } - .thenReturn(createGroupOutput.groupState[.groupKeys]) - cache.when { try $0.performAndPushChange(.any, for: .any, sessionId: .any, change: { _ in }) }.thenReturn(nil) - cache.when { $0.pinnedPriority(.any, threadId: .any, threadVariant: .any) } - .thenReturn(LibSession.defaultNewThreadPriority) - cache.when { $0.disappearingMessagesConfig(threadId: .any, threadVariant: .any) } - .thenReturn(nil) - cache.when { $0.isAdmin(groupSessionId: .any) }.thenReturn(true) + cache.defaultInitialSetup( + configs: [ + .userGroups: .userGroups(conf), + .groupInfo: createGroupOutput.groupState[.groupInfo], + .groupMembers: createGroupOutput.groupState[.groupMembers], + .groupKeys: createGroupOutput.groupState[.groupKeys] + ] + ) } ) diff --git a/SessionMessagingKitTests/LibSession/LibSessionSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionSpec.swift index 8f583852f1..d135064fcf 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionSpec.swift @@ -22,6 +22,7 @@ class LibSessionSpec: QuickSpec { @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( @@ -76,7 +77,6 @@ class LibSessionSpec: QuickSpec { name: "TestGroup", description: nil, displayPictureUrl: nil, - displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [], using: dependencies @@ -89,33 +89,14 @@ class LibSessionSpec: QuickSpec { var secretKey: [UInt8] = Array(Data(hex: TestConstants.edSecretKey)) _ = user_groups_init(&conf, &secretKey, nil, 0, nil) - cache.when { $0.setConfig(for: .any, sessionId: .any, to: .any) }.thenReturn(()) - cache.when { $0.config(for: .userGroups, sessionId: .any) } - .thenReturn(.userGroups(conf)) - cache.when { $0.config(for: .groupInfo, sessionId: .any) } - .thenReturn(createGroupOutput.groupState[.groupInfo]) - cache.when { $0.config(for: .groupMembers, sessionId: .any) } - .thenReturn(createGroupOutput.groupState[.groupMembers]) - cache.when { $0.config(for: .groupKeys, sessionId: .any) } - .thenReturn(createGroupOutput.groupState[.groupKeys]) - cache.when { $0.configNeedsDump(.any) }.thenReturn(false) - cache - .when { try $0.createDump(config: .any, for: .any, sessionId: .any, timestampMs: .any) } - .thenReturn(nil) - cache - .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) - - switch args[test: 0] as? ConfigDump.Variant { - case .userGroups: try? callback?(.userGroups(conf)) - case .groupInfo: try? callback?(createGroupOutput.groupState[.groupInfo]) - case .groupMembers: try? callback?(createGroupOutput.groupState[.groupMembers]) - case .groupKeys: try? callback?(createGroupOutput.groupState[.groupKeys]) - default: break - } - } - .thenReturn(()) + cache.defaultInitialSetup( + configs: [ + .userGroups: .userGroups(conf), + .groupInfo: createGroupOutput.groupState[.groupInfo], + .groupMembers: createGroupOutput.groupState[.groupMembers], + .groupKeys: createGroupOutput.groupState[.groupKeys] + ] + ) } ) @TestState var userGroupsConfig: LibSession.Config! @@ -345,18 +326,14 @@ 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([]) mockStorage.write { db in - try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - do { _ = try LibSession.createGroup( db, name: "Testname", description: nil, displayPictureUrl: nil, - displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [], using: dependencies @@ -381,7 +358,6 @@ class LibSessionSpec: QuickSpec { name: "Testname", description: nil, displayPictureUrl: nil, - displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [], using: dependencies @@ -404,7 +380,6 @@ class LibSessionSpec: QuickSpec { name: "Testname", description: nil, displayPictureUrl: nil, - displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [( id: "123456", @@ -431,7 +406,6 @@ class LibSessionSpec: QuickSpec { name: "Testname", description: nil, displayPictureUrl: nil, - displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [], using: dependencies @@ -455,7 +429,6 @@ class LibSessionSpec: QuickSpec { name: "Testname", description: nil, displayPictureUrl: "TestUrl", - displayPictureFilename: "TestFilename", displayPictureEncryptionKey: Data([1, 2, 3]), members: [], using: dependencies @@ -471,7 +444,6 @@ class LibSessionSpec: QuickSpec { )) expect(createGroupOutput.group.name).to(equal("Testname")) expect(createGroupOutput.group.displayPictureUrl).to(equal("TestUrl")) - expect(createGroupOutput.group.displayPictureFilename).to(equal("TestFilename")) expect(createGroupOutput.group.displayPictureEncryptionKey).to(equal(Data([1, 2, 3]))) expect(createGroupOutput.group.formationTimestamp).to(equal(1234567890)) expect(createGroupOutput.group.invited).to(beFalse()) @@ -485,15 +457,14 @@ class LibSessionSpec: QuickSpec { name: "Testname", description: nil, displayPictureUrl: nil, - displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [( id: "051111111111111111111111111111111111111111111111111111111111111111", profile: Profile( id: "051111111111111111111111111111111111111111111111111111111111111111", name: "TestName", - profilePictureUrl: "testUrl", - profileEncryptionKey: Data([1, 2, 3]) + displayPictureUrl: "testUrl", + displayPictureEncryptionKey: Data([1, 2, 3]) ) )], using: dependencies @@ -531,7 +502,6 @@ class LibSessionSpec: QuickSpec { name: "Testname", description: nil, displayPictureUrl: nil, - displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [( id: "051111111111111111111111111111111111111111111111111111111111111111", @@ -559,7 +529,6 @@ class LibSessionSpec: QuickSpec { name: "Testname", description: nil, displayPictureUrl: nil, - displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [( id: "051111111111111111111111111111111111111111111111111111111111111111", @@ -585,7 +554,6 @@ class LibSessionSpec: QuickSpec { name: "Testname", description: nil, displayPictureUrl: nil, - displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [( id: "051111111111111111111111111111111111111111111111111111111111111111", @@ -661,7 +629,6 @@ class LibSessionSpec: QuickSpec { name: "Testname", description: nil, displayPictureUrl: nil, - displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [( id: "051111111111111111111111111111111111111111111111111111111111111111", @@ -702,7 +669,6 @@ class LibSessionSpec: QuickSpec { name: "Testname", description: nil, displayPictureUrl: nil, - displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [( id: "051111111111111111111111111111111111111111111111111111111111111111", diff --git a/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift index 032407d486..56ab349c08 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift @@ -247,7 +247,6 @@ fileprivate extension LibSessionUtilSpec { expect(contact2.notifications).to(equal(CONVO_NOTIFY_ALL)) expect(contact2.mute_until).to(equal(Int64(nowTs + 1800))) - // Since we've made changes, we should need to push new config to the swarm, *and* should need // to dump the updated state: expect(config_needs_push(conf)).to(beTrue()) @@ -2864,62 +2863,3 @@ fileprivate extension LibSessionUtilSpec { } } } - -// MARK: - Convenience - -private extension LibSessionUtilSpec { - static func has(_ conf: UnsafeMutablePointer?, with numRecords: inout Int, hitLimit expectedLimit: Int) -> Bool { - // Have a hard limit (ie. don't want to loop over this limit as it likely means something is busted elsewhere - // and we are in an infinite loop) - guard numRecords < 2500 else { return true } - - // When generating push data the actual data generated is based on a diff from the current state to the - // next state - this means that adding 100 records at once is a different size from adding 1 at a time, - // but since adding them 1 at a time is really inefficient we want to try to be smart about calling - // `config_push` when we are far away from the limit, but do so in such a way that we still get accurate - // sizes as we approach the limit (this includes the "diff" values which include the last 5 changes) - // - // **Note:** `config_push` returns null when it hits the config limit - let distanceToLimit: Int = (expectedLimit - numRecords) - - switch distanceToLimit { - case Int.min...50: - // Within 50 records of the expected limit we want to check every record - guard let result: UnsafeMutablePointer = config_push(conf) else { return true } - - // We successfully generated the config push and didn't hit the limit - free(UnsafeMutableRawPointer(mutating: result)) - - case 50...100: - // Between 50 and 100 records of the expected limit only check every `10` records - if numRecords.isMultiple(of: 10) { - guard let result: UnsafeMutablePointer = config_push(conf) else { return true } - - // We successfully generated the config push and didn't hit the limit - free(UnsafeMutableRawPointer(mutating: result)) - } - - case 100...200: - // Between 100 and 200 records of the expected limit only check every `25` records - if numRecords.isMultiple(of: 25) { - guard let result: UnsafeMutablePointer = config_push(conf) else { return true } - - // We successfully generated the config push and didn't hit the limit - free(UnsafeMutableRawPointer(mutating: result)) - } - - default: - // Otherwise check every `50` records - if numRecords.isMultiple(of: 50) { - guard let result: UnsafeMutablePointer = config_push(conf) else { return true } - - // We successfully generated the config push and didn't hit the limit - free(UnsafeMutableRawPointer(mutating: result)) - } - } - - // Increment the number of records - numRecords += 1 - return false - } -} diff --git a/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupAPISpec.swift index 4771cf2052..35b3ba1679 100644 --- a/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupAPISpec.swift @@ -1,7 +1,6 @@ // Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit import Quick @@ -14,19 +13,13 @@ class CryptoOpenGroupAPISpec: QuickSpec { // MARK: Configuration @TestState var dependencies: TestDependencies! = TestDependencies() - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( - customWriter: try! DatabaseQueue(), - migrationTargets: [ - SNUtilitiesKit.self, - SNMessagingKit.self - ], - using: dependencies, - initialData: { 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 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(singleton: .crypto, in: dependencies) var crypto: Crypto! = Crypto(using: .any) // MARK: - Crypto for OpenGroupAPI describe("Crypto for OpenGroupAPI") { @@ -187,17 +180,13 @@ class CryptoOpenGroupAPISpec: QuickSpec { context("when encrypting with the session blinding protocol") { // MARK: ---- can encrypt for a blind15 recipient correctly it("can encrypt for a blind15 recipient correctly") { - let result: Data? = mockStorage.read { db in - try crypto.tryGenerate( - .ciphertextWithSessionBlindingProtocol( - db, - plaintext: "TestMessage".data(using: .utf8)!, - recipientBlindedId: "15\(TestConstants.blind15PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + let result: Data? = try? crypto.tryGenerate( + .ciphertextWithSessionBlindingProtocol( + plaintext: "TestMessage".data(using: .utf8)!, + recipientBlindedId: "15\(TestConstants.blind15PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } + ) // Note: A Nonce is used for this so we can't compare the exact value when not mocked expect(result).toNot(beNil()) @@ -206,17 +195,13 @@ class CryptoOpenGroupAPISpec: QuickSpec { // MARK: ---- can encrypt for a blind25 recipient correctly it("can encrypt for a blind25 recipient correctly") { - let result: Data? = mockStorage.read { db in - try crypto.tryGenerate( - .ciphertextWithSessionBlindingProtocol( - db, - plaintext: "TestMessage".data(using: .utf8)!, - recipientBlindedId: "25\(TestConstants.blind25PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + let result: Data? = try? crypto.tryGenerate( + .ciphertextWithSessionBlindingProtocol( + plaintext: "TestMessage".data(using: .utf8)!, + recipientBlindedId: "25\(TestConstants.blind25PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } + ) // Note: A Nonce is used for this so we can't compare the exact value when not mocked expect(result).toNot(beNil()) @@ -225,60 +210,45 @@ class CryptoOpenGroupAPISpec: QuickSpec { // MARK: ---- includes a version at the start of the encrypted value it("includes a version at the start of the encrypted value") { - let result: Data? = mockStorage.read { db in - try crypto.tryGenerate( - .ciphertextWithSessionBlindingProtocol( - db, - plaintext: "TestMessage".data(using: .utf8)!, - recipientBlindedId: "15\(TestConstants.blind15PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + let result: Data? = try? crypto.tryGenerate( + .ciphertextWithSessionBlindingProtocol( + plaintext: "TestMessage".data(using: .utf8)!, + recipientBlindedId: "15\(TestConstants.blind15PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } + ) expect(result?.toHexString().prefix(2)).to(equal("00")) } // MARK: ---- throws an error if the recipient isn't a blinded id it("throws an error if the recipient isn't a blinded id") { - mockStorage.read { db in - expect { - try crypto.tryGenerate( - .ciphertextWithSessionBlindingProtocol( - db, - plaintext: "TestMessage".data(using: .utf8)!, - recipientBlindedId: "05\(TestConstants.publicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + expect { + try crypto.tryGenerate( + .ciphertextWithSessionBlindingProtocol( + plaintext: "TestMessage".data(using: .utf8)!, + recipientBlindedId: "05\(TestConstants.publicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } - .to(throwError(MessageSenderError.encryptionFailed)) + ) } + .to(throwError(MessageSenderError.encryptionFailed)) } // MARK: ---- throws an error if there is no ed25519 keyPair it("throws an error if there is no ed25519 keyPair") { - mockStorage.write { db in - _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } + mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) - mockStorage.read { db in - expect { - try crypto.tryGenerate( - .ciphertextWithSessionBlindingProtocol( - db, - plaintext: "TestMessage".data(using: .utf8)!, - recipientBlindedId: "15\(TestConstants.blind15PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + expect { + try crypto.tryGenerate( + .ciphertextWithSessionBlindingProtocol( + plaintext: "TestMessage".data(using: .utf8)!, + recipientBlindedId: "15\(TestConstants.blind15PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } - .to(throwError(MessageSenderError.noUserED25519KeyPair)) + ) } + .to(throwError(MessageSenderError.noUserED25519KeyPair)) } } @@ -286,21 +256,17 @@ class CryptoOpenGroupAPISpec: QuickSpec { context("when decrypting with the session blinding protocol") { // MARK: ---- can decrypt a blind15 message correctly it("can decrypt a blind15 message correctly") { - let result = mockStorage.read { db in - try crypto.tryGenerate( - .plaintextWithSessionBlindingProtocol( - db, - ciphertext: Data( - base64Encoded: "AMuM6E07xyYzN1/gP64v9TelMjkylHsFZznTzE7rDIykIHBHKbdkLnXo4Q1iVWdD" + - "ct9F9YqIsRsqmdLl1t6nfQtWoiUSkjBChvg3J61f7rpS3/A+" - )!, - senderId: "15\(TestConstants.blind15PublicKey)", - recipientId: "15\(TestConstants.blind15PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + let result = try? crypto.tryGenerate( + .plaintextWithSessionBlindingProtocol( + ciphertext: Data( + base64Encoded: "AMuM6E07xyYzN1/gP64v9TelMjkylHsFZznTzE7rDIykIHBHKbdkLnXo4Q1iVWdD" + + "ct9F9YqIsRsqmdLl1t6nfQtWoiUSkjBChvg3J61f7rpS3/A+" + )!, + senderId: "15\(TestConstants.blind15PublicKey)", + recipientId: "15\(TestConstants.blind15PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } + ) expect(String(data: (result?.plaintext ?? Data()), encoding: .utf8)).to(equal("TestMessage")) expect(result?.senderSessionIdHex).to(equal("05\(TestConstants.publicKey)")) @@ -308,21 +274,17 @@ class CryptoOpenGroupAPISpec: QuickSpec { // MARK: ---- can decrypt a blind25 message correctly it("can decrypt a blind25 message correctly") { - let result = mockStorage.read { db in - try crypto.tryGenerate( - .plaintextWithSessionBlindingProtocol( - db, - ciphertext: Data( - base64Encoded: "ALLcu/jtQsel6HewKdRCsRYXrQl7r60Oz2SX/DKmjCRo4mO2yqMx2+oGwm39n6+p" + - "6dK1n+UWPnm4qGRiN6BvZ+xwNsBruPgyW1EV9i8AcEO0P/1X" - )!, - senderId: "25\(TestConstants.blind25PublicKey)", - recipientId: "25\(TestConstants.blind25PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + let result = try? crypto.tryGenerate( + .plaintextWithSessionBlindingProtocol( + ciphertext: Data( + base64Encoded: "ALLcu/jtQsel6HewKdRCsRYXrQl7r60Oz2SX/DKmjCRo4mO2yqMx2+oGwm39n6+p" + + "6dK1n+UWPnm4qGRiN6BvZ+xwNsBruPgyW1EV9i8AcEO0P/1X" + )!, + senderId: "25\(TestConstants.blind25PublicKey)", + recipientId: "25\(TestConstants.blind25PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } + ) expect(String(data: (result?.plaintext ?? Data()), encoding: .utf8)).to(equal("TestMessage")) expect(result?.senderSessionIdHex).to(equal("05\(TestConstants.publicKey)")) @@ -330,114 +292,91 @@ class CryptoOpenGroupAPISpec: QuickSpec { // MARK: ---- throws an error if there is no ed25519 keyPair it("throws an error if there is no ed25519 keyPair") { - mockStorage.write { db in - _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } + mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) - mockStorage.read { db in - expect { - try crypto.tryGenerate( - .plaintextWithSessionBlindingProtocol( - db, - ciphertext: Data( - base64Encoded: "SRP0eBUWh4ez6ppWjUs5/Wph5fhnPRgB5zsWWnTz+FBAw/YI3oS2pDpIfyetMTbU" + - "sFMhE5G4PbRtQFey1hsxLl221Qivc3ayaX2Mm/X89Dl8e45BC+Lb/KU9EdesxIK4pVgYXs9XrMtX3v8" + - "dt0eBaXneOBfr7qB8pHwwMZjtkOu1ED07T9nszgbWabBphUfWXe2U9K3PTRisSCI=" - )!, - senderId: "25\(TestConstants.blind25PublicKey)", - recipientId: "25\(TestConstants.blind25PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + expect { + try crypto.tryGenerate( + .plaintextWithSessionBlindingProtocol( + ciphertext: Data( + base64Encoded: "SRP0eBUWh4ez6ppWjUs5/Wph5fhnPRgB5zsWWnTz+FBAw/YI3oS2pDpIfyetMTbU" + + "sFMhE5G4PbRtQFey1hsxLl221Qivc3ayaX2Mm/X89Dl8e45BC+Lb/KU9EdesxIK4pVgYXs9XrMtX3v8" + + "dt0eBaXneOBfr7qB8pHwwMZjtkOu1ED07T9nszgbWabBphUfWXe2U9K3PTRisSCI=" + )!, + senderId: "25\(TestConstants.blind25PublicKey)", + recipientId: "25\(TestConstants.blind25PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } - .to(throwError(MessageSenderError.noUserED25519KeyPair)) + ) } + .to(throwError(MessageSenderError.noUserED25519KeyPair)) } // MARK: ---- throws an error if the data is too short it("throws an error if the data is too short") { - mockStorage.read { db in - expect { - try crypto.tryGenerate( - .plaintextWithSessionBlindingProtocol( - db, - ciphertext: Data([1, 2, 3]), - senderId: "15\(TestConstants.blind15PublicKey)", - recipientId: "15\(TestConstants.blind15PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + expect { + try crypto.tryGenerate( + .plaintextWithSessionBlindingProtocol( + ciphertext: Data([1, 2, 3]), + senderId: "15\(TestConstants.blind15PublicKey)", + recipientId: "15\(TestConstants.blind15PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } - .to(throwError(MessageReceiverError.decryptionFailed)) + ) } + .to(throwError(MessageReceiverError.decryptionFailed)) } // MARK: ---- throws an error if the data version is not 0 it("throws an error if the data version is not 0") { - mockStorage.read { db in - expect { - try crypto.tryGenerate( - .plaintextWithSessionBlindingProtocol( - db, - ciphertext: ( - Data([1]) + - "TestMessage".data(using: .utf8)! + - Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")! - ), - senderId: "15\(TestConstants.blind15PublicKey)", - recipientId: "15\(TestConstants.blind15PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + expect { + try crypto.tryGenerate( + .plaintextWithSessionBlindingProtocol( + ciphertext: ( + Data([1]) + + "TestMessage".data(using: .utf8)! + + Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")! + ), + senderId: "15\(TestConstants.blind15PublicKey)", + recipientId: "15\(TestConstants.blind15PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } - .to(throwError(MessageReceiverError.decryptionFailed)) + ) } + .to(throwError(MessageReceiverError.decryptionFailed)) } // MARK: ---- throws an error if it cannot decrypt the data it("throws an error if it cannot decrypt the data") { - mockStorage.read { db in - expect { - try crypto.tryGenerate( - .plaintextWithSessionBlindingProtocol( - db, - ciphertext: "RandomData".data(using: .utf8)!, - senderId: "25\(TestConstants.blind25PublicKey)", - recipientId: "25\(TestConstants.blind25PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + expect { + try crypto.tryGenerate( + .plaintextWithSessionBlindingProtocol( + ciphertext: "RandomData".data(using: .utf8)!, + senderId: "25\(TestConstants.blind25PublicKey)", + recipientId: "25\(TestConstants.blind25PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } - .to(throwError(MessageReceiverError.decryptionFailed)) + ) } + .to(throwError(MessageReceiverError.decryptionFailed)) } // MARK: ---- throws an error if the inner bytes are too short it("throws an error if the inner bytes are too short") { - mockStorage.read { db in - expect { - try crypto.tryGenerate( - .plaintextWithSessionBlindingProtocol( - db, - ciphertext: ( - Data([0]) + - "TestMessage".data(using: .utf8)! + - Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")! - ), - senderId: "15\(TestConstants.blind15PublicKey)", - recipientId: "15\(TestConstants.blind15PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + expect { + try crypto.tryGenerate( + .plaintextWithSessionBlindingProtocol( + ciphertext: ( + Data([0]) + + "TestMessage".data(using: .utf8)! + + Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")! + ), + senderId: "15\(TestConstants.blind15PublicKey)", + recipientId: "15\(TestConstants.blind15PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } - .to(throwError(MessageReceiverError.decryptionFailed)) + ) } + .to(throwError(MessageReceiverError.decryptionFailed)) } } } diff --git a/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift b/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift index 51fbff1966..325689d553 100644 --- a/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift @@ -95,8 +95,7 @@ class OpenGroupSpec: QuickSpec { outboxLatestMessageId: 0, pollFailureCount: 0, permissions: ---, - displayPictureFilename: null, - lastDisplayPictureUpdate: 0.0 + displayPictureOriginalUrl: null ) """)) } diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index 87f362c45c..fa13eeea02 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -19,36 +19,6 @@ class OpenGroupAPISpec: QuickSpec { dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) dependencies.forceSynchronous = true } - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( - customWriter: try! DatabaseQueue(), - migrationTargets: [ - SNUtilitiesKit.self, - SNMessagingKit.self - ], - 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, - sequenceNumber: 0, - inboxLatestMessageId: 0, - outboxLatestMessageId: 0 - ).insert(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - } - ) @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork() @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( initialSetup: { crypto in @@ -78,8 +48,34 @@ class OpenGroupAPISpec: QuickSpec { 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(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( + initialSetup: { $0.defaultInitialSetup() } + ) @TestState var disposables: [AnyCancellable]! = [] @TestState var error: Error? @@ -87,17 +83,35 @@ class OpenGroupAPISpec: QuickSpec { describe("an OpenGroupAPI") { // MARK: -- when preparing a poll request context("when preparing a poll request") { + @TestState var preparedRequest: Network.PreparedRequest>? + // MARK: ---- generates the correct request it("generates the correct request") { - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, hasPerformedInitialPoll: false, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/batch")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -112,15 +126,31 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- retrieves recent messages if there was no last message it("retrieves recent messages if there was no last message") { - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, hasPerformedInitialPoll: false, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints[test: 2].asType(OpenGroupAPI.Endpoint.self)) .to(equal(.roomMessagesRecent("testRoom"))) @@ -128,20 +158,31 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- retrieves recent messages if there was a last message and it has not performed the initial poll and the last message was too long ago it("retrieves recent messages if there was a last message and it has not performed the initial poll and the last message was too long ago") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 121)) - } - - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 121 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, hasPerformedInitialPoll: false, timeSinceLastPoll: (CommunityPoller.maxInactivityPeriod + 1), + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints[test: 2].asType(OpenGroupAPI.Endpoint.self)) .to(equal(.roomMessagesRecent("testRoom"))) @@ -149,20 +190,31 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- retrieves recent messages if there was a last message and it has performed an initial poll but it was not too long ago it("retrieves recent messages if there was a last message and it has performed an initial poll but it was not too long ago") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 122)) - } - - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 122 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, hasPerformedInitialPoll: false, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints[test: 2].asType(OpenGroupAPI.Endpoint.self)) .to(equal(.roomMessagesSince("testRoom", seqNo: 122))) @@ -170,20 +222,31 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- retrieves recent messages if there was a last message and there has already been a poll this session it("retrieves recent messages if there was a last message and there has already been a poll this session") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 123)) - } - - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 123 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, hasPerformedInitialPoll: true, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints[test: 2].asType(OpenGroupAPI.Endpoint.self)) .to(equal(.roomMessagesSince("testRoom", seqNo: 123))) @@ -191,24 +254,33 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- when unblinded context("when unblinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - } - } - // MARK: ------ does not call the inbox and outbox endpoints it("does not call the inbox and outbox endpoints") { - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, + hasPerformedInitialPoll: true, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).toNot(contain(.inbox)) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).toNot(contain(.outbox)) @@ -218,26 +290,36 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- when blinded and checking for message requests context("when blinded and checking for message requests") { beforeEach { - mockStorage.write { db in - db[.checkForCommunityMessageRequests] = true - - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) - } + mockLibSessionCache.when { $0.get(.checkForCommunityMessageRequests) }.thenReturn(true) } // MARK: ------ includes the inbox and outbox endpoints it("includes the inbox and outbox endpoints") { - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, + hasPerformedInitialPoll: true, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).to(contain(.inbox)) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).to(contain(.outbox)) @@ -245,70 +327,124 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ------ retrieves recent inbox messages if there was no last message it("retrieves recent inbox messages if there was no last message") { - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, hasPerformedInitialPoll: true, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).to(contain(.inbox)) } // MARK: ------ retrieves inbox messages since the last message if there was one it("retrieves inbox messages since the last message if there was one") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.inboxLatestMessageId.set(to: 124)) - } - - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 124, + lastOutboxMessageId: 0, hasPerformedInitialPoll: true, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).to(contain(.inboxSince(id: 124))) } // MARK: ------ retrieves recent outbox messages if there was no last message it("retrieves recent outbox messages if there was no last message") { - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, hasPerformedInitialPoll: true, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).to(contain(.outbox)) } // MARK: ------ retrieves outbox messages since the last message if there was one it("retrieves outbox messages since the last message if there was one") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.outboxLatestMessageId.set(to: 125)) - } - - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 125, hasPerformedInitialPoll: true, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).to(contain(.outboxSince(id: 125))) } @@ -317,61 +453,98 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- when blinded and not checking for message requests context("when blinded and not checking for message requests") { beforeEach { - mockStorage.write { db in - db[.checkForCommunityMessageRequests] = false - - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) - } + mockLibSessionCache.when { $0.get(.checkForCommunityMessageRequests) }.thenReturn(false) } // MARK: ------ includes the inbox and outbox endpoints it("does not include the inbox endpoint") { - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, + hasPerformedInitialPoll: true, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).toNot(contain(.inbox)) } // MARK: ------ does not retrieve recent inbox messages if there was no last message it("does not retrieve recent inbox messages if there was no last message") { - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, hasPerformedInitialPoll: true, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).toNot(contain(.inbox)) } // MARK: ------ does not retrieve inbox messages since the last message if there was one it("does not retrieve inbox messages since the last message if there was one") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.inboxLatestMessageId.set(to: 124)) - } - - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 124, + lastOutboxMessageId: 0, hasPerformedInitialPoll: true, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).toNot(contain(.inboxSince(id: 124))) } @@ -382,13 +555,21 @@ class OpenGroupAPISpec: QuickSpec { context("when preparing a capabilities request") { // MARK: ---- generates the request correctly it("generates the request and handles the response correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedCapabilities( - db, - server: "testserver", + var preparedRequest: Network.PreparedRequest? + expect { + preparedRequest = try OpenGroupAPI.preparedCapabilities( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/capabilities")) expect(preparedRequest?.method.rawValue).to(equal("GET")) @@ -399,13 +580,21 @@ class OpenGroupAPISpec: QuickSpec { context("when preparing a rooms request") { // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? = mockStorage.read { db in - try OpenGroupAPI.preparedRooms( - db, - server: "testserver", + var preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? + expect { + preparedRequest = try OpenGroupAPI.preparedRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/rooms")) expect(preparedRequest?.method.rawValue).to(equal("GET")) @@ -414,16 +603,25 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing a capabilitiesAndRoom request context("when preparing a capabilitiesAndRoom request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedCapabilitiesAndRoom( - db, - for: "testRoom", - on: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedCapabilitiesAndRoom( + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints.count).to(equal(2)) expect(preparedRequest?.batchEndpoints[test: 0].asType(OpenGroupAPI.Endpoint.self)) @@ -443,16 +641,24 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedCapabilitiesAndRoom( - db, - for: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { $0.send(using: dependencies) } + expect { + preparedRequest = try OpenGroupAPI.preparedCapabilitiesAndRoom( + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), + using: dependencies + ) + }.toNot(throwError()) + + preparedRequest + .send(using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -462,7 +668,6 @@ class OpenGroupAPISpec: QuickSpec { } // MARK: ---- and given an invalid response - context("and given an invalid response") { // MARK: ------ errors when not given a room response it("errors when not given a room response") { @@ -472,16 +677,24 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedCapabilitiesAndRoom( - db, - for: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { $0.send(using: dependencies) } + expect { + preparedRequest = try OpenGroupAPI.preparedCapabilitiesAndRoom( + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), + using: dependencies + ) + }.toNot(throwError()) + + preparedRequest + .send(using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -498,16 +711,24 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedCapabilitiesAndRoom( - db, - for: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { $0.send(using: dependencies) } + expect { + preparedRequest = try OpenGroupAPI.preparedCapabilitiesAndRoom( + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), + using: dependencies + ) + }.toNot(throwError()) + + preparedRequest + .send(using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -522,15 +743,24 @@ class OpenGroupAPISpec: QuickSpec { describe("an OpenGroupAPI") { // MARK: -- when preparing a capabilitiesAndRooms request context("when preparing a capabilitiesAndRooms request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedCapabilitiesAndRooms( - db, - on: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedCapabilitiesAndRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints.count).to(equal(2)) expect(preparedRequest?.batchEndpoints[test: 0].asType(OpenGroupAPI.Endpoint.self)) @@ -550,15 +780,23 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomsResponse)? - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedCapabilitiesAndRooms( - db, - on: "testserver", - using: dependencies - ) - } - .flatMap { $0.send(using: dependencies) } + expect { + preparedRequest = try OpenGroupAPI.preparedCapabilitiesAndRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), + using: dependencies + ) + }.toNot(throwError()) + + preparedRequest + .send(using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -585,15 +823,23 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomsResponse)? - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedCapabilitiesAndRooms( - db, - on: "testserver", - using: dependencies - ) - } - .flatMap { $0.send(using: dependencies) } + expect { + preparedRequest = try OpenGroupAPI.preparedCapabilitiesAndRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), + using: dependencies + ) + }.toNot(throwError()) + + preparedRequest + .send(using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -610,15 +856,23 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomsResponse)? - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedCapabilitiesAndRooms( - db, - on: "testserver", - using: dependencies - ) - } - .flatMap { $0.send(using: dependencies) } + expect { + preparedRequest = try OpenGroupAPI.preparedCapabilitiesAndRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), + using: dependencies + ) + }.toNot(throwError()) + + preparedRequest + .send(using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -631,20 +885,29 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing a send message request context("when preparing a send message request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedSend( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedSend( plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", + roomToken: "testRoom", whisperTo: nil, whisperMods: false, fileIds: nil, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/testRoom/message")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -652,27 +915,27 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- when unblinded context("when unblinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - } - } - // MARK: ------ signs the message correctly it("signs the message correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedSend( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedSend( plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", + roomToken: "testRoom", whisperTo: nil, whisperMods: false, fileIds: nil, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) let requestBody: OpenGroupAPI.SendMessageRequest? = try? preparedRequest?.body? .decoded(as: OpenGroupAPI.SendMessageRequest.self, using: dependencies) @@ -680,123 +943,113 @@ class OpenGroupAPISpec: QuickSpec { expect(requestBody?.signature).to(equal("TestStandardSignature".data(using: .utf8))) } - // MARK: ------ fails to sign if there is no open group - it("fails to sign if there is no open group") { - mockStorage.write { db in - _ = try OpenGroup.deleteAll(db) - } + // MARK: ------ fails to sign if there is no ed25519SecretKey + it("fails to sign if there is no ed25519SecretKey") { + mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + expect { + preparedRequest = try OpenGroupAPI.preparedSend( + plaintext: "test".data(using: .utf8)!, + roomToken: "testRoom", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } - // MARK: ------ fails to sign if there is no user key pair - it("fails to sign if there is no user key pair") { - mockStorage.write { db in - _ = try Identity.filter(id: .x25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .x25519PrivateKey).deleteAll(db) - } + // MARK: ------ fails to sign if there is ed25519Seed + it("fails to sign if there is ed25519Seed") { + mockGeneralCache.when { $0.ed25519Seed }.thenReturn([]) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + expect { + preparedRequest = try OpenGroupAPI.preparedSend( + plaintext: "test".data(using: .utf8)!, + roomToken: "testRoom", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } // MARK: ------ fails to sign if no signature is generated it("fails to sign if no signature is generated") { - mockCrypto.reset() // The 'keyPair' value doesn't equate so have to explicitly reset mockCrypto .when { $0.generate(.signatureXed25519(data: .any, curve25519PrivateKey: .any)) } .thenReturn(nil) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + expect { + preparedRequest = try OpenGroupAPI.preparedSend( + plaintext: "test".data(using: .utf8)!, + roomToken: "testRoom", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } } // MARK: ---- when blinded context("when blinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) - } - } - // MARK: ------ signs the message correctly it("signs the message correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedSend( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedSend( plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", + roomToken: "testRoom", whisperTo: nil, whisperMods: false, fileIds: nil, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) let requestBody: OpenGroupAPI.SendMessageRequest? = try? preparedRequest?.body? .decoded(as: OpenGroupAPI.SendMessageRequest.self, using: dependencies) @@ -804,64 +1057,57 @@ class OpenGroupAPISpec: QuickSpec { expect(requestBody?.signature).to(equal("TestSogsSignature".data(using: .utf8))) } - // MARK: ------ fails to sign if there is no open group - it("fails to sign if there is no open group") { - mockStorage.write { db in - _ = try OpenGroup.deleteAll(db) - } + // MARK: ------ fails to sign if there is no ed25519SecretKey + it("fails to sign if there is no ed25519SecretKey") { + mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + expect { + preparedRequest = try OpenGroupAPI.preparedSend( + plaintext: "test".data(using: .utf8)!, + roomToken: "testRoom", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } - // MARK: ------ fails to sign if there is no ed key pair key - it("fails to sign if there is no ed key pair key") { - mockStorage.write { db in - _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } + // MARK: ------ fails to sign if there is no ed25519Seed + it("fails to sign if there is no ed25519Seed") { + mockGeneralCache.when { $0.ed25519Seed }.thenReturn([]) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + expect { + preparedRequest = try OpenGroupAPI.preparedSend( + plaintext: "test".data(using: .utf8)!, + roomToken: "testRoom", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } @@ -871,27 +1117,26 @@ class OpenGroupAPISpec: QuickSpec { .when { $0.generate(.signatureBlind15(message: .any, serverPublicKey: .any, ed25519SecretKey: .any)) } .thenReturn(nil) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + expect { + preparedRequest = try OpenGroupAPI.preparedSend( + plaintext: "test".data(using: .utf8)!, + roomToken: "testRoom", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } } @@ -899,17 +1144,26 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing an individual message request context("when preparing an individual message request") { + var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedMessage( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedMessage( id: 123, - in: "testRoom", - on: "testserver", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/testRoom/message/123")) expect(preparedRequest?.method.rawValue).to(equal("GET")) @@ -918,30 +1172,28 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing an update message request context("when preparing an update message request") { - beforeEach { - mockStorage.write { db in - _ = try Identity - .filter(id: .ed25519PublicKey) - .updateAll(db, Identity.Columns.data.set(to: Data())) - _ = try Identity - .filter(id: .ed25519SecretKey) - .updateAll(db, Identity.Columns.data.set(to: Data())) - } - } + @TestState var preparedRequest: Network.PreparedRequest? // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedMessageUpdate( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedMessageUpdate( id: 123, plaintext: "test".data(using: .utf8)!, fileIds: nil, - in: "testRoom", - on: "testserver", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/testRoom/message/123")) expect(preparedRequest?.method.rawValue).to(equal("PUT")) @@ -949,26 +1201,26 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- when unblinded context("when unblinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - } - } - // MARK: ------ signs the message correctly it("signs the message correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedMessageUpdate( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedMessageUpdate( id: 123, plaintext: "test".data(using: .utf8)!, fileIds: nil, - in: "testRoom", - on: "testserver", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) let requestBody: OpenGroupAPI.UpdateMessageRequest? = try? preparedRequest?.body? .decoded(as: OpenGroupAPI.UpdateMessageRequest.self, using: dependencies) @@ -976,119 +1228,109 @@ class OpenGroupAPISpec: QuickSpec { expect(requestBody?.signature).to(equal("TestStandardSignature".data(using: .utf8))) } - // MARK: ------ fails to sign if there is no open group - it("fails to sign if there is no open group") { - mockStorage.write { db in - _ = try OpenGroup.deleteAll(db) - } + // MARK: ------ fails to sign if there is no ed25519SecretKey + it("fails to sign if there is no ed25519SecretKey") { + mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) + + expect { + preparedRequest = try OpenGroupAPI.preparedMessageUpdate( + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } - - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } - // MARK: ------ fails to sign if there is no user key pair - it("fails to sign if there is no user key pair") { - mockStorage.write { db in - _ = try Identity.filter(id: .x25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .x25519PrivateKey).deleteAll(db) - } + // MARK: ------ fails to sign if there is no ed25519Seed + it("fails to sign if there is no ed25519Seed") { + mockGeneralCache.when { $0.ed25519Seed }.thenReturn([]) + + expect { + preparedRequest = try OpenGroupAPI.preparedMessageUpdate( + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } - - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } // MARK: ------ fails to sign if no signature is generated it("fails to sign if no signature is generated") { - mockCrypto.reset() // The 'keyPair' value doesn't equate so have to explicitly reset mockCrypto .when { $0.generate(.signatureXed25519(data: .any, curve25519PrivateKey: .any)) } .thenReturn(nil) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testServer", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } - - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect { + preparedRequest = try OpenGroupAPI.preparedMessageUpdate( + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) } } // MARK: ---- when blinded context("when blinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) - } - } - // MARK: ------ signs the message correctly it("signs the message correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedMessageUpdate( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedMessageUpdate( id: 123, plaintext: "test".data(using: .utf8)!, fileIds: nil, - in: "testRoom", - on: "testserver", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) let requestBody: OpenGroupAPI.UpdateMessageRequest? = try? preparedRequest?.body? .decoded(as: OpenGroupAPI.UpdateMessageRequest.self, using: dependencies) @@ -1096,62 +1338,55 @@ class OpenGroupAPISpec: QuickSpec { expect(requestBody?.signature).to(equal("TestSogsSignature".data(using: .utf8))) } - // MARK: ------ fails to sign if there is no open group - it("fails to sign if there is no open group") { - mockStorage.write { db in - _ = try OpenGroup.deleteAll(db) - } + // MARK: ------ fails to sign if there is no ed25519SecretKey + it("fails to sign if there is no ed25519SecretKey") { + mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) + + expect { + preparedRequest = try OpenGroupAPI.preparedMessageUpdate( + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } - - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } - // MARK: ------ fails to sign if there is no ed key pair key - it("fails to sign if there is no ed key pair key") { - mockStorage.write { db in - _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } + // MARK: ------ fails to sign if there is no ed25519Seed + it("fails to sign if there is no ed25519Seed") { + mockGeneralCache.when { $0.ed25519Seed }.thenReturn([]) + + expect { + preparedRequest = try OpenGroupAPI.preparedMessageUpdate( + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } - - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } @@ -1161,26 +1396,25 @@ class OpenGroupAPISpec: QuickSpec { .when { $0.generate(.signatureBlind15(message: .any, serverPublicKey: .any, ed25519SecretKey: .any)) } .thenReturn(nil) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } - - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect { + preparedRequest = try OpenGroupAPI.preparedMessageUpdate( + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) } } @@ -1188,17 +1422,26 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing a delete message request context("when preparing a delete message request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedMessageDelete( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedMessageDelete( id: 123, - in: "testRoom", - on: "testserver", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/testRoom/message/123")) expect(preparedRequest?.method.rawValue).to(equal("DELETE")) @@ -1207,17 +1450,26 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing a delete all messages request context("when preparing a delete all messages request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedMessagesDeleteAll( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedMessagesDeleteAll( sessionId: "testUserId", - in: "testRoom", - on: "testserver", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/testRoom/all/testUserId")) expect(preparedRequest?.method.rawValue).to(equal("DELETE")) @@ -1226,17 +1478,26 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing a pin message request context("when preparing a pin message request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedPinMessage( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedPinMessage( id: 123, - in: "testRoom", - on: "testserver", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/testRoom/pin/123")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -1245,17 +1506,26 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing an unpin message request context("when preparing an unpin message request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUnpinMessage( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUnpinMessage( id: 123, - in: "testRoom", - on: "testserver", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/testRoom/unpin/123")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -1264,16 +1534,25 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing an unpin all request context("when preparing an unpin all request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUnpinAll( - db, - in: "testRoom", - on: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedUnpinAll( + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/testRoom/unpin/all")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -1282,36 +1561,26 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when generaing an upload request context("when generaing an upload request") { - beforeEach { - mockStorage.write { db in - try OpenGroup( - server: "http://oxen.io", - roomToken: "testRoom", - publicKey: TestConstants.publicKey, - isActive: true, - name: "Test", - roomDescription: nil, - imageId: nil, - userCount: 0, - infoUpdates: 0, - sequenceNumber: 0, - inboxLatestMessageId: 0, - outboxLatestMessageId: 0 - ).insert(db) - } - } + @TestState var preparedRequest: Network.PreparedRequest? // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUpload( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUpload( data: Data([1, 2, 3]), - to: "testRoom", - on: "testServer", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/testRoom/file")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -1320,24 +1589,7 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when generaing a download request context("when generaing a download request") { - beforeEach { - mockStorage.write { db in - try OpenGroup( - server: "http://oxen.io", - roomToken: "testRoom", - publicKey: TestConstants.publicKey, - isActive: true, - name: "Test", - roomDescription: nil, - imageId: nil, - userCount: 0, - infoUpdates: 0, - sequenceNumber: 0, - inboxLatestMessageId: 0, - outboxLatestMessageId: 0 - ).insert(db) - } - } + @TestState var preparedRequest: Network.PreparedRequest? // MARK: ---- generates the download url string correctly it("generates the download url string correctly") { @@ -1347,15 +1599,22 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- generates the download destination correctly when given an id it("generates the download destination correctly when given an id") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedDownload( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedDownload( fileId: "1", - from: "roomToken", - on: "http://oxen.io", + roomToken: "roomToken", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/roomToken/file/1")) expect(preparedRequest?.method.rawValue).to(equal("GET")) @@ -1369,15 +1628,22 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- generates the download request correctly when given a URL it("generates the download request correctly when given a URL") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedDownload( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedDownload( url: URL(string: "http://oxen.io/room/roomToken/file/1")!, - from: "roomToken", - on: "http://oxen.io", + roomToken: "roomToken", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/roomToken/file/1")) expect(preparedRequest?.method.rawValue).to(equal("GET")) @@ -1392,15 +1658,24 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing an inbox request context("when preparing an inbox request") { + @TestState var preparedRequest: Network.PreparedRequest<[OpenGroupAPI.DirectMessage]?>? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.DirectMessage]?>? = mockStorage.read { db in - try OpenGroupAPI.preparedInbox( - db, - on: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedInbox( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/inbox")) expect(preparedRequest?.method.rawValue).to(equal("GET")) @@ -1409,16 +1684,25 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing an inbox since request context("when preparing an inbox since request") { + @TestState var preparedRequest: Network.PreparedRequest<[OpenGroupAPI.DirectMessage]?>? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.DirectMessage]?>? = mockStorage.read { db in - try OpenGroupAPI.preparedInboxSince( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedInboxSince( id: 1, - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/inbox/since/1")) expect(preparedRequest?.method.rawValue).to(equal("GET")) @@ -1427,15 +1711,24 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing a clear inbox request context("when preparing an inbox since request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedClearInbox( - db, - on: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedClearInbox( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/inbox")) expect(preparedRequest?.method.rawValue).to(equal("DELETE")) @@ -1444,17 +1737,26 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing a send direct message request context("when preparing a send direct message request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedSend( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedSend( ciphertext: "test".data(using: .utf8)!, toInboxFor: "testUserId", - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/inbox/testUserId")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -1465,18 +1767,27 @@ class OpenGroupAPISpec: QuickSpec { describe("an OpenGroupAPI") { // MARK: -- when preparing a ban user request context("when preparing a ban user request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUserBan( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUserBan( sessionId: "testUserId", for: nil, from: nil, - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/user/testUserId/ban")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -1484,16 +1795,23 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- does a global ban if no room tokens are provided it("does a global ban if no room tokens are provided") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUserBan( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUserBan( sessionId: "testUserId", for: nil, from: nil, - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) let requestBody: OpenGroupAPI.UserBanRequest? = try? preparedRequest?.body? .decoded(as: OpenGroupAPI.UserBanRequest.self, using: dependencies) @@ -1503,16 +1821,23 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- does room specific bans if room tokens are provided it("does room specific bans if room tokens are provided") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUserBan( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUserBan( sessionId: "testUserId", for: nil, from: ["testRoom"], - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) let requestBody: OpenGroupAPI.UserBanRequest? = try? preparedRequest?.body? .decoded(as: OpenGroupAPI.UserBanRequest.self, using: dependencies) @@ -1523,17 +1848,26 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing an unban user request context("when preparing an unban user request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUserUnban( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUserUnban( sessionId: "testUserId", from: nil, - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/user/testUserId/unban")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -1541,15 +1875,22 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- does a global unban if no room tokens are provided it("does a global unban if no room tokens are provided") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUserUnban( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUserUnban( sessionId: "testUserId", from: nil, - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) let requestBody: OpenGroupAPI.UserUnbanRequest? = try? preparedRequest?.body? .decoded(as: OpenGroupAPI.UserUnbanRequest.self, using: dependencies) @@ -1559,15 +1900,22 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- does room specific unbans if room tokens are provided it("does room specific unbans if room tokens are provided") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUserUnban( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUserUnban( sessionId: "testUserId", from: ["testRoom"], - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) let requestBody: OpenGroupAPI.UserUnbanRequest? = try? preparedRequest?.body? .decoded(as: OpenGroupAPI.UserUnbanRequest.self, using: dependencies) @@ -1578,20 +1926,29 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing a user permissions request context("when preparing a user permissions request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUserModeratorUpdate( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUserModeratorUpdate( sessionId: "testUserId", moderator: true, admin: nil, visible: true, for: nil, - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/user/testUserId/moderator")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -1599,18 +1956,25 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- does a global update if no room tokens are provided it("does a global update if no room tokens are provided") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUserModeratorUpdate( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUserModeratorUpdate( sessionId: "testUserId", moderator: true, admin: nil, visible: true, for: nil, - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) let requestBody: OpenGroupAPI.UserModeratorRequest? = try? preparedRequest?.body? .decoded(as: OpenGroupAPI.UserModeratorRequest.self, using: dependencies) @@ -1620,18 +1984,25 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- does room specific updates if room tokens are provided it("does room specific updates if room tokens are provided") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUserModeratorUpdate( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUserModeratorUpdate( sessionId: "testUserId", moderator: true, admin: nil, visible: true, for: ["testRoom"], - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) let requestBody: OpenGroupAPI.UserModeratorRequest? = try? preparedRequest?.body? .decoded(as: OpenGroupAPI.UserModeratorRequest.self, using: dependencies) @@ -1641,44 +2012,52 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- fails if neither moderator or admin are set it("fails if neither moderator or admin are set") { - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedUserModeratorUpdate( - db, - sessionId: "testUserId", - moderator: nil, - admin: nil, - visible: true, - for: nil, - on: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + expect { + preparedRequest = try OpenGroupAPI.preparedUserModeratorUpdate( + sessionId: "testUserId", + moderator: nil, + admin: nil, + visible: true, + for: nil, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(NetworkError.invalidPreparedRequest)) - expect(preparationError).to(matchError(NetworkError.invalidPreparedRequest)) expect(preparedRequest).to(beNil()) } } // MARK: -- when preparing a ban and delete all request context("when preparing a ban and delete all request") { + @TestState var preparedRequest: Network.PreparedRequest>? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedUserBanAndDeleteAllMessages( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUserBanAndDeleteAllMessages( sessionId: "testUserId", - in: "testRoom", - on: "testserver", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/sequence")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -1694,100 +2073,48 @@ class OpenGroupAPISpec: QuickSpec { describe("an OpenGroupAPI") { // MARK: -- when signing context("when signing") { - // MARK: ---- fails when there is no serverPublicKey - it("fails when there is no serverPublicKey") { - mockStorage.write { db in - _ = try OpenGroup.deleteAll(db) - } - - var preparationError: Error? - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } - - expect(preparationError).to(matchError(OpenGroupAPIError.noPublicKey)) - expect(preparedRequest).to(beNil()) - } + @TestState var preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? - // MARK: ---- fails when there is no userEdKeyPair - it("fails when there is no userEdKeyPair") { - mockStorage.write { db in - _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } - - var preparationError: Error? - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } - - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) - expect(preparedRequest).to(beNil()) - } - - // MARK: ---- fails when the serverPublicKey is not a hex string - it("fails when the serverPublicKey is not a hex string") { - mockStorage.write { db in - _ = try OpenGroup.updateAll(db, OpenGroup.Columns.publicKey.set(to: "TestString!!!")) - } - - var preparationError: Error? - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + // MARK: ---- fails when there is no ed25519SecretKey + it("fails when there is no ed25519SecretKey") { + mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) + + expect { + preparedRequest = try OpenGroupAPI.preparedRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } // MARK: ---- when unblinded context("when unblinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - } - } - // MARK: ------ signs correctly it("signs correctly") { - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? = mockStorage.read { db in - try OpenGroupAPI.preparedRooms( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/rooms")) expect(preparedRequest?.method.rawValue).to(equal("GET")) @@ -1810,45 +2137,43 @@ class OpenGroupAPISpec: QuickSpec { .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } .thenThrow(CryptoError.failedToGenerateOutput) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + expect { + preparedRequest = try OpenGroupAPI.preparedRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } } // MARK: ---- when blinded context("when blinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) - } - } - // MARK: ------ signs correctly it("signs correctly") { - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? = mockStorage.read { db in - try OpenGroupAPI.preparedRooms( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/rooms")) expect(preparedRequest?.method.rawValue).to(equal("GET")) @@ -1871,22 +2196,21 @@ class OpenGroupAPISpec: QuickSpec { .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } .thenReturn(nil) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + expect { + preparedRequest = try OpenGroupAPI.preparedRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } @@ -1896,22 +2220,21 @@ class OpenGroupAPISpec: QuickSpec { .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } .thenReturn(nil) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + expect { + preparedRequest = try OpenGroupAPI.preparedRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } } @@ -1919,6 +2242,8 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when sending context("when sending") { + @TestState var preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? + beforeEach { mockNetwork .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } @@ -1929,15 +2254,23 @@ class OpenGroupAPISpec: QuickSpec { it("triggers sending correctly") { var response: (info: ResponseInfoType, data: [OpenGroupAPI.Room])? - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - .flatMap { $0.send(using: dependencies) } + expect { + preparedRequest = try OpenGroupAPI.preparedRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), + using: dependencies + ) + }.toNot(throwError()) + + preparedRequest? + .send(using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 9edde76359..14f16ede1a 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -41,14 +41,12 @@ class OpenGroupManagerSpec: QuickSpec { openGroupWhisperTo: nil, state: .sending, recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - transientDependencies: nil + mostRecentFailureText: nil ) @TestState var testGroupThread: SessionThread! = SessionThread( id: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), variant: .community, - creationDateTimestamp: 0, - using: dependencies + creationDateTimestamp: 0 ) @TestState var testOpenGroup: OpenGroup! = OpenGroup( server: "http://127.0.0.1", @@ -178,6 +176,17 @@ class OpenGroupManagerSpec: QuickSpec { 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(.ciphertextWithXChaCha20(plaintext: .any, encKey: .any)) } + .thenReturn(Data([1, 2, 3])) } ) @TestState(defaults: .standard, in: dependencies) var mockUserDefaults: MockUserDefaults! = MockUserDefaults( @@ -194,8 +203,15 @@ class OpenGroupManagerSpec: QuickSpec { @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([]) @@ -219,6 +235,24 @@ class OpenGroupManagerSpec: QuickSpec { 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)) @@ -527,8 +561,7 @@ class OpenGroupManagerSpec: QuickSpec { messageDraft: nil, notificationSound: nil, mutedUntilTimestamp: nil, - onlyNotifyForMentions: false, - using: dependencies + onlyNotifyForMentions: false ).insert(db) } @@ -560,8 +593,7 @@ class OpenGroupManagerSpec: QuickSpec { messageDraft: nil, notificationSound: nil, mutedUntilTimestamp: nil, - onlyNotifyForMentions: false, - using: dependencies + onlyNotifyForMentions: false ).insert(db) } @@ -651,7 +683,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- stores the open group server it("stores the open group server") { mockStorage - .writePublisher { (db: Database) -> Bool in + .writePublisher { db -> Bool in openGroupManager.add( db, roomToken: "testRoom", @@ -672,13 +704,12 @@ class OpenGroupManagerSpec: QuickSpec { .sinkAndStore(in: &disposables) expect( - mockStorage - .read { (db: Database) in - try OpenGroup - .select(.threadId) - .asRequest(of: String.self) - .fetchOne(db) - } + mockStorage.read { db in + try OpenGroup + .select(.threadId) + .asRequest(of: String.self) + .fetchOne(db) + } ) .to(equal(OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"))) } @@ -686,7 +717,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- adds a poller it("adds a poller") { mockStorage - .writePublisher { (db: Database) -> Bool in + .writePublisher { db -> Bool in openGroupManager.add( db, roomToken: "testRoom", @@ -730,7 +761,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ does not reset the sequence number or update the public key it("does not reset the sequence number or update the public key") { mockStorage - .writePublisher { (db: Database) -> Bool in + .writePublisher { db -> Bool in openGroupManager.add( db, roomToken: "testRoom", @@ -792,7 +823,7 @@ class OpenGroupManagerSpec: QuickSpec { var error: Error? mockStorage - .writePublisher { (db: Database) -> Bool in + .writePublisher { db -> Bool in openGroupManager.add( db, roomToken: "testRoom", @@ -1440,7 +1471,7 @@ class OpenGroupManagerSpec: QuickSpec { beforeEach { mockStorage.write { db in try OpenGroup - .updateAll(db, OpenGroup.Columns.displayPictureFilename.set(to: nil)) + .updateAll(db, OpenGroup.Columns.displayPictureOriginalUrl.set(to: nil)) } } @@ -1510,8 +1541,7 @@ class OpenGroupManagerSpec: QuickSpec { imageId: "12", userCount: 0, infoUpdates: 10, - displayPictureFilename: "test", - lastDisplayPictureUpdate: 1234567890 + displayPictureOriginalUrl: "http://127.0.0.1/room/testRoom/12" ).insert(db) } @@ -1543,7 +1573,7 @@ class OpenGroupManagerSpec: QuickSpec { expect( mockStorage.read { db -> String? in try OpenGroup - .select(.displayPictureFilename) + .select(.displayPictureOriginalUrl) .asRequest(of: String.self) .fetchOne(db) } @@ -1564,8 +1594,7 @@ class OpenGroupManagerSpec: QuickSpec { imageId: "12", userCount: 0, infoUpdates: 10, - displayPictureFilename: "test", - lastDisplayPictureUpdate: 1234567890 + displayPictureOriginalUrl: "http://127.0.0.1/room/testRoom/10" ).insert(db) } @@ -1602,7 +1631,7 @@ class OpenGroupManagerSpec: QuickSpec { expect( mockStorage.read { db -> String? in try OpenGroup - .select(.displayPictureFilename) + .select(.displayPictureOriginalUrl) .asRequest(of: String.self) .fetchOne(db) } @@ -1645,7 +1674,7 @@ class OpenGroupManagerSpec: QuickSpec { expect( mockStorage.read { db -> String? in try OpenGroup - .select(.displayPictureFilename) + .select(.displayPictureOriginalUrl) .asRequest(of: String.self) .fetchOne(db) } @@ -1919,12 +1948,10 @@ class OpenGroupManagerSpec: QuickSpec { .when { $0.generate( .plaintextWithSessionBlindingProtocol( - .any, ciphertext: .any, senderId: .any, recipientId: .any, - serverPublicKey: .any, - using: .any + serverPublicKey: .any ) ) } @@ -2063,12 +2090,10 @@ class OpenGroupManagerSpec: QuickSpec { .when { $0.generate( .plaintextWithSessionBlindingProtocol( - .any, ciphertext: .any, senderId: .any, recipientId: .any, - serverPublicKey: .any, - using: .any + serverPublicKey: .any ) ) } @@ -2221,12 +2246,10 @@ class OpenGroupManagerSpec: QuickSpec { .when { $0.generate( .plaintextWithSessionBlindingProtocol( - .any, ciphertext: .any, senderId: .any, recipientId: .any, - serverPublicKey: .any, - using: .any + serverPublicKey: .any ) ) } @@ -2301,29 +2324,31 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: ---- uses an empty set for moderators by default - it("uses an empty set for moderators by default") { + // MARK: ---- has no moderators by default + it("has no moderators by default") { expect( mockStorage.read { db in openGroupManager.isUserModeratorOrAdmin( db, publicKey: "05\(TestConstants.publicKey)", for: "testRoom", - on: "http://127.0.0.1" + on: "http://127.0.0.1", + currentUserSessionIds: ["05\(TestConstants.publicKey)"] ) } ).to(beFalse()) } - // MARK: ---- uses an empty set for admins by default - it("uses an empty set for admins by default") { + // MARK: ----has no admins by default + it("has no admins by default") { expect( mockStorage.read { db in openGroupManager.isUserModeratorOrAdmin( db, publicKey: "05\(TestConstants.publicKey)", for: "testRoom", - on: "http://127.0.0.1" + on: "http://127.0.0.1", + currentUserSessionIds: ["05\(TestConstants.publicKey)"] ) } ).to(beFalse()) @@ -2334,7 +2359,7 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.write { db in try GroupMember( groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "05\(TestConstants.publicKey)", + profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", role: .moderator, roleStatus: .accepted, isHidden: false @@ -2345,9 +2370,10 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.read { db in openGroupManager.isUserModeratorOrAdmin( db, - publicKey: "05\(TestConstants.publicKey)", + publicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", for: "testRoom", - on: "http://127.0.0.1" + on: "http://127.0.0.1", + currentUserSessionIds: ["05\(TestConstants.publicKey)"] ) } ).to(beTrue()) @@ -2358,7 +2384,7 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.write { db in try GroupMember( groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "05\(TestConstants.publicKey)", + profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", role: .admin, roleStatus: .accepted, isHidden: false @@ -2369,9 +2395,10 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.read { db in openGroupManager.isUserModeratorOrAdmin( db, - publicKey: "05\(TestConstants.publicKey)", + publicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", for: "testRoom", - on: "http://127.0.0.1" + on: "http://127.0.0.1", + currentUserSessionIds: ["05\(TestConstants.publicKey)"] ) } ).to(beTrue()) @@ -2382,7 +2409,7 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.write { db in try GroupMember( groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "05\(TestConstants.publicKey)", + profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", role: .moderator, roleStatus: .accepted, isHidden: true @@ -2393,9 +2420,10 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.read { db in openGroupManager.isUserModeratorOrAdmin( db, - publicKey: "05\(TestConstants.publicKey)", + publicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", for: "testRoom", - on: "http://127.0.0.1" + on: "http://127.0.0.1", + currentUserSessionIds: ["05\(TestConstants.publicKey)"] ) } ).to(beTrue()) @@ -2406,7 +2434,7 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.write { db in try GroupMember( groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "05\(TestConstants.publicKey)", + profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", role: .admin, roleStatus: .accepted, isHidden: true @@ -2417,9 +2445,10 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.read { db in openGroupManager.isUserModeratorOrAdmin( db, - publicKey: "05\(TestConstants.publicKey)", + publicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", for: "testRoom", - on: "http://127.0.0.1" + on: "http://127.0.0.1", + currentUserSessionIds: ["05\(TestConstants.publicKey)"] ) } ).to(beTrue()) @@ -2433,82 +2462,24 @@ class OpenGroupManagerSpec: QuickSpec { db, publicKey: "InvalidValue", for: "testRoom", - on: "http://127.0.0.1" + on: "http://127.0.0.1", + currentUserSessionIds: ["05\(TestConstants.publicKey)"] ) } ).to(beFalse()) } - // MARK: ---- and the key is a standard session id - context("and the key is a standard session id") { - // MARK: ------ returns false if the key is not the users session id - it("returns false if the key is not the users session id") { - mockStorage.write { db in - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - - try Identity(variant: .x25519PublicKey, data: Data(hex: otherKey)).upsert(db) - try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).upsert(db) - } - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "05\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beFalse()) - } - - // MARK: ------ returns true if the key is the current users and the users unblinded id is a moderator or admin - it("returns true if the key is the current users and the users unblinded id is a moderator or admin") { - mockStorage.write { db in - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - - try GroupMember( - groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "00\(otherKey)", - role: .moderator, - roleStatus: .accepted, - isHidden: false - ).insert(db) - - try Identity(variant: .ed25519PublicKey, data: Data(hex: otherKey)).upsert(db) - try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).upsert(db) - } - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "05\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beTrue()) - } - - // MARK: ------ returns true if the key is the current users and the users blinded id is a moderator or admin - it("returns true if the key is the current users and the users blinded id is a moderator or admin") { - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - mockCrypto - .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } - .thenReturn( - KeyPair( - publicKey: Data(hex: otherKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ) - ) + // MARK: ---- and the key belongs to the current user + context("and the key belongs to the current user") { + // MARK: ------ matches a blinded key + it("matches a blinded key ") { mockStorage.write { db in try GroupMember( groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "15\(otherKey)", - role: .moderator, + profileId: "15\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", + role: .admin, roleStatus: .accepted, - isHidden: false + isHidden: true ).insert(db) } @@ -2518,264 +2489,32 @@ class OpenGroupManagerSpec: QuickSpec { db, publicKey: "05\(TestConstants.publicKey)", for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beTrue()) - } - } - - // MARK: ---- and the key is unblinded - context("and the key is unblinded") { - // MARK: ------ returns false if unable to retrieve the user ed25519 key - it("returns false if unable to retrieve the user ed25519 key") { - mockStorage.write { db in - try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "00\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beFalse()) - } - - // MARK: ------ returns false if the key is not the users unblinded id - it("returns false if the key is not the users unblinded id") { - mockStorage.write { db in - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - - try Identity(variant: .ed25519PublicKey, data: Data(hex: otherKey)).upsert(db) - try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).upsert(db) - } - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "00\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beFalse()) - } - - // MARK: ------ returns true if the key is the current users and the users session id is a moderator or admin - it("returns true if the key is the current users and the users session id is a moderator or admin") { - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - mockGeneralCache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: otherKey)) - mockStorage.write { db in - try GroupMember( - groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "05\(otherKey)", - role: .moderator, - roleStatus: .accepted, - isHidden: false - ).insert(db) - - try Identity(variant: .x25519PublicKey, data: Data(hex: otherKey)).upsert(db) - try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).upsert(db) - try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.publicKey)).upsert(db) - try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).upsert(db) - } - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "00\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beTrue()) - } - - // MARK: ------ returns true if the key is the current users and the users blinded id is a moderator or admin - it("returns true if the key is the current users and the users blinded id is a moderator or admin") { - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - mockCrypto - .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } - .thenReturn( - KeyPair( - publicKey: Data(hex: otherKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ) - ) - mockStorage.write { db in - try GroupMember( - groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "15\(otherKey)", - role: .moderator, - roleStatus: .accepted, - isHidden: false - ).insert(db) - - try Identity(variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey)).upsert(db) - try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).upsert(db) - try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.publicKey)).upsert(db) - try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).upsert(db) - } - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "00\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beTrue()) - } - } - - // MARK: ---- and the key is blinded - context("and the key is blinded") { - // MARK: ------ returns false if unable to retrieve the user ed25519 key - it("returns false if unable to retrieve the user ed25519 key") { - mockStorage.write { db in - try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "15\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beFalse()) - } - - // MARK: ------ returns false if unable generate a blinded key - it("returns false if unable generate a blinded key") { - mockCrypto - .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } - .thenReturn(nil) - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "15\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beFalse()) - } - - // MARK: ------ returns false if the key is not the users blinded id - it("returns false if the key is not the users blinded id") { - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - mockCrypto - .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } - .thenReturn( - KeyPair( - publicKey: Data(hex: otherKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ) - ) - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "15\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beFalse()) - } - - // MARK: ------ returns true if the key is the current users and the users session id is a moderator or admin - it("returns true if the key is the current users and the users session id is a moderator or admin") { - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - mockGeneralCache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: otherKey)) - mockCrypto - .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } - .thenReturn( - KeyPair( - publicKey: Data(hex: TestConstants.publicKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ) - ) - mockStorage.write { db in - try GroupMember( - groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "05\(otherKey)", - role: .moderator, - roleStatus: .accepted, - isHidden: false - ).insert(db) - - try Identity(variant: .x25519PublicKey, data: Data(hex: otherKey)).upsert(db) - try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).upsert(db) - try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.publicKey)).upsert(db) - try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).upsert(db) - } - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "15\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" + on: "http://127.0.0.1", + currentUserSessionIds: [ + "05\(TestConstants.publicKey)", + "15\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))" + ] ) } ).to(beTrue()) } - // MARK: ------ returns true if the key is the current users and the users unblinded id is a moderator or admin - it("returns true if the key is the current users and the users unblinded id is a moderator or admin") { - mockCrypto - .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } - .thenReturn( - KeyPair( - publicKey: Data(hex: TestConstants.publicKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ) + // 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]) + mockStorage.read { db in + openGroupManager.isUserModeratorOrAdmin( + db, + publicKey: "05\(TestConstants.publicKey)", + for: "testRoom", + on: "http://127.0.0.1", + currentUserSessionIds: ["05\(TestConstants.publicKey)"] ) - mockStorage.write { db in - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - - try GroupMember( - groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "00\(otherKey)", - role: .moderator, - roleStatus: .accepted, - isHidden: false - ).insert(db) - - try Identity(variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey)).upsert(db) - try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).upsert(db) - try Identity(variant: .ed25519PublicKey, data: Data(hex: otherKey)).upsert(db) - try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).upsert(db) } - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "15\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beTrue()) + expect(mockCrypto).to(call(.exactly(times: 1), matchingParameters: .all) { + $0.generate(.ed25519KeyPair(seed: [4, 5, 6])) + }) } } } @@ -2784,7 +2523,9 @@ class OpenGroupManagerSpec: QuickSpec { context("when accessing the default rooms publisher") { // 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.when { $0.bool(forKey: UserDefaults.BoolKey.isMainAppActive.rawValue) }.thenReturn(true) + mockAppGroupDefaults + .when { $0.bool(forKey: UserDefaults.BoolKey.isMainAppActive.rawValue) } + .thenReturn(true) mockStorage.write { db in try OpenGroup( server: OpenGroupAPI.defaultServer, @@ -2799,8 +2540,15 @@ class OpenGroupManagerSpec: QuickSpec { } let expectedRequest: Network.PreparedRequest! = mockStorage.read { db in try OpenGroupAPI.preparedCapabilitiesAndRooms( - db, - on: OpenGroupAPI.defaultServer, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: OpenGroupAPI.defaultServer, + publicKey: OpenGroupAPI.defaultServerPublicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) } diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift index 270524aa42..13a6d55a77 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift @@ -129,14 +129,29 @@ class MessageReceiverGroupsSpec: QuickSpec { ) } .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.. Void)? = (untrackedArgs[test: 0] as? () throws -> Void) - try? callback?() - } - .thenReturn(()) - cache - .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) - - switch args[test: 0] as? ConfigDump.Variant { - case .userGroups: try? callback?(userGroupsConfig) - case .convoInfoVolatile: try? callback?(convoInfoVolatileConfig) - case .groupInfo: try? callback?(groupInfoConfig) - case .groupMembers: try? callback?(groupMembersConfig) - case .groupKeys: try? callback?(groupKeysConfig) - default: break - } - } - .thenReturn(()) - cache - .when { $0.pinnedPriority(.any, threadId: .any, threadVariant: .any) } - .thenReturn(LibSession.defaultNewThreadPriority) - cache - .when { $0.disappearingMessagesConfig(threadId: .any, threadVariant: .any) } - .thenReturn(nil) - cache.when { $0.isAdmin(groupSessionId: .any) }.thenReturn(true) + initialSetup: { + $0.defaultInitialSetup( + configs: [ + .userGroups: userGroupsConfig, + .convoInfoVolatile: convoInfoVolatileConfig, + .groupInfo: groupInfoConfig, + .groupMembers: groupMembersConfig, + .groupKeys: groupKeysConfig + ] + ) } ) @TestState(cache: .snodeAPI, in: dependencies) var mockSnodeAPICache: MockSnodeAPICache! = MockSnodeAPICache( - initialSetup: { cache in - cache.when { $0.clockOffsetMs }.thenReturn(0) - cache.when { $0.currentOffsetTimestampMs() }.thenReturn(1234567890000) - } + initialSetup: { $0.defaultInitialSetup() } ) @TestState var mockSwarmPoller: MockSwarmPoller! = MockSwarmPoller( initialSetup: { cache in @@ -258,14 +225,7 @@ class MessageReceiverGroupsSpec: QuickSpec { } ) @TestState(singleton: .notificationsManager, in: dependencies) var mockNotificationsManager: MockNotificationsManager! = MockNotificationsManager( - initialSetup: { notificationsManager in - notificationsManager - .when { $0.notifyUser(.any, for: .any, in: .any, applicationState: .any) } - .thenReturn(()) - notificationsManager - .when { $0.cancelNotifications(identifiers: .any) } - .thenReturn(()) - } + initialSetup: { $0.defaultInitialSetup() } ) @TestState(singleton: .appContext, in: dependencies) var mockAppContext: MockAppContext! = MockAppContext( initialSetup: { appContext in @@ -397,6 +357,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -418,6 +379,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -446,6 +408,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -488,6 +451,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -525,6 +489,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -543,6 +508,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -562,6 +528,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -590,6 +557,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -609,6 +577,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -629,6 +598,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -654,49 +624,36 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } expect(mockNotificationsManager) .to(call(.exactly(times: 1), matchingParameters: .all) { notificationsManager in - notificationsManager.notifyUser( - .any, - for: Interaction( - id: 1, - serverHash: nil, - messageUuid: nil, + notificationsManager.addNotificationRequest( + content: NotificationContent( threadId: groupId.hexString, - authorId: "051111111111111111111111111111111" + "111111111111111111111111111111111", - variant: .infoGroupInfoInvited, - body: ClosedGroup.MessageInfo - .invited("0511...1111", "TestGroup") - .infoString(using: dependencies), - timestampMs: 1234567890000, - receivedAtTimestampMs: 1234567890000, - wasRead: false, - hasMention: false, - expiresInSeconds: nil, - expiresStartedAtMs: nil, - linkPreviewUrl: nil, - openGroupServerMessageId: nil, - openGroupWhisper: false, - openGroupWhisperMods: false, - openGroupWhisperTo: nil, - state: .sent, - recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - transientDependencies: EquatableIgnoring(value: dependencies) + threadVariant: .group, + identifier: "\(groupId.hexString)-1", + category: .incomingMessage, + title: "notificationsIosGroup" + .put(key: "name", value: "0511...1111") + .put(key: "conversation_name", value: "TestGroupName") + .localized(), + body: "messageNewYouveGot" + .putNumber(1) + .localized(), + sound: .defaultNotificationSound, + applicationState: .active ), - in: SessionThread( - id: groupId.hexString, - variant: .group, - creationDateTimestamp: 1234567890, - shouldBeVisible: true, - isDraft: false, - using: dependencies + notificationSettings: Preferences.NotificationSettings( + previewType: .nameAndPreview, + sound: .defaultNotificationSound, + mentionsOnly: false, + mutedUntil: nil ), - applicationState: .active + extensionBaseUnreadCount: nil ) }) } @@ -723,6 +680,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -745,6 +703,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -772,6 +731,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -792,6 +752,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -816,17 +777,17 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } expect(mockNotificationsManager) .toNot(call { notificationsManager in - notificationsManager.notifyUser( - .any, - for: .any, - in: .any, - applicationState: .any + notificationsManager.addNotificationRequest( + content: .any, + notificationSettings: .any, + extensionBaseUnreadCount: .any ) }) } @@ -891,6 +852,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -960,6 +922,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -986,6 +949,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -1018,6 +982,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -1039,6 +1004,16 @@ class MessageReceiverGroupsSpec: QuickSpec { groups_members_set(groupMembersConf, &member) mockStorage.write { db in + try Contact( + id: "051111111111111111111111111111111111111111111111111111111111111111", + isTrusted: true, + isApproved: true, + isBlocked: false, + lastKnownClientVersion: nil, + didApproveMe: true, + hasBeenBlocked: false, + using: dependencies + ).insert(db) try SessionThread.upsert( db, id: groupId.hexString, @@ -1074,6 +1049,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: promoteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) }) @@ -1095,12 +1071,17 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: promoteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } - expect(LibSession.isAdmin(groupSessionId: groupId, using: dependencies)) - .to(beTrue()) + expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.loadAdminKey( + groupIdentitySeed: groupSeed, + groupSessionId: SessionId(.group, publicKey: [1, 2, 3]) + ) + }) } // MARK: ---- replaces the memberAuthData with the admin key in the database @@ -1124,6 +1105,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: promoteMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -1164,6 +1146,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: infoChangedMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) @@ -1182,6 +1165,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: infoChangedMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) @@ -1202,6 +1186,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: infoChangedMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) @@ -1219,6 +1204,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: infoChangedMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -1255,6 +1241,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: infoChangedMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -1291,6 +1278,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: infoChangedMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -1343,6 +1331,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberChangedMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) @@ -1361,6 +1350,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberChangedMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) @@ -1381,6 +1371,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberChangedMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) @@ -1403,6 +1394,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberChangedMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -1438,6 +1430,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberChangedMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -1476,6 +1469,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberChangedMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -1515,6 +1509,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberChangedMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -1555,6 +1550,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberChangedMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -1589,6 +1585,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberChangedMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -1624,6 +1621,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberChangedMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -1660,6 +1658,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberChangedMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -1694,6 +1693,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberChangedMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -1729,6 +1729,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberChangedMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -1770,6 +1771,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberLeftMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -1790,6 +1792,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberLeftMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) @@ -1808,6 +1811,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberLeftMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) @@ -1854,6 +1858,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberLeftMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -1873,6 +1878,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberLeftMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -1891,6 +1897,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberLeftMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -1920,6 +1927,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberLeftMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -1982,6 +1990,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberLeftNotificationMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -2011,6 +2020,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: memberLeftNotificationMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -2054,6 +2064,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteResponseMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) @@ -2072,6 +2083,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteResponseMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) @@ -2087,6 +2099,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteResponseMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -2142,6 +2155,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteResponseMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -2185,6 +2199,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteResponseMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -2216,6 +2231,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteResponseMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -2239,6 +2255,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: inviteResponseMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -2287,8 +2304,7 @@ class MessageReceiverGroupsSpec: QuickSpec { openGroupWhisperTo: nil, state: .sent, recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - transientDependencies: EquatableIgnoring(value: dependencies) + mostRecentFailureText: nil ).inserted(db) _ = try Interaction( @@ -2312,8 +2328,7 @@ class MessageReceiverGroupsSpec: QuickSpec { openGroupWhisperTo: nil, state: .sent, recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - transientDependencies: EquatableIgnoring(value: dependencies) + mostRecentFailureText: nil ).inserted(db) _ = try Interaction( @@ -2337,8 +2352,7 @@ class MessageReceiverGroupsSpec: QuickSpec { openGroupWhisperTo: nil, state: .sent, recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - transientDependencies: EquatableIgnoring(value: dependencies) + mostRecentFailureText: nil ).inserted(db) _ = try Interaction( @@ -2362,8 +2376,7 @@ class MessageReceiverGroupsSpec: QuickSpec { openGroupWhisperTo: nil, state: .sent, recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - transientDependencies: EquatableIgnoring(value: dependencies) + mostRecentFailureText: nil ).inserted(db) } } @@ -2385,6 +2398,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: deleteContentMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) @@ -2403,6 +2417,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: deleteContentMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) @@ -2423,6 +2438,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: deleteContentMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) }.to(throwError(MessageReceiverError.invalidMessage)) @@ -2448,6 +2464,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: deleteContentMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -2476,6 +2493,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: deleteContentMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -2502,6 +2520,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: deleteContentMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -2548,6 +2567,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: deleteContentMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -2597,6 +2617,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: deleteContentMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -2625,6 +2646,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: deleteContentMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -2651,6 +2673,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: deleteContentMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -2679,6 +2702,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: deleteContentMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -2708,6 +2732,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: deleteContentMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -2782,6 +2807,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: deleteContentMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -2817,6 +2843,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, message: deleteContentMessage, serverExpirationTimestamp: 1234567890, + suppressNotifications: false, using: dependencies ) } @@ -2899,8 +2926,7 @@ class MessageReceiverGroupsSpec: QuickSpec { openGroupWhisperTo: nil, state: .sent, recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - transientDependencies: EquatableIgnoring(value: dependencies) + mostRecentFailureText: nil ).inserted(db) try ConfigDump( @@ -3208,10 +3234,9 @@ class MessageReceiverGroupsSpec: QuickSpec { ) } - var cGroupId: [CChar] = 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(ugroups_group_is_kicked(&userGroup)).to(beTrue()) + expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.markAsKicked(groupSessionIds: [groupId.hexString]) + }) } } @@ -3328,6 +3353,7 @@ class MessageReceiverGroupsSpec: QuickSpec { message: visibleMessage, serverExpirationTimestamp: nil, associatedWithProto: visibleMessageProto, + suppressNotifications: false, using: dependencies ) } @@ -3372,6 +3398,7 @@ class MessageReceiverGroupsSpec: QuickSpec { message: visibleMessage, serverExpirationTimestamp: nil, associatedWithProto: visibleMessageProto, + suppressNotifications: false, using: dependencies ) } @@ -3404,6 +3431,7 @@ class MessageReceiverGroupsSpec: QuickSpec { message: visibleMessage, serverExpirationTimestamp: nil, associatedWithProto: visibleMessageProto, + suppressNotifications: false, using: dependencies ) } diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift index e12d8bb998..1bd4a1b614 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift @@ -128,11 +128,14 @@ class MessageSenderGroupsSpec: QuickSpec { .when { $0.generate(.uuid()) } .thenReturn(UUID(uuidString: "00000000-0000-0000-0000-000000000000")!) crypto - .when { $0.generate(.encryptedDataDisplayPicture(data: .any, key: .any, using: .any)) } + .when { $0.generate(.encryptedDataDisplayPicture(data: .any, key: .any)) } .thenReturn(TestConstants.validImageData) crypto .when { $0.generate(.ciphertextForGroupMessage(groupSessionId: .any, message: .any)) } .thenReturn("TestGroupMessageCiphertext".data(using: .utf8)!) + crypto + .when { $0.generate(.hash(message: .any)) } + .thenReturn(Array(Data(hex: "01010101010101010101010101010101"))) } ) @TestState(singleton: .keychain, in: dependencies) var mockKeychain: MockKeychain! = MockKeychain( @@ -146,6 +149,17 @@ class MessageSenderGroupsSpec: QuickSpec { ) } .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.. Void)? = (untrackedArgs[test: 0] as? () throws -> Void) - try? callback?() - } - .thenReturn(()) - cache - .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) - - switch args[test: 0] as? ConfigDump.Variant { - case .userGroups: try? callback?(userGroupsConfig) - case .groupInfo: try? callback?(groupInfoConfig) - case .groupMembers: try? callback?(groupMembersConfig) - case .groupKeys: try? callback?(groupKeysConfig) - default: break - } - } - .thenReturn(()) + cache.defaultInitialSetup( + configs: [ + .userGroups: userGroupsConfig, + .groupInfo: groupInfoConfig, + .groupMembers: groupMembersConfig, + .groupKeys: groupKeysConfig + ] + ) cache - .when { - try $0.createDumpMarkingAsPushed( - data: .any, - sentTimestamp: .any, - swarmPublicKey: .any - ) - } - .thenReturn([]) - cache - .when { $0.pinnedPriority(.any, threadId: .any, threadVariant: .any) } - .thenReturn(LibSession.defaultNewThreadPriority) - cache.when { $0.disappearingMessagesConfig(threadId: .any, threadVariant: .any) } - .thenReturn(nil) + .when { try $0.pendingPushes(swarmPublicKey: .any) } + .thenReturn(LibSession.PendingPushes(obsoleteHashes: ["testHash"])) } ) @TestState(cache: .snodeAPI, in: dependencies) var mockSnodeAPICache: MockSnodeAPICache! = MockSnodeAPICache( - initialSetup: { cache in - cache.when { $0.hardfork }.thenReturn(0) - cache.when { $0.hardfork = .any }.thenReturn(()) - cache.when { $0.softfork }.thenReturn(0) - cache.when { $0.softfork = .any }.thenReturn(()) - cache.when { $0.clockOffsetMs }.thenReturn(0) - cache.when { $0.setClockOffsetMs(.any) }.thenReturn(()) - cache.when { $0.currentOffsetTimestampMs() }.thenReturn(1234567890000) - } + initialSetup: { $0.defaultInitialSetup() } ) @TestState var mockSwarmPoller: MockSwarmPoller! = MockSwarmPoller( initialSetup: { cache in @@ -275,6 +234,9 @@ class MessageSenderGroupsSpec: QuickSpec { cache.when { $0.stopAndRemoveAllPollers() }.thenReturn(()) } ) + @TestState(singleton: .fileManager, in: dependencies) var mockFileManager: MockFileManager! = MockFileManager( + initialSetup: { $0.defaultInitialSetup() } + ) @TestState var disposables: [AnyCancellable]! = [] @TestState var error: Error? @TestState var thread: SessionThread? @@ -285,8 +247,8 @@ class MessageSenderGroupsSpec: QuickSpec { context("when creating a group") { beforeEach { mockLibSessionCache - .when { try $0.pendingChanges(.any, swarmPublicKey: .any) } - .thenReturn(LibSession.PendingChanges()) + .when { try $0.pendingPushes(swarmPublicKey: .any) } + .thenReturn(LibSession.PendingPushes()) } // MARK: ---- loads the state into the cache @@ -390,9 +352,7 @@ class MessageSenderGroupsSpec: QuickSpec { expect(dbValue?.name).to(equal("TestGroupName")) expect(dbValue?.formationTimestamp).to(equal(1234567890)) expect(dbValue?.displayPictureUrl).to(beNil()) - expect(dbValue?.displayPictureFilename).to(beNil()) expect(dbValue?.displayPictureEncryptionKey).to(beNil()) - expect(dbValue?.lastDisplayPictureUpdate).to(equal(1234567890)) expect(dbValue?.groupIdentityPrivateKey?.toHexString()).to(equal(groupSecretKey.toHexString())) expect(dbValue?.authData).to(beNil()) expect(dbValue?.invited).to(beFalse()) @@ -454,11 +414,11 @@ class MessageSenderGroupsSpec: QuickSpec { // MARK: ---- syncs the group configuration messages it("syncs the group configuration messages") { mockLibSessionCache - .when { try $0.pendingChanges(.any, swarmPublicKey: .any) } + .when { try $0.pendingPushes(swarmPublicKey: .any) } .thenReturn( - LibSession.PendingChanges( + LibSession.PendingPushes( pushData: [ - LibSession.PendingChanges.PushData( + LibSession.PendingPushes.PushData( data: [Data([1, 2, 3])], seqNo: 2, variant: .groupInfo @@ -493,7 +453,7 @@ class MessageSenderGroupsSpec: QuickSpec { .preparedSendMessage( message: SnodeMessage( recipient: groupId.hexString, - data: Data([1, 2, 3]).base64EncodedString(), + data: Data([1, 2, 3]), ttl: ConfigDump.Variant.groupInfo.ttl, timestampMs: 1234567890 ), @@ -710,8 +670,6 @@ class MessageSenderGroupsSpec: QuickSpec { let groups: [ClosedGroup]? = mockStorage.read { db in try ClosedGroup.fetchAll(db) } expect(groups?.first?.displayPictureUrl).to(equal("http://filev2.getsession.org/file/1")) - expect(groups?.first?.displayPictureFilename) - .to(equal("00000000-0000-0000-0000-000000000000.jpg")) expect(groups?.first?.displayPictureEncryptionKey) .to(equal(Data((0..? + beforeEach { mockCrypto .when { - $0.generate(.ciphertextWithSessionProtocol(.any, plaintext: .any, destination: .any, using: .any)) + $0.generate(.ciphertextWithSessionProtocol(plaintext: .any, destination: .any)) } .thenReturn(Data([1, 2, 3])) mockCrypto @@ -60,21 +74,26 @@ class MessageSenderSpec: QuickSpec { // MARK: ---- can encrypt correctly it("can encrypt correctly") { - let result: Network.PreparedRequest? = mockStorage.read { db in - try? MessageSender.preparedSend( - db, + expect { + preparedRequest = try MessageSender.preparedSend( message: VisibleMessage( text: "TestMessage" ), to: .contact(publicKey: "05\(TestConstants.publicKey)"), namespace: .default, interactionId: nil, - fileIds: [], + attachments: nil, + authMethod: Authentication.standard( + sessionId: SessionId(.standard, hex: TestConstants.publicKey), + ed25519PublicKey: Array(Data(hex: TestConstants.edPublicKey)), + ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)) + ), + onEvent: nil, using: dependencies ) - } + }.toNot(throwError()) - expect(result).toNot(beNil()) + expect(preparedRequest).toNot(beNil()) } } } diff --git a/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift b/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift new file mode 100644 index 0000000000..3a198825fd --- /dev/null +++ b/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift @@ -0,0 +1,1393 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUIKit +import SessionUtilitiesKit + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class NotificationsManagerSpec: QuickSpec { + override class func spec() { + // MARK: Configuration + + @TestState var dependencies: TestDependencies! = TestDependencies( + initialState: { + $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.hasAtLeastOneDedupeRecord(threadId: .any) }.thenReturn(false) + } + ) + @TestState(singleton: .notificationsManager, in: dependencies) var mockNotificationsManager: MockNotificationsManager! = MockNotificationsManager( + initialSetup: { $0.defaultInitialSetup() } + ) + @TestState var message: Message! = VisibleMessage( + sender: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", + sentTimestampMs: 1234567892, + text: "Test" + ) + @TestState var threadId: String! = "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))" + @TestState var notificationSettings: Preferences.NotificationSettings! = Preferences.NotificationSettings( + previewType: .nameAndPreview, + sound: .defaultNotificationSound, + mentionsOnly: false, + mutedUntil: nil + ) + + // MARK: - a NotificationsManager - Ensure Should Show + describe("a NotificationsManager when ensuring we should show notifications") { + // MARK: -- throws if the message has no sender + it("throws if the message has no sender") { + message = VisibleMessage( + sentTimestampMs: message.sentTimestampMs, + text: "Test" + ) + + expect { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: threadId, + threadVariant: .contact, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.to(throwError(MessageReceiverError.invalidSender)) + } + + // MARK: -- throws if the message was sent to note to self + it("throws if the message was sent to note to self") { + expect { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: "05\(TestConstants.publicKey)", + threadVariant: .contact, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.to(throwError(MessageReceiverError.selfSend)) + } + + // MARK: -- throws if the message was sent by the current user + it("throws if the message was sent by the current user") { + message = VisibleMessage( + sender: "05\(TestConstants.publicKey)", + sentTimestampMs: message.sentTimestampMs, + text: "Test" + ) + + expect { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: threadId, + threadVariant: .contact, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.to(throwError(MessageReceiverError.selfSend)) + } + + // MARK: -- throws if notifications are muted + it("throws if notifications are muted") { + expect { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: threadId, + threadVariant: .contact, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: Preferences.NotificationSettings( + previewType: .nameAndPreview, + sound: .defaultNotificationSound, + mentionsOnly: false, + mutedUntil: Date(timeIntervalSince1970: 1234567891).timeIntervalSince1970 + ), + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.to(throwError(MessageReceiverError.ignorableMessage)) + } + + // MARK: -- throws if the message is not an incoming message + it("throws if the message is not an incoming message") { + expect { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: threadId, + threadVariant: .contact, + interactionVariant: .standardIncomingDeleted, + isMessageRequest: false, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.to(throwError(MessageReceiverError.ignorableMessage)) + } + + // MARK: -- for mentions only + context("for mentions only") { + beforeEach { + notificationSettings = Preferences.NotificationSettings( + previewType: .nameAndPreview, + sound: .defaultNotificationSound, + mentionsOnly: true, + mutedUntil: nil + ) + } + + // MARK: ---- throws if the user is not mentioned + it("throws if the user is not mentioned") { + expect { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: threadId, + threadVariant: .contact, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.to(throwError(MessageReceiverError.ignorableMessage)) + } + + // MARK: ---- does not throw if the current user is mentioned + it("does not throw if the current user is mentioned") { + message = VisibleMessage( + sender: message.sender, + sentTimestampMs: message.sentTimestampMs, + text: "Test @05\(TestConstants.publicKey)" + ) + + expect { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: threadId, + threadVariant: .contact, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.toNot(throwError()) + } + + // MARK: ---- does not throw if the message quoted a message sent by the current user + it("does not throw if the message quoted a message sent by the current user") { + message = VisibleMessage( + sender: message.sender, + sentTimestampMs: message.sentTimestampMs, + text: "Test", + quote: VisibleMessage.VMQuote( + timestamp: 1234567880, + authorId: "05\(TestConstants.publicKey)", + text: "TestQuote" + ) + ) + + expect { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: threadId, + threadVariant: .contact, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.toNot(throwError()) + } + } + + // MARK: -- for reactions + context("for reactions") { + beforeEach { + message = VisibleMessage( + sender: message.sender, + sentTimestampMs: message.sentTimestampMs, + text: nil, + reaction: VisibleMessage.VMReaction( + timestamp: 1234567880, + publicKey: "05\(TestConstants.publicKey)", + emoji: "A", + kind: .react + ) + ) + } + + // MARK: ---- throws if the message was a reaction sent to a non contact conversation + it("throws if the message was a reaction sent to a non contact conversation") { + expect { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: threadId, + threadVariant: .contact, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: Preferences.NotificationSettings( + previewType: .nameAndPreview, + sound: .defaultNotificationSound, + mentionsOnly: false, + mutedUntil: nil + ), + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.toNot(throwError()) + expect { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: threadId, + threadVariant: .legacyGroup, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: Preferences.NotificationSettings( + previewType: .nameAndPreview, + sound: .defaultNotificationSound, + mentionsOnly: false, + mutedUntil: nil + ), + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.to(throwError(MessageReceiverError.ignorableMessage)) + expect { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: "03\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", + threadVariant: .group, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: Preferences.NotificationSettings( + previewType: .nameAndPreview, + sound: .defaultNotificationSound, + mentionsOnly: false, + mutedUntil: nil + ), + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.to(throwError(MessageReceiverError.ignorableMessage)) + expect { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: "https://open.getsession.org.test", + threadVariant: .community, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: Preferences.NotificationSettings( + previewType: .nameAndPreview, + sound: .defaultNotificationSound, + mentionsOnly: false, + mutedUntil: nil + ), + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.to(throwError(MessageReceiverError.ignorableMessage)) + } + } + + // MARK: -- for call messages + context("for call messages") { + beforeEach { + message = CallMessage( + uuid: "1234", + kind: .preOffer, + sdps: [], + state: .missed, + sentTimestampMs: message.sentTimestampMs, + sender: message.sender + ) + } + + // MARK: ---- throws if the message was sent to a non contact conversation + it("throws if the message was sent to a non contact conversation") { + expect { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: threadId, + threadVariant: .contact, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.toNot(throwError()) + expect { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: threadId, + threadVariant: .legacyGroup, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.to(throwError(MessageReceiverError.invalidMessage)) + expect { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: "03\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", + threadVariant: .group, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.to(throwError(MessageReceiverError.invalidMessage)) + expect { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: "https://open.getsession.org.test", + threadVariant: .community, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.to(throwError(MessageReceiverError.invalidMessage)) + } + + // MARK: ---- throws if the message is not a preOffer + it("throws if the message is not a preOffer") { + message = CallMessage( + uuid: "1234", + kind: .offer, + sdps: [], + sentTimestampMs: message.sentTimestampMs, + sender: message.sender + ) + + expect { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: threadId, + threadVariant: .contact, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.to(throwError(MessageReceiverError.ignorableMessage)) + } + + // MARK: ---- throws for the expected states + it("throws for the expected states") { + let nonThrowingStates: Set = [ + .missed, .permissionDenied, .permissionDeniedMicrophone + ] + let stateToError: [String: String] = CallMessage.MessageInfo.State.allCases + .filter { !nonThrowingStates.contains($0) } + .reduce(into: [:]) { result, next in + result["\(next)"] = "\(MessageReceiverError.ignorableMessage)" + } + var result: [String: String] = [:] + + CallMessage.MessageInfo.State.allCases.forEach { state in + do { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: CallMessage( + uuid: "1234", + kind: .preOffer, + sdps: [], + state: state, + sentTimestampMs: message.sentTimestampMs, + sender: message.sender + ), + threadId: threadId, + threadVariant: .contact, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + } + catch { result["\(state)"] = "\(error)" } + } + expect(result).to(equal(stateToError)) + } + } + + // MARK: -- does not throw for a group invitation + it("does not throw for a group invitation") { + expect { + message = GroupUpdateInviteMessage( + inviteeSessionIdHexString: "", + groupSessionId: SessionId(.standard, hex: TestConstants.publicKey), + groupName: "TestName", + memberAuthData: Data([1, 2, 3]), + adminSignature: Authentication.Signature.standard(signature: [1, 2, 3]), + sentTimestampMs: message.sentTimestampMs, + sender: message.sender + ) + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: threadId, + threadVariant: .contact, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.toNot(throwError()) + } + + // MARK: -- does not throw for a group promotion + it("does not throw for a group promotion") { + expect { + message = GroupUpdatePromoteMessage( + groupIdentitySeed: Data([1, 2, 3]), + groupName: "TestName", + profile: nil, + sentTimestampMs: message.sentTimestampMs, + sender: message.sender + ) + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: threadId, + threadVariant: .contact, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.toNot(throwError()) + } + + // MARK: -- throws for the expected message types + it("throws for the expected message types") { + let nonThrowingMessageTypes: [Message.Type] = [ + VisibleMessage.self, CallMessage.self, GroupUpdateInviteMessage.self, GroupUpdatePromoteMessage.self + ] + let throwingMessages: [Message] = [ + ReadReceipt(timestamps: [], sender: message.sender), + TypingIndicator(kind: .started, sender: message.sender), + DataExtractionNotification(kind: .mediaSaved(timestamp: 0), sender: message.sender), + ExpirationTimerUpdate(sender: message.sender), + UnsendRequest(timestamp: 0, author: "", sender: message.sender), + MessageRequestResponse(isApproved: false, sender: message.sender), + GroupUpdateInfoChangeMessage( + changeType: .name, + adminSignature: Authentication.Signature.standard(signature: [1, 2, 3]), + sender: message.sender + ), + GroupUpdateMemberChangeMessage( + changeType: .added, + memberSessionIds: [], + historyShared: false, + adminSignature: Authentication.Signature.standard(signature: [1, 2, 3]), + sender: message.sender + ), + GroupUpdateMemberLeftMessage(sender: message.sender), + GroupUpdateMemberLeftNotificationMessage(sender: message.sender), + GroupUpdateInviteResponseMessage(isApproved: false, sender: message.sender), + GroupUpdateDeleteMemberContentMessage( + memberSessionIds: [], + messageHashes: [], + adminSignature: nil, + sender: message.sender + ), + LibSessionMessage(ciphertext: Data([1, 2, 3]), sender: message.sender) + ] + + /// If this line fails then we need to create a new message type in one of the above arrays + expect(Message.Variant.allCases.count - nonThrowingMessageTypes.count).to(equal(throwingMessages.count)) + let messageTypeNameToError: [String: String] = throwingMessages + .reduce(into: [:]) { result, next in + result["\(type(of: next))"] = "\(MessageReceiverError.ignorableMessage)" + } + var result: [String: String] = [:] + + throwingMessages.forEach { throwingMessage in + do { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: throwingMessage, + threadId: threadId, + threadVariant: .contact, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + } + catch { result["\(type(of: throwingMessage))"] = "\(error)" } + } + expect(result).to(equal(messageTypeNameToError)) + } + + // MARK: -- throws if the sender is blocked + it("throws if the sender is blocked") { + expect { + mockLibSessionCache + .when { $0.isContactBlocked(contactId: .any) } + .thenReturn(true) + + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: threadId, + threadVariant: .contact, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.to(throwError(MessageReceiverError.senderBlocked)) + } + + // MARK: -- throws if the message was already read + it("throws if the message was already read") { + expect { + mockLibSessionCache + .when { + $0.conversationLastRead( + threadId: .any, + threadVariant: .any, + openGroupUrlInfo: .any + ) + } + .thenReturn(1234567899) + + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: threadId, + threadVariant: .contact, + interactionVariant: .standardIncoming, + isMessageRequest: false, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.to(throwError(MessageReceiverError.ignorableMessage)) + } + + // MARK: -- throws if the message was sent to a message request and we should not show + it("throws if the message was sent to a message request and we should not show") { + expect { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: threadId, + threadVariant: .contact, + interactionVariant: .standardIncoming, + isMessageRequest: true, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { false }, + using: dependencies + ) + }.to(throwError(MessageReceiverError.ignorableMessageRequestMessage(threadId))) + } + + // MARK: -- does not throw if the message was sent to a message request and we should show + it("does not throw if the message was sent to a message request and we should show") { + expect { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: threadId, + threadVariant: .contact, + interactionVariant: .standardIncoming, + isMessageRequest: true, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { true }, + using: dependencies + ) + }.toNot(throwError()) + } + + // MARK: -- does not throw if the conversation type does not support message requests + it("does not throw if the conversation type does not support message requests") { + expect { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: threadId, + threadVariant: .legacyGroup, + interactionVariant: .standardIncoming, + isMessageRequest: true, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { false }, + using: dependencies + ) + }.toNot(throwError()) + expect { + try mockNotificationsManager.ensureWeShouldShowNotification( + message: message, + threadId: "https://open.getsession.org.test", + threadVariant: .community, + interactionVariant: .standardIncoming, + isMessageRequest: true, + notificationSettings: notificationSettings, + openGroupUrlInfo: nil, + currentUserSessionIds: ["05\(TestConstants.publicKey)"], + shouldShowForMessageRequest: { false }, + using: dependencies + ) + }.toNot(throwError()) + } + } + + // MARK: - a NotificationsManager - Notification Title + describe("a NotificationsManager when generating the notification title") { + // MARK: -- returns the app name if we should not show a name + it("returns the app name if we should not show a name") { + notificationSettings = Preferences.NotificationSettings( + previewType: .noNameNoPreview, + sound: .defaultNotificationSound, + mentionsOnly: false, + mutedUntil: nil + ) + + expect { + try mockNotificationsManager.notificationTitle( + message: message, + threadId: threadId, + threadVariant: .contact, + isMessageRequest: false, + notificationSettings: notificationSettings, + displayNameRetriever: { _ in nil }, + groupNameRetriever: { _, _ in nil }, + using: dependencies + ) + }.to(equal(Constants.app_name)) + } + + // MARK: -- returns the app name if the message is for a message request + it("returns the app name if the message is for a message request") { + expect { + try mockNotificationsManager.notificationTitle( + message: message, + threadId: threadId, + threadVariant: .contact, + isMessageRequest: true, + notificationSettings: notificationSettings, + displayNameRetriever: { _ in nil }, + groupNameRetriever: { _, _ in nil }, + using: dependencies + ) + }.to(equal(Constants.app_name)) + } + + // MARK: -- returns the app name if there is no sender + it("returns the app name if there is no sender") { + message.sender = nil + + expect { + try mockNotificationsManager.notificationTitle( + message: message, + threadId: threadId, + threadVariant: .contact, + isMessageRequest: false, + notificationSettings: notificationSettings, + displayNameRetriever: { _ in nil }, + groupNameRetriever: { _, _ in nil }, + using: dependencies + ) + }.to(equal(Constants.app_name)) + } + + // MARK: -- returns the name returned by the displayNameRetriever + it("returns the name returned by the displayNameRetriever") { + expect { + try mockNotificationsManager.notificationTitle( + message: message, + threadId: threadId, + threadVariant: .contact, + isMessageRequest: false, + notificationSettings: notificationSettings, + displayNameRetriever: { _ in "TestName" }, + groupNameRetriever: { _, _ in nil }, + using: dependencies + ) + }.to(equal("TestName")) + } + + // MARK: -- returns the truncated sender id when the displayNameRetriever returns null + it("returns the truncated sender id when the displayNameRetriever returns null") { + expect { + try mockNotificationsManager.notificationTitle( + message: message, + threadId: threadId, + threadVariant: .contact, + isMessageRequest: false, + notificationSettings: notificationSettings, + displayNameRetriever: { _ in nil }, + groupNameRetriever: { _, _ in nil }, + using: dependencies + ) + }.to(equal("0588...c65b")) + } + + [SessionThread.Variant.group, SessionThread.Variant.community].forEach { variant in + // MARK: -- when sent to a X + context("when sent to a \(variant)") { + // MARK: ---- returns the formatted string containing the retrieved name and group name + it("returns the formatted string containing the retrieved name and group name") { + expect { + try mockNotificationsManager.notificationTitle( + message: message, + threadId: threadId, + threadVariant: variant, + isMessageRequest: false, + notificationSettings: notificationSettings, + displayNameRetriever: { _ in "TestName" }, + groupNameRetriever: { _, _ in "TestGroup" }, + using: dependencies + ) + }.to(equal( + "notificationsIosGroup" + .put(key: "name", value: "TestName") + .put(key: "conversation_name", value: "TestGroup") + .localized() + )) + } + + // 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") + + expect { + try mockNotificationsManager.notificationTitle( + message: message, + threadId: threadId, + threadVariant: variant, + isMessageRequest: false, + notificationSettings: notificationSettings, + displayNameRetriever: { _ in nil }, + groupNameRetriever: { _, _ in "TestGroup" }, + using: dependencies + ) + }.to(equal( + "notificationsIosGroup" + .put(key: "name", value: "0588...c65b") + .put(key: "conversation_name", value: "TestGroup") + .localized() + )) + } + + // MARK: ---- returns the formatted string containing the retrieved name and default group name when the retriever fails to return a group name + it("returns the formatted string containing the retrieved name and default group name when the retriever fails to return a group name") { + expect { + try mockNotificationsManager.notificationTitle( + message: message, + threadId: threadId, + threadVariant: variant, + isMessageRequest: false, + notificationSettings: notificationSettings, + displayNameRetriever: { _ in "TestName" }, + groupNameRetriever: { _, _ in nil }, + using: dependencies + ) + }.to(equal( + "notificationsIosGroup" + .put(key: "name", value: "TestName") + .put(key: "conversation_name", value: "groupUnknown".localized()) + .localized() + )) + } + } + } + + // MARK: -- throws for legacy groups + it("throws for legacy groups") { + expect { + try mockNotificationsManager.notificationTitle( + message: message, + threadId: threadId, + threadVariant: .legacyGroup, + isMessageRequest: false, + notificationSettings: notificationSettings, + displayNameRetriever: { _ in nil }, + groupNameRetriever: { _, _ in nil }, + using: dependencies + ) + }.to(throwError(MessageReceiverError.ignorableMessage)) + } + } + + // MARK: - a NotificationsManager - Notification Body + describe("a NotificationsManager when generating the notification body") { + // MARK: -- returns the expected string for a message request + it("returns the expected string for a message request") { + expect { + mockNotificationsManager.notificationBody( + message: message, + threadVariant: .contact, + isMessageRequest: true, + notificationSettings: notificationSettings, + interactionVariant: .standardIncoming, + attachmentDescriptionInfo: nil, + currentUserSessionIds: [], + displayNameRetriever: { _ in nil }, + using: dependencies + ) + }.to(equal("messageRequestsNew".localized())) + } + + // MARK: -- returns a generic message when there is no sender + it("returns a generic message when there is no sender") { + message.sender = nil + + expect { + mockNotificationsManager.notificationBody( + message: message, + threadVariant: .contact, + isMessageRequest: false, + notificationSettings: notificationSettings, + interactionVariant: .standardIncoming, + attachmentDescriptionInfo: nil, + currentUserSessionIds: [], + displayNameRetriever: { _ in nil }, + using: dependencies + ) + }.to(equal("messageNewYouveGot".putNumber(1).localized())) + } + + // MARK: -- returns a generic message when the preview type does not include the body + it("returns a generic message when the preview type does not include the body") { + expect { + mockNotificationsManager.notificationBody( + message: message, + threadVariant: .contact, + isMessageRequest: false, + notificationSettings: Preferences.NotificationSettings( + previewType: .nameNoPreview, + sound: .defaultNotificationSound, + mentionsOnly: false, + mutedUntil: nil + ), + interactionVariant: .standardIncoming, + attachmentDescriptionInfo: nil, + currentUserSessionIds: [], + displayNameRetriever: { _ in nil }, + using: dependencies + ) + }.to(equal("messageNewYouveGot".putNumber(1).localized())) + expect { + mockNotificationsManager.notificationBody( + message: message, + threadVariant: .contact, + isMessageRequest: false, + notificationSettings: Preferences.NotificationSettings( + previewType: .noNameNoPreview, + sound: .defaultNotificationSound, + mentionsOnly: false, + mutedUntil: nil + ), + interactionVariant: .standardIncoming, + attachmentDescriptionInfo: nil, + currentUserSessionIds: [], + displayNameRetriever: { _ in nil }, + using: dependencies + ) + }.to(equal("messageNewYouveGot".putNumber(1).localized())) + } + + // MARK: -- returns the expected reaction message when there is a reaction + it("returns the expected reaction message when there is a reaction") { + message = VisibleMessage( + sender: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", + text: nil, + reaction: VisibleMessage.VMReaction( + timestamp: 1234567880, + publicKey: "05\(TestConstants.publicKey)", + emoji: "A", + kind: .react + ) + ) + + expect { + mockNotificationsManager.notificationBody( + message: message, + threadVariant: .contact, + isMessageRequest: false, + notificationSettings: notificationSettings, + interactionVariant: .standardIncoming, + attachmentDescriptionInfo: nil, + currentUserSessionIds: [], + displayNameRetriever: { _ in nil }, + using: dependencies + ) + }.to(equal("emojiReactsNotification".put(key: "emoji", value: "A").localized())) + } + + // MARK: -- returns the message preview text for a visible message + it("returns the message preview text for a visible message") { + expect { + mockNotificationsManager.notificationBody( + message: message, + threadVariant: .contact, + isMessageRequest: false, + notificationSettings: notificationSettings, + interactionVariant: .standardIncoming, + attachmentDescriptionInfo: nil, + currentUserSessionIds: [], + displayNameRetriever: { _ in nil }, + using: dependencies + ) + }.to(equal("Test")) + } + + // MARK: -- resolves a mention that is not the sender + it("resolves a mention that is not the sender") { + message = VisibleMessage( + sender: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", + text: "Hey @05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "3"))", + profile: VisibleMessage.VMProfile(displayName: "TestSender") + ) + + expect { + mockNotificationsManager.notificationBody( + message: message, + threadVariant: .contact, + isMessageRequest: false, + notificationSettings: notificationSettings, + interactionVariant: .standardIncoming, + attachmentDescriptionInfo: nil, + currentUserSessionIds: [], + displayNameRetriever: { _ in "TestMention" }, + using: dependencies + ) + }.to(equal("Hey @TestMention")) + } + + // MARK: -- returns a generic message if no interaction variant is provided + it("returns a generic message if no interaction variant is provided") { + expect { + mockNotificationsManager.notificationBody( + message: message, + threadVariant: .contact, + isMessageRequest: false, + notificationSettings: notificationSettings, + interactionVariant: nil, + attachmentDescriptionInfo: nil, + currentUserSessionIds: [], + displayNameRetriever: { _ in nil }, + using: dependencies + ) + }.to(equal("messageNewYouveGot".putNumber(1).localized())) + } + + // MARK: -- for a call message missed due to permissions + context("for a call message missed due to permissions") { + beforeEach { + message = CallMessage( + uuid: "1234", + kind: .preOffer, + sdps: [], + state: .permissionDenied, + sender: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))" + ) + } + + // MARK: ---- returns a missed call message + it("returns a missed call message") { + expect { + mockNotificationsManager.notificationBody( + message: message, + threadVariant: .contact, + isMessageRequest: false, + notificationSettings: notificationSettings, + interactionVariant: nil, + attachmentDescriptionInfo: nil, + currentUserSessionIds: [], + displayNameRetriever: { _ in "TestName" }, + using: dependencies + ) + }.to(equal("callsYouMissedCallPermissions".put(key: "name", value: "TestName").localizedDeformatted())) + } + + // MARK: ---- includes the senders display name if retrieved + it("includes the senders display name if retrieved") { + expect { + mockNotificationsManager.notificationBody( + message: message, + threadVariant: .contact, + isMessageRequest: false, + notificationSettings: notificationSettings, + interactionVariant: nil, + attachmentDescriptionInfo: nil, + currentUserSessionIds: [], + displayNameRetriever: { _ in "TestName" }, + using: dependencies + ) + }.to(equal("callsYouMissedCallPermissions".put(key: "name", value: "TestName").localizedDeformatted())) + } + + // MARK: ---- defaults to the truncated id if it cannot retrieve a display name + it("defaults to the truncated id if it cannot retrieve a display name") { + expect { + mockNotificationsManager.notificationBody( + message: message, + threadVariant: .contact, + isMessageRequest: false, + notificationSettings: notificationSettings, + interactionVariant: nil, + attachmentDescriptionInfo: nil, + currentUserSessionIds: [], + displayNameRetriever: { _ in nil }, + using: dependencies + ) + }.to(equal("callsYouMissedCallPermissions".put(key: "name", value: "0588...c65b").localizedDeformatted())) + } + } + + // MARK: -- for a missed call message + context("for a missed call message") { + beforeEach { + message = CallMessage( + uuid: "1234", + kind: .preOffer, + sdps: [], + state: .missed, + sender: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))" + ) + } + + // MARK: ---- returns a missed call message + it("returns a missed call message") { + expect { + mockNotificationsManager.notificationBody( + message: message, + threadVariant: .contact, + isMessageRequest: false, + notificationSettings: notificationSettings, + interactionVariant: nil, + attachmentDescriptionInfo: nil, + currentUserSessionIds: [], + displayNameRetriever: { _ in "TestName" }, + using: dependencies + ) + }.to(equal("callsMissedCallFrom".put(key: "name", value: "TestName").localizedDeformatted())) + } + + // MARK: ---- includes the senders display name if retrieved + it("includes the senders display name if retrieved") { + expect { + mockNotificationsManager.notificationBody( + message: message, + threadVariant: .contact, + isMessageRequest: false, + notificationSettings: notificationSettings, + interactionVariant: nil, + attachmentDescriptionInfo: nil, + currentUserSessionIds: [], + displayNameRetriever: { _ in "TestName" }, + using: dependencies + ) + }.to(equal( + "callsMissedCallFrom" + .put(key: "name", value: "TestName") + .localizedDeformatted() + )) + } + + // MARK: ---- defaults to the truncated id if it cannot retrieve a display name + it("defaults to the truncated id if it cannot retrieve a display name") { + expect { + mockNotificationsManager.notificationBody( + message: message, + threadVariant: .contact, + isMessageRequest: false, + notificationSettings: notificationSettings, + interactionVariant: nil, + attachmentDescriptionInfo: nil, + currentUserSessionIds: [], + displayNameRetriever: { _ in nil }, + using: dependencies + ) + }.to(equal( + "callsMissedCallFrom" + .put(key: "name", value: "0588...c65b") + .localizedDeformatted() + )) + } + } + + // MARK: -- returns a generic message in all other cases + it("returns a generic message in all other cases") { + message = ReadReceipt(timestamps: [], sender: message.sender) + + expect { + mockNotificationsManager.notificationBody( + message: message, + threadVariant: .contact, + isMessageRequest: false, + notificationSettings: notificationSettings, + interactionVariant: .standardIncoming, + attachmentDescriptionInfo: nil, + currentUserSessionIds: [], + displayNameRetriever: { _ in nil }, + using: dependencies + ) + }.to(equal("messageNewYouveGot".putNumber(1).localized())) + } + } + + // MARK: - a NotificationsManager - Notify User + describe("a NotificationsManager when notifying the user") { + // MARK: -- checks if the conversation is a message request + it("checks if the conversation is a message request") { + expect { + try mockNotificationsManager.notifyUser( + message: message, + threadId: "05\(TestConstants.publicKey)", + threadVariant: .contact, + interactionIdentifier: "TestId", + interactionVariant: .standardIncoming, + attachmentDescriptionInfo: nil, + openGroupUrlInfo: nil, + applicationState: .background, + extensionBaseUnreadCount: 1, + currentUserSessionIds: [], + displayNameRetriever: { _ in nil }, + groupNameRetriever: { _, _ in nil }, + shouldShowForMessageRequest: { false } + ) + }.toNot(throwError()) + expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) { + $0.isMessageRequest(threadId: "05\(TestConstants.publicKey)", threadVariant: .contact) + }) + } + + // MARK: -- retrieves notification settings from the notification maanager + it("retrieves notification settings from the notification maanager") { + expect { + try mockNotificationsManager.notifyUser( + message: message, + threadId: "05\(TestConstants.publicKey)", + threadVariant: .contact, + interactionIdentifier: "TestId", + interactionVariant: .standardIncoming, + attachmentDescriptionInfo: nil, + openGroupUrlInfo: nil, + applicationState: .background, + extensionBaseUnreadCount: 1, + currentUserSessionIds: [], + displayNameRetriever: { _ in nil }, + groupNameRetriever: { _, _ in nil }, + shouldShowForMessageRequest: { false } + ) + }.toNot(throwError()) + expect(mockNotificationsManager).to(call(.exactly(times: 1), matchingParameters: .all) { + $0.settings(threadId: "05\(TestConstants.publicKey)", threadVariant: .contact) + }) + } + + // 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 + .when { $0.isMessageRequest(threadId: .any, threadVariant: .any) } + .thenReturn(true) + + expect { + try mockNotificationsManager.notifyUser( + message: message, + threadId: "05\(TestConstants.publicKey)", + threadVariant: .contact, + interactionIdentifier: "TestId", + interactionVariant: .standardIncoming, + attachmentDescriptionInfo: nil, + openGroupUrlInfo: nil, + applicationState: .background, + extensionBaseUnreadCount: 1, + currentUserSessionIds: [], + displayNameRetriever: { _ in nil }, + groupNameRetriever: { _, _ in nil }, + shouldShowForMessageRequest: { + didCallShouldShowForMessageRequest = true + return false + } + ) + }.to(throwError(MessageReceiverError.ignorableMessageRequestMessage("05\(TestConstants.publicKey)"))) + expect(didCallShouldShowForMessageRequest).to(beTrue()) + } + + // MARK: -- adds the notification request + it("adds the notification request") { + expect { + try mockNotificationsManager.notifyUser( + message: message, + threadId: "05\(TestConstants.publicKey)", + threadVariant: .contact, + interactionIdentifier: "TestId", + interactionVariant: .standardIncoming, + attachmentDescriptionInfo: nil, + openGroupUrlInfo: nil, + applicationState: .background, + extensionBaseUnreadCount: 1, + currentUserSessionIds: [], + displayNameRetriever: { _ in nil }, + groupNameRetriever: { _, _ in nil }, + 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 + ) + }) + } + + // MARK: -- uses a random identifier for reaction notifications + it("uses a random identifier for reaction notifications") { + message = VisibleMessage( + sender: message.sender, + sentTimestampMs: message.sentTimestampMs, + text: nil, + reaction: VisibleMessage.VMReaction( + timestamp: 1234567880, + publicKey: "05\(TestConstants.publicKey)", + emoji: "A", + kind: .react + ) + ) + dependencies.uuid = UUID(uuidString: "00000000-0000-0000-0000-000000000001") + + expect { + try mockNotificationsManager.notifyUser( + message: message, + threadId: "05\(TestConstants.publicKey)", + threadVariant: .contact, + interactionIdentifier: "TestId", + interactionVariant: .standardIncoming, + attachmentDescriptionInfo: nil, + openGroupUrlInfo: nil, + applicationState: .background, + extensionBaseUnreadCount: 1, + currentUserSessionIds: [], + displayNameRetriever: { _ in nil }, + groupNameRetriever: { _, _ in nil }, + 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 + ) + }) + } + } + } +} diff --git a/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift b/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift index ebbbaae296..c5d2eaead2 100644 --- a/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift @@ -11,7 +11,7 @@ import Nimble @testable import SessionMessagingKit -class CommunityPollerSpec: QuickSpec { +class CommunityPollerSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -78,6 +78,10 @@ class CommunityPollerSpec: QuickSpec { @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( @@ -86,6 +90,35 @@ class CommunityPollerSpec: QuickSpec { 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 @@ -100,14 +133,15 @@ class CommunityPollerSpec: QuickSpec { it("creates pollers for all of the communities") { cache.startAllPollers() - expect(cache.serversBeingPolled).to(equal(["testserver", "testserver1"])) + await expect(cache.serversBeingPolled).toEventually(equal(["testserver", "testserver1"])) } // MARK: ---- updates the isPolling flag it("updates the isPolling flag") { cache.startAllPollers() - expect(cache.allPollers.count).to(equal(2)) + 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()) } diff --git a/SessionMessagingKitTests/Utilities/ExtensionHelperSpec.swift b/SessionMessagingKitTests/Utilities/ExtensionHelperSpec.swift new file mode 100644 index 0000000000..ebf44dadbf --- /dev/null +++ b/SessionMessagingKitTests/Utilities/ExtensionHelperSpec.swift @@ -0,0 +1,2084 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtil +import SessionSnodeKit +import SessionUtilitiesKit + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class ExtensionHelperSpec: AsyncSpec { + override class func spec() { + // MARK: Configuration + + @TestState var dependencies: TestDependencies! = TestDependencies( + initialState: { dependencies in + dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) + dependencies.forceSynchronous = true + } + ) + @TestState(singleton: .extensionHelper, in: dependencies) var extensionHelper: ExtensionHelper! = ExtensionHelper(using: dependencies) + @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + customWriter: try! DatabaseQueue(), + migrationTargets: [ + SNUtilitiesKit.self, + SNMessagingKit.self + ], + 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 mockLogger: MockLogger! = MockLogger(primaryPrefix: "Mock", using: dependencies) + + // 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") + }) + } + + // MARK: -- when writing an encrypted file + context("when writing an encrypted file") { + // MARK: ---- ensures the write directory exists + it("ensures the write directory exists") { + try? extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + try? $0.ensureDirectoryExists(at: "/test/extensionCache/conversations/010203/dedupe") + }) + } + + // MARK: ---- protects the write directory + it("protects the write directory") { + try? extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + try? $0.protectFileOrFolder(at: "/test/extensionCache/conversations/010203/dedupe") + }) + } + + // MARK: ---- generates a temporary file path + it("generates a temporary file path") { + try? extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + $0.temporaryFilePath(fileExtension: nil) + }) + } + + // MARK: ---- writes the encrypted data to the temporary file path + it("writes the encrypted data to the temporary file path") { + try? extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + $0.createFile(atPath: "tmpFile", contents: Data([4, 5, 6])) + }) + } + + // MARK: ---- removes any existing file from the destination path + it("removes any existing file from the destination path") { + try? extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.removeItem(atPath: "/test/extensionCache/conversations/010203/dedupe/010203") + }) + } + + // MARK: ---- moves the temporary file to the destination path + it("moves the temporary file to the destination path") { + try? extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.moveItem( + atPath: "tmpFile", + toPath: "/test/extensionCache/conversations/010203/dedupe/010203" + ) + }) + } + + // MARK: ---- throws when failing to retrieve the encryption key + it("throws when failing to retrieve the encryption key") { + mockKeychain + .when { + try $0.getOrGenerateEncryptionKey( + forKey: .any, + length: .any, + cat: .any, + legacyKey: .any, + legacyService: .any + ) + } + .thenThrow(TestError.mock) + + expect { + try extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + }.to(throwError(ExtensionHelperError.noEncryptionKey)) + } + + // MARK: ---- throws encryption errors + it("throws encryption errors") { + mockCrypto + .when { + try $0.tryGenerate( + .ciphertextWithXChaCha20(plaintext: .any, encKey: .any) + ) + } + .thenThrow(TestError.mock) + + expect { + try extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + }.to(throwError(TestError.mock)) + } + + // MARK: ---- throws when it fails to write to disk + it("throws when it fails to write to disk") { + mockFileManager + .when { $0.createFile(atPath: .any, contents: .any) } + .thenReturn(false) + + expect { + try extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + }.to(throwError(ExtensionHelperError.failedToWriteToFile)) + } + + // 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 + .when { try $0.removeItem(atPath: .any) } + .thenThrow(TestError.mock) + + expect { + try extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + }.toNot(throwError(TestError.mock)) + } + + // 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 + .when { try $0.moveItem(atPath: .any, toPath: .any) } + .thenThrow(TestError.mock) + + expect { + try extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + }.to(throwError(TestError.mock)) + } + } + } + + // MARK: - an ExtensionHelper - User Metadata + describe("an ExtensionHelper") { + // MARK: -- when saving user metadata + context("when saving user metadata") { + // MARK: ---- saves the file successfully + it("saves the file successfully") { + expect { + try extensionHelper.saveUserMetadata( + sessionId: SessionId(.standard, hex: TestConstants.publicKey), + ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)), + 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.moveItem(atPath: "tmpFile", toPath: "/test/extensionCache/metadata") + }) + } + + // MARK: ---- throws when failing to write the file + it("throws when failing to write the file") { + mockFileManager + .when { try $0.moveItem(atPath: .any, toPath: .any) } + .thenThrow(TestError.mock) + + expect { + try extensionHelper.saveUserMetadata( + sessionId: SessionId(.standard, hex: TestConstants.publicKey), + ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)), + unreadCount: 1 + ) + }.to(throwError(TestError.mock)) + } + } + + // MARK: -- when loading user metadata + 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 + .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } + .thenReturn( + try! JSONEncoder(using: dependencies) + .encode( + ExtensionHelper.UserMetadata( + sessionId: SessionId(.standard, hex: TestConstants.publicKey), + ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)), + unreadCount: 1 + ) + ) + ) + + let result: ExtensionHelper.UserMetadata? = extensionHelper.loadUserMetadata() + + expect(result).toNot(beNil()) + expect(result?.sessionId).to(equal(SessionId(.standard, hex: TestConstants.publicKey))) + expect(result?.ed25519SecretKey).to(equal(Array(Data(hex: TestConstants.edSecretKey)))) + expect(result?.unreadCount).to(equal(1)) + } + + // MARK: ---- returns null if there is no file + it("returns null if there is no file") { + mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(nil) + + let result: ExtensionHelper.UserMetadata? = extensionHelper.loadUserMetadata() + + expect(result).to(beNil()) + } + + // 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 + .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } + .thenReturn(nil) + + let result: ExtensionHelper.UserMetadata? = extensionHelper.loadUserMetadata() + + expect(result).to(beNil()) + } + + // 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 + .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } + .thenReturn(Data([1, 2, 3])) + + let result: ExtensionHelper.UserMetadata? = extensionHelper.loadUserMetadata() + + expect(result).to(beNil()) + } + } + } + + // MARK: - an ExtensionHelper - Deduping + describe("an ExtensionHelper") { + // MARK: -- when checking whether a single dedupe record exists + context("when checking whether a single dedupe record exists") { + // MARK: ---- returns true when at least one record exists + it("returns true when at least one record exists") { + mockFileManager.when { $0.isDirectoryEmpty(atPath: .any) }.thenReturn(false) + + expect(extensionHelper.hasAtLeastOneDedupeRecord(threadId: "threadId")).to(beTrue()) + } + + // MARK: ---- returns false when a record does not exist + it("returns false when a record does not exist") { + mockFileManager.when { $0.isDirectoryEmpty(atPath: .any) }.thenReturn(true) + + expect(extensionHelper.hasAtLeastOneDedupeRecord(threadId: "threadId")).to(beFalse()) + } + + // MARK: ---- returns false when failing to generate a hash + it("returns false when failing to generate a hash") { + mockFileManager.when { $0.isDirectoryEmpty(atPath: .any) }.thenReturn(false) + mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) + + expect(extensionHelper.hasAtLeastOneDedupeRecord(threadId: "threadId")).to(beFalse()) + } + } + + // MARK: -- when checking for dedupe records + 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) + + expect(extensionHelper.dedupeRecordExists( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + )).to(beTrue()) + } + + // 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) + + expect(extensionHelper.dedupeRecordExists( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + )).to(beFalse()) + } + + // 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) + + expect(extensionHelper.dedupeRecordExists( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + )).to(beFalse()) + } + } + + // MARK: -- when creating dedupe records + context("when creating dedupe records") { + // MARK: ---- writes the file successfully + it("writes the file successfully") { + try? extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + try? $0.moveItem( + atPath: "tmpFile", + toPath: "/test/extensionCache/conversations/010203/dedupe/010203" + ) + }) + } + + // 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) + + expect { + try extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + }.to(throwError(ExtensionHelperError.failedToStoreDedupeRecord)) + } + + // MARK: ---- throws when failing to write the file + it("throws when failing to write the file") { + mockFileManager + .when { try $0.moveItem(atPath: .any, toPath: .any) } + .thenThrow(TestError.mock) + + expect { + try extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + }.to(throwError(TestError.mock)) + } + } + + // MARK: -- when removing dedupe records + context("when removing dedupe records") { + // MARK: ---- removes the file successfully + it("removes the file successfully") { + try? extensionHelper.removeDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + try? $0.removeItem(atPath: "/test/extensionCache/conversations/010203/dedupe/010203") + }) + } + + // MARK: ---- removes the parent directory if it is empty + it("removes the parent directory if it is empty") { + try? extensionHelper.removeDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + try? $0.removeItem(atPath: "/test/extensionCache/conversations/010203/dedupe") + }) + } + + // 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? extensionHelper.removeDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + + expect(mockFileManager).toNot(call(.exactly(times: 1), matchingParameters: .all) { + try? $0.removeItem(atPath: "/test/extensionCache/conversations/010203/dedupe") + }) + } + + // 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) + + expect { + try extensionHelper.removeDedupeRecord( + threadId: "threadId", + 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") + }) + } + + // 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) + + expect { + try extensionHelper.removeDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + }.to(throwError(TestError.mock)) + } + } + } + + // MARK: - an ExtensionHelper - Config Dumps + describe("an ExtensionHelper") { + beforeEach { + Log.setup(with: mockLogger) + } + + // MARK: -- when retrieving the last updated timestamp + context("when retrieving the last updated timestamp") { + // MARK: ---- returns the timestamp + it("returns the timestamp") { + mockFileManager + .when { try $0.attributesOfItem(atPath: .any) } + .thenReturn([.modificationDate: Date(timeIntervalSince1970: 1234567890)]) + + expect(extensionHelper.lastUpdatedTimestamp( + for: SessionId(.standard, hex: TestConstants.publicKey), + variant: .userProfile + )).to(equal(1234567890)) + } + + // 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) + + expect(extensionHelper.lastUpdatedTimestamp( + for: SessionId(.standard, hex: TestConstants.publicKey), + variant: .userProfile + )).to(equal(0)) + } + + // 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) + + expect(extensionHelper.lastUpdatedTimestamp( + for: SessionId(.standard, hex: TestConstants.publicKey), + variant: .userProfile + )).to(equal(0)) + } + } + + // MARK: -- when replicating a config dump + context("when replicating a config dump") { + // MARK: ---- replicates successfully + it("replicates successfully") { + extensionHelper.replicate( + dump: ConfigDump( + variant: .userProfile, + sessionId: "05\(TestConstants.publicKey)", + data: Data([1, 2, 3]), + timestampMs: 1234567890 + ), + 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.moveItem( + atPath: "tmpFile", + toPath: "/test/extensionCache/conversations/010203/dumps/010203" + ) + }) + } + + // 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.moveItem(atPath: .any, toPath: .any) }) + } + + // 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) + + extensionHelper.replicate( + dump: ConfigDump( + variant: .userProfile, + sessionId: "05\(TestConstants.publicKey)", + data: Data([1, 2, 3]), + timestampMs: 1234567890 + ), + replaceExisting: true + ) + + expect(mockFileManager).toNot(call { $0.createFile(atPath: .any, contents: .any) }) + expect(mockFileManager).toNot(call { try $0.moveItem(atPath: .any, toPath: .any) }) + } + + // 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) + + extensionHelper.replicate( + dump: ConfigDump( + variant: .userProfile, + sessionId: "05\(TestConstants.publicKey)", + data: Data([1, 2, 3]), + timestampMs: 1234567890 + ), + replaceExisting: false + ) + + expect(mockFileManager).toNot(call { $0.createFile(atPath: .any, contents: .any) }) + expect(mockFileManager).toNot(call { try $0.moveItem(atPath: .any, toPath: .any) }) + } + + // MARK: ---- logs an error when failing to write the file + it("logs an error when failing to write the file") { + mockFileManager + .when { try $0.moveItem(atPath: .any, toPath: .any) } + .thenThrow(TestError.mock) + + extensionHelper.replicate( + dump: ConfigDump( + variant: .userProfile, + sessionId: "05\(TestConstants.publicKey)", + data: Data([1, 2, 3]), + timestampMs: 1234567890 + ), + replaceExisting: true + ) + + expect(mockLogger.logs).to(equal([ + MockLogger.LogOutput( + level: .error, + categories: [ + Log.Category.create( + "ExtensionHelper", + customPrefix: "", + customSuffix: "", + defaultLevel: .info + ) + ], + message: "Failed to replicate userProfile dump for 05\(TestConstants.publicKey).", + file: "SessionMessagingKit/ExtensionHelper.swift", + function: "replicate(dump:replaceExisting:)" + ) + ])) + } + } + + // MARK: -- when replicating all config dumps + context("when replicating all config dumps") { + @TestState var mockValues: [String: [UInt8]]! = [ + "ConvoIdSalt-05\(TestConstants.publicKey)": [1, 2, 3], + "DumpSalt-\(ConfigDump.Variant.userProfile)": [2, 3, 4], + "ConvoIdSalt-03\(TestConstants.publicKey)": [4, 5, 6], + "DumpSalt-\(ConfigDump.Variant.groupInfo)": [5, 6, 7] + ] + + beforeEach { + mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(nil) + mockFileManager + .when { try $0.attributesOfItem(atPath: .any) } + .thenReturn([.modificationDate: Date(timeIntervalSince1970: 1234567800)]) + mockValues.forEach { key, value in + mockCrypto + .when { $0.generate(.hash(message: Array(key.data(using: .utf8)!))) } + .thenReturn(value) + } + mockCrypto + .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } + .thenReturn(Data([1, 2, 3])) + mockCrypto + .when { $0.generate(.ciphertextWithXChaCha20(plaintext: Data([2, 3, 4]), encKey: .any)) } + .thenReturn(Data([2, 3, 4])) + mockCrypto + .when { $0.generate(.ciphertextWithXChaCha20(plaintext: Data([5, 6, 7]), encKey: .any)) } + .thenReturn(Data([5, 6, 7])) + + mockStorage.write { db in + try ConfigDump( + variant: .userProfile, + sessionId: "05\(TestConstants.publicKey)", + data: Data([2, 3, 4]), + timestampMs: 1234567890 + ).insert(db) + try ConfigDump( + variant: .groupInfo, + sessionId: "03\(TestConstants.publicKey)", + data: Data([5, 6, 7]), + timestampMs: 1234567890 + ).insert(db) + } + } + + // MARK: ---- replicates successfully + it("replicates successfully") { + extensionHelper.replicateAllConfigDumpsIfNeeded( + userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)") + ) + await expect(mockFileManager).toEventually(call(.exactly(times: 1), matchingParameters: .all) { + $0.createFile(atPath: "tmpFile", contents: Data(base64Encoded: "AgME")) + }) + await expect(mockFileManager).toEventually(call(.exactly(times: 1), matchingParameters: .all) { + try $0.moveItem( + atPath: "tmpFile", + toPath: "/test/extensionCache/conversations/010203/dumps/020304" + ) + }) + await expect(mockFileManager).toEventually(call(.exactly(times: 1), matchingParameters: .all) { + $0.createFile(atPath: "tmpFile", contents: Data(base64Encoded: "BQYH")) + }) + await expect(mockFileManager).toEventually(call(.exactly(times: 1), matchingParameters: .all) { + try $0.moveItem( + atPath: "tmpFile", + toPath: "/test/extensionCache/conversations/040506/dumps/050607" + ) + }) + } + + // MARK: ---- replicates if the existing user profile dump cannot be decrypted + it("replicates if the existing user profile dump cannot be decrypted") { + mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3])) + mockCrypto + .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } + .thenReturn(nil) + + extensionHelper.replicateAllConfigDumpsIfNeeded( + userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)") + ) + + await expect(mockFileManager).toEventually(call(.exactly(times: 1), matchingParameters: .all) { + $0.createFile(atPath: "tmpFile", contents: Data(base64Encoded: "AgME")) + }) + await expect(mockFileManager).toEventually(call(.exactly(times: 1), matchingParameters: .all) { + try $0.moveItem( + atPath: "tmpFile", + toPath: "/test/extensionCache/conversations/010203/dumps/020304" + ) + }) + await expect(mockFileManager).toEventually(call(.exactly(times: 1), matchingParameters: .all) { + $0.createFile(atPath: "tmpFile", contents: Data(base64Encoded: "BQYH")) + }) + await expect(mockFileManager).toEventually(call(.exactly(times: 1), matchingParameters: .all) { + try $0.moveItem( + atPath: "tmpFile", + toPath: "/test/extensionCache/conversations/040506/dumps/050607" + ) + }) + } + + // MARK: ---- does nothing when failing to generate a hash + it("does nothing when failing to generate a hash") { + mockValues.forEach { key, value in + mockCrypto + .when { $0.generate(.hash(message: Array(key.data(using: .utf8)!))) } + .thenReturn(nil) + } + + extensionHelper.replicateAllConfigDumpsIfNeeded( + userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)") + ) + + expect(mockFileManager).toNot(call { $0.createFile(atPath: .any, contents: .any) }) + expect(mockFileManager).toNot(call { try $0.moveItem(atPath: .any, toPath: .any) }) + } + + // MARK: ---- does nothing if a valid user profile dump already exists + it("does nothing if a valid user profile dump already exists") { + mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3])) + mockCrypto + .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } + .thenReturn(Data([1, 2, 3])) + + extensionHelper.replicateAllConfigDumpsIfNeeded( + userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)") + ) + + expect(mockFileManager).toNot(call { $0.createFile(atPath: .any, contents: .any) }) + expect(mockFileManager).toNot(call { try $0.moveItem(atPath: .any, toPath: .any) }) + } + + // 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( + userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)") + ) + + expect(mockFileManager).toNot(call { $0.createFile(atPath: .any, contents: .any) }) + expect(mockFileManager).toNot(call { try $0.moveItem(atPath: .any, toPath: .any) }) + } + + // 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 + .when { try $0.attributesOfItem(atPath: .any) } + .thenReturn([.modificationDate: Date(timeIntervalSince1970: 1234567891)]) + + extensionHelper.replicateAllConfigDumpsIfNeeded( + userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)") + ) + + expect(mockFileManager).toNot(call { $0.createFile(atPath: .any, contents: .any) }) + expect(mockFileManager).toNot(call { try $0.moveItem(atPath: .any, toPath: .any) }) + } + + // 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) + + extensionHelper.replicateAllConfigDumpsIfNeeded( + userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)") + ) + + expect(mockFileManager).toNot(call { $0.createFile(atPath: .any, contents: .any) }) + expect(mockFileManager).toNot(call { try $0.moveItem(atPath: .any, toPath: .any) }) + } + } + + // MARK: -- when refreshing the dump modified date + context("when refreshing the dump modified date") { + beforeEach { + mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) + } + + // MARK: ---- updates the modified date + it("updates the modified date") { + extensionHelper.refreshDumpModifiedDate( + sessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"), + 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" + ) + }) + } + + // 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) + + extensionHelper.refreshDumpModifiedDate( + sessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"), + variant: .userProfile + ) + + expect(mockFileManager).toNot(call { try $0.setAttributes(.any, ofItemAtPath: .any) }) + } + + // 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) + + extensionHelper.refreshDumpModifiedDate( + sessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"), + variant: .userProfile + ) + + expect(mockFileManager).toNot(call { try $0.setAttributes(.any, ofItemAtPath: .any) }) + } + } + + // MARK: -- when loading user configs + context("when loading user configs") { + beforeEach { + mockCrypto + .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } + .thenReturn(Data([1, 2, 3])) + } + + // MARK: ---- sets the configs for each of the user variants + it("sets the configs for each of the user variants") { + let ptr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: 1) + let configs: [LibSession.Config] = [ + .userProfile(ptr), .userGroups(ptr), .contacts(ptr), .convoInfoVolatile(ptr) + ] + configs.forEach { config in + mockLibSessionCache + .when { + try $0.loadState( + for: config.variant, + sessionId: .any, + userEd25519SecretKey: .any, + groupEd25519SecretKey: .any, + cachedData: .any + ) + } + .thenReturn(config) + } + + extensionHelper.loadUserConfigState( + into: mockLibSessionCache, + 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) + ) + }) + + 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 + .when { + try $0.loadState( + for: .any, + sessionId: .any, + userEd25519SecretKey: .any, + groupEd25519SecretKey: .any, + cachedData: .any + ) + } + .thenReturn(nil) + + extensionHelper.loadUserConfigState( + into: mockLibSessionCache, + 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 + ) + }) + } + } + + // MARK: -- when loading group configs + context("when loading group configs") { + beforeEach { + mockCrypto + .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } + .thenReturn(Data([1, 2, 3])) + } + + // MARK: ---- sets the configs for each of the group variants + it("sets the configs for each of the group variants") { + let ptr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: 1) + let keysPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: 1) + let configs: [LibSession.Config] = [ + .groupKeys(keysPtr, info: ptr, members: ptr), .groupMembers(ptr), .groupInfo(ptr) + ] + configs.forEach { config in + mockLibSessionCache + .when { + try $0.loadState( + for: config.variant, + sessionId: .any, + userEd25519SecretKey: .any, + groupEd25519SecretKey: .any, + cachedData: .any + ) + } + .thenReturn(config) + } + + expect { + try extensionHelper.loadGroupConfigStateIfNeeded( + into: mockLibSessionCache, + swarmPublicKey: "03\(TestConstants.publicKey)", + 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) + ) + }) + + keysPtr.deallocate() + ptr.deallocate() + } + + // MARK: ---- returns correct config load results + it("returns correct config load results") { + let ptr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: 1) + let keysPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: 1) + let configs: [LibSession.Config] = [ + .groupKeys(keysPtr, info: ptr, members: ptr), .groupInfo(ptr) + ] + + mockCrypto.removeMocksFor { $0.generate(.hash(message: .any)) } + configs.forEach { config in + mockLibSessionCache + .when { + try $0.loadState( + for: config.variant, + sessionId: .any, + userEd25519SecretKey: .any, + groupEd25519SecretKey: .any, + cachedData: .any + ) + } + .thenReturn(config) + mockCrypto + .when { $0.generate(.hash(message: Array("DumpSalt-\(config.variant)".utf8))) } + .thenReturn([0, 1, 2]) + } + mockCrypto + .when { $0.generate(.hash(message: Array("ConvoIdSalt-03\(TestConstants.publicKey)".utf8))) } + .thenReturn([4, 5, 6]) + mockCrypto + .when { + $0.generate(.hash(message: Array("DumpSalt-\(ConfigDump.Variant.groupMembers)".utf8))) + } + .thenReturn(nil) + + var result: [ConfigDump.Variant: Bool] = [:] + expect { + result = try extensionHelper.loadGroupConfigStateIfNeeded( + into: mockLibSessionCache, + swarmPublicKey: "03\(TestConstants.publicKey)", + userEd25519SecretKey: [1, 2, 3] + ) + }.toNot(throwError()) + expect(result).to(equal([ + ConfigDump.Variant.groupKeys: true, + ConfigDump.Variant.groupMembers: false, + ConfigDump.Variant.groupInfo: true + ])) + + keysPtr.deallocate() + ptr.deallocate() + } + + // 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) + + expect { + try extensionHelper.loadGroupConfigStateIfNeeded( + into: mockLibSessionCache, + swarmPublicKey: "03\(TestConstants.publicKey)", + userEd25519SecretKey: [1, 2, 3] + ) + }.toNot(throwError()) + expect(mockLibSessionCache).toNot(call { + $0.setConfig(for: .any, sessionId: .any, to: .any) + }) + } + + // MARK: ---- does nothing if the provided public key is not for a group + it("does nothing if the provided public key is not for a group") { + expect { + try extensionHelper.loadGroupConfigStateIfNeeded( + into: mockLibSessionCache, + swarmPublicKey: "05\(TestConstants.publicKey)", + userEd25519SecretKey: [1, 2, 3] + ) + }.toNot(throwError()) + expect(mockLibSessionCache).toNot(call { + $0.setConfig(for: .any, sessionId: .any, to: .any) + }) + } + } + } + + // MARK: - an ExtensionHelper - Notification Settings + describe("an ExtensionHelper") { + struct NotificationSettings: Codable { + let threadId: String + let mentionsOnly: Bool + let mutedUntil: TimeInterval? + } + + // MARK: -- when replicating notification settings + context("when replicating notification settings") { + beforeEach { + mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(nil) + mockFileManager + .when { try $0.attributesOfItem(atPath: .any) } + .thenReturn([.modificationDate: Date(timeIntervalSince1970: 1234567800)]) + mockCrypto + .when { $0.generate(.hash(message: .any)) } + .thenReturn([0, 1, 2]) + mockCrypto + .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } + .thenReturn(Data([1, 2, 3])) + } + + // MARK: ---- replicates successfully + it("replicates successfully") { + try? extensionHelper.replicate( + settings: [ + "Test1": Preferences.NotificationSettings( + previewType: .nameAndPreview, + sound: .note, + mentionsOnly: false, + mutedUntil: nil + ) + ], + 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.moveItem( + atPath: "tmpFile", + toPath: "/test/extensionCache/notificationSettings" + ) + }) + } + + // MARK: ---- excludes values with default settings + it("excludes values with default settings") { + let expectedResult: [NotificationSettings] = [ + NotificationSettings( + threadId: "Test2", + mentionsOnly: false, + mutedUntil: 1234 + ) + ] + + try? extensionHelper.replicate( + settings: [ + "Test1": Preferences.NotificationSettings( + previewType: .nameAndPreview, + sound: .note, + mentionsOnly: false, + mutedUntil: nil + ), + "Test2": Preferences.NotificationSettings( + previewType: .nameAndPreview, + sound: .note, + mentionsOnly: false, + mutedUntil: 1234 + ) + ], + 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] + ) + ) + }) + } + + // 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? extensionHelper.replicate( + settings: [ + "Test1": Preferences.NotificationSettings( + previewType: .nameAndPreview, + sound: .note, + mentionsOnly: false, + mutedUntil: nil + ) + ], + replaceExisting: false + ) + + expect(mockFileManager).toNot(call { $0.createFile(atPath: .any, contents: .any) }) + expect(mockFileManager).toNot(call { try $0.moveItem(atPath: .any, toPath: .any) }) + } + + // 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) + + try? extensionHelper.replicate( + settings: [ + "Test1": Preferences.NotificationSettings( + previewType: .nameAndPreview, + sound: .note, + mentionsOnly: false, + mutedUntil: nil + ) + ], + replaceExisting: true + ) + + expect(mockFileManager).toNot(call { $0.createFile(atPath: .any, contents: .any) }) + expect(mockFileManager).toNot(call { try $0.moveItem(atPath: .any, toPath: .any) }) + } + } + + // MARK: -- when loading notification settings + 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 + .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } + .thenReturn( + try! JSONEncoder(using: dependencies) + .encode( + [ + NotificationSettings( + threadId: "Test1", + mentionsOnly: false, + mutedUntil: nil + ), + NotificationSettings( + threadId: "Test2", + mentionsOnly: true, + mutedUntil: 12345 + ) + ] + ) + ) + + let result: [String: Preferences.NotificationSettings]? = extensionHelper.loadNotificationSettings( + previewType: .nameAndPreview, + sound: .note + ) + + try require(result).toNot(beNil()) + try require(result?["Test1"]).toNot(beNil()) + try require(result?["Test2"]).toNot(beNil()) + expect(result?["Test1"]?.previewType).to(equal(.nameAndPreview)) + expect(result?["Test1"]?.sound).to(equal(.note)) + expect(result?["Test1"]?.mentionsOnly).to(beFalse()) + expect(result?["Test1"]?.mutedUntil).to(beNil()) + expect(result?["Test2"]?.previewType).to(equal(.nameAndPreview)) + expect(result?["Test2"]?.sound).to(equal(.note)) + expect(result?["Test2"]?.mentionsOnly).to(beTrue()) + expect(result?["Test2"]?.mutedUntil).to(equal(12345)) + } + + // MARK: ---- returns null if there is no file + it("returns null if there is no file") { + mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(nil) + + let result: [String: Preferences.NotificationSettings]? = extensionHelper.loadNotificationSettings( + previewType: .nameAndPreview, + sound: .note + ) + + expect(result).to(beNil()) + } + + // 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 + .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } + .thenReturn(nil) + + let result: [String: Preferences.NotificationSettings]? = extensionHelper.loadNotificationSettings( + previewType: .nameAndPreview, + sound: .note + ) + + expect(result).to(beNil()) + } + + // 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 + .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } + .thenReturn(Data([1, 2, 3])) + + let result: [String: Preferences.NotificationSettings]? = extensionHelper.loadNotificationSettings( + previewType: .nameAndPreview, + sound: .note + ) + + expect(result).to(beNil()) + } + } + } + + // MARK: - an ExtensionHelper - Messages + describe("an ExtensionHelper") { + beforeEach { + Log.setup(with: mockLogger) + } + + // MARK: -- when retrieving the unread message count + context("when retrieving the unread message count") { + // MARK: ---- returns the count + it("returns the count") { + mockFileManager + .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } + .thenReturn(["a"]) + mockFileManager + .when { + try $0.contentsOfDirectory( + atPath: "/test/extensionCache/conversations/a/unread" + ) + } + .thenReturn(["b", "c", "d", "e", "f"]) + mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) + + expect(extensionHelper.unreadMessageCount()).to(equal(5)) + } + + // MARK: ---- adds the total from multiple conversations + it("adds the total from multiple conversations") { + mockFileManager + .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } + .thenReturn(["a", "b"]) + mockFileManager + .when { + try $0.contentsOfDirectory( + atPath: "/test/extensionCache/conversations/a/unread" + ) + } + .thenReturn(["c", "d", "e"]) + mockFileManager + .when { + try $0.contentsOfDirectory( + atPath: "/test/extensionCache/conversations/b/unread" + ) + } + .thenReturn(["f", "g", "h"]) + mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) + + expect(extensionHelper.unreadMessageCount()).to(equal(6)) + } + + // MARK: ---- ignores hidden files in the conversations directory + it("ignores hidden files in the conversations directory") { + mockFileManager + .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } + .thenReturn([".test", "a"]) + mockFileManager + .when { + try $0.contentsOfDirectory( + atPath: "/test/extensionCache/conversations/a/unread" + ) + } + .thenReturn(["b", "c", "d", "e", "f"]) + mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) + + expect(extensionHelper.unreadMessageCount()).to(equal(5)) + } + + // MARK: ---- ignores hidden files in the unread directory + it("ignores hidden files in the unread directory") { + mockFileManager + .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } + .thenReturn(["a"]) + mockFileManager + .when { + try $0.contentsOfDirectory( + atPath: "/test/extensionCache/conversations/a/unread" + ) + } + .thenReturn([".test", "b", "c", "d", "e", "f"]) + mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) + + expect(extensionHelper.unreadMessageCount()).to(equal(5)) + } + + // MARK: ---- ignores conversations without an unread directory + it("ignores conversations without an unread directory") { + mockFileManager + .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } + .thenReturn(["a", "b"]) + mockFileManager + .when { + try $0.contentsOfDirectory( + atPath: "/test/extensionCache/conversations/a/unread" + ) + } + .thenReturn(["c", "d", "e"]) + mockFileManager + .when { + try $0.contentsOfDirectory( + atPath: "/test/extensionCache/conversations/b/unread" + ) + } + .thenReturn(["f", "g", "h"]) + mockFileManager + .when { $0.fileExists(atPath: "/test/extensionCache/conversations/a/unread") } + .thenReturn(true) + mockFileManager + .when { $0.fileExists(atPath: "/test/extensionCache/conversations/b/unread") } + .thenReturn(false) + + expect(extensionHelper.unreadMessageCount()).to(equal(3)) + } + + // MARK: ---- returns null if retrieving the conversation hashes throws + it("returns null if retrieving the conversation hashes throws") { + mockFileManager + .when { try $0.contentsOfDirectory(atPath: .any) } + .thenThrow(TestError.mock) + + expect(extensionHelper.unreadMessageCount()).to(beNil()) + } + + // 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 + .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } + .thenReturn(["a"]) + mockFileManager + .when { + try $0.contentsOfDirectory( + atPath: "/test/extensionCache/conversations/a/unread" + ) + } + .thenThrow(TestError.mock) + + expect(extensionHelper.unreadMessageCount()).to(beNil()) + } + } + + // MARK: -- when saving a message + context("when saving a message") { + // MARK: ---- saves the message correctly + it("saves the message correctly") { + expect { + try extensionHelper.saveMessage( + SnodeReceivedMessage( + snode: nil, + publicKey: "05\(TestConstants.publicKey)", + namespace: .default, + rawMessage: GetMessagesResponse.RawMessage( + base64EncodedDataString: "TestData", + expirationMs: nil, + hash: "TestHash", + timestampMs: 1234567890 + ) + ), + isUnread: false + ) + }.toNot(throwError()) + } + + // MARK: ---- saves config messages to the correct path + it("saves config messages to the correct path") { + expect { + try extensionHelper.saveMessage( + SnodeReceivedMessage( + snode: nil, + publicKey: "05\(TestConstants.publicKey)", + namespace: .configUserProfile, + rawMessage: GetMessagesResponse.RawMessage( + base64EncodedDataString: "TestData", + expirationMs: nil, + hash: "TestHash", + timestampMs: 1234567890 + ) + ), + isUnread: false + ) + }.toNot(throwError()) + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.moveItem( + atPath: "tmpFile", + toPath: "/test/extensionCache/conversations/010203/config/010203" + ) + }) + } + + // MARK: ---- saves unread standard messages to the correct path + it("saves unread standard messages to the correct path") { + expect { + try extensionHelper.saveMessage( + SnodeReceivedMessage( + snode: nil, + publicKey: "05\(TestConstants.publicKey)", + namespace: .default, + rawMessage: GetMessagesResponse.RawMessage( + base64EncodedDataString: "TestData", + expirationMs: nil, + hash: "TestHash", + timestampMs: 1234567890 + ) + ), + isUnread: true + ) + }.toNot(throwError()) + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.moveItem( + atPath: "tmpFile", + toPath: "/test/extensionCache/conversations/010203/unread/010203" + ) + }) + } + + // MARK: ---- saves read standard messages to the correct path + it("saves read standard messages to the correct path") { + expect { + try extensionHelper.saveMessage( + SnodeReceivedMessage( + snode: nil, + publicKey: "05\(TestConstants.publicKey)", + namespace: .default, + rawMessage: GetMessagesResponse.RawMessage( + base64EncodedDataString: "TestData", + expirationMs: nil, + hash: "TestHash", + timestampMs: 1234567890 + ) + ), + isUnread: false + ) + }.toNot(throwError()) + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.moveItem( + atPath: "tmpFile", + toPath: "/test/extensionCache/conversations/010203/read/010203" + ) + }) + } + + // 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) + + expect { + try extensionHelper.saveMessage( + SnodeReceivedMessage( + snode: nil, + publicKey: "05\(TestConstants.publicKey)", + namespace: .default, + rawMessage: GetMessagesResponse.RawMessage( + base64EncodedDataString: "TestData", + expirationMs: nil, + hash: "TestHash", + timestampMs: 1234567890 + ) + ), + isUnread: false + ) + }.toNot(throwError()) + expect(mockFileManager).toNot(call { + try $0.moveItem(atPath: .any, toPath: .any) + }) + } + + // MARK: ---- throws when failing to write the file + it("throws when failing to write the file") { + mockFileManager + .when { try $0.moveItem(atPath: .any, toPath: .any) } + .thenThrow(TestError.mock) + + expect { + try extensionHelper.saveMessage( + SnodeReceivedMessage( + snode: nil, + publicKey: "05\(TestConstants.publicKey)", + namespace: .default, + rawMessage: GetMessagesResponse.RawMessage( + base64EncodedDataString: "TestData", + expirationMs: nil, + hash: "TestHash", + timestampMs: 1234567890 + ) + ), + isUnread: false + ) + }.to(throwError(TestError.mock)) + } + } + + // MARK: -- when waiting for messages to be loaded + 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()) + } + + // 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()) + + extensionHelper.willLoadMessages() + await expect { + await extensionHelper.waitUntilMessagesAreLoaded(timeout: .milliseconds(50)) + }.to(beFalse()) + } + } + + // MARK: -- when loading messages + context("when loading messages") { + @TestState var mockValues: [String: String]! = [ + "/test/extensionCache/conversations": "a", + "/test/extensionCache/conversations/a/config": "b", + "/test/extensionCache/conversations/a/read": "c", + "/test/extensionCache/conversations/a/unread": "d", + "/test/extensionCache/conversations/010203/config": "d", + "/test/extensionCache/conversations/010203/read": "e", + "/test/extensionCache/conversations/010203/unread": "f", + "/test/extensionCache/conversations/0000550000/config": "g", + "/test/extensionCache/conversations/0000550000/read": "h", + "/test/extensionCache/conversations/0000550000/unread": "i" + ] + + beforeEach { + mockValues.forEach { key, value in + mockFileManager + .when { try $0.contentsOfDirectory(atPath: key) } + .thenReturn([value]) + } + 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 + .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } + .thenReturn( + try! JSONEncoder(using: dependencies) + .encode( + SnodeReceivedMessage( + snode: nil, + publicKey: "05\(TestConstants.publicKey)", + namespace: .default, + rawMessage: GetMessagesResponse.RawMessage( + base64EncodedDataString: try! MessageWrapper.wrap( + type: .sessionMessage, + timestampMs: 1234567890, + content: Data([1, 2, 3]) + ).base64EncodedString(), + expirationMs: nil, + hash: "TestHash", + timestampMs: 1234567890 + ) + ) + ) + ) + + let content = SNProtoContent.builder() + let dataMessage = SNProtoDataMessage.builder() + dataMessage.setBody("Test") + content.setDataMessage(try! dataMessage.build()) + 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()) + + let interactions: [Interaction]? = mockStorage.read { try Interaction.fetchAll($0) } + expect(interactions?.count).to(equal(1)) + expect(interactions?.map { $0.body }).to(equal(["Test"])) + } + + // MARK: ---- always tries to load messages from the current users conversation + it("always tries to load messages from the current users conversation") { + mockCrypto + .when { + $0.generate(.hash( + message: Array("ConvoIdSalt-05\(TestConstants.publicKey)".data(using: .utf8)!) + )) + } + .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" + ) + }) + } + + // 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]" + ) + ] + ) + ])) + } + + // MARK: ---- removes messages from disk + it("removes messages from disk") { + mockFileManager.when { try $0.contentsOfDirectory(atPath: .any) }.thenReturn([]) + mockCrypto + .when { + $0.generate(.hash( + message: Array("ConvoIdSalt-05\(TestConstants.publicKey)".data(using: .utf8)!) + )) + } + .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" + ) + }) + } + + // MARK: ---- logs when finished + it("logs when finished") { + mockLogger.logs = [] // Clear logs first to make it easier to debug + mockValues.forEach { key, value in + mockFileManager + .when { try $0.contentsOfDirectory(atPath: key) } + .thenReturn([]) + } + mockFileManager + .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } + .thenReturn(["a"]) + mockFileManager + .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations/a/read") } + .thenReturn(["c"]) + + await expect { try await extensionHelper.loadMessages() }.toNot(throwError()) + expect(mockLogger.logs).to(contain( + MockLogger.LogOutput( + level: .info, + categories: [ + Log.Category.create( + "ExtensionHelper", + customPrefix: "", + customSuffix: "", + defaultLevel: .info + ) + ], + message: "Finished: Successfully processed 1/1 standard messages, 0/0 config messages.", + file: "SessionMessagingKit/ExtensionHelper.swift", + function: "loadMessages()" + ) + )) + } + + // MARK: ---- logs an error when failing to process a config message + it("logs an error when failing to process a config message") { + mockLogger.logs = [] // Clear logs first to make it easier to debug + mockValues.forEach { key, value in + mockFileManager + .when { try $0.contentsOfDirectory(atPath: key) } + .thenReturn([]) + } + mockFileManager + .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } + .thenReturn(["a"]) + mockFileManager + .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations/a/config") } + .thenReturn(["b"]) + mockCrypto + .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } + .thenReturn(nil) + + await expect { try await extensionHelper.loadMessages() }.toNot(throwError()) + expect(mockLogger.logs).to(contain( + MockLogger.LogOutput( + level: .error, + categories: [ + Log.Category.create( + "ExtensionHelper", + customPrefix: "", + customSuffix: "", + defaultLevel: .info + ) + ], + message: "Discarding some config message changes due to error: Failed to read from file.", + file: "SessionMessagingKit/ExtensionHelper.swift", + function: "loadMessages()" + ) + )) + expect(mockLogger.logs).to(contain( + MockLogger.LogOutput( + level: .info, + categories: [ + Log.Category.create( + "ExtensionHelper", + customPrefix: "", + customSuffix: "", + defaultLevel: .info + ) + ], + message: "Finished: Successfully processed 0/0 standard messages, 0/1 config messages.", + file: "SessionMessagingKit/ExtensionHelper.swift", + function: "loadMessages()" + ) + )) + } + + // MARK: ---- logs an error when failing to process a standard message + it("logs an error when failing to process a standard message") { + mockLogger.logs = [] // Clear logs first to make it easier to debug + mockValues.forEach { key, value in + mockFileManager + .when { try $0.contentsOfDirectory(atPath: key) } + .thenReturn([]) + } + mockFileManager + .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } + .thenReturn(["a"]) + mockFileManager + .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations/a/read") } + .thenReturn(["c"]) + mockCrypto + .when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) } + .thenReturn(nil) + + await expect { try await extensionHelper.loadMessages() }.toNot(throwError()) + expect(mockLogger.logs).to(contain( + MockLogger.LogOutput( + level: .error, + categories: [ + Log.Category.create( + "ExtensionHelper", + customPrefix: "", + customSuffix: "", + defaultLevel: .info + ) + ], + message: "Discarding standard message due to error: Failed to read from file.", + file: "SessionMessagingKit/ExtensionHelper.swift", + function: "loadMessages()" + ) + )) + expect(mockLogger.logs).to(contain( + MockLogger.LogOutput( + level: .info, + categories: [ + Log.Category.create( + "ExtensionHelper", + customPrefix: "", + customSuffix: "", + defaultLevel: .info + ) + ], + message: "Finished: Successfully processed 0/1 standard messages, 0/0 config messages.", + file: "SessionMessagingKit/ExtensionHelper.swift", + function: "loadMessages()" + ) + )) + } + + // MARK: ---- succeeds even if it fails to remove files after processing + it("succeeds even if it fails to remove files after processing") { + mockLogger.logs = [] // Clear logs first to make it easier to debug + mockValues.forEach { key, value in + mockFileManager + .when { try $0.contentsOfDirectory(atPath: key) } + .thenReturn([]) + } + mockFileManager + .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") } + .thenReturn(["a"]) + mockFileManager + .when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations/a/read") } + .thenReturn(["c"]) + mockFileManager.when { try $0.removeItem(atPath: .any) }.thenThrow(TestError.mock) + mockCrypto + .when { + $0.generate(.hash( + message: Array("ConvoIdSalt-05\(TestConstants.publicKey)".data(using: .utf8)!) + )) + } + .thenReturn(Array(Data(hex: "0000550000"))) + + await expect { try await extensionHelper.loadMessages() }.toNot(throwError()) + expect(mockLogger.logs).to(contain( + MockLogger.LogOutput( + level: .info, + categories: [ + Log.Category.create( + "ExtensionHelper", + customPrefix: "", + customSuffix: "", + defaultLevel: .info + ) + ], + message: "Finished: Successfully processed 1/1 standard messages, 0/0 config messages.", + file: "SessionMessagingKit/ExtensionHelper.swift", + function: "loadMessages()" + ) + )) + } + } + } + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/CommonSMKMockExtensions.swift b/SessionMessagingKitTests/_TestUtilities/CommonSMKMockExtensions.swift index bb628afacd..9fbd47688d 100644 --- a/SessionMessagingKitTests/_TestUtilities/CommonSMKMockExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/CommonSMKMockExtensions.swift @@ -26,6 +26,19 @@ 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, @@ -38,8 +51,7 @@ extension SessionThread: Mocked { mutedUntilTimestamp: nil, onlyNotifyForMentions: false, markedAsUnread: nil, - pinnedPriority: nil, - using: .any + pinnedPriority: nil ) } @@ -47,6 +59,10 @@ 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, @@ -54,7 +70,7 @@ extension Interaction: Mocked { messageUuid: nil, threadId: .mock, authorId: .mock, - variant: .standardIncoming, + variant: .mock, body: .mock, timestampMs: 1234567890, receivedAtTimestampMs: 1234567890, @@ -69,11 +85,81 @@ extension Interaction: Mocked { openGroupWhisperTo: nil, state: .sent, recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - transientDependencies: nil + mostRecentFailureText: nil + ) +} + +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(Data([1, 2, 3])) + static var mock: ImageDataManager.DataSource = ImageDataManager.DataSource.data("Id", 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/MockDisplayPictureCache.swift b/SessionMessagingKitTests/_TestUtilities/MockDisplayPictureCache.swift index 724af09f70..6e51eb570f 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockDisplayPictureCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockDisplayPictureCache.swift @@ -7,7 +7,7 @@ import SessionUtilitiesKit @testable import SessionMessagingKit class MockDisplayPictureCache: Mock, DisplayPictureCacheType { - var downloadsToSchedule: Set { + var downloadsToSchedule: Set { get { return mock() } set { mockNoReturn(args: [newValue]) } } diff --git a/SessionMessagingKitTests/_TestUtilities/MockExtensionHelper.swift b/SessionMessagingKitTests/_TestUtilities/MockExtensionHelper.swift new file mode 100644 index 0000000000..673b6c3232 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockExtensionHelper.swift @@ -0,0 +1,114 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionSnodeKit +import SessionUtilitiesKit + +@testable import SessionMessagingKit + +class MockExtensionHelper: Mock, ExtensionHelperType { + func deleteCache() { + mockNoReturn() + } + + // MARK: - User Metadata + + func saveUserMetadata( + sessionId: SessionId, + ed25519SecretKey: [UInt8], + unreadCount: Int? + ) throws { + try mockThrowingNoReturn(args: [sessionId, ed25519SecretKey, unreadCount]) + } + + func loadUserMetadata() -> ExtensionHelper.UserMetadata? { + return mock() + } + + // MARK: - Deduping + + func hasAtLeastOneDedupeRecord(threadId: String) -> Bool { + return mock(args: [threadId]) + } + + func dedupeRecordExists(threadId: String, uniqueIdentifier: String) -> Bool { + return mock(args: [threadId, uniqueIdentifier]) + } + + func createDedupeRecord(threadId: String, uniqueIdentifier: String) throws { + return try mockThrowing(args: [threadId, uniqueIdentifier]) + } + + func removeDedupeRecord(threadId: String, uniqueIdentifier: String) throws { + return try mockThrowing(args: [threadId, uniqueIdentifier]) + } + + // MARK: - Config Dumps + + func lastUpdatedTimestamp(for sessionId: SessionId, variant: ConfigDump.Variant) -> TimeInterval { + return mock(args: [sessionId, variant]) + } + + func replicate(dump: ConfigDump?, replaceExisting: Bool) { + mockNoReturn(args: [dump, replaceExisting]) + } + + func replicateAllConfigDumpsIfNeeded(userSessionId: SessionId) { + mockNoReturn(args: [userSessionId]) + } + + func refreshDumpModifiedDate(sessionId: SessionId, variant: ConfigDump.Variant) { + mockNoReturn(args: [sessionId, variant]) + } + + func loadUserConfigState( + into cache: LibSessionCacheType, + userSessionId: SessionId, + userEd25519SecretKey: [UInt8] + ) { + mockNoReturn(args: [cache, userSessionId, userEd25519SecretKey]) + } + + func loadGroupConfigStateIfNeeded( + into cache: LibSessionCacheType, + swarmPublicKey: String, + userEd25519SecretKey: [UInt8] + ) throws -> [ConfigDump.Variant: Bool] { + return mock(args: [cache, swarmPublicKey, userEd25519SecretKey]) + } + + // MARK: - Notification Settings + + func replicate(settings: [String: Preferences.NotificationSettings], replaceExisting: Bool) throws { + try mockThrowingNoReturn(args: [settings, replaceExisting]) + } + + func loadNotificationSettings( + previewType: Preferences.NotificationPreviewType, + sound: Preferences.Sound + ) -> [String: Preferences.NotificationSettings]? { + return mock(args: [previewType, sound]) + } + + // MARK: - Messages + + func unreadMessageCount() -> Int? { + return mock() + } + + func saveMessage(_ message: SnodeReceivedMessage?, isUnread: Bool) throws { + try mockThrowingNoReturn(args: [message, isUnread]) + } + + func willLoadMessages() { + mockNoReturn() + } + + func loadMessages() async throws { + try mockThrowingNoReturn() + } + + @discardableResult func waitUntilMessagesAreLoaded(timeout: DispatchTimeInterval) async -> Bool { + return mock(args: [timeout]) + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockImageDataManager.swift b/SessionMessagingKitTests/_TestUtilities/MockImageDataManager.swift index 66f4bfb808..d409bede10 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockImageDataManager.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockImageDataManager.swift @@ -6,11 +6,17 @@ import SessionUIKit @testable import SessionMessagingKit class MockImageDataManager: Mock, ImageDataManagerType { - @discardableResult func loadImageData( - identifier: String, - source: ImageDataManager.DataSource + @discardableResult func load( + _ source: ImageDataManager.DataSource ) async -> ImageDataManager.ProcessedImageData? { - return mock(args: [identifier, source]) + return mock(args: [source]) + } + + func load( + _ source: ImageDataManager.DataSource, + onComplete: @escaping (ImageDataManager.ProcessedImageData?) -> Void + ) { + mockNoReturn(args: [source], untrackedArgs: [onComplete]) } func cacheImage(_ image: UIImage, for identifier: String) async { diff --git a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift index 56408d066b..469fc6785d 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift @@ -2,6 +2,7 @@ import Foundation import Combine +import SessionUIKit import SessionUtilitiesKit import GRDB @@ -13,17 +14,27 @@ class MockLibSessionCache: Mock, LibSessionCacheType { // MARK: - State Management - func loadState(_ db: Database, requestId: String?) { + func loadState(_ db: ObservingDatabase, requestId: String?) { mockNoReturn(args: [requestId], untrackedArgs: [db]) } func loadDefaultStateFor( variant: ConfigDump.Variant, sessionId: SessionId, - userEd25519KeyPair: KeyPair, + userEd25519SecretKey: [UInt8], groupEd25519SecretKey: [UInt8]? ) { - mockNoReturn(args: [variant, sessionId, userEd25519KeyPair, groupEd25519SecretKey]) + mockNoReturn(args: [variant, sessionId, userEd25519SecretKey, groupEd25519SecretKey]) + } + + func loadState( + for variant: ConfigDump.Variant, + sessionId: SessionId, + userEd25519SecretKey: [UInt8], + groupEd25519SecretKey: [UInt8]?, + cachedData: Data? + ) throws -> LibSession.Config { + return try mockThrowing(args: [variant, sessionId, userEd25519SecretKey, groupEd25519SecretKey, cachedData]) } func hasConfig(for variant: ConfigDump.Variant, sessionId: SessionId) -> Bool { @@ -53,7 +64,7 @@ class MockLibSessionCache: Mock, LibSessionCacheType { // MARK: - Pushes - func syncAllPendingChanges(_ db: Database) { + func syncAllPendingPushes(_ db: ObservingDatabase) { mockNoReturn(untrackedArgs: [db]) } @@ -62,7 +73,7 @@ class MockLibSessionCache: Mock, LibSessionCacheType { } func performAndPushChange( - _ db: Database, + _ db: ObservingDatabase, for variant: ConfigDump.Variant, sessionId: SessionId, change: @escaping (LibSession.Config?) throws -> () @@ -70,17 +81,29 @@ class MockLibSessionCache: Mock, LibSessionCacheType { try mockThrowingNoReturn(args: [variant, sessionId], untrackedArgs: [db, change]) } - func pendingChanges(_ db: Database, swarmPublicKey: String) throws -> LibSession.PendingChanges { - return mock(args: [swarmPublicKey], untrackedArgs: [db]) + func perform( + for variant: ConfigDump.Variant, + sessionId: SessionId, + change: @escaping (LibSession.Config?) throws -> () + ) throws -> LibSession.Mutation { + return try mockThrowing(args: [variant, sessionId], untrackedArgs: [change]) + } + + func pendingPushes(swarmPublicKey: String) throws -> LibSession.PendingPushes { + return mock(args: [swarmPublicKey]) } func createDumpMarkingAsPushed( - data: [(pushData: LibSession.PendingChanges.PushData, hash: String?)], + data: [(pushData: LibSession.PendingPushes.PushData, hash: String?)], sentTimestamp: Int64, swarmPublicKey: String ) throws -> [ConfigDump] { return try mockThrowing(args: [data, sentTimestamp, swarmPublicKey]) } + + func addEvent(_ event: ObservedEvent) { + mockNoReturn(args: [event]) + } // MARK: - Config Message Handling @@ -92,8 +115,33 @@ class MockLibSessionCache: Mock, LibSessionCacheType { return mock(args: [swarmPublicKey]) } + func mergeConfigMessages( + swarmPublicKey: String, + 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]) + } + func handleConfigMessages( - _ db: Database, + _ db: ObservingDatabase, swarmPublicKey: String, messages: [ConfigMessageReceiveJob.Details.MessageInfo] ) throws { @@ -107,24 +155,303 @@ class MockLibSessionCache: Mock, LibSessionCacheType { try mockThrowingNoReturn(args: [swarmPublicKey, messages]) } - // MARK: - Value Access + // MARK: - State Access + + var displayName: String? { mock() } + + func has(_ key: Setting.BoolKey) -> Bool { + return mock(generics: [Bool.self], args: [key]) + } + + func has(_ key: Setting.EnumKey) -> Bool { + return mock(generics: [Setting.EnumKey.self], args: [key]) + } + + func get(_ key: Setting.BoolKey) -> Bool { + return mock(generics: [Bool.self], args: [key]) + } + + func get(_ key: Setting.EnumKey) -> T? where T : LibSessionConvertibleEnum { + return mock(generics: [T.self], args: [key]) + } + + func set(_ key: Setting.BoolKey, _ value: Bool?) { + mockNoReturn(generics: [Bool.self], args: [key, value]) + } + + func set(_ key: Setting.EnumKey, _ value: T?) where T : LibSessionConvertibleEnum { + mockNoReturn(generics: [T.self], args: [key, value]) + } + + @discardableResult func updateProfile(displayName: String, displayPictureUrl: String?, displayPictureEncryptionKey: Data?) throws -> Profile? { + return try mockThrowing(args: [displayName, displayPictureUrl, displayPictureEncryptionKey]) + } + + func canPerformChange( + threadId: String, + threadVariant: SessionThread.Variant, + changeTimestampMs: Int64 + ) -> Bool { + return mock(args: [threadId, threadVariant, changeTimestampMs]) + } + + func conversationInConfig( + threadId: String, + threadVariant: SessionThread.Variant, + visibleOnly: Bool, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Bool { + return mock(args: [threadId, threadVariant, visibleOnly, openGroupUrlInfo]) + } - public func pinnedPriority( - _ db: Database, + func conversationDisplayName( + threadId: String, + threadVariant: SessionThread.Variant, + contactProfile: Profile?, + visibleMessage: VisibleMessage?, + openGroupName: String?, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> String { + return mock(args: [threadId, threadVariant, contactProfile, visibleMessage, openGroupName, openGroupUrlInfo]) + } + + func conversationLastRead( + threadId: String, + threadVariant: SessionThread.Variant, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Int64? { + return mock(args: [threadId, threadVariant, openGroupUrlInfo]) + } + + func isMessageRequest( threadId: String, threadVariant: SessionThread.Variant - ) -> Int32? { - return mock(args: [threadId, threadVariant], untrackedArgs: [db]) + ) -> Bool { + return mock(args: [threadId, threadVariant]) } - public func disappearingMessagesConfig( + func pinnedPriority( + threadId: String, + threadVariant: SessionThread.Variant, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Int32 { + return mock(args: [threadId, threadVariant, openGroupUrlInfo]) + } + + func notificationSettings( + threadId: String?, + threadVariant: SessionThread.Variant, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Preferences.NotificationSettings { + return mock(args: [threadId, threadVariant, openGroupUrlInfo]) + } + + func disappearingMessagesConfig( threadId: String, threadVariant: SessionThread.Variant ) -> DisappearingMessagesConfiguration? { return mock(args: [threadId, threadVariant]) } + func isContactBlocked(contactId: String) -> Bool { + return mock(args: [contactId]) + } + + func profile( + contactId: String, + threadId: String?, + threadVariant: SessionThread.Variant?, + visibleMessage: VisibleMessage? + ) -> Profile? { + return mock(args: [contactId, threadId, threadVariant, visibleMessage]) + } + + func displayPictureUrl(threadId: String, threadVariant: SessionThread.Variant) -> String? { + return mock(args: [threadId, threadVariant]) + } + + func hasCredentials(groupSessionId: SessionId) -> Bool { + return mock(args: [groupSessionId]) + } + + func secretKey(groupSessionId: SessionId) -> [UInt8]? { + return mock(args: [groupSessionId]) + } + func isAdmin(groupSessionId: SessionId) -> Bool { return mock(args: [groupSessionId]) } + + func loadAdminKey( + groupIdentitySeed: Data, + groupSessionId: SessionId + ) throws { + try mockThrowingNoReturn(args: [groupIdentitySeed, groupSessionId]) + } + + func markAsInvited(groupSessionIds: [String]) throws { + try mockThrowingNoReturn(args: [groupSessionIds]) + } + + func markAsKicked(groupSessionIds: [String]) throws { + try mockThrowingNoReturn(args: [groupSessionIds]) + } + + func wasKickedFromGroup(groupSessionId: SessionId) -> Bool { + return mock(args: [groupSessionId]) + } + + func groupName(groupSessionId: SessionId) -> String? { + return mock(args: [groupSessionId]) + } + + func groupIsDestroyed(groupSessionId: SessionId) -> Bool { + return mock(args: [groupSessionId]) + } + + func groupDeleteBefore(groupSessionId: SessionId) -> TimeInterval? { + return mock(args: [groupSessionId]) + } + + func groupDeleteAttachmentsBefore(groupSessionId: SessionId) -> TimeInterval? { + return mock(args: [groupSessionId]) + } +} + +// MARK: - Convenience + +extension Mock where T == LibSessionCacheType { + func defaultInitialSetup(configs: [ConfigDump.Variant: LibSession.Config?] = [:]) { + let userSessionId: SessionId = SessionId(.standard, hex: TestConstants.publicKey) + + configs.forEach { key, value in + switch value { + case .none: break + case .some(let config): 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 + .when { + $0.loadDefaultStateFor( + variant: .any, + sessionId: .any, + userEd25519SecretKey: .any, + groupEd25519SecretKey: .any + ) + } + .thenReturn(()) + self + .when { try $0.pendingPushes(swarmPublicKey: .any) } + .thenReturn(LibSession.PendingPushes()) + self.when { $0.configNeedsDump(.any) }.thenReturn(false) + self + .when { try $0.createDump(config: .any, for: .any, sessionId: .any, timestampMs: .any) } + .thenReturn(nil) + 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) + try? callback?() + } + .thenReturn(()) + 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) + + switch configs[(args[test: 0] as? ConfigDump.Variant ?? .invalid)] { + case .none: break + case .some(let config): try? callback?(config) + } + } + .thenReturn(()) + 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) + + switch configs[(args[test: 0] as? ConfigDump.Variant ?? .invalid)] { + case .none: break + case .some(let config): try? callback?(config) + } + } + .thenReturn(nil) + self + .when { + try $0.createDumpMarkingAsPushed( + data: .any, + sentTimestamp: .any, + swarmPublicKey: .any + ) + } + .thenReturn([]) + self + .when { + $0.conversationInConfig( + threadId: .any, + threadVariant: .any, + visibleOnly: .any, + openGroupUrlInfo: .any + ) + } + .thenReturn(true) + self + .when { + $0.conversationLastRead( + threadId: .any, + threadVariant: .any, + openGroupUrlInfo: .any + ) + } + .thenReturn(nil) + self + .when { $0.canPerformChange(threadId: .any, threadVariant: .any, changeTimestampMs: .any) } + .thenReturn(true) + self + .when { $0.isMessageRequest(threadId: .any, threadVariant: .any) } + .thenReturn(false) + self + .when { $0.pinnedPriority(threadId: .any, threadVariant: .any, openGroupUrlInfo: .any) } + .thenReturn(LibSession.defaultNewThreadPriority) + self + .when { $0.disappearingMessagesConfig(threadId: .any, threadVariant: .any) } + .thenReturn(nil) + self.when { $0.isContactBlocked(contactId: .any) }.thenReturn(false) + 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 + .when { $0.displayPictureUrl(threadId: .any, threadVariant: .any) } + .thenReturn(nil) + } } diff --git a/SessionMessagingKitTests/_TestUtilities/MockNotificationsManager.swift b/SessionMessagingKitTests/_TestUtilities/MockNotificationsManager.swift index e4ed767686..d73ce6685d 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockNotificationsManager.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockNotificationsManager.swift @@ -21,45 +21,54 @@ public class MockNotificationsManager: Mock, Notificat mockNoReturn(args: [delegate]) } - public func registerNotificationSettings() -> AnyPublisher { + public func registerSystemNotificationSettings() -> AnyPublisher { return mock() } - public func notifyUser( - _ db: Database, - for interaction: Interaction, - in thread: SessionThread, - applicationState: UIApplication.State - ) { - mockNoReturn(args: [interaction, thread, applicationState], untrackedArgs: [db]) + public func settings(threadId: String?, threadVariant: SessionThread.Variant) -> Preferences.NotificationSettings { + return mock(args: [threadId, threadVariant]) } - public func notifyUser( - _ db: Database, - forIncomingCall interaction: Interaction, - in thread: SessionThread, - applicationState: UIApplication.State + public func updateSettings( + threadId: String, + threadVariant: SessionThread.Variant, + mentionsOnly: Bool, + mutedUntil: TimeInterval? ) { - mockNoReturn(args: [interaction, thread, applicationState], untrackedArgs: [db]) + return mock(args: [threadId, threadVariant, mentionsOnly, mutedUntil]) } - public func notifyUser( - _ db: Database, - forReaction reaction: Reaction, - in thread: SessionThread, - applicationState: UIApplication.State - ) { - mockNoReturn(args: [reaction, thread, applicationState], untrackedArgs: [db]) + public func notificationUserInfo( + threadId: String, + threadVariant: SessionThread.Variant + ) -> [String: Any] { + return mock(args: [threadId, threadVariant]) + } + + public func notificationShouldPlaySound(applicationState: UIApplication.State) -> Bool { + return mock(args: [applicationState]) } - public func notifyForFailedSend(_ db: Database, in thread: SessionThread, applicationState: UIApplication.State) { - mockNoReturn(args: [thread, applicationState], untrackedArgs: [db]) + public func notifyForFailedSend( + threadId: String, + threadVariant: SessionThread.Variant, + applicationState: UIApplication.State + ) { + mockNoReturn(args: [threadId, threadVariant, applicationState]) } public func scheduleSessionNetworkPageLocalNotifcation(force: Bool) { mockNoReturn(args: [force]) } + public func addNotificationRequest( + content: NotificationContent, + notificationSettings: Preferences.NotificationSettings, + extensionBaseUnreadCount: Int? + ) { + mockNoReturn(args: [content, notificationSettings, extensionBaseUnreadCount]) + } + public func cancelNotifications(identifiers: [String]) { mockNoReturn(args: [identifiers]) } @@ -68,3 +77,48 @@ public class MockNotificationsManager: Mock, Notificat mockNoReturn() } } + +// MARK: - Convenience + +extension Mock where T == NotificationsManagerType { + func defaultInitialSetup() { + self + .when { $0.notificationUserInfo(threadId: .any, threadVariant: .any) } + .thenReturn([:]) + self + .when { $0.notificationShouldPlaySound(applicationState: .any) } + .thenReturn(false) + self + .when { + $0.addNotificationRequest( + content: .any, + notificationSettings: .any, + extensionBaseUnreadCount: .any + ) + } + .thenReturn(()) + self + .when { $0.cancelNotifications(identifiers: .any) } + .thenReturn(()) + self + .when { $0.settings(threadId: .any, threadVariant: .any) } + .thenReturn( + Preferences.NotificationSettings( + previewType: .nameAndPreview, + sound: .note, + mentionsOnly: false, + mutedUntil: nil + ) + ) + self + .when { + $0.updateSettings( + threadId: .any, + threadVariant: .any, + mentionsOnly: .any, + mutedUntil: .any + ) + } + .thenReturn(()) + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockPoller.swift b/SessionMessagingKitTests/_TestUtilities/MockPoller.swift index 0918751fa2..b3f044e3bd 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockPoller.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockPoller.swift @@ -77,6 +77,6 @@ class MockPoller: Mock, PollerType { func pollerDidStart() { mockNoReturn() } func poll(forceSynchronousProcessing: Bool) -> AnyPublisher { mock(args: [forceSynchronousProcessing]) } - func nextPollDelay() -> TimeInterval { mock() } + func nextPollDelay() -> AnyPublisher { mock() } func handlePollError(_ error: Error, _ lastError: Error?) -> PollerErrorResponse { mock(args: [error, lastError]) } } diff --git a/SessionMessagingKitTests/_TestUtilities/MockSwarmPoller.swift b/SessionMessagingKitTests/_TestUtilities/MockSwarmPoller.swift index ca43abdf2b..43ab428f96 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockSwarmPoller.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockSwarmPoller.swift @@ -73,6 +73,6 @@ class MockSwarmPoller: Mock, SwarmPollerType & Pol func pollerDidStart() { mockNoReturn() } func poll(forceSynchronousProcessing: Bool) -> AnyPublisher { mock(args: [forceSynchronousProcessing]) } - func nextPollDelay() -> TimeInterval { mock() } + func nextPollDelay() -> AnyPublisher { mock() } func handlePollError(_ error: Error, _ lastError: Error?) -> PollerErrorResponse { mock(args: [error, lastError]) } } diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index 54cc8cf079..cf343cc500 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -2,292 +2,121 @@ import Foundation import Combine -import GRDB -import UserNotifications -import SessionUIKit -import SignalUtilitiesKit import SessionMessagingKit import SessionUtilitiesKit public class NSENotificationPresenter: NotificationsManagerType { - private let dependencies: Dependencies + public let dependencies: Dependencies private var notifications: [String: UNNotificationRequest] = [:] + @ThreadSafeObject private var settingsStorage: [String: Preferences.NotificationSettings] = [:] + @ThreadSafe private var notificationSound: Preferences.Sound = .defaultNotificationSound + @ThreadSafe private var notificationPreviewType: Preferences.NotificationPreviewType = .defaultPreviewType // MARK: - Initialization required public init(using dependencies: Dependencies) { self.dependencies = dependencies + + dependencies.mutate(cache: .libSession) { + notificationPreviewType = $0.get(.preferencesNotificationPreviewType) + .defaulting(to: .defaultPreviewType) + notificationSound = $0.get(.defaultNotificationSound) + .defaulting(to: .defaultNotificationSound) + } + _settingsStorage.set( + to: dependencies[singleton: .extensionHelper] + .loadNotificationSettings( + previewType: notificationPreviewType, + sound: notificationSound + ) + .defaulting(to: [:]) + ) } // MARK: - Registration public func setDelegate(_ delegate: (any UNUserNotificationCenterDelegate)?) {} - public func registerNotificationSettings() -> AnyPublisher { + public func registerSystemNotificationSettings() -> AnyPublisher { return Just(()).eraseToAnyPublisher() } - // MARK: - Presentation + // MARK: - Unique Logic - public func notifyUser( - _ db: Database, - for interaction: Interaction, - in thread: SessionThread, - applicationState: UIApplication.State - ) { - let isMessageRequest: Bool = SessionThread.isMessageRequest( - db, - threadId: thread.id, - userSessionId: dependencies[cache: .general].sessionId, - includeNonVisible: true - ) - - // Ensure we should be showing a notification for the thread - guard thread.shouldShowNotification(db, for: interaction, isMessageRequest: isMessageRequest, using: dependencies) else { - Log.info("Ignoring notification because thread reported that we shouldn't show it.") - return - } - - let senderName: String = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant, using: dependencies) - let groupName: String = SessionThread.displayName( - threadId: thread.id, - variant: thread.variant, - closedGroupName: (try? thread.closedGroup.fetchOne(db))?.name, - openGroupName: (try? thread.openGroup.fetchOne(db))?.name - ) - var notificationTitle: String = senderName - - if thread.variant == .legacyGroup || thread.variant == .group || thread.variant == .community { - if thread.onlyNotifyForMentions && !interaction.hasMention { - // Ignore PNs if the group is set to only notify for mentions - return - } - - notificationTitle = "notificationsIosGroup" - .put(key: "name", value: senderName) - .put(key: "conversation_name", value: groupName) - .localized() - } - - let snippet: String = (Interaction - .notificationPreviewText(db, interaction: interaction, using: dependencies) - .filteredForDisplay - .nullIfEmpty? - .replacingMentions(for: thread.id, using: dependencies)) - .defaulting(to: "messageNewYouveGot" - .putNumber(1) - .localized() - ) - - let userInfo: [String: Any] = [ - NotificationServiceExtension.isFromRemoteKey: true, - NotificationServiceExtension.threadIdKey: thread.id, - NotificationServiceExtension.threadVariantRaw: thread.variant.rawValue - ] - - let notificationContent = UNMutableNotificationContent() - notificationContent.userInfo = userInfo - notificationContent.sound = thread.notificationSound - .defaulting(to: db[.defaultNotificationSound] ?? Preferences.Sound.defaultNotificationSound) - .notificationSound(isQuiet: false) - - /// Update the app badge in case the unread count changed - if let unreadCount: Int = try? Interaction.fetchAppBadgeUnreadCount(db, using: dependencies) { - notificationContent.badge = NSNumber(value: unreadCount) - } - - // Title & body - let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] - .defaulting(to: .defaultPreviewType) - - switch previewType { - case .nameAndPreview: - notificationContent.title = notificationTitle - notificationContent.body = snippet - - case .nameNoPreview: - notificationContent.title = notificationTitle - notificationContent.body = "messageNewYouveGot" - .putNumber(1) - .localized() - - case .noNameNoPreview: - notificationContent.title = Constants.app_name - notificationContent.body = "messageNewYouveGot" - .putNumber(1) - .localized() - } - - // If it's a message request then overwrite the body to be something generic (only show a notification - // when receiving a new message request if there aren't any others or the user had hidden them) - if isMessageRequest { - notificationContent.title = Constants.app_name - notificationContent.body = "messageRequestsNew".localized() - } - - // Add request (try to group notifications for interactions from open groups) - let identifier: String = Interaction.notificationIdentifier( - for: (interaction.id ?? 0), - threadId: thread.id, - shouldGroupMessagesForThread: (thread.variant == .community) - ) - var trigger: UNNotificationTrigger? - - if thread.variant == .community { - trigger = UNTimeIntervalNotificationTrigger( - timeInterval: Notifications.delayForGroupedNotifications, - repeats: false + public func settings(threadId: String? = nil, threadVariant: SessionThread.Variant) -> Preferences.NotificationSettings { + return settingsStorage[threadId].defaulting( + to: Preferences.NotificationSettings( + previewType: notificationPreviewType, + sound: notificationSound, + mentionsOnly: false, + mutedUntil: nil ) - - let numberExistingNotifications: Int? = notifications[identifier]? - .content - .userInfo[NotificationServiceExtension.threadNotificationCounter] - .asType(Int.self) - var numberOfNotifications: Int = (numberExistingNotifications ?? 1) - - if numberExistingNotifications != nil { - numberOfNotifications += 1 // Add one for the current notification - - notificationContent.title = (previewType == .noNameNoPreview ? - notificationContent.title : - groupName - ) - notificationContent.body = "messageNewYouveGot" - .putNumber(numberOfNotifications) - .localized() - } - - notificationContent.userInfo[NotificationServiceExtension.threadNotificationCounter] = numberOfNotifications - } - - addNotifcationRequest( - identifier: identifier, - notificationContent: notificationContent, - trigger: trigger ) } - public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) { - // No call notifications for muted or group threads - guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } - guard - thread.variant != .legacyGroup && - thread.variant != .group && - thread.variant != .community - else { return } - guard - interaction.variant == .infoCall, - let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8), - let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode( - CallMessage.MessageInfo.self, - from: infoMessageData - ) - else { return } - - // Only notify missed calls - switch messageInfo.state { - case .missed, .permissionDenied, .permissionDeniedMicrophone: break - default: return - } - - let userInfo: [String: Any] = [ - NotificationServiceExtension.isFromRemoteKey: true, - NotificationServiceExtension.threadIdKey: thread.id, - NotificationServiceExtension.threadVariantRaw: thread.variant.rawValue + public func updateSettings( + threadId: String, + threadVariant: SessionThread.Variant, + mentionsOnly: Bool, + mutedUntil: TimeInterval? + ) {} + + public func notificationUserInfo(threadId: String, threadVariant: SessionThread.Variant) -> [String: Any] { + return [ + NotificationUserInfoKey.isFromRemote: true, + NotificationUserInfoKey.threadId: threadId, + NotificationUserInfoKey.threadVariantRaw: threadVariant.rawValue ] - - let notificationContent = UNMutableNotificationContent() - notificationContent.userInfo = userInfo - notificationContent.sound = thread.notificationSound - .defaulting(to: db[.defaultNotificationSound] ?? Preferences.Sound.defaultNotificationSound) - .notificationSound(isQuiet: false) - - /// Update the app badge in case the unread count changed - if let unreadCount: Int = try? Interaction.fetchAppBadgeUnreadCount(db, using: dependencies) { - notificationContent.badge = NSNumber(value: unreadCount) - } - - notificationContent.title = Constants.app_name - notificationContent.body = "" - - let senderName: String = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant, using: dependencies) - - switch messageInfo.state { - case .permissionDenied: - notificationContent.body = "callsYouMissedCallPermissions" - .put(key: "name", value: senderName) - .localizedDeformatted() - case .permissionDeniedMicrophone: - notificationContent.body = "callsMissedCallFrom" - .put(key: "name", value: senderName) - .localizedDeformatted() - default: - break - } - - addNotifcationRequest( - identifier: UUID().uuidString, - notificationContent: notificationContent, - trigger: nil - ) } - public func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread, applicationState: UIApplication.State) { - let isMessageRequest: Bool = SessionThread.isMessageRequest( - db, - threadId: thread.id, - userSessionId: dependencies[cache: .general].sessionId, - includeNonVisible: true - ) - - // No reaction notifications for muted, group threads or message requests - guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } - guard - thread.variant != .legacyGroup && - thread.variant != .group && - thread.variant != .community - else { return } - guard !isMessageRequest else { return } - - let notificationTitle = Profile.displayName(db, id: reaction.authorId, threadVariant: thread.variant, using: dependencies) - var notificationBody = "emojiReactsNotification" - .put(key: "emoji", value: reaction.emoji) - .localized() - - // Title & body - let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] - .defaulting(to: .nameAndPreview) - - switch previewType { - case .nameAndPreview: break - default: notificationBody = "messageNewYouveGot" - .putNumber(1) - .localized() - } - - let userInfo: [String: Any] = [ - NotificationServiceExtension.isFromRemoteKey: true, - NotificationServiceExtension.threadIdKey: thread.id, - NotificationServiceExtension.threadVariantRaw: thread.variant.rawValue - ] - - let notificationContent = UNMutableNotificationContent() - notificationContent.userInfo = userInfo - notificationContent.sound = thread.notificationSound - .defaulting(to: db[.defaultNotificationSound] ?? Preferences.Sound.defaultNotificationSound) - .notificationSound(isQuiet: false) - notificationContent.title = notificationTitle - notificationContent.body = notificationBody - - addNotifcationRequest(identifier: UUID().uuidString, notificationContent: notificationContent, trigger: nil) + public func notificationShouldPlaySound(applicationState: UIApplication.State) -> Bool { + return true } - public func notifyForFailedSend(_ db: Database, in thread: SessionThread, applicationState: UIApplication.State) { + // MARK: - Presentation + + public func notifyForFailedSend(threadId: String, threadVariant: SessionThread.Variant, applicationState: UIApplication.State) { // Not possible in the NotificationServiceExtension } public func scheduleSessionNetworkPageLocalNotifcation(force: Bool) {} + public func addNotificationRequest( + content: NotificationContent, + notificationSettings: Preferences.NotificationSettings, + extensionBaseUnreadCount: Int? + ) { + let notificationContent: UNMutableNotificationContent = content.toMutableContent( + shouldPlaySound: notificationShouldPlaySound(applicationState: content.applicationState) + ) + + /// Since we will have already written the message to disk at this stage we can just add the number of unread message files + /// directly to the `originalUnreadCount` in order to get the updated unread count + if + let extensionBaseUnreadCount: Int = extensionBaseUnreadCount, + let unreadPendingMessageCount: Int = dependencies[singleton: .extensionHelper].unreadMessageCount() + { + notificationContent.badge = NSNumber(value: extensionBaseUnreadCount + unreadPendingMessageCount) + } + + let request = UNNotificationRequest( + identifier: content.identifier, + content: notificationContent, + trigger: nil + ) + + Log.info("Add remote notification request: \(content.identifier)") + let semaphore = DispatchSemaphore(value: 0) + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + Log.error("Failed to add notification request '\(content.identifier)' due to error: \(error)") + } + semaphore.signal() + } + semaphore.wait() + Log.info("Finish adding remote notification request '\(content.identifier)") + } + // MARK: - Clearing public func cancelNotifications(identifiers: [String]) { @@ -302,43 +131,3 @@ public class NSENotificationPresenter: NotificationsManagerType { notificationCenter.removeAllDeliveredNotifications() } } - -// MARK: - Convenience -private extension NSENotificationPresenter { - func addNotifcationRequest(identifier: String, notificationContent: UNNotificationContent, trigger: UNNotificationTrigger?) { - let request = UNNotificationRequest(identifier: identifier, content: notificationContent, trigger: trigger) - - Log.info("Add remote notification request: \(identifier)") - let semaphore = DispatchSemaphore(value: 0) - UNUserNotificationCenter.current().add(request) { error in - if let error = error { - Log.error("Failed to add notification request '\(identifier)' due to error: \(error)") - } - semaphore.signal() - } - semaphore.wait() - Log.info("Finish adding remote notification request '\(identifier)") - } -} - -private extension String { - - func replacingMentions(for threadID: String, using dependencies: Dependencies) -> String { - var result = self - let regex = try! NSRegularExpression(pattern: "@[0-9a-fA-F]{66}", options: []) - var mentions: [(range: NSRange, publicKey: String)] = [] - var m0 = regex.firstMatch(in: result, options: .withoutAnchoringBounds, range: NSRange(location: 0, length: result.utf16.count)) - while let m1 = m0 { - let publicKey = String((result as NSString).substring(with: m1.range).dropFirst()) // Drop the @ - var matchEnd = m1.range.location + m1.range.length - - if let displayName: String = Profile.displayNameNoFallback(id: publicKey, using: dependencies) { - result = (result as NSString).replacingCharacters(in: m1.range, with: "@\(displayName)") // stringlint:ignore - mentions.append((range: NSRange(location: m1.range.location, length: displayName.utf16.count + 1), publicKey: publicKey)) // + 1 to include the @ - matchEnd = m1.range.location + displayName.utf16.count - } - m0 = regex.firstMatch(in: result, options: .withoutAnchoringBounds, range: NSRange(location: matchEnd, length: result.utf16.count - matchEnd)) - } - return result - } -} diff --git a/SessionNotificationServiceExtension/NotificationResolution.swift b/SessionNotificationServiceExtension/NotificationResolution.swift index 47803dda19..92928372b1 100644 --- a/SessionNotificationServiceExtension/NotificationResolution.swift +++ b/SessionNotificationServiceExtension/NotificationResolution.swift @@ -3,7 +3,7 @@ // stringlint:disable import Foundation -import SessionSnodeKit +import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit @@ -14,18 +14,17 @@ enum NotificationResolution: CustomStringConvertible { case ignoreDueToMainAppRunning case ignoreDueToNoContentFromApple case ignoreDueToNonLegacyGroupLegacyNotification + case ignoreDueToSelfSend case ignoreDueToOutdatedMessage case ignoreDueToRequiresNoNotification + case ignoreDueToMessageRequest(String) case ignoreDueToDuplicateMessage + case ignoreDueToDuplicateCall case ignoreDueToContentSize(PushNotificationAPI.NotificationMetadata) case errorTimeout case errorNotReadyForExtensions - case errorNoContentLegacy - case errorDatabaseInvalid - case errorDatabaseMigrations(Error) - case errorTransactionFailure - case errorLegacyGroupKeysMissing + case errorLegacyPushNotification case errorCallFailure case errorNoContent(PushNotificationAPI.NotificationMetadata) case errorProcessing(PushNotificationAPI.ProcessResult) @@ -34,32 +33,36 @@ enum NotificationResolution: CustomStringConvertible { public var description: String { switch self { - case .success(let metadata): return "Completed: Handled notification from namespace: \(metadata.namespace)" + case .success(let metadata): + return "Completed: Handled notification from \(metadata.namespace) namespace for \(metadata.accountId)" + case .successCall: return "Completed: Notified main app of call message" case .ignoreDueToMainAppRunning: return "Ignored: Main app running" case .ignoreDueToNoContentFromApple: return "Ignored: No content" case .ignoreDueToNonLegacyGroupLegacyNotification: return "Ignored: Non-group legacy notification" - case .ignoreDueToOutdatedMessage: return "Ignored: Alteady seen message" + case .ignoreDueToSelfSend: return "Ignored: Self send" + case .ignoreDueToOutdatedMessage: return "Ignored: Already seen message" case .ignoreDueToRequiresNoNotification: return "Ignored: Message requires no notification" + case .ignoreDueToMessageRequest(let threadId): + return "Ignored: Subsequent message in message request \(threadId)" case .ignoreDueToDuplicateMessage: return "Ignored: Duplicate message (probably received it just before going to the background)" + + case .ignoreDueToDuplicateCall: + return "Ignored: Duplicate call (probably received after the call ended)" case .ignoreDueToContentSize(let metadata): - return "Ignored: Notification content from namespace: \(metadata.namespace) was too long: \(metadata.dataLength)" + return "Ignored: Notification content from \(metadata.namespace) namespace was too long (\(Format.fileSize(UInt(metadata.dataLength))))" case .errorTimeout: return "Failed: Execution time expired" case .errorNotReadyForExtensions: return "Failed: App not ready for extensions" - case .errorNoContentLegacy: return "Failed: Legacy notification contained invalid payload" - case .errorDatabaseInvalid: return "Failed: Database in invalid state" - case .errorDatabaseMigrations(let error): return "Failed: Database migration error: \(error)" - case .errorTransactionFailure: return "Failed: Unexpected database transaction rollback" - case .errorLegacyGroupKeysMissing: return "Failed: No legacy group decryption keys" + case .errorLegacyPushNotification: return "Failed: Legacy push notifications are no longer supported" case .errorCallFailure: return "Failed: Failed to handle call message" case .errorNoContent(let metadata): - return "Failed: Notification from namespace: \(metadata.namespace) contained no content, expected dataLength: \(metadata.dataLength)" + return "Failed: Notification from namespace: \(metadata.namespace) contained no content, expected dataLength (\(Format.fileSize(UInt(metadata.dataLength))))" case .errorProcessing(let result): return "Failed: Unable to process notification (\(result))" case .errorMessageHandling(let error): return "Failed: Handling the message (\(error))" @@ -70,15 +73,16 @@ enum NotificationResolution: CustomStringConvertible { public var logLevel: Log.Level { switch self { case .success, .successCall, .ignoreDueToMainAppRunning, .ignoreDueToNoContentFromApple, - .ignoreDueToNonLegacyGroupLegacyNotification, .ignoreDueToOutdatedMessage, - .ignoreDueToRequiresNoNotification, .ignoreDueToDuplicateMessage, .ignoreDueToContentSize: + .ignoreDueToSelfSend, .ignoreDueToNonLegacyGroupLegacyNotification, + .ignoreDueToOutdatedMessage, .ignoreDueToRequiresNoNotification, + .ignoreDueToMessageRequest, .ignoreDueToDuplicateMessage, .ignoreDueToDuplicateCall, + .ignoreDueToContentSize: return .info - case .errorNotReadyForExtensions, .errorNoContentLegacy, .errorNoContent, .errorCallFailure: + case .errorNotReadyForExtensions, .errorLegacyPushNotification, .errorNoContent, .errorCallFailure: return .warn - case .errorTimeout, .errorDatabaseInvalid, .errorDatabaseMigrations, .errorTransactionFailure, - .errorLegacyGroupKeysMissing, .errorProcessing, .errorMessageHandling, .errorOther: + case .errorTimeout, .errorProcessing, .errorMessageHandling, .errorOther: return .error } } diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 880b224488..74b0a1d115 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -1,16 +1,11 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import AVFAudio -import Combine -import GRDB import CallKit import UserNotifications -import BackgroundTasks import SessionUIKit import SessionMessagingKit import SessionSnodeKit -import SignalUtilitiesKit import SessionUtilitiesKit // MARK: - Log.Category @@ -25,24 +20,15 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension // Called via the OS so create a default 'Dependencies' instance private var dependencies: Dependencies = Dependencies.createEmpty() private var startTime: CFTimeInterval = 0 - private var contentHandler: ((UNNotificationContent) -> Void)? - private var request: UNNotificationRequest? + private var cachedNotificationInfo: NotificationInfo = .invalid @ThreadSafe private var hasCompleted: Bool = false - - // stringlint:ignore_start - public static let isFromRemoteKey = "remote" - public static let threadIdKey = "Signal.AppNotificationsUserInfoKey.threadId" - public static let threadVariantRaw = "Signal.AppNotificationsUserInfoKey.threadVariantRaw" - public static let threadNotificationCounter = "Session.AppNotificationsUserInfoKey.threadNotificationCounter" - private static let callPreOfferLargeNotificationSupressionDuration: TimeInterval = 30 - // stringlint:ignore_stop - + // MARK: Did receive a remote push notification request override public func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.startTime = CACurrentMediaTime() - self.contentHandler = contentHandler - self.request = request + self.cachedNotificationInfo = self.cachedNotificationInfo.with(requestId: request.identifier) + self.cachedNotificationInfo = self.cachedNotificationInfo.with(contentHandler: contentHandler) /// Create a new `Dependencies` instance each time so we don't need to worry about state from previous /// notifications causing issues with new notifications @@ -53,11 +39,11 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension // Abort if the main app is running guard !dependencies[defaults: .appGroup, key: .isMainAppActive] else { - return self.completeSilenty(.ignoreDueToMainAppRunning, requestId: request.identifier) + return self.completeSilenty(self.cachedNotificationInfo, .ignoreDueToMainAppRunning) } - guard let notificationContent = request.content.mutableCopy() as? UNMutableNotificationContent else { - return self.completeSilenty(.ignoreDueToNoContentFromApple, requestId: request.identifier) + guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else { + return self.completeSilenty(self.cachedNotificationInfo, .ignoreDueToNoContentFromApple) } Log.info(.cat, "didReceive called with requestId: \(request.identifier).") @@ -70,405 +56,913 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension } } - /// Actually perform the setup - self.performSetup(requestId: request.identifier) { [weak self] in - self?.handleNotification(notificationContent, requestId: request.identifier) + /// Setup the extension and handle the notification + var notificationInfo: NotificationInfo = self.cachedNotificationInfo.with(content: content) + var processedNotification: ProcessedNotification = (self.cachedNotificationInfo, .invalid, "", nil, nil) + + do { + let mainAppUnreadCount: Int = try performSetup(notificationInfo) + notificationInfo = try extractNotificationInfo(notificationInfo, mainAppUnreadCount) + try setupGroupIfNeeded(notificationInfo) + + processedNotification = try processNotification(notificationInfo) + try handleNotification(processedNotification) + } + catch { + handleError( + error, + info: notificationInfo, + processedNotification: processedNotification, + contentHandler: contentHandler + ) } } - private func handleNotification(_ notificationContent: UNMutableNotificationContent, requestId: String) { + // MARK: - Setup + + private func performSetup(_ info: NotificationInfo) throws -> Int { + Log.info(.cat, "Performing setup for requestId: \(info.requestId).") + + // stringlint:ignore_start + Log.setup(with: Logger( + primaryPrefix: "NotificationServiceExtension", + customDirectory: "\(dependencies[singleton: .fileManager].appSharedDataDirectoryPath)/Logs/NotificationExtension", + using: dependencies + )) + LibSession.setupLogger(using: dependencies) + // stringlint:ignore_stop + + /// Try to load the `UserMetadata` before doing any further setup (if it doesn't exist then there is no need to continue) + guard let userMetadata: ExtensionHelper.UserMetadata = dependencies[singleton: .extensionHelper].loadUserMetadata() else { + throw NotificationError.notReadyForExtension + } + + /// Setup Version Info and Network + dependencies.warmCache(cache: .appVersion) + + /// Configure the different targets + SNUtilitiesKit.configure( + networkMaxFileSize: Network.maxFileSize, + using: dependencies + ) + SNMessagingKit.configure(using: dependencies) + + /// The `NotificationServiceExtension` needs custom behaviours for it's notification presenter so set it up here + dependencies.set(singleton: .notificationsManager, to: NSENotificationPresenter(using: dependencies)) + + /// Cache the users secret key + dependencies.mutate(cache: .general) { + $0.setSecretKey(ed25519SecretKey: userMetadata.ed25519SecretKey) + } + + /// Load the `libSession` state into memory using the `extensionHelper` + let cache: LibSession.Cache = LibSession.Cache( + userSessionId: userMetadata.sessionId, + using: dependencies + ) + dependencies[singleton: .extensionHelper].loadUserConfigState( + into: cache, + userSessionId: userMetadata.sessionId, + userEd25519SecretKey: userMetadata.ed25519SecretKey + ) + dependencies.set(cache: .libSession, to: cache) + + return userMetadata.unreadCount + } + + private func setupGroupIfNeeded(_ info: NotificationInfo) throws { + let loadResult: [ConfigDump.Variant: Bool] = try dependencies.mutate(cache: .libSession) { cache in + try dependencies[singleton: .extensionHelper].loadGroupConfigStateIfNeeded( + into: cache, + swarmPublicKey: info.metadata.accountId, + userEd25519SecretKey: dependencies[cache: .general].ed25519SecretKey + ) + } + + /// Log the result if it is a notification for a group + if (try? SessionId(from: info.metadata.accountId).prefix) == .group { + let resultString: String = ConfigDump.Variant.groupVariants + .map { "\($0): \(loadResult[$0] ?? false)" } + .joined(separator: ", ") + Log.info(.cat, "Setup group \(info.metadata.accountId) config state (\(resultString)) for requestId: \(info.requestId).") + } + } + + // MARK: - Notification Handling + + private func extractNotificationInfo(_ info: NotificationInfo, _ mainAppUnreadCount: Int) throws -> NotificationInfo { let (maybeData, metadata, result) = PushNotificationAPI.processNotification( - notificationContent: notificationContent, + notificationContent: info.content, using: dependencies ) - guard - (result == .success || result == .legacySuccess), - let data: Data = maybeData - else { - switch (result, metadata.namespace.isConfigNamespace) { - // If we got an explicit failure, or we got a success but no content then show - // the fallback notification - case (.success, false), (.legacySuccess, false), (.failure, false): - return self.handleFailure( - for: notificationContent, - metadata: metadata, - threadVariant: nil, - threadDisplayName: nil, - resolution: .errorProcessing(result), - requestId: requestId - ) + switch (result, maybeData, metadata.namespace.isConfigNamespace) { + /// If we got an explicit failure, or we got a success but no content then show the fallback notification + case (.failure, _, false), (.success, .none, false): + throw NotificationError.processingErrorWithFallback(result, metadata) - case (.success, _), (.legacySuccess, _), (.failure, _): - return self.completeSilenty(.errorProcessing(result), requestId: requestId) + case (.success, .some(let data), _): + return NotificationInfo( + content: info.content, + requestId: info.requestId, + contentHandler: info.contentHandler, + metadata: metadata, + data: data, + mainAppUnreadCount: mainAppUnreadCount + ) - // Just log if the notification was too long (a ~2k message should be able to fit so - // these will most commonly be call or config messages) - case (.successTooLong, _): - return self.completeSilenty(.ignoreDueToContentSize(metadata), requestId: requestId) + default: throw NotificationError.processingError(result, metadata) + } + } + + private func processNotification(_ info: NotificationInfo) throws -> ProcessedNotification { + let processedMessage: ProcessedMessage = try MessageReceiver.parse( + data: info.data, + origin: .swarm( + publicKey: info.metadata.accountId, + namespace: { + switch (info.metadata.namespace, (try? SessionId(from: info.metadata.accountId))?.prefix) { + /// There was a bug at one point where the metadata would include a `null` value for the namespace + /// because the storage server didn't have an explicit `namespace_id` for the + /// `revokedRetrievableGroupMessages` namespace + /// + /// This code tries to work around that issue + /// + /// **Note:** This issue was present in storage server version `2.10.0` but this work-around should + /// be removed once the network has been fully updated with a fix + case (.unknown, .group): + return .revokedRetrievableGroupMessages + + default: return info.metadata.namespace + } + }(), + serverHash: info.metadata.hash, + serverTimestampMs: info.metadata.createdTimestampMs, + serverExpirationTimestamp: ( + (TimeInterval(dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + SnodeReceivedMessage.defaultExpirationMs) / 1000) + ) + ), + using: dependencies + ) + try MessageDeduplication.ensureMessageIsNotADuplicate(processedMessage, using: dependencies) + + var threadVariant: SessionThread.Variant? + var threadDisplayName: String? + + switch processedMessage { + case .invalid: throw MessageReceiverError.invalidMessage + case .config: + threadVariant = nil + threadDisplayName = nil - case (.failureNoContent, _): return self.completeSilenty(.errorNoContent(metadata), requestId: requestId) - case (.legacyFailure, _): return self.completeSilenty(.errorNoContentLegacy, requestId: requestId) - case (.legacyForceSilent, _): - return self.completeSilenty(.ignoreDueToNonLegacyGroupLegacyNotification, requestId: requestId) - } + case .standard(let threadId, let threadVariantVal, _, let messageInfo, _): + threadVariant = threadVariantVal + threadDisplayName = dependencies.mutate(cache: .libSession) { cache in + cache.conversationDisplayName( + threadId: threadId, + threadVariant: threadVariantVal, + contactProfile: nil, /// No database access in the NSE + visibleMessage: messageInfo.message as? VisibleMessage, + openGroupName: nil, /// Community PNs not currently supported + openGroupUrlInfo: nil /// Community PNs not currently supported + ) + } + + /// There is some dedupe logic for a `CallMessage` as, depending on the state of the call, we may want to + /// consider the message a duplicate + try MessageDeduplication.ensureCallMessageIsNotADuplicate( + threadId: threadId, + callMessage: messageInfo.message as? CallMessage, + using: dependencies + ) } - let isCallOngoing: Bool = ( - dependencies[defaults: .appGroup, key: .isCallOngoing] && - (dependencies[defaults: .appGroup, key: .lastCallPreOffer] != nil) + return ( + info, + processedMessage, + processedMessage.threadId, + threadVariant, + threadDisplayName ) + } + + private func handleNotification(_ notification: ProcessedNotification) throws { + switch notification.processedMessage { + case .invalid: throw MessageReceiverError.invalidMessage + case .config(let swarmPublicKey, let namespace, let serverHash, let serverTimestampMs, let data, _): + try handleConfigMessage( + notification, + swarmPublicKey: swarmPublicKey, + namespace: namespace, + serverHash: serverHash, + serverTimestampMs: serverTimestampMs, + data: data + ) + + case .standard(let threadId, let threadVariant, let proto, let messageInfo, _): + try handleStandardMessage( + notification, + threadId: threadId, + threadVariant: threadVariant, + proto: proto, + messageInfo: messageInfo + ) + } + } + + private func handleConfigMessage( + _ notification: ProcessedNotification, + swarmPublicKey: String, + namespace: SnodeAPI.Namespace, + serverHash: String, + serverTimestampMs: Int64, + data: Data + ) throws { + try dependencies.mutate(cache: .libSession) { cache in + try cache.mergeConfigMessages( + swarmPublicKey: swarmPublicKey, + messages: [ + ConfigMessageReceiveJob.Details.MessageInfo( + namespace: namespace, + serverHash: serverHash, + serverTimestampMs: serverTimestampMs, + data: data + ) + ], + afterMerge: { sessionId, variant, config, timestampMs, _ in + try updateConfigIfNeeded( + cache: cache, + config: config, + variant: variant, + sessionId: sessionId, + timestampMs: timestampMs + ) + return nil + } + ) + } - let hasMicrophonePermission: Bool = { - switch Permissions.microphone { - case .undetermined: return dependencies[defaults: .appGroup, key: .lastSeenHasMicrophonePermission] - default: return (Permissions.microphone == .granted) - } - }() - - // HACK: It is important to use write synchronously here to avoid a race condition - // where the completeSilenty() is called before the local notification request - // is added to notification center - dependencies[singleton: .storage].write { [weak self, dependencies] db in - var processedThreadId: String? - var processedThreadVariant: SessionThread.Variant? - var threadDisplayName: String? + /// Write the message to disk via the `extensionHelper` so the main app will have it immediately instead of having to wait + /// for a poll to return + do { + try dependencies[singleton: .extensionHelper].saveMessage( + SnodeReceivedMessage( + snode: nil, + publicKey: notification.info.metadata.accountId, + namespace: notification.info.metadata.namespace, + rawMessage: GetMessagesResponse.RawMessage( + base64EncodedDataString: notification.info.data.base64EncodedString(), + expirationMs: notification.info.metadata.expirationTimestampMs, + hash: notification.info.metadata.hash, + timestampMs: serverTimestampMs + ) + ), + isUnread: false + ) + } + catch { Log.error(.cat, "Failed to save config message to disk: \(error).") } + + /// Since we successfully handled the message we should now create the dedupe file for the message so we don't + /// show duplicate PNs + try MessageDeduplication.createDedupeFile(notification.processedMessage, using: dependencies) + + /// No notification should be shown for config messages so we can just succeed silently here + completeSilenty(notification.info, .success(notification.info.metadata)) + } + + private func updateConfigIfNeeded( + cache: LibSessionCacheType, + config: LibSession.Config?, + variant: ConfigDump.Variant, + sessionId: SessionId, + timestampMs: Int64 + ) throws { + guard cache.configNeedsDump(config) else { + return dependencies[singleton: .extensionHelper].refreshDumpModifiedDate( + sessionId: sessionId, + variant: variant + ) + } + + /// Update the replicated extension config dump (this way any subsequent push notifications will use the correct + /// data - eg. group encryption keys) + try dependencies[singleton: .extensionHelper].replicate( + dump: cache.createDump( + config: config, + for: variant, + sessionId: sessionId, + timestampMs: timestampMs + ), + replaceExisting: true + ) + } + + private func handleStandardMessage( + _ notification: ProcessedNotification, + threadId: String, + threadVariant: SessionThread.Variant, + proto: SNProtoContent, + messageInfo: MessageReceiveJob.Details.MessageInfo + ) throws { + /// Throw if the message is outdated and shouldn't be processed (this is based on pretty flaky logic which checks if the config + /// has been updated since the message was sent - this should be reworked to be less edge-case prone in the future) + try MessageReceiver.throwIfMessageOutdated( + message: messageInfo.message, + threadId: threadId, + threadVariant: threadVariant, + openGroupUrlInfo: nil, /// Communities current don't support PNs + using: dependencies + ) + + /// No need to check blinded ids as Communities currently don't support PNs + let userSessionId: SessionId = dependencies[cache: .general].sessionId + let currentUserSessionIds: Set = [userSessionId.hexString] + + /// Define the `displayNameRetriever` so it can be reused + let displayNameRetriever: (String) -> String? = { [dependencies] sessionId in + (dependencies + .mutate(cache: .libSession) { cache in + cache.profile( + contactId: sessionId, + threadId: threadId, + threadVariant: threadVariant, + visibleMessage: (messageInfo.message as? VisibleMessage) + ) + }? + .displayName(for: threadVariant)) + .defaulting(to: Profile.truncated(id: sessionId, threadVariant: threadVariant)) + } + + /// Handle any specific logic needed for the notification extension based on the message type + switch messageInfo.message { + /// These have no notification-related behaviours so no need to do anything + case is TypingIndicator, is DataExtractionNotification, is ExpirationTimerUpdate, + is MessageRequestResponse: + break - do { - let processedMessage: ProcessedMessage = try Message.processRawReceivedMessageAsNotification( - db, - data: data, - metadata: metadata, - using: dependencies + /// `ReadReceipt` and `UnsendRequest` messages only include basic information which can be used to lookup a + /// message so need database access in order to do anything (including removing existing notifications) so just ignore them + case is ReadReceipt, is UnsendRequest: break + + /// The invite control message for `group` conversations can result in a member who was kicked from a group + /// being re-added so we should handle that case (as it could result in the user starting to get valid notifications again) + /// + /// Otherwise just save the message to disk + case let inviteMessage as GroupUpdateInviteMessage: + try MessageReceiver.validateGroupInvite(message: inviteMessage, using: dependencies) + + /// Only update the state if the user had previously been kicked from the group + try dependencies.mutate(cache: .libSession) { cache in + guard + cache.wasKickedFromGroup(groupSessionId: inviteMessage.groupSessionId), + let config: LibSession.Config = cache.config(for: .userGroups, sessionId: userSessionId) + else { return } + + try cache.markAsInvited(groupSessionIds: [inviteMessage.groupSessionId.hexString]) + try LibSession.upsert( + groups: [ + LibSession.GroupUpdateInfo( + groupSessionId: inviteMessage.groupSessionId.hexString, + authData: inviteMessage.memberAuthData + ) + ], + in: config, + using: dependencies + ) + + try updateConfigIfNeeded( + cache: cache, + config: config, + variant: .userGroups, + sessionId: userSessionId, + timestampMs: ( + inviteMessage.sentTimestampMs.map { Int64($0) } ?? + Int64(dependencies.dateNow.timeIntervalSince1970 * 1000) + ) + ) + } + + /// Save the message and complete silently + try saveMessage( + notification, + threadId: threadId, + threadVariant: threadVariant, + messageInfo: messageInfo, + currentUserSessionIds: currentUserSessionIds ) + completeSilenty(notification.info, .success(notification.info.metadata)) + return + + /// The promote control message for `group` conversations can result in a member who was kicked from a group + /// being re-added so we should handle that case (as it could result in the user starting to get valid notifications again) + /// + /// Otherwise just save the message to disk + case let promoteMessage as GroupUpdatePromoteMessage: + guard + let sentTimestampMs: UInt64 = promoteMessage.sentTimestampMs, + let groupIdentityKeyPair: KeyPair = dependencies[singleton: .crypto].generate( + .ed25519KeyPair(seed: Array(promoteMessage.groupIdentitySeed)) + ) + else { throw MessageReceiverError.invalidMessage } - switch processedMessage { - /// Custom handle config messages (as they don't get handled by the normal `MessageReceiver.handle` call - case .config(let swarmPublicKey, let namespace, let serverHash, let serverTimestampMs, let data): - try dependencies.mutate(cache: .libSession) { cache in - try cache.handleConfigMessages( - db, - swarmPublicKey: swarmPublicKey, - messages: [ - ConfigMessageReceiveJob.Details.MessageInfo( - namespace: namespace, - serverHash: serverHash, - serverTimestampMs: serverTimestampMs, - data: data - ) - ] + let groupSessionId: SessionId = SessionId(.group, publicKey: groupIdentityKeyPair.publicKey) + + try dependencies.mutate(cache: .libSession) { cache in + guard let userGroupsConfig: LibSession.Config = cache.config(for: .userGroups, sessionId: userSessionId) else { + return + } + + /// Add the admin key to the `userGroups` config + try LibSession.upsert( + groups: [ + LibSession.GroupUpdateInfo( + groupSessionId: groupSessionId.hexString, + groupIdentityPrivateKey: Data(groupIdentityKeyPair.secretKey) ) - } + ], + in: userGroupsConfig, + using: dependencies + ) + + /// If we were previously marked as kicked from the group then we need to mark the user as invited again to + /// clear the kicked state + if cache.wasKickedFromGroup(groupSessionId: groupSessionId) { + try cache.markAsInvited(groupSessionIds: [groupSessionId.hexString]) + } + + /// Save the updated `userGroups` config + try updateConfigIfNeeded( + cache: cache, + config: userGroupsConfig, + variant: .userGroups, + sessionId: userSessionId, + timestampMs: Int64(sentTimestampMs) + ) + + /// If we have a `groupKeys` config then we also need to update it with the admin key + guard let groupKeysConfig: LibSession.Config = cache.config(for: .groupKeys, sessionId: groupSessionId) else { + return + } - /// Due to the way the `CallMessage` works we need to custom handle it's behaviour within the notification - /// extension, for all other message types we want to just use the standard `MessageReceiver.handle` call - case .standard(let threadId, let threadVariant, _, let messageInfo) where messageInfo.message is CallMessage: - processedThreadId = threadId - processedThreadVariant = threadVariant + try cache.loadAdminKey( + groupIdentitySeed: promoteMessage.groupIdentitySeed, + groupSessionId: groupSessionId + ) + try updateConfigIfNeeded( + cache: cache, + config: groupKeysConfig, + variant: .groupKeys, + sessionId: groupSessionId, + timestampMs: Int64(sentTimestampMs) + ) + } + + /// Save the message to disk and complete silently + try saveMessage( + notification, + threadId: threadId, + threadVariant: threadVariant, + messageInfo: messageInfo, + currentUserSessionIds: currentUserSessionIds + ) + completeSilenty(notification.info, .success(notification.info.metadata)) + return + + /// The `kickedMessage` for a `group` conversation will result in the credentials for the group being removed and + /// if the device receives subsequent notifications for the group which fail to decrypt (due to key rotation after being kicked) + /// then they will fail silently instead of using the fallback notification + case let libSessionMessage as LibSessionMessage: + let info: [MessageReceiver.LibSessionMessageInfo] = try MessageReceiver.decryptLibSessionMessage( + threadId: threadId, + threadVariant: threadVariant, + message: libSessionMessage, + using: dependencies + ) + + try info.forEach { senderSessionId, domain, plaintext in + switch domain { + case LibSession.Crypto.Domain.kickedMessage: + /// Ensure the `groupKicked` message was valid before continuing + try LibSessionMessage.validateGroupKickedMessage( + plaintext: plaintext, + userSessionId: userSessionId, + groupSessionId: senderSessionId, + using: dependencies + ) + + /// Mark the group as kicked and save the updated config dump + try dependencies.mutate(cache: .libSession) { cache in + try cache.markAsKicked(groupSessionIds: [senderSessionId.hexString]) + + guard let config: LibSession.Config = cache.config(for: .userGroups, sessionId: userSessionId) else { + return + } + + try updateConfigIfNeeded( + cache: cache, + config: config, + variant: .userGroups, + sessionId: userSessionId, + timestampMs: ( + libSessionMessage.sentTimestampMs.map { Int64($0) } ?? + Int64(dependencies.dateNow.timeIntervalSince1970 * 1000) + ) + ) + } + + default: Log.error(.messageReceiver, "Received libSession encrypted message with unsupported domain: \(domain)") + } + } + + /// Save the message and generate any deduplication files needed + try saveMessage( + notification, + threadId: threadId, + threadVariant: threadVariant, + messageInfo: messageInfo, + currentUserSessionIds: currentUserSessionIds + ) + completeSilenty(notification.info, .success(notification.info.metadata)) + return + + case let callMessage as CallMessage: + switch callMessage.kind { + case .preOffer: Log.info(.calls, "Received pre-offer message with uuid: \(callMessage.uuid).") + case .offer: Log.info(.calls, "Received offer message.") + case .answer: Log.info(.calls, "Received answer message.") + case .endCall: Log.info(.calls, "Received end call message.") + case .provisionalAnswer, .iceCandidates: break + } + + let areCallsEnabled: Bool = dependencies.mutate(cache: .libSession) { cache in + cache.get(.areCallsEnabled) + } + let hasMicrophonePermission: Bool = { + switch Permissions.microphone { + case .undetermined: return dependencies[defaults: .appGroup, key: .lastSeenHasMicrophonePermission] + default: return (Permissions.microphone == .granted) + } + }() + let isCallOngoing: Bool = ( + dependencies[defaults: .appGroup, key: .isCallOngoing] && + (dependencies[defaults: .appGroup, key: .lastCallPreOffer] != nil) + ) + + /// Handle the call as needed + switch ((areCallsEnabled && hasMicrophonePermission), isCallOngoing, callMessage.kind) { + case (false, _, _): + /// Update the `CallMessage.state` value so the correct notification logic can occur + callMessage.state = (areCallsEnabled ? .permissionDeniedMicrophone : .permissionDenied) - guard let callMessage = messageInfo.message as? CallMessage else { - throw MessageReceiverError.ignorableMessage + case (true, true, _): + guard let sender: String = callMessage.sender else { + throw MessageReceiverError.invalidMessage } + guard + let userEdKeyPair: KeyPair = dependencies[singleton: .crypto].generate( + .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) + ) + else { throw SnodeAPIError.noKeyPair } - // Throw if the message is outdated and shouldn't be processed - try MessageReceiver.throwIfMessageOutdated( - db, - message: messageInfo.message, - threadId: threadId, - threadVariant: threadVariant, - using: dependencies - ) + 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 + callMessage.state = .missed - // FIXME: Do we need to call it here? It does nothing other than log what kind of message we received - try MessageReceiver.handleCallMessage( - db, - threadId: threadId, - threadVariant: threadVariant, - message: callMessage, - using: dependencies - ) + let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + try MessageReceiver + .sendIncomingCallOfferInBusyStateResponse( + threadId: threadId, + message: callMessage, + disappearingMessagesConfiguration: dependencies.mutate(cache: .libSession) { cache in + cache.disappearingMessagesConfig(threadId: threadId, threadVariant: threadVariant) + }, + authMethod: Authentication.standard( + sessionId: SessionId(.standard, hex: sender), + ed25519PublicKey: userEdKeyPair.publicKey, + ed25519SecretKey: userEdKeyPair.secretKey + ), + onEvent: { _ in }, /// Do nothing for any of the message sending events + using: dependencies + ) + .send(using: dependencies) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: semaphore.signal() + case .failure(let error): + Log.error(.cat, "Failed to send incoming call offer in busy state response: \(error)") + semaphore.signal() + } + } + ) + let result = semaphore.wait(timeout: .now() + .seconds(Int(Network.defaultTimeout))) - guard case .preOffer = callMessage.kind else { - throw MessageReceiverError.ignorableMessage + switch (result, hasCompleted) { + case (.timedOut, _), (_, true): throw NotificationError.timeout + case (.success, false): break /// Show the notification and write the message to disk } - switch ((db[.areCallsEnabled] && hasMicrophonePermission), isCallOngoing) { - case (false, _): - if - let sender: String = callMessage.sender, - let interaction: Interaction = try MessageReceiver.insertCallInfoMessage( - db, - for: callMessage, - state: (db[.areCallsEnabled] ? .permissionDeniedMicrophone : .permissionDenied), - using: dependencies - ) - { - let thread: SessionThread = try SessionThread.upsert( - db, - id: sender, - variant: .contact, - values: SessionThread.TargetValues( - creationDateTimestamp: .useExistingOrSetTo( - (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) - ), - shouldBeVisible: .useExisting - ), - using: dependencies - ) - - // Notify the user if the call message wasn't already read - if !interaction.wasRead { - dependencies[singleton: .notificationsManager].notifyUser( - db, - forIncomingCall: interaction, - in: thread, - applicationState: .background - ) - } - } - - case (true, true): - try MessageReceiver.handleIncomingCallOfferInBusyState( - db, - message: callMessage, - using: dependencies - ) - - case (true, false): - try MessageReceiver.insertCallInfoMessage(db, for: callMessage, using: dependencies) - - // Perform any required post-handling logic - try MessageReceiver.postHandleMessage( - db, + case (true, false, .preOffer): + guard + let sender: String = callMessage.sender, + let sentTimestampMs: UInt64 = callMessage.sentTimestampMs, + threadVariant == .contact, + dependencies.mutate(cache: .libSession, { cache in + !cache.isMessageRequest( threadId: threadId, - threadVariant: threadVariant, - message: messageInfo.message, - using: dependencies + threadVariant: threadVariant ) - - return self?.handleSuccessForIncomingCall(db, for: callMessage, requestId: requestId) - } + }) + else { throw MessageReceiverError.invalidMessage } - // Perform any required post-handling logic - try MessageReceiver.postHandleMessage( - db, + /// Save the message and generate any deduplication files needed + try saveMessage( + notification, threadId: threadId, threadVariant: threadVariant, - message: messageInfo.message, - using: dependencies + messageInfo: messageInfo, + currentUserSessionIds: currentUserSessionIds ) - case .standard(let threadId, let threadVariant, let proto, let messageInfo): - processedThreadId = threadId - processedThreadVariant = threadVariant - threadDisplayName = SessionThread.displayName( - threadId: threadId, - variant: threadVariant, - closedGroupName: (threadVariant != .group && threadVariant != .legacyGroup ? nil : - try? ClosedGroup - .select(.name) - .filter(id: threadId) - .asRequest(of: String.self) - .fetchOne(db) - ), - openGroupName: (threadVariant != .community ? nil : - try? OpenGroup - .select(.name) - .filter(id: threadId) - .asRequest(of: String.self) - .fetchOne(db) - ), - isNoteToSelf: (threadId == dependencies[cache: .general].sessionId.hexString), - profile: (threadVariant != .contact ? nil : - try? Profile - .filter(id: threadId) - .fetchOne(db) - ) - ) - - try MessageReceiver.handle( - db, - threadId: threadId, + /// Handle the message as a successful call + return handleSuccessForIncomingCall( + notification, threadVariant: threadVariant, - message: messageInfo.message, - serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, - associatedWithProto: proto, - using: dependencies + callMessage: callMessage, + sender: sender, + sentTimestampMs: sentTimestampMs, + displayNameRetriever: displayNameRetriever ) + + default: break /// Send all other cases through the standard notification handling } - db.afterNextTransaction( - onCommit: { _ in self?.completeSilenty(.success(metadata), requestId: requestId) }, - onRollback: { _ in self?.completeSilenty(.errorTransactionFailure, requestId: requestId) } + case is VisibleMessage: break + + /// For any other message we don't have any custom handling (and don't want to show a notification) so just save these + /// messages to disk to be processed on next launch (letting the main app do any error handling) and just complete silently + default: + try saveMessage( + notification, + threadId: threadId, + threadVariant: threadVariant, + messageInfo: messageInfo, + currentUserSessionIds: currentUserSessionIds ) - } - catch { - // If an error occurred we want to rollback the transaction (by throwing) and then handle - // the error outside of the database - let handleError = { - // Dispatch to the next run loop to ensure we are out of the database write thread before - // handling the result (and suspending the database) - DispatchQueue.main.async { - switch (error, processedThreadVariant, metadata.namespace.isConfigNamespace) { - case (MessageReceiverError.noGroupKeyPair, _, _): - self?.completeSilenty(.errorLegacyGroupKeysMissing, requestId: requestId) - - case (MessageReceiverError.outdatedMessage, _, _): - self?.completeSilenty(.ignoreDueToOutdatedMessage, requestId: requestId) - - case (MessageReceiverError.ignorableMessage, _, _): - self?.completeSilenty(.ignoreDueToRequiresNoNotification, requestId: requestId) - - case (MessageReceiverError.duplicateMessage, _, _), - (MessageReceiverError.duplicateControlMessage, _, _), - (MessageReceiverError.duplicateMessageNewSnode, _, _): - self?.completeSilenty(.ignoreDueToDuplicateMessage, requestId: requestId) - - /// If it was a `decryptionFailed` error, but it was for a config namespace then just fail silently (don't - /// want to show the fallback notification in this case) - case (MessageReceiverError.decryptionFailed, _, true): - self?.completeSilenty(.errorMessageHandling(.decryptionFailed), requestId: requestId) - - /// If it was a `decryptionFailed` error for a group conversation and the group doesn't exist or - /// doesn't have auth info (ie. group destroyed or member kicked), then just fail silently (don't want - /// to show the fallback notification in these cases) - case (MessageReceiverError.decryptionFailed, .group, _): - guard - let threadId: String = processedThreadId, - let group: ClosedGroup = try? ClosedGroup.fetchOne(db, id: threadId), ( - group.groupIdentityPrivateKey != nil || - group.authData != nil - ) - else { - self?.completeSilenty(.errorMessageHandling(.decryptionFailed), requestId: requestId) - return - } - - /// The thread exists and we should have been able to decrypt so show the fallback message - self?.handleFailure( - for: notificationContent, - metadata: metadata, - threadVariant: processedThreadVariant, - threadDisplayName: threadDisplayName, - resolution: .errorMessageHandling(.decryptionFailed), - requestId: requestId - ) - - case (let msgError as MessageReceiverError, _, _): - self?.handleFailure( - for: notificationContent, - metadata: metadata, - threadVariant: processedThreadVariant, - threadDisplayName: threadDisplayName, - resolution: .errorMessageHandling(msgError), - requestId: requestId - ) - - default: - self?.handleFailure( - for: notificationContent, - metadata: metadata, - threadVariant: processedThreadVariant, - threadDisplayName: threadDisplayName, - resolution: .errorOther(error), - requestId: requestId - ) + completeSilenty(notification.info, .success(notification.info.metadata)) + return + } + + /// Since we are going to save the message and generate deduplication files we need to determine whether we would want + /// to show the message in case it is a message request (this is done by checking if there are already any dedupe records + /// for this conversation so needs to be done before they are generated) + let isMessageRequest: Bool = dependencies.mutate(cache: .libSession) { cache in + cache.isMessageRequest( + threadId: threadId, + threadVariant: threadVariant + ) + } + let shouldShowForMessageRequest: Bool = (!isMessageRequest ? false : + !dependencies[singleton: .extensionHelper].hasAtLeastOneDedupeRecord(threadId: threadId) + ) + + /// Save the message and generate any deduplication files needed + try saveMessage( + notification, + threadId: threadId, + threadVariant: threadVariant, + messageInfo: messageInfo, + currentUserSessionIds: currentUserSessionIds + ) + + /// Try to show a notification for the message + try dependencies[singleton: .notificationsManager].notifyUser( + message: messageInfo.message, + threadId: threadId, + threadVariant: threadVariant, + interactionIdentifier: notification.info.metadata.hash, + interactionVariant: Interaction.Variant( + message: messageInfo.message, + currentUserSessionIds: currentUserSessionIds + ), + attachmentDescriptionInfo: proto.dataMessage?.attachments.map { attachment in + Attachment.DescriptionInfo(id: "", proto: attachment) + }, + openGroupUrlInfo: nil, /// Communities currently don't support PNs + applicationState: .background, + extensionBaseUnreadCount: notification.info.mainAppUnreadCount, + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: displayNameRetriever, + groupNameRetriever: { threadId, threadVariant in + switch threadVariant { + case .group: + let groupId: SessionId = SessionId(.group, hex: threadId) + return dependencies.mutate(cache: .libSession) { cache in + cache.groupName(groupSessionId: groupId) } - } + + case .community: return nil /// Communities currently don't support PNs + default: return nil } - - db.afterNextTransaction( - onCommit: { _ in handleError() }, - onRollback: { _ in handleError() } - ) - throw error + }, + shouldShowForMessageRequest: { shouldShowForMessageRequest } + ) + + /// Since we succeeded we can complete silently + completeSilenty(notification.info, .success(notification.info.metadata)) + } + + private func saveMessage( + _ notification: ProcessedNotification, + threadId: String, + threadVariant: SessionThread.Variant, + messageInfo: MessageReceiveJob.Details.MessageInfo, + currentUserSessionIds: Set + ) throws { + /// Write the message to disk via the `extensionHelper` so the main app will have it immediately instead of having to wait + /// for a poll to return + do { + guard let sentTimestamp: Int64 = messageInfo.message.sentTimestampMs.map(Int64.init) else { + throw MessageReceiverError.invalidMessage } + + try dependencies[singleton: .extensionHelper].saveMessage( + SnodeReceivedMessage( + snode: nil, + publicKey: notification.info.metadata.accountId, + namespace: notification.info.metadata.namespace, + rawMessage: GetMessagesResponse.RawMessage( + base64EncodedDataString: notification.info.data.base64EncodedString(), + expirationMs: notification.info.metadata.expirationTimestampMs, + hash: notification.info.metadata.hash, + timestampMs: Int64(sentTimestamp) + ) + ), + isUnread: ( + /// Ensure the type of message can actually be unread + Interaction.Variant( + message: messageInfo.message, + currentUserSessionIds: currentUserSessionIds + )?.canBeUnread == true && + /// Ensure the message hasn't been read on another device + dependencies.mutate(cache: .libSession, { cache in + !cache.timestampAlreadyRead( + threadId: threadId, + threadVariant: threadVariant, + timestampMs: (messageInfo.message.sentTimestampMs.map { Int64($0) } ?? 0), /// Default to unread + openGroupUrlInfo: nil /// Communities currently don't support PNs + ) + }) && + { + /// If it's not a `CallMessage` or is a `preOffer` than it can be unread + guard + let callMessage: CallMessage = messageInfo.message as? CallMessage, + callMessage.kind != .preOffer + else { return true } + + /// If there is a dedupe record for the `preOffer` of this call, or a dedupe record for the call in general + /// then it would have already incremented the unread count so this message shouldn't count + do { + try MessageDeduplication.ensureMessageIsNotADuplicate( + threadId: threadId, + uniqueIdentifier: callMessage.preOfferDedupeIdentifier, + using: dependencies + ) + try MessageDeduplication.ensureMessageIsNotADuplicate( + threadId: threadId, + uniqueIdentifier: callMessage.preOfferDedupeIdentifier, + using: dependencies + ) + } + catch { return false } + + /// Otherwise the call should increment the count + return true + }() + ) + ) } + catch { Log.error(.cat, "Failed to save message to disk: \(error).") } + + /// Since we successfully handled the message we should now create the dedupe file for the message so we don't + /// show duplicate PNs + try MessageDeduplication.createDedupeFile(notification.processedMessage, using: dependencies) + try MessageDeduplication.createCallDedupeFilesIfNeeded( + threadId: threadId, + callMessage: messageInfo.message as? CallMessage, + using: dependencies + ) } - - // MARK: Setup - - private func performSetup(requestId: String, completion: @escaping () -> Void) { - Log.info(.cat, "Performing setup for requestId: \(requestId).") - - dependencies.warmCache(cache: .appVersion) - - AppSetup.setupEnvironment( - requestId: requestId, - appSpecificBlock: { [dependencies] in - // stringlint:ignore_start - Log.setup(with: Logger( - primaryPrefix: "NotificationServiceExtension", - customDirectory: "\(dependencies[singleton: .fileManager].appSharedDataDirectoryPath)/Logs/NotificationExtension", - using: dependencies - )) - // stringlint:ignore_stop + + private func handleError( + _ error: Error, + info: NotificationInfo, + processedNotification: ProcessedNotification?, + contentHandler: ((UNNotificationContent) -> Void) + ) { + switch (error, (try? SessionId(from: info.metadata.accountId))?.prefix, info.metadata.namespace.isConfigNamespace) { + case (NotificationError.timeout, _, _): + self.completeSilenty(info, .errorTimeout) + + case (NotificationError.notReadyForExtension, _, _): + self.completeSilenty(info, .errorNotReadyForExtensions) - /// The `NotificationServiceExtension` needs custom behaviours for it's notification presenter so set it up here - dependencies.set(singleton: .notificationsManager, to: NSENotificationPresenter(using: dependencies)) + case (NotificationError.processingErrorWithFallback(let result, let errorMetadata), _, _): + self.handleFailure( + info.with(metadata: errorMetadata), + threadVariant: nil, + threadDisplayName: nil, + resolution: .errorProcessing(result) + ) - // Setup LibSession - LibSession.setupLogger(using: dependencies) + /// Just log if the notification was too long (a ~2k message should be able to fit so these will most commonly be call + /// or config messages) + case (NotificationError.processingError(let result, let errorMetadata), _, _) where result == .successTooLong: + self.completeSilenty(info.with(metadata: errorMetadata), .ignoreDueToContentSize(errorMetadata)) - // Configure the different targets - SNUtilitiesKit.configure( - networkMaxFileSize: Network.maxFileSize, - using: dependencies - ) - SNMessagingKit.configure(using: dependencies) - }, - migrationsCompletion: { [weak self, dependencies] result in - switch result { - case .failure(let error): self?.completeSilenty(.errorDatabaseMigrations(error), requestId: requestId) - case .success: - DispatchQueue.main.async { - // Ensure storage is actually valid - guard dependencies[singleton: .storage].isValid else { - self?.completeSilenty(.errorDatabaseInvalid, requestId: requestId) - return - } - - // We should never receive a non-voip notification on an app that doesn't support - // app extensions since we have to inform the service we wanted these, so in theory - // this path should never occur. However, the service does have our push token - // so it is possible that could change in the future. If it does, do nothing - // and don't disturb the user. Messages will be processed when they open the app. - guard dependencies[singleton: .storage, key: .isReadyForAppExtensions] else { - self?.completeSilenty(.errorNotReadyForExtensions, requestId: requestId) - return - } - - // If the app wasn't ready then mark it as ready now - if !dependencies[singleton: .appReadiness].isAppReady { - // Note that this does much more than set a flag; it will also run all deferred blocks. - dependencies[singleton: .appReadiness].setAppReady() - } - - completion() - } + case (NotificationError.processingError(let result, let errorMetadata), _, _) where result == .failureNoContent: + self.completeSilenty(info.with(metadata: errorMetadata), .errorNoContent(errorMetadata)) + + case (NotificationError.processingError(let result, let errorMetadata), _, _) where result == .legacyFailure: + self.completeSilenty(info.with(metadata: errorMetadata), .errorLegacyPushNotification) + + case (NotificationError.processingError(let result, let errorMetadata), _, _): + self.completeSilenty(info.with(metadata: errorMetadata), .errorProcessing(result)) + + case (MessageReceiverError.selfSend, _, _): + self.completeSilenty(info, .ignoreDueToSelfSend) + + case (MessageReceiverError.noGroupKeyPair, _, _): + self.completeSilenty(info, .errorLegacyPushNotification) + + case (MessageReceiverError.outdatedMessage, _, _): + self.completeSilenty(info, .ignoreDueToOutdatedMessage) + + case (MessageReceiverError.ignorableMessage, _, _): + self.completeSilenty(info, .ignoreDueToRequiresNoNotification) + + case (MessageReceiverError.ignorableMessageRequestMessage(let threadId), _, _): + self.completeSilenty(info, .ignoreDueToMessageRequest(threadId)) + + case (MessageReceiverError.duplicateMessage, _, _): + self.completeSilenty(info, .ignoreDueToDuplicateMessage) + + case (MessageReceiverError.duplicatedCall, _, _): + self.completeSilenty(info, .ignoreDueToDuplicateCall) + + /// If it was a `decryptionFailed` error, but it was for a config namespace then just fail silently (don't + /// want to show the fallback notification in this case) + case (MessageReceiverError.decryptionFailed, _, true): + self.completeSilenty(info, .errorMessageHandling(.decryptionFailed)) + + /// If it was a `decryptionFailed` error for a group conversation and the group doesn't exist or + /// doesn't have auth info (ie. group destroyed or member kicked), then just fail silently (don't want + /// to show the fallback notification in these cases) + case (MessageReceiverError.decryptionFailed, .group, _): + guard + let threadId: String = processedNotification?.threadId, + dependencies.mutate(cache: .libSession, { cache in + cache.hasCredentials(groupSessionId: SessionId(.group, hex: threadId)) + }) + else { + self.completeSilenty(info, .errorMessageHandling(.decryptionFailed)) + return } - }, - using: dependencies - ) + + /// The thread exists and we should have been able to decrypt so show the fallback message + self.handleFailure( + info, + threadVariant: processedNotification?.threadVariant, + threadDisplayName: processedNotification?.threadDisplayName, + resolution: .errorMessageHandling(.decryptionFailed) + ) + + case (let msgError as MessageReceiverError, _, _): + self.handleFailure( + info, + threadVariant: processedNotification?.threadVariant, + threadDisplayName: processedNotification?.threadDisplayName, + resolution: .errorMessageHandling(msgError) + ) + + default: + self.handleFailure( + info, + threadVariant: processedNotification?.threadVariant, + threadDisplayName: processedNotification?.threadDisplayName, + resolution: .errorOther(error) + ) + } } // MARK: Handle completion override public func serviceExtensionTimeWillExpire() { - // Called just before the extension will be terminated by the system. - // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. - completeSilenty(.errorTimeout, requestId: (request?.identifier ?? "N/A")) // stringlint:ignore + /// Called just before the extension will be terminated by the system + completeSilenty(cachedNotificationInfo, .errorTimeout) } - private func completeSilenty(_ resolution: NotificationResolution, requestId: String) { - // This can be called from within database threads so to prevent blocking and weird - // behaviours make sure to send it to the main thread instead - guard Thread.isMainThread else { - return DispatchQueue.main.async { [weak self] in - self?.completeSilenty(resolution, requestId: requestId) - } - } - + private func completeSilenty(_ info: NotificationInfo, _ resolution: NotificationResolution) { // Ensure we only run this once guard _hasCompleted.performUpdateAndMap({ (true, $0) }) == false else { return } @@ -477,170 +971,197 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension switch resolution { case .ignoreDueToMainAppRunning: break default: - /// Update the app badge in case the unread count changed - if - let unreadCount: Int = dependencies[singleton: .storage].read({ [dependencies] db in - try Interaction.fetchAppBadgeUnreadCount(db, using: dependencies) - }) - { - silentContent.badge = NSNumber(value: unreadCount) + /// Since we will have already written the message to disk at this stage we can just add the number of unread message files + /// directly to the `mainAppUnreadCount` in order to get the updated unread count + if let unreadPendingMessageCount: Int = dependencies[singleton: .extensionHelper].unreadMessageCount() { + silentContent.badge = NSNumber(value: info.mainAppUnreadCount + unreadPendingMessageCount) } - - dependencies[singleton: .storage].suspendDatabaseAccess() } let duration: CFTimeInterval = (CACurrentMediaTime() - startTime) - Log.custom(resolution.logLevel, [.cat], "\(resolution) after \(.seconds(duration), unit: .ms), requestId: \(requestId).") + Log.custom(resolution.logLevel, [.cat], "\(resolution) after \(.seconds(duration), unit: .ms), requestId: \(info.requestId).") Log.flush() Log.reset() - self.contentHandler!(silentContent) + info.contentHandler(silentContent) } private func handleSuccessForIncomingCall( - _ db: Database, - for callMessage: CallMessage, - requestId: String + _ notification: ProcessedNotification, + threadVariant: SessionThread.Variant, + callMessage: CallMessage, + sender: String, + sentTimestampMs: UInt64, + displayNameRetriever: @escaping (String) -> String? ) { - if Preferences.isCallKitSupported { - guard let caller: String = callMessage.sender, let timestamp = callMessage.sentTimestampMs else { return } - let contactName: String = Profile.displayName( - db, - id: caller, - threadVariant: .contact, - using: dependencies - ) - - let reportCall: () -> () = { [weak self, dependencies] in - // stringlint:ignore_start - let payload: [String: Any] = [ - "uuid": callMessage.uuid, - "caller": caller, - "timestamp": timestamp, - "contactName": contactName - ] - // stringlint:ignore_stop - - CXProvider.reportNewIncomingVoIPPushPayload(payload) { error in - if let error = error { - Log.error(.cat, "Failed to notify main app of call message: \(error).") - dependencies[singleton: .storage].read { db in - self?.handleFailureForVoIP(db, for: callMessage, requestId: requestId) - } - } - else { - dependencies[defaults: .appGroup, key: .lastCallPreOffer] = Date() - self?.completeSilenty(.successCall, requestId: requestId) - } - } - } - - db.afterNextTransaction( - onCommit: { _ in reportCall() }, - onRollback: { _ in reportCall() } + guard Preferences.isCallKitSupported else { + return handleFailureForVoIP( + notification, + threadVariant: threadVariant, + callMessage: callMessage, + displayNameRetriever: displayNameRetriever ) } - else { - self.handleFailureForVoIP(db, for: callMessage, requestId: requestId) + + let payload: [String: Any] = [ + VoipPayloadKey.uuid.rawValue: callMessage.uuid, + VoipPayloadKey.caller.rawValue: sender, + VoipPayloadKey.timestamp.rawValue: sentTimestampMs, + VoipPayloadKey.contactName.rawValue: displayNameRetriever(sender) + .defaulting(to: Profile.truncated(id: sender, threadVariant: threadVariant)) + ] + + CXProvider.reportNewIncomingVoIPPushPayload(payload) { [weak self, dependencies] error in + if let error = error { + Log.error(.cat, "Failed to notify main app of call message: \(error).") + self?.handleFailureForVoIP( + notification, + threadVariant: threadVariant, + callMessage: callMessage, + displayNameRetriever: displayNameRetriever + ) + } + else { + dependencies[defaults: .appGroup, key: .lastCallPreOffer] = Date() + self?.completeSilenty(notification.info, .successCall) + } } } - private func handleFailureForVoIP(_ db: Database, for callMessage: CallMessage, requestId: String) { - let notificationContent = UNMutableNotificationContent() - notificationContent.userInfo = [ NotificationServiceExtension.isFromRemoteKey : true ] - notificationContent.title = Constants.app_name - - /// Update the app badge in case the unread count changed - if let unreadCount: Int = try? Interaction.fetchAppBadgeUnreadCount(db, using: dependencies) { - notificationContent.badge = NSNumber(value: unreadCount) - } + private func handleFailureForVoIP( + _ notification: ProcessedNotification, + threadVariant: SessionThread.Variant, + callMessage: CallMessage, + displayNameRetriever: (String) -> String? + ) { + let content: UNMutableNotificationContent = UNMutableNotificationContent() + content.userInfo = [ NotificationUserInfoKey.isFromRemote: true ] + content.title = Constants.app_name + content.body = callMessage.sender + .map { sender in displayNameRetriever(sender) } + .map { senderDisplayName in + "callsIncoming" + .put(key: "name", value: senderDisplayName) + .localized() + } + .defaulting(to: "callsIncomingUnknown".localized()) - if let sender: String = callMessage.sender { - let senderDisplayName: String = Profile.displayName(db, id: sender, threadVariant: .contact, using: dependencies) - notificationContent.body = "callsIncoming" - .put(key: "name", value: senderDisplayName) - .localized() - } - else { - notificationContent.body = "callsIncomingUnknown".localized() + /// Since we will have already written the message to disk at this stage we can just add the number of unread message files + /// directly to the `mainAppUnreadCount` in order to get the updated unread count + if let unreadPendingMessageCount: Int = dependencies[singleton: .extensionHelper].unreadMessageCount() { + content.badge = NSNumber(value: notification.info.mainAppUnreadCount + unreadPendingMessageCount) } - let identifier = self.request?.identifier ?? UUID().uuidString - let request = UNNotificationRequest(identifier: identifier, content: notificationContent, trigger: nil) + let request = UNNotificationRequest( + identifier: notification.info.requestId, + content: content, + trigger: nil + ) let semaphore = DispatchSemaphore(value: 0) UNUserNotificationCenter.current().add(request) { error in if let error = error { - Log.error(.cat, "Failed to add notification request for requestId: \(requestId) due to error: \(error).") + Log.error(.cat, "Failed to add notification request for requestId: \(notification.info.requestId) due to error: \(error).") } semaphore.signal() } semaphore.wait() - Log.info(.cat, "Add remote notification request for requestId: \(requestId).") + Log.info(.cat, "Add remote notification request for requestId: \(notification.info.requestId).") - db.afterNextTransaction( - onCommit: { [weak self] _ in self?.completeSilenty(.errorCallFailure, requestId: requestId) }, - onRollback: { [weak self] _ in self?.completeSilenty(.errorTransactionFailure, requestId: requestId) } - ) + completeSilenty(notification.info, .errorCallFailure) } private func handleFailure( - for content: UNMutableNotificationContent, - metadata: PushNotificationAPI.NotificationMetadata, + _ info: NotificationInfo, threadVariant: SessionThread.Variant?, threadDisplayName: String?, - resolution: NotificationResolution, - requestId: String + resolution: NotificationResolution ) { - // This can be called from within database threads so to prevent blocking and weird - // behaviours make sure to send it to the main thread instead - guard Thread.isMainThread else { - return DispatchQueue.main.async { [weak self] in - self?.handleFailure( - for: content, - metadata: metadata, - threadVariant: threadVariant, - threadDisplayName: threadDisplayName, - resolution: resolution, - requestId: requestId - ) - } - } - let duration: CFTimeInterval = (CACurrentMediaTime() - startTime) - let previewType: Preferences.NotificationPreviewType = dependencies[singleton: .storage, key: .preferencesNotificationPreviewType] - .defaulting(to: .nameAndPreview) - Log.error(.cat, "\(resolution) after \(.seconds(duration), unit: .ms), showing generic failure message for message from namespace: \(metadata.namespace), requestId: \(requestId).") - - /// Now we are done with the database, we should suspend it - if !dependencies[defaults: .appGroup, key: .isMainAppActive] { - dependencies[singleton: .storage].suspendDatabaseAccess() - } + let targetThreadVariant: SessionThread.Variant = (threadVariant ?? .contact) /// Fallback to `contact` + let notificationSettings: Preferences.NotificationSettings = dependencies[singleton: .notificationsManager].settings( + threadId: info.metadata.accountId, + threadVariant: targetThreadVariant + ) + Log.error(.cat, "\(resolution) after \(.seconds(duration), unit: .ms), showing generic failure message for message from namespace: \(info.metadata.namespace), requestId: \(info.requestId).") /// Clear the logger Log.flush() Log.reset() - content.title = Constants.app_name - content.userInfo = [ NotificationServiceExtension.isFromRemoteKey: true ] + info.content.title = Constants.app_name + info.content.userInfo = [ NotificationUserInfoKey.isFromRemote: true ] /// If it's a notification for a group conversation, the notification preferences are right and we have a name for the group /// then we should include it in the notification content - switch (threadVariant, previewType, threadDisplayName) { + switch (targetThreadVariant, notificationSettings.previewType, threadDisplayName) { case (.group, .nameAndPreview, .some(let name)), (.group, .nameNoPreview, .some(let name)), (.legacyGroup, .nameAndPreview, .some(let name)), (.legacyGroup, .nameNoPreview, .some(let name)): - content.body = "messageNewYouveGotGroup" + info.content.body = "messageNewYouveGotGroup" .putNumber(1) .put(key: "group_name", value: name) .localized() default: - content.body = "messageNewYouveGot" + info.content.body = "messageNewYouveGot" .putNumber(1) .localized() } - contentHandler!(content) + info.contentHandler(info.content) hasCompleted = true } } + +// MARK: - Convenience + +private extension NotificationServiceExtension { + struct NotificationInfo { + static let invalid: NotificationInfo = NotificationInfo( + content: UNMutableNotificationContent(), + requestId: "N/A", // stringlint:ignore + contentHandler: { _ in }, + metadata: .invalid, + data: Data(), + mainAppUnreadCount: 0 + ) + + let content: UNMutableNotificationContent + let requestId: String + let contentHandler: ((UNNotificationContent) -> Void) + let metadata: PushNotificationAPI.NotificationMetadata + let data: Data + let mainAppUnreadCount: Int + + func with( + requestId: String? = nil, + content: UNMutableNotificationContent? = nil, + contentHandler: ((UNNotificationContent) -> Void)? = nil, + metadata: PushNotificationAPI.NotificationMetadata? = nil + ) -> NotificationInfo { + return NotificationInfo( + content: (content ?? self.content), + requestId: (requestId ?? self.requestId), + contentHandler: (contentHandler ?? self.contentHandler), + metadata: (metadata ?? self.metadata), + data: data, + mainAppUnreadCount: mainAppUnreadCount + ) + } + } + + typealias ProcessedNotification = ( + info: NotificationInfo, + processedMessage: ProcessedMessage, + threadId: String, + threadVariant: SessionThread.Variant?, + threadDisplayName: String? + ) + + enum NotificationError: Error { + case notReadyForExtension + case processingErrorWithFallback(PushNotificationAPI.ProcessResult, PushNotificationAPI.NotificationMetadata) + case processingError(PushNotificationAPI.ProcessResult, PushNotificationAPI.NotificationMetadata) + case timeout + } +} diff --git a/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift b/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift index 7dfd5aba25..8f2bc2fbdf 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift @@ -3,7 +3,6 @@ // stringlint:disable import UIKit -import SignalUtilitiesKit import SessionUtilitiesKit final class NotificationServiceExtensionContext: AppContext { diff --git a/SessionShareExtension/SAEScreenLockViewController.swift b/SessionShareExtension/SAEScreenLockViewController.swift index 748bbb450f..216365d4cb 100644 --- a/SessionShareExtension/SAEScreenLockViewController.swift +++ b/SessionShareExtension/SAEScreenLockViewController.swift @@ -6,21 +6,23 @@ import SessionUIKit import SessionUtilitiesKit final class SAEScreenLockViewController: ScreenLockViewController { - private let dependencies: Dependencies private var hasShownAuthUIOnce: Bool = false private var isShowingAuthUI: Bool = false - private weak var shareViewDelegate: ShareViewDelegate? + private let hasUserMetadata: Bool + private let onUnlock: () -> Void + private let onCancel: () -> Void // MARK: - Initialization - init(shareViewDelegate: ShareViewDelegate, using dependencies: Dependencies) { - self.dependencies = dependencies + init(hasUserMetadata: Bool, onUnlock: @escaping () -> Void, onCancel: @escaping () -> Void) { + self.hasUserMetadata = hasUserMetadata + self.onUnlock = onUnlock + self.onCancel = onCancel super.init() self.onUnlockPressed = { [weak self] in self?.unlockButtonWasTapped() } - self.shareViewDelegate = shareViewDelegate } required init?(coder aDecoder: NSCoder) { @@ -117,7 +119,7 @@ final class SAEScreenLockViewController: ScreenLockViewController { Log.info("unlock screen lock succeeded.") self?.isShowingAuthUI = false - self?.shareViewDelegate?.shareViewWasUnlocked() + self?.onUnlock() }, failure: { [weak self] error in Log.assertOnMainThread() @@ -188,6 +190,6 @@ final class SAEScreenLockViewController: ScreenLockViewController { } private func cancelShareExperience() { - self.shareViewDelegate?.shareViewWasCancelled() + self.onCancel() } } diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index 10b24e1aa2..157df71a43 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import AVFoundation import Combine import CoreServices import UniformTypeIdentifiers @@ -10,7 +11,7 @@ import SessionSnodeKit import SessionUtilitiesKit import SessionMessagingKit -final class ShareNavController: UINavigationController, ShareViewDelegate { +final class ShareNavController: UINavigationController { public static var attachmentPrepPublisher: AnyPublisher<[SignalAttachment], Error>? /// The `ShareNavController` is initialized from a storyboard so we need to manually initialize this @@ -47,15 +48,17 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { additionalMigrationTargets: [DeprecatedUIKitMigrationTarget.self], appSpecificBlock: { [dependencies] in // stringlint:ignore_start - Log.setup(with: Logger( - primaryPrefix: "SessionShareExtension", - customDirectory: "\(dependencies[singleton: .fileManager].appSharedDataDirectoryPath)/Logs/ShareExtension", - using: dependencies - )) + if !Log.loggerExists(withPrefix: "SessionShareExtension") { + Log.setup(with: Logger( + primaryPrefix: "SessionShareExtension", + customDirectory: "\(dependencies[singleton: .fileManager].appSharedDataDirectoryPath)/Logs/ShareExtension", + using: dependencies + )) + LibSession.setupLogger(using: dependencies) + } // stringlint:ignore_stop // Setup LibSession - LibSession.setupLogger(using: dependencies) dependencies.warmCache(cache: .libSessionNetwork) // Configure the different targets @@ -75,12 +78,19 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { /// 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[singleton: .storage].read { db -> ThemeSettings in - (db[.theme], db[.themePrimaryColor], db[.themeMatchSystemDayNightCycle]) + themeSettings: dependencies.mutate(cache: .libSession) { cache -> ThemeSettings in + ( + cache.get(.theme), + cache.get(.themePrimaryColor), + cache.get(.themeMatchSystemDayNightCycle) + ) } ) - self?.versionMigrationsDidComplete() + let maybeUserMetadata: ExtensionHelper.UserMetadata? = dependencies[singleton: .extensionHelper] + .loadUserMetadata() + + self?.versionMigrationsDidComplete(userMetadata: maybeUserMetadata) } } }, @@ -104,23 +114,23 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { ThemeManager.traitCollectionDidChange(previousTraitCollection) } - func versionMigrationsDidComplete() { + func versionMigrationsDidComplete(userMetadata: ExtensionHelper.UserMetadata?) { Log.assertOnMainThread() /// Now that the migrations are completed schedule config syncs for **all** configs that have pending changes to /// 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 ready for app extensions to run - if dependencies[singleton: .appContext].isAppForegroundAndActive && dependencies[singleton: .storage, key: .isReadyForAppExtensions] { + if dependencies[singleton: .appContext].isAppForegroundAndActive && userMetadata != nil { dependencies[singleton: .storage].writeAsync { [dependencies] db in - dependencies.mutate(cache: .libSession) { $0.syncAllPendingChanges(db) } + dependencies.mutate(cache: .libSession) { $0.syncAllPendingPushes(db) } } } - checkIsAppReady(migrationsCompleted: true) + checkIsAppReady(migrationsCompleted: true, userMetadata: userMetadata) } - func checkIsAppReady(migrationsCompleted: Bool) { + func checkIsAppReady(migrationsCompleted: Bool, userMetadata: ExtensionHelper.UserMetadata?) { Log.assertOnMainThread() // If something went wrong during startup then show the UI still (it has custom UI for @@ -128,15 +138,16 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { guard migrationsCompleted, dependencies[singleton: .storage].isValid, - !dependencies[singleton: .appReadiness].isAppReady - else { return showLockScreenOrMainContent() } + !dependencies[singleton: .appReadiness].isAppReady, + userMetadata != nil + else { return showLockScreenOrMainContent(userMetadata: userMetadata) } // Note that this does much more than set a flag; // it will also run all deferred blocks. dependencies[singleton: .appReadiness].setAppReady() dependencies.mutate(cache: .appVersion) { $0.saeLaunchDidComplete() } - showLockScreenOrMainContent() + showLockScreenOrMainContent(userMetadata: userMetadata) } override func viewDidLoad() { @@ -150,7 +161,7 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { Log.assertOnMainThread() Log.flush() - if dependencies[singleton: .storage, key: .isScreenLockEnabled] { + if dependencies.mutate(cache: .libSession, { $0.get(.isScreenLockEnabled) }) { self.dismiss(animated: false) { [weak self] in Log.assertOnMainThread() self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) @@ -170,22 +181,28 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { // MARK: - Updating - private func showLockScreenOrMainContent() { - if dependencies[singleton: .storage, key: .isScreenLockEnabled] && !dependencies[defaults: .appGroup, key: .isMainAppActive] { - showLockScreen() + private func showLockScreenOrMainContent(userMetadata: ExtensionHelper.UserMetadata?) { + if dependencies.mutate(cache: .libSession, { $0.get(.isScreenLockEnabled) }) { + showLockScreen(userMetadata: userMetadata) } else { - showMainContent() + showMainContent(userMetadata: userMetadata) } } - private func showLockScreen() { - let screenLockVC = SAEScreenLockViewController(shareViewDelegate: self, using: dependencies) + private func showLockScreen(userMetadata: ExtensionHelper.UserMetadata?) { + let screenLockVC = SAEScreenLockViewController( + hasUserMetadata: userMetadata != nil, + onUnlock: { [weak self] in self?.showMainContent(userMetadata: userMetadata) }, + onCancel: { [weak self] in + self?.shareViewWasCompleted(threadId: nil, interactionId: nil) + } + ) setViewControllers([ screenLockVC ], animated: false) } - private func showMainContent() { - let threadPickerVC: ThreadPickerVC = ThreadPickerVC(using: dependencies) + private func showMainContent(userMetadata: ExtensionHelper.UserMetadata?) { + let threadPickerVC: ThreadPickerVC = ThreadPickerVC(userMetadata: userMetadata, using: dependencies) threadPickerVC.shareNavController = self setViewControllers([ threadPickerVC ], animated: false) @@ -206,16 +223,14 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { ShareNavController.attachmentPrepPublisher = publisher } - func shareViewWasUnlocked() { - showMainContent() - } - - func shareViewWasCompleted() { - extensionContext?.completeRequest(returningItems: [], completionHandler: nil) - } - - func shareViewWasCancelled() { - extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + func shareViewWasCompleted(threadId: String?, interactionId: Int64?) { + dependencies[defaults: .appGroup, key: .lastSharedThreadId] = threadId + + if let interactionId: Int64 = interactionId { + dependencies[defaults: .appGroup, key: .lastSharedMessageId] = Int(interactionId) + } + + extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) } func shareViewFailed(error: Error) { @@ -727,13 +742,8 @@ private struct SAESNUIKitConfig: SNUIKit.ConfigType { // MARK: - Functions - func themeChanged(_ theme: Theme, _ primaryColor: Theme.PrimaryColor, _ matchSystemNightModeSetting: Bool) { - dependencies[singleton: .storage].write { db in - db[.theme] = theme - db[.themePrimaryColor] = primaryColor - db[.themeMatchSystemDayNightCycle] = matchSystemNightModeSetting - } - } + /// Unable to change the theme from the Share extension + func themeChanged(_ theme: Theme, _ primaryColor: Theme.PrimaryColor, _ matchSystemNightModeSetting: Bool) {} func navBarSessionIcon() -> NavBarSessionIcon { switch (dependencies[feature: .serviceNetwork], dependencies[feature: .forceOffline]) { @@ -762,36 +772,16 @@ private struct SAESNUIKitConfig: SNUIKit.ConfigType { func removeCachedContextualActionInfo(tableViewHash: Int, keys: [String]) {} - func placeholderIconCacher(cacheKey: String, generator: @escaping () -> UIImage) -> UIImage { - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - var cachedIcon: UIImage? - - Task { - switch await dependencies[singleton: .imageDataManager].cachedImage(identifier: cacheKey)?.type { - case .staticImage(let image): cachedIcon = image - case .animatedImage(let frames, _): cachedIcon = frames.first // Shouldn't be possible - case .none: break - } - - semaphore.signal() - } - semaphore.wait() - - switch cachedIcon { - case .some(let image): return image - case .none: - let generatedImage: UIImage = generator() - Task { - await dependencies[singleton: .imageDataManager].cacheImage( - generatedImage, - for: cacheKey - ) - } - return generatedImage - } - } - func shouldShowStringKeys() -> Bool { return dependencies[feature: .showStringKeys] } + + func asset(for path: String, mimeType: String, sourceFilename: String?) -> (asset: AVURLAsset, cleanup: () -> Void)? { + return AVURLAsset.asset( + for: path, + mimeType: mimeType, + sourceFilename: sourceFilename, + using: dependencies + ) + } } diff --git a/SessionShareExtension/SimplifiedConversationCell.swift b/SessionShareExtension/SimplifiedConversationCell.swift index f61c1212f7..0d421c8caf 100644 --- a/SessionShareExtension/SimplifiedConversationCell.swift +++ b/SessionShareExtension/SimplifiedConversationCell.swift @@ -91,7 +91,7 @@ final class SimplifiedConversationCell: UITableViewCell { profilePictureView.update( publicKey: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant, - displayPictureFilename: cellViewModel.displayPictureFilename, + displayPictureUrl: cellViewModel.threadDisplayPictureUrl, profile: cellViewModel.profile, additionalProfile: cellViewModel.additionalProfile, using: dependencies diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index f6c4089928..5afa962cc8 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -23,8 +23,8 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView // MARK: - Intialization - init(using dependencies: Dependencies) { - viewModel = ThreadPickerViewModel(using: dependencies) + init(userMetadata: ExtensionHelper.UserMetadata?, using dependencies: Dependencies) { + viewModel = ThreadPickerViewModel(userMetadata: userMetadata, using: dependencies) super.init(nibName: nil, bundle: nil) } @@ -67,21 +67,22 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView result.textAlignment = .center result.themeTextColor = .textPrimary result.numberOfLines = 0 - result.isHidden = true + result.isHidden = (viewModel.userMetadata != nil) return result }() private lazy var tableView: UITableView = { - let tableView: UITableView = UITableView() - tableView.themeBackgroundColor = .backgroundPrimary - tableView.separatorStyle = .none - tableView.register(view: SimplifiedConversationCell.self) - tableView.showsVerticalScrollIndicator = false - tableView.dataSource = self - tableView.delegate = self + let result: UITableView = UITableView() + result.themeBackgroundColor = .backgroundPrimary + result.separatorStyle = .none + result.register(view: SimplifiedConversationCell.self) + result.showsVerticalScrollIndicator = false + result.dataSource = self + result.delegate = self + result.isHidden = (viewModel.userMetadata == nil) - return tableView + return result }() // MARK: - Lifecycle @@ -90,7 +91,6 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView super.viewDidLoad() navigationItem.titleView = titleLabel - ThemeManager.applyNavigationStylingIfNeeded(to: self) view.themeBackgroundColor = .backgroundPrimary view.addSubview(tableView) @@ -103,6 +103,9 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + /// Apply the nav styling in `viewWillAppear` instead of `viewDidLoad` as it's possible the nav stack isn't fully setup + /// and could crash when trying to access it (whereas by the time `viewWillAppear` is called it should be setup) + ThemeManager.applyNavigationStylingIfNeeded(to: self) startObservingChanges() } @@ -137,10 +140,9 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView private func startObservingChanges() { guard dataChangeObservable == nil else { return } - noAccountErrorLabel.isHidden = viewModel.dependencies[singleton: .storage, key: .isReadyForAppExtensions] - tableView.isHidden = !viewModel.dependencies[singleton: .storage, key: .isReadyForAppExtensions] + tableView.isHidden = !noAccountErrorLabel.isHidden - guard viewModel.dependencies[singleton: .storage, key: .isReadyForAppExtensions] else { return } + guard viewModel.userMetadata != nil else { return } // Start observing for data changes dataChangeObservable = self.viewModel.dependencies[singleton: .storage].start( @@ -258,6 +260,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView /// 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 + var sharedInteractionId: Int64? dependencies[singleton: .network] .getSwarm(for: swarmPublicKey) .tryFlatMapWithRandomSnode(using: dependencies) { snode in @@ -266,22 +269,20 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView .send(using: dependencies) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .flatMapStorageWritePublisher(using: dependencies) { db, _ -> (Interaction, [Network.PreparedRequest]) in + .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 } // Update the thread to be visible (if it isn't already) if !thread.shouldBeVisible || thread.pinnedPriority == LibSession.hiddenPriority { - _ = try SessionThread - .filter(id: threadId) - .updateAllAndConfig( - db, - SessionThread.Columns.shouldBeVisible.set(to: true), - SessionThread.Columns.pinnedPriority.set(to: LibSession.visiblePriority), - SessionThread.Columns.isDraft.set(to: false), - using: dependencies - ) + try SessionThread.updateVisibility( + db, + threadId: threadId, + isVisible: true, + additionalChanges: [SessionThread.Columns.isDraft.set(to: false)], + using: dependencies + ) } // Create the interaction @@ -305,6 +306,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView linkPreviewUrl: (isSharingUrl ? attachments.first?.linkPreviewDraft?.urlString : nil), using: dependencies ).inserted(db) + sharedInteractionId = interaction.id guard let interactionId: Int64 = interaction.id else { throw StorageError.failedToSave @@ -333,32 +335,47 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView } // Process any attachments - try Attachment.process( + try AttachmentUploader.process( db, - attachments: Attachment.prepare(attachments: finalAttachments, using: dependencies), + 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, + threadVariant: threadVariant, + using: dependencies + ) let attachmentState: MessageSendJob.AttachmentState = try MessageSendJob .fetchAttachmentState(db, interactionId: interactionId) - let preparedUploads: [Network.PreparedRequest] = try Attachment + let preparedUploads: [Network.PreparedRequest<(attachment: Attachment, fileId: String)>] = try Attachment .filter(ids: attachmentState.allAttachmentIds) .fetchAll(db) .map { attachment in - try attachment.preparedUpload( - db, - threadId: threadId, + 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 + ) - return (interaction, preparedUploads) + return (visibleMessage, destination, interaction.id, authMethod, preparedUploads) } - .flatMap { (interaction: Interaction, preparedUploads: [Network.PreparedRequest]) -> AnyPublisher<(interaction: Interaction, fileIds: [String]), Error> in + .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((interaction, [])) + return Just((message, destination, interactionId, authMethod, [])) .setFailureType(to: Error.self) .eraseToAnyPublisher() } @@ -366,30 +383,39 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView return Publishers .MergeMany(preparedUploads.map { $0.send(using: dependencies) }) .collect() - .map { results in (interaction, results.map { _, id in id }) } + .map { results in (message, destination, interactionId, authMethod, results.map { _, value in value }) } .eraseToAnyPublisher() } - .flatMapStorageWritePublisher(using: dependencies) { db, info -> Network.PreparedRequest in - // Prepare the message send data - guard - let threadVariant: SessionThread.Variant = try SessionThread - .filter(id: info.interaction.threadId) - .select(.variant) - .asRequest(of: SessionThread.Variant.self) - .fetchOne(db) - else { throw MessageSenderError.noThread } - - return try MessageSender + .tryFlatMap { message, destination, interactionId, authMethod, attachments -> AnyPublisher<(Message, [Attachment]), Error> in + try MessageSender .preparedSend( - db, - interaction: info.interaction, - fileIds: info.fileIds, - threadId: threadId, - threadVariant: threadVariant, + 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() } - .flatMap { $0.send(using: dependencies) } + .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 + try? attachment.upsert(db) + } + } + } + ) .receive(on: DispatchQueue.main) .sinkUntilComplete( receiveCompletion: { [weak self] result in @@ -399,7 +425,10 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView activityIndicator.dismiss { } switch result { - case .finished: self?.shareNavController?.shareViewWasCompleted() + case .finished: self?.shareNavController?.shareViewWasCompleted( + threadId: threadId, + interactionId: sharedInteractionId + ) case .failure(let error): self?.shareNavController?.shareViewFailed(error: error) } } diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index 94022afe67..22b101b8ee 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -11,9 +11,11 @@ public class ThreadPickerViewModel { // MARK: - Initialization public let dependencies: Dependencies + public let userMetadata: ExtensionHelper.UserMetadata? - init(using dependencies: Dependencies) { + init(userMetadata: ExtensionHelper.UserMetadata?, using dependencies: Dependencies) { self.dependencies = dependencies + self.userMetadata = userMetadata } // MARK: - Content @@ -41,27 +43,24 @@ public class ThreadPickerViewModel { .map { threadViewModel in let wasKickedFromGroup: Bool = ( threadViewModel.threadVariant == .group && - LibSession.wasKickedFromGroup( - groupSessionId: SessionId(.group, hex: threadViewModel.threadId), - using: dependencies - ) + dependencies.mutate(cache: .libSession) { cache in + cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadViewModel.threadId)) + } ) let groupIsDestroyed: Bool = ( threadViewModel.threadVariant == .group && - LibSession.groupIsDestroyed( - groupSessionId: SessionId(.group, hex: threadViewModel.threadId), - using: dependencies - ) + dependencies.mutate(cache: .libSession) { cache in + cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadViewModel.threadId)) + } ) return threadViewModel.populatingPostQueryData( - db, - currentUserBlinded15SessionIdForThisThread: nil, - currentUserBlinded25SessionIdForThisThread: nil, + recentReactionEmoji: nil, + openGroupCapabilities: nil, + currentUserSessionIds: [userSessionId.hexString], wasKickedFromGroup: wasKickedFromGroup, groupIsDestroyed: groupIsDestroyed, - threadCanWrite: threadViewModel.determineInitialCanWriteFlag(using: dependencies), - using: dependencies + threadCanWrite: threadViewModel.determineInitialCanWriteFlag(using: dependencies) ) } } diff --git a/SessionSnodeKit/Configuration.swift b/SessionSnodeKit/Configuration.swift index 00caee23de..af6ed9cc8d 100644 --- a/SessionSnodeKit/Configuration.swift +++ b/SessionSnodeKit/Configuration.swift @@ -26,7 +26,9 @@ public enum SNSnodeKit: MigratableTarget { // Just to make the external API nice _006_DropSnodeCache.self, _007_SplitSnodeReceivedMessageInfo.self, _008_ResetUserConfigLastHashes.self - ] + ], + [], // Renamed `Setting` to `KeyValueStore` + [] ] ) } diff --git a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift index 91f2719e89..02f160fe0c 100644 --- a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -12,7 +12,7 @@ enum _001_InitialSetupMigration: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.create(table: "snode") { t in t.column("public_ip", .text) t.column("storage_port", .integer) @@ -44,6 +44,6 @@ enum _001_InitialSetupMigration: Migration { t.uniqueKey(["key", "hash"]) } - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift index 6635914e29..e92355cc9e 100644 --- a/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -12,7 +12,14 @@ enum _002_SetupStandardJobs: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { + /// Only insert jobs if the `jobs` table exists or we aren't running tests (when running tests this allows us to skip running the + /// SNUtilitiesKit migrations) + guard + !SNUtilitiesKit.isRunningTests || + ((try? db.tableExists("job")) == true) + else { return MigrationExecution.updateProgress(1) } + // Note: We also want this job to run both onLaunch and onActive as we want it to block // 'onLaunch' and 'onActive' doesn't support blocking jobs try db.execute(sql: """ @@ -32,6 +39,6 @@ enum _002_SetupStandardJobs: Migration { ) """) - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 3ecef144ac..4e826bc308 100644 --- a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -10,13 +10,7 @@ enum _003_YDBToGRDBMigration: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { - guard - !SNUtilitiesKit.isRunningTests && - MigrationHelper.userExists(db) - else { return Storage.update(progress: 1, for: self, in: target, using: dependencies) } - - Log.error(.migration, "Attempted to perform legacy migation") - throw StorageError.migrationNoLongerSupported + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { + MigrationExecution.updateProgress(1) } } diff --git a/SessionSnodeKit/Database/Migrations/_004_FlagMessageHashAsDeletedOrInvalid.swift b/SessionSnodeKit/Database/Migrations/_004_FlagMessageHashAsDeletedOrInvalid.swift index 4ce2043a28..486665167c 100644 --- a/SessionSnodeKit/Database/Migrations/_004_FlagMessageHashAsDeletedOrInvalid.swift +++ b/SessionSnodeKit/Database/Migrations/_004_FlagMessageHashAsDeletedOrInvalid.swift @@ -13,12 +13,12 @@ enum _004_FlagMessageHashAsDeletedOrInvalid: Migration { static let minExpectedRunDuration: TimeInterval = 0.2 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.alter(table: "snodeReceivedMessageInfo") { t in t.add(column: "wasDeletedOrInvalid", .boolean) .indexed() // Faster querying } - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionSnodeKit/Database/Migrations/_005_AddSnodeReveivedMessageInfoPrimaryKey.swift b/SessionSnodeKit/Database/Migrations/_005_AddSnodeReveivedMessageInfoPrimaryKey.swift index 7ea09da522..acc361590d 100644 --- a/SessionSnodeKit/Database/Migrations/_005_AddSnodeReveivedMessageInfoPrimaryKey.swift +++ b/SessionSnodeKit/Database/Migrations/_005_AddSnodeReveivedMessageInfoPrimaryKey.swift @@ -11,7 +11,7 @@ enum _005_AddSnodeReveivedMessageInfoPrimaryKey: Migration { static let minExpectedRunDuration: TimeInterval = 0.2 static let createdTables: [(TableRecord & FetchableRecord).Type] = [SnodeReceivedMessageInfo.self] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { // SQLite doesn't support adding a new primary key after creation so we need to create a new table with // the setup we want, copy data from the old table over, drop the old table and rename the new table try db.create(table: "tmpSnodeReceivedMessageInfo") { t in @@ -40,6 +40,6 @@ enum _005_AddSnodeReveivedMessageInfoPrimaryKey: Migration { try db.create(indexOn: "snodeReceivedMessageInfo", columns: ["expirationDateMs"]) try db.create(indexOn: "snodeReceivedMessageInfo", columns: ["wasDeletedOrInvalid"]) - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionSnodeKit/Database/Migrations/_006_DropSnodeCache.swift b/SessionSnodeKit/Database/Migrations/_006_DropSnodeCache.swift index 3141d1081c..b2a3d41bd2 100644 --- a/SessionSnodeKit/Database/Migrations/_006_DropSnodeCache.swift +++ b/SessionSnodeKit/Database/Migrations/_006_DropSnodeCache.swift @@ -11,7 +11,7 @@ enum _006_DropSnodeCache: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.drop(table: "snode") try db.drop(table: "snodeSet") @@ -23,6 +23,6 @@ enum _006_DropSnodeCache: Migration { ].map { "\($0.rawValue)" }.joined(separator: ", ") try db.execute(sql: "DELETE FROM job WHERE variant IN (\(variantsToDelete))") - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionSnodeKit/Database/Migrations/_007_SplitSnodeReceivedMessageInfo.swift b/SessionSnodeKit/Database/Migrations/_007_SplitSnodeReceivedMessageInfo.swift index 87be202bbd..aa74f45ff4 100644 --- a/SessionSnodeKit/Database/Migrations/_007_SplitSnodeReceivedMessageInfo.swift +++ b/SessionSnodeKit/Database/Migrations/_007_SplitSnodeReceivedMessageInfo.swift @@ -11,7 +11,7 @@ enum _007_SplitSnodeReceivedMessageInfo: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [SnodeReceivedMessageInfo.self] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { /// Fetch the existing values and then drop the table let existingValues: [Row] = try Row.fetchAll(db, sql: """ SELECT key, hash, expirationDateMs, wasDeletedOrInvalid @@ -132,6 +132,6 @@ enum _007_SplitSnodeReceivedMessageInfo: Migration { ) } - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionSnodeKit/Database/Migrations/_008_ResetUserConfigLastHashes.swift b/SessionSnodeKit/Database/Migrations/_008_ResetUserConfigLastHashes.swift index 03a1b8f814..468ee6999c 100644 --- a/SessionSnodeKit/Database/Migrations/_008_ResetUserConfigLastHashes.swift +++ b/SessionSnodeKit/Database/Migrations/_008_ResetUserConfigLastHashes.swift @@ -12,12 +12,12 @@ enum _008_ResetUserConfigLastHashes: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.execute(literal: """ DELETE FROM snodeReceivedMessageInfo WHERE namespace IN (\(SnodeAPI.Namespace.configContacts.rawValue), \(SnodeAPI.Namespace.configUserProfile.rawValue), \(SnodeAPI.Namespace.configUserGroups.rawValue), \(SnodeAPI.Namespace.configConvoInfoVolatile.rawValue)) """) - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift index 68b6c394f6..a54cbc083f 100644 --- a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift +++ b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift @@ -73,7 +73,7 @@ public extension SnodeReceivedMessageInfo { public extension SnodeReceivedMessageInfo { /// This method fetches the last non-expired hash from the database for message retrieval static func fetchLastNotExpired( - _ db: Database, + _ db: ObservingDatabase, for snode: LibSession.Snode, namespace: SnodeAPI.Namespace, swarmPublicKey: String, @@ -100,7 +100,7 @@ public extension SnodeReceivedMessageInfo { /// solely duplicate messages (for the specific service node - if even one message in a response is new for that service node then this shouldn't /// be called if if the message has already been received and processed by a separate service node) static func handlePotentialDeletedOrInvalidHash( - _ db: Database, + _ db: ObservingDatabase, potentiallyInvalidHashes: [String], otherKnownValidHashes: [String] = [] ) throws { @@ -125,4 +125,12 @@ public extension SnodeReceivedMessageInfo { ) } } + + func storeUpdatedLastHash(_ db: ObservingDatabase) -> Bool { + do { + _ = try self.inserted(db) + return true + } + catch { return false } + } } diff --git a/SessionSnodeKit/LibSession/LibSession+Networking.swift b/SessionSnodeKit/LibSession/LibSession+Networking.swift index 74f2fd1dba..3967b15282 100644 --- a/SessionSnodeKit/LibSession/LibSession+Networking.swift +++ b/SessionSnodeKit/LibSession/LibSession+Networking.swift @@ -502,7 +502,7 @@ private extension NetworkStatus { // MARK: - Snode extension LibSession { - public struct Snode: Hashable, CustomStringConvertible { + public struct Snode: Codable, Hashable, CustomStringConvertible { public let ip: String public let quicPort: UInt16 public let ed25519PubkeyHex: String diff --git a/SessionSnodeKit/Models/GetMessagesResponse.swift b/SessionSnodeKit/Models/GetMessagesResponse.swift index 8d99afb629..9c15c1e070 100644 --- a/SessionSnodeKit/Models/GetMessagesResponse.swift +++ b/SessionSnodeKit/Models/GetMessagesResponse.swift @@ -11,15 +11,27 @@ public class GetMessagesResponse: SnodeResponse { public class RawMessage: Codable { private enum CodingKeys: String, CodingKey { case base64EncodedDataString = "data" - case expiration + case expirationMs = "expiration" case hash case timestampMs = "timestamp" } public let base64EncodedDataString: String - public let expiration: Int64? + 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] @@ -27,6 +39,21 @@ public class GetMessagesResponse: SnodeResponse { // 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) diff --git a/SessionSnodeKit/Models/SnodeAuthenticatedRequestBody.swift b/SessionSnodeKit/Models/SnodeAuthenticatedRequestBody.swift index f6213ebfcb..4bcaa17c42 100644 --- a/SessionSnodeKit/Models/SnodeAuthenticatedRequestBody.swift +++ b/SessionSnodeKit/Models/SnodeAuthenticatedRequestBody.swift @@ -41,15 +41,17 @@ public class SnodeAuthenticatedRequestBody: Encodable { try container.encodeIfPresent(timestampMs, forKey: .timestampMs) switch authMethod.info { - case .standard(let sessionId, let ed25519KeyPair): + case .standard(let sessionId, let ed25519PublicKey): try container.encode(sessionId.hexString, forKey: .pubkey) - try container.encode(ed25519KeyPair.publicKey.toHexString(), forKey: .ed25519PublicKey) + 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 { diff --git a/SessionSnodeKit/Models/SnodeMessage.swift b/SessionSnodeKit/Models/SnodeMessage.swift index 07cf76f093..a9fbcdd1e3 100644 --- a/SessionSnodeKit/Models/SnodeMessage.swift +++ b/SessionSnodeKit/Models/SnodeMessage.swift @@ -27,9 +27,9 @@ public final class SnodeMessage: Codable { // MARK: - Initialization - public init(recipient: String, data: String, ttl: UInt64, timestampMs: UInt64) { + public init(recipient: String, data: Data, ttl: UInt64, timestampMs: UInt64) { self.recipient = recipient - self.data = data + self.data = data.base64EncodedString() self.ttl = ttl self.timestampMs = timestampMs } @@ -43,7 +43,9 @@ extension SnodeMessage { self.init( recipient: try container.decode(String.self, forKey: .recipient), - data: try container.decode(String.self, forKey: .data), + 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) ) diff --git a/SessionSnodeKit/Models/SnodeReceivedMessage.swift b/SessionSnodeKit/Models/SnodeReceivedMessage.swift index c950a74a6b..99604b49e8 100644 --- a/SessionSnodeKit/Models/SnodeReceivedMessage.swift +++ b/SessionSnodeKit/Models/SnodeReceivedMessage.swift @@ -5,18 +5,36 @@ import Foundation import SessionUtilitiesKit -public struct SnodeReceivedMessage: CustomDebugStringConvertible { +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 defaultExpirationSeconds: Int64 = ((15 * 24 * 60 * 60) * 1000) + public static let defaultExpirationMs: Int64 = ((15 * 24 * 60 * 60) * 1000) - public let info: SnodeReceivedMessageInfo + /// 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: SnodeAPI.Namespace + public let hash: String public let timestampMs: Int64 + public let expirationTimestampMs: Int64 public let data: Data - init?( - snode: LibSession.Snode, + 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: SnodeAPI.Namespace, rawMessage: GetMessagesResponse.RawMessage @@ -26,23 +44,22 @@ public struct SnodeReceivedMessage: CustomDebugStringConvertible { return nil } - self.info = SnodeReceivedMessageInfo( - snode: snode, - swarmPublicKey: publicKey, - namespace: namespace, - hash: rawMessage.hash, - expirationDateMs: (rawMessage.expiration ?? SnodeReceivedMessage.defaultExpirationSeconds) - ) + 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( - hash: \(info.hash), - expirationMs: \(info.expirationDateMs), + swarmPublicKey: \(swarmPublicKey), + namespace: \(namespace), + hash: \(hash), + expirationTimestampMs: \(expirationTimestampMs), timestampMs: \(timestampMs), data: \(data.base64EncodedString()) ) diff --git a/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI+Network.swift b/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI+Network.swift index 947e55d15d..fb26688ac1 100644 --- a/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI+Network.swift +++ b/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI+Network.swift @@ -41,15 +41,12 @@ extension SessionNetworkAPI { .eraseToAnyPublisher() } - return dependencies[singleton: .storage] - .readPublisher { db -> Network.PreparedRequest in - try SessionNetworkAPI - .prepareInfo( - db, - using: dependencies - ) + return Result { + try SessionNetworkAPI + .prepareInfo(using: dependencies) } - .flatMap { $0.send(using: dependencies) } + .publisher + .flatMap { [dependencies] in $0.send(using: dependencies) } .map { _, info in info } .flatMapStorageWritePublisher(using: dependencies) { [dependencies] db, info -> Bool in // Token info diff --git a/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI.swift b/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI.swift index 1d1891a51d..b2be2d86ba 100644 --- a/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI.swift +++ b/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI.swift @@ -4,7 +4,6 @@ import Foundation import Combine -import GRDB import SessionUtilitiesKit public enum SessionNetworkAPI { @@ -18,7 +17,6 @@ public enum SessionNetworkAPI { /// `GET/info` public static func prepareInfo( - _ db: Database, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( @@ -35,13 +33,12 @@ public enum SessionNetworkAPI { requestAndPathBuildTimeout: Network.defaultTimeout, using: dependencies ) - .signed(db, with: SessionNetworkAPI.signRequest, using: dependencies) + .signed(with: SessionNetworkAPI.signRequest, using: dependencies) } // MARK: - Authentication fileprivate static func signatureHeaders( - _ db: Database, url: URL, method: HTTPMethod, body: Data?, @@ -52,7 +49,6 @@ public enum SessionNetworkAPI { .appending(url.query.map { value in "?\(value)" }) let signResult: (publicKey: String, signature: [UInt8]) = try sign( - db, timestamp: timestamp, method: method.rawValue, path: path, @@ -68,7 +64,6 @@ public enum SessionNetworkAPI { } private static func sign( - _ db: Database, timestamp: UInt64, method: String, path: String, @@ -81,9 +76,11 @@ public enum SessionNetworkAPI { }() guard - let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), + !dependencies[cache: .general].ed25519SecretKey.isEmpty, let blinded07KeyPair: KeyPair = dependencies[singleton: .crypto].generate( - .versionBlinded07KeyPair(ed25519SecretKey: userEdKeyPair.secretKey) + .versionBlinded07KeyPair( + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey + ) ), let signatureResult: [UInt8] = dependencies[singleton: .crypto].generate( .signatureVersionBlind07( @@ -91,10 +88,10 @@ public enum SessionNetworkAPI { method: method, path: path, body: bodyString, - ed25519SecretKey: userEdKeyPair.secretKey + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey ) ) - else { throw NetworkError.signingFailed } + else { throw CryptoError.signatureGenerationFailed } return ( publicKey: SessionId(.versionBlinded07, publicKey: blinded07KeyPair.publicKey).hexString, @@ -103,22 +100,17 @@ public enum SessionNetworkAPI { } private static func signRequest( - _ db: Database, preparedRequest: Network.PreparedRequest, using dependencies: Dependencies ) throws -> Network.Destination { - guard let url: URL = preparedRequest.destination.url else { - throw NetworkError.signingFailed - } - - guard case let .server(info) = preparedRequest.destination else { - throw NetworkError.signingFailed - } + guard + let url: URL = preparedRequest.destination.url, + case let .server(info) = preparedRequest.destination + else { throw NetworkError.invalidPreparedRequest } return .server( info: info.updated( with: try signatureHeaders( - db, url: url, method: preparedRequest.method, body: preparedRequest.body, diff --git a/SessionSnodeKit/SnodeAPI/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI/SnodeAPI.swift index e818cee76d..af511bf056 100644 --- a/SessionSnodeKit/SnodeAPI/SnodeAPI.swift +++ b/SessionSnodeKit/SnodeAPI/SnodeAPI.swift @@ -18,7 +18,7 @@ public final class SnodeAPI { public typealias PollResponse = [SnodeAPI.Namespace: (info: ResponseInfoType, data: PreparedGetMessagesResponse?)] public static func preparedPoll( - _ db: Database, + _ db: ObservingDatabase, namespaces: [SnodeAPI.Namespace], refreshingConfigHashes: [String] = [], from snode: LibSession.Snode, @@ -68,7 +68,7 @@ public final class SnodeAPI { requests: requests, requireAllBatchResponses: true, snode: snode, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, using: dependencies ) .map { (_: ResponseInfoType, batchResponse: Network.BatchResponse) -> [SnodeAPI.Namespace: (info: ResponseInfoType, data: PreparedGetMessagesResponse?)] in @@ -151,7 +151,7 @@ public final class SnodeAPI { public typealias PreparedGetMessagesResponse = (messages: [SnodeReceivedMessage], lastHash: String?) public static func preparedGetMessages( - _ db: Database, + _ db: ObservingDatabase, namespace: SnodeAPI.Namespace, snode: LibSession.Snode, maxSize: Int64? = nil, @@ -163,7 +163,7 @@ public final class SnodeAPI { db, for: snode, namespace: namespace, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, using: dependencies )? .hash @@ -173,9 +173,9 @@ public final class SnodeAPI { return try SnodeAPI.prepareRequest( request: Request( endpoint: .getMessages, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, body: LegacyGetMessagesRequest( - pubkey: authMethod.swarmPublicKey, + pubkey: try authMethod.swarmPublicKey, lastHash: (maybeLastHash ?? ""), namespace: namespace, maxCount: nil, @@ -190,7 +190,7 @@ public final class SnodeAPI { return try SnodeAPI.prepareRequest( request: Request( endpoint: .getMessages, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, body: GetMessagesRequest( lastHash: (maybeLastHash ?? ""), namespace: namespace, @@ -205,12 +205,12 @@ public final class SnodeAPI { }() return preparedRequest - .map { _, response -> (messages: [SnodeReceivedMessage], lastHash: String?) in + .tryMap { _, response -> (messages: [SnodeReceivedMessage], lastHash: String?) in return ( - response.messages.compactMap { rawMessage -> SnodeReceivedMessage? in + try response.messages.compactMap { rawMessage -> SnodeReceivedMessage? in SnodeReceivedMessage( snode: snode, - publicKey: authMethod.swarmPublicKey, + publicKey: try authMethod.swarmPublicKey, namespace: namespace, rawMessage: rawMessage ) @@ -231,8 +231,9 @@ public final class SnodeAPI { // Hash the ONS name using BLAKE2b guard - let nameAsData: [UInt8] = onsName.data(using: .utf8).map({ Array($0) }), - let nameHash = dependencies[singleton: .crypto].generate(.hash(message: nameAsData)) + let nameHash = dependencies[singleton: .crypto].generate( + .hash(message: Array(onsName.utf8)) + ) else { return Fail(error: SnodeAPIError.onsHashingFailed) .eraseToAnyPublisher() @@ -293,7 +294,7 @@ public final class SnodeAPI { .prepareRequest( request: Request( endpoint: .getExpiries, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, body: GetExpiriesRequest( messageHashes: serverHashes, authMethod: authMethod, @@ -319,7 +320,7 @@ public final class SnodeAPI { return try SnodeAPI.prepareRequest( request: Request( endpoint: .sendMessage, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, body: LegacySendMessagesRequest( message: message, namespace: namespace @@ -335,7 +336,7 @@ public final class SnodeAPI { return try SnodeAPI.prepareRequest( request: Request( endpoint: .sendMessage, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, body: SendMessageRequest( message: message, namespace: namespace, @@ -353,7 +354,7 @@ public final class SnodeAPI { return request .tryMap { _, response -> SendMessagesResponse in try response.validateResultMap( - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, using: dependencies ) @@ -380,7 +381,7 @@ public final class SnodeAPI { .prepareRequest( request: Request( endpoint: .expire, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, body: UpdateExpiryRequest( messageHashes: serverHashes, expiryMs: UInt64(updatedExpiryMs), @@ -395,7 +396,7 @@ public final class SnodeAPI { .tryMap { _, response -> [String: UpdateExpiryResponseResult] in do { return try response.validResultMap( - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, validationData: serverHashes, using: dependencies ) @@ -424,7 +425,7 @@ public final class SnodeAPI { let groupedExpiryResult: [UInt64: [String]] = targetResult.changed .updated(with: targetResult.unchanged) .groupedByValue() - .nullIfEmpty() + .nullIfEmpty else { return } dependencies[singleton: .storage].writeAsync { db in @@ -453,7 +454,7 @@ public final class SnodeAPI { .prepareRequest( request: Request( endpoint: .revokeSubaccount, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, body: RevokeSubaccountRequest( subaccountsToRevoke: subaccountsToRevoke, authMethod: authMethod, @@ -465,7 +466,7 @@ public final class SnodeAPI { ) .tryMap { _, response -> Void in try response.validateResultMap( - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, validationData: (subaccountsToRevoke, timestampMs), using: dependencies ) @@ -485,7 +486,7 @@ public final class SnodeAPI { .prepareRequest( request: Request( endpoint: .unrevokeSubaccount, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, body: UnrevokeSubaccountRequest( subaccountsToUnrevoke: subaccountsToUnrevoke, authMethod: authMethod, @@ -497,7 +498,7 @@ public final class SnodeAPI { ) .tryMap { _, response -> Void in try response.validateResultMap( - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, validationData: (subaccountsToUnrevoke, timestampMs), using: dependencies ) @@ -518,7 +519,7 @@ public final class SnodeAPI { .prepareRequest( request: Request( endpoint: .deleteMessages, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, body: DeleteMessagesRequest( messageHashes: serverHashes, requireSuccessfulDeletion: requireSuccessfulDeletion, @@ -530,7 +531,7 @@ public final class SnodeAPI { ) .tryMap { _, response -> [String: Bool] in let validResultMap: [String: Bool] = try response.validResultMap( - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, validationData: serverHashes, using: dependencies ) @@ -563,7 +564,7 @@ public final class SnodeAPI { .prepareRequest( request: Request( endpoint: .deleteAll, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, requiresLatestNetworkTime: true, body: DeleteAllMessagesRequest( namespace: namespace, @@ -583,7 +584,7 @@ public final class SnodeAPI { } return try response.validResultMap( - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, validationData: targetInfo.timestampMs, using: dependencies ) @@ -601,7 +602,7 @@ public final class SnodeAPI { .prepareRequest( request: Request( endpoint: .deleteAllBefore, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, requiresLatestNetworkTime: true, body: DeleteAllBeforeRequest( beforeMs: beforeMs, @@ -616,7 +617,7 @@ public final class SnodeAPI { ) .tryMap { _, response -> [String: Bool] in try response.validResultMap( - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, validationData: beforeMs, using: dependencies ) diff --git a/SessionSnodeKit/SnodeAPI/SnodeAPIError.swift b/SessionSnodeKit/SnodeAPI/SnodeAPIError.swift index 9e54ab832a..0de7eb2ba8 100644 --- a/SessionSnodeKit/SnodeAPI/SnodeAPIError.swift +++ b/SessionSnodeKit/SnodeAPI/SnodeAPIError.swift @@ -12,7 +12,6 @@ public enum SnodeAPIError: Error, CustomStringConvertible { case noKeyPair case signingFailed case signatureVerificationFailed - case invalidAuthentication case invalidIP case responseFailedValidation case unauthorised @@ -46,7 +45,6 @@ public enum SnodeAPIError: Error, CustomStringConvertible { 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 .invalidAuthentication: return "Invalid Authentication (SnodeAPIError.invalidAuthentication)." case .invalidIP: return "Invalid IP (SnodeAPIError.invalidIP)." case .responseFailedValidation: return "Response failed validation (SnodeAPIError.responseFailedValidation)." case .unauthorised: return "Unauthorized (SnodeAPIError.unauthorised)." diff --git a/SessionSnodeKit/SnodeAPI/SnodeAPINamespace.swift b/SessionSnodeKit/SnodeAPI/SnodeAPINamespace.swift index c41fcc845c..c6b23c1e75 100644 --- a/SessionSnodeKit/SnodeAPI/SnodeAPINamespace.swift +++ b/SessionSnodeKit/SnodeAPI/SnodeAPINamespace.swift @@ -23,6 +23,9 @@ public extension SnodeAPI { /// `USER_GROUPS` config messages case configUserGroups = 5 + /// `Local` config messages (not actually stored in this namespace, but need an identifier) + case configLocal = 9999 + /// Messages sent to an updated closed group are stored in this namespace case groupMessages = 11 @@ -83,7 +86,7 @@ public extension SnodeAPI { case .configUserProfile, .configContacts, .configConvoInfoVolatile, .configUserGroups, .configGroupInfo, .configGroupMembers, .configGroupKeys, - .unknown, .all: + .configLocal, .unknown, .all: return false } } @@ -98,7 +101,7 @@ public extension SnodeAPI { public var isCurrentUserNamespace: Bool { switch self { - case .default, .configUserProfile, .configContacts, .configConvoInfoVolatile, .configUserGroups: + case .default, .configUserProfile, .configContacts, .configConvoInfoVolatile, .configUserGroups, .configLocal: return true case .configGroupInfo, .configGroupMembers, .configGroupKeys, .groupMessages, @@ -110,7 +113,7 @@ public extension SnodeAPI { public var isConfigNamespace: Bool { switch self { case .configUserProfile, .configContacts, .configConvoInfoVolatile, .configUserGroups, - .configGroupInfo, .configGroupMembers, .configGroupKeys: + .configGroupInfo, .configGroupMembers, .configGroupKeys, .configLocal: return true case .`default`, .legacyClosedGroup, .groupMessages, .revokedRetrievableGroupMessages, @@ -126,7 +129,7 @@ public extension SnodeAPI { /// which was encrypted with a key included in the `configGroupKeys` within the same poll) public var processingOrder: Int { switch self { - case .configUserProfile, .configContacts, .configGroupKeys: return 0 + case .configUserProfile, .configContacts, .configGroupKeys, .configLocal: return 0 case .configUserGroups, .configGroupInfo, .configGroupMembers: return 1 case .configConvoInfoVolatile: return 2 @@ -143,7 +146,7 @@ public extension SnodeAPI { case .configGroupKeys: return true case .`default`, .legacyClosedGroup, .groupMessages, .configUserProfile, .configContacts, .configConvoInfoVolatile, .configUserGroups, .configGroupInfo, .configGroupMembers, - .revokedRetrievableGroupMessages, .unknown, .all: + .revokedRetrievableGroupMessages, .configLocal, .unknown, .all: return false } } @@ -172,7 +175,7 @@ public extension SnodeAPI { case .configUserProfile, .configContacts, .configConvoInfoVolatile, .configUserGroups, .configGroupInfo, .configGroupMembers, .configGroupKeys, - .revokedRetrievableGroupMessages, .unknown, .all: + .revokedRetrievableGroupMessages, .configLocal, .unknown, .all: return 1 } } @@ -205,6 +208,7 @@ public extension SnodeAPI { case .configContacts: return "configContacts" case .configConvoInfoVolatile: return "configConvoInfoVolatile" case .configUserGroups: return "configUserGroups" + case .configLocal: return "configLocal" case .groupMessages: return "groupMessages" case .configGroupInfo: return "configGroupInfo" case .configGroupMembers: return "configGroupMembers" diff --git a/SessionSnodeKit/Types/BencodeResponse.swift b/SessionSnodeKit/Types/BencodeResponse.swift index f4cd368930..d33325d6a4 100644 --- a/SessionSnodeKit/Types/BencodeResponse.swift +++ b/SessionSnodeKit/Types/BencodeResponse.swift @@ -12,33 +12,28 @@ extension BencodeResponse: Decodable { public init(from decoder: Decoder) throws { var container: UnkeyedDecodingContainer = try decoder.unkeyedContainer() - /// The first element will be the request info - info = try { - /// First try to decode it directly - if let info: T = try? container.decode(T.self) { - return info - } - - /// If that failed then we need to reset the container and try decode it as a JSON string - container = try decoder.unkeyedContainer() - let infoString: String = try container.decode(String.self) + do { + /// Try to decode the first element as `T` directly (this will increment the decoder past the first element whether it + /// succeeds or fails) + self.info = try container.decode(T.self) + } + catch { + /// If that failed then we need a new container in order to try to decode the first element again, so create a new one and + /// try decode the first element as a JSON string + var retryContainer: UnkeyedDecodingContainer = try decoder.unkeyedContainer() + let infoString: String = try retryContainer.decode(String.self) let infoData: Data = try infoString.data(using: .ascii) ?? { throw NetworkError.parsingFailed }() /// Pass the `dependencies` through to the `JSONDecoder` if we have them, if /// we don't then it's the responsibility of the decoding type to throw when `dependencies` /// isn't present but is required let jsonDecoder: JSONDecoder = (decoder.dependencies.map { JSONDecoder(using: $0) } ?? JSONDecoder()) - return try jsonDecoder.decode(T.self, from: infoData) - }() - - - /// The second element (if present) will be the response data and should just - guard container.count == 2 else { - data = nil - return + self.info = try jsonDecoder.decode(T.self, from: infoData) } - data = try container.decode(Data.self) + /// The second element (if present) will be the response data and should just decode directly (we can use the initial + /// `container` since it should be sitting at the second element) + self.data = (container.isAtEnd ? nil : try container.decode(Data.self)) } } diff --git a/SessionSnodeKit/Types/NetworkError.swift b/SessionSnodeKit/Types/NetworkError.swift index 841e02d363..8938775521 100644 --- a/SessionSnodeKit/Types/NetworkError.swift +++ b/SessionSnodeKit/Types/NetworkError.swift @@ -8,7 +8,6 @@ public enum NetworkError: Error, Equatable, CustomStringConvertible { case invalidState case invalidURL case invalidPreparedRequest - case signingFailed case forbidden case notFound case parsingFailed @@ -30,7 +29,6 @@ public enum NetworkError: Error, Equatable, CustomStringConvertible { case .invalidState: return "The network is in an invalid state (NetworkError.invalidState)." case .invalidURL: return "Invalid URL (NetworkError.invalidURL)." case .invalidPreparedRequest: return "Invalid PreparedRequest provided (NetworkError.invalidPreparedRequest)." - case .signingFailed: return "Couldn't sign request (NetworkError.signingFailed)." case .forbidden: return "Forbidden (NetworkError.forbidden)." case .notFound: return "Not Found (NetworkError.notFound)." case .parsingFailed: return "Invalid response (NetworkError.parsingFailed)." diff --git a/SessionSnodeKit/Types/PreparedRequest.swift b/SessionSnodeKit/Types/PreparedRequest.swift index 78db337506..67c1697324 100644 --- a/SessionSnodeKit/Types/PreparedRequest.swift +++ b/SessionSnodeKit/Types/PreparedRequest.swift @@ -447,11 +447,10 @@ extension Network.PreparedRequest: ErasedPreparedRequest { public extension Network.PreparedRequest { func signed( - _ db: Database, - with requestSigner: (Database, Network.PreparedRequest, Dependencies) throws -> Network.Destination, + with requestSigner: (Network.PreparedRequest, Dependencies) throws -> Network.Destination, using dependencies: Dependencies ) throws -> Network.PreparedRequest { - let signedDestination: Network.Destination = try requestSigner(db, self, dependencies) + let signedDestination: Network.Destination = try requestSigner(self, dependencies) return Network.PreparedRequest( body: body, diff --git a/SessionSnodeKitTests/_TestUtilities/MockSnodeAPICache.swift b/SessionSnodeKitTests/_TestUtilities/MockSnodeAPICache.swift index a3767b8e45..75111962b8 100644 --- a/SessionSnodeKitTests/_TestUtilities/MockSnodeAPICache.swift +++ b/SessionSnodeKitTests/_TestUtilities/MockSnodeAPICache.swift @@ -22,10 +22,27 @@ class MockSnodeAPICache: Mock, SnodeAPICacheType { var clockOffsetMs: Int64 { mock() } func currentOffsetTimestampMs() -> T where T: Numeric { - return mock() + 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/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift index 9e1e109fdb..f7bc07b1cb 100644 --- a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift @@ -11,7 +11,7 @@ import SessionUtilitiesKit @testable import Session -class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { +class ThreadDisappearingMessagesSettingsViewModelSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -32,8 +32,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { try SessionThread( id: "TestId", variant: .contact, - creationDateTimestamp: 0, - using: dependencies + creationDateTimestamp: 0 ).insert(db) } ) @@ -404,14 +403,14 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { ) ) - footerButtonInfo?.onTap() + await MainActor.run { [footerButtonInfo] in footerButtonInfo?.onTap() } - expect(didDismissScreen).to(beTrue()) + await expect(didDismissScreen).toEventually(beTrue()) } // MARK: ------ saves the updated config it("saves the updated config") { - footerButtonInfo?.onTap() + await MainActor.run { [footerButtonInfo] in footerButtonInfo?.onTap() } let updatedConfig: DisappearingMessagesConfiguration? = mockStorage.read { db in try DisappearingMessagesConfiguration.fetchOne(db, id: "TestId") diff --git a/SessionTests/Conversations/Settings/ThreadNotificationSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadNotificationSettingsViewModelSpec.swift index 740d6c02eb..260158f36c 100644 --- a/SessionTests/Conversations/Settings/ThreadNotificationSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadNotificationSettingsViewModelSpec.swift @@ -11,7 +11,7 @@ import SessionUtilitiesKit @testable import Session -class ThreadNotificationSettingsViewModelSpec: QuickSpec { +class ThreadNotificationSettingsViewModelSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -32,8 +32,7 @@ class ThreadNotificationSettingsViewModelSpec: QuickSpec { try SessionThread( id: "TestId", variant: .contact, - creationDateTimestamp: 0, - using: dependencies + creationDateTimestamp: 0 ).insert(db) } ) @@ -47,8 +46,12 @@ class ThreadNotificationSettingsViewModelSpec: QuickSpec { .thenReturn(nil) } ) + @TestState(singleton: .notificationsManager, in: dependencies) var mockNotificationsManager: MockNotificationsManager! = MockNotificationsManager( + initialSetup: { $0.defaultInitialSetup() } + ) @TestState var viewModel: ThreadNotificationSettingsViewModel! = ThreadNotificationSettingsViewModel( threadId: "TestId", + threadVariant: .contact, threadNotificationSettings: .init( threadOnlyNotifyForMentions: nil, threadMutedUntilTimestamp: nil @@ -142,11 +145,14 @@ class ThreadNotificationSettingsViewModelSpec: QuickSpec { .filter(id: "TestId") .updateAll( db, - SessionThread.Columns.mutedUntilTimestamp.set(to: Date.distantFuture.timeIntervalSince1970) + SessionThread.Columns.mutedUntilTimestamp.set( + to: Date.distantFuture.timeIntervalSince1970 + ) ) } viewModel = ThreadNotificationSettingsViewModel( threadId: "TestId", + threadVariant: .contact, threadNotificationSettings: .init( threadOnlyNotifyForMentions: false, threadMutedUntilTimestamp: Date.distantFuture.timeIntervalSince1970 @@ -252,6 +258,7 @@ class ThreadNotificationSettingsViewModelSpec: QuickSpec { } viewModel = ThreadNotificationSettingsViewModel( threadId: "TestId", + threadVariant: .contact, threadNotificationSettings: .init( threadOnlyNotifyForMentions: false, threadMutedUntilTimestamp: Date.distantFuture.timeIntervalSince1970 @@ -396,24 +403,23 @@ class ThreadNotificationSettingsViewModelSpec: QuickSpec { ) ) - footerButtonInfo?.onTap() + await MainActor.run { [footerButtonInfo] in footerButtonInfo?.onTap() } - expect(didDismissScreen).to(beTrue()) + await expect(didDismissScreen).toEventually(beTrue()) } // MARK: ------ saves the updated settings it("saves the updated settings") { - footerButtonInfo?.onTap() - - let updatedSettings: TimeInterval? = mockStorage.read { db in - try SessionThread - .select(SessionThread.Columns.mutedUntilTimestamp) - .filter(id: "TestId") - .asRequest(of: TimeInterval.self) - .fetchOne(db) - } + await MainActor.run { [footerButtonInfo] in footerButtonInfo?.onTap() } - expect(updatedSettings).to(beGreaterThan(0)) + await expect(mockNotificationsManager).toEventually(call(.exactly(times: 1), matchingParameters: .all) { + $0.updateSettings( + threadId: "TestId", + threadVariant: .contact, + mentionsOnly: false, + mutedUntil: Date.distantFuture.timeIntervalSince1970 + ) + }) } } } diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index 5cb75cbd37..bf98964bb1 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -64,30 +64,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { } ) @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( - initialSetup: { cache in - cache - .when { try $0.performAndPushChange(.any, for: .any, sessionId: .any, change: { _ in }) } - .thenReturn(()) - cache - .when { $0.pinnedPriority(.any, threadId: .any, threadVariant: .any) } - .thenReturn(LibSession.defaultNewThreadPriority) - cache.when { $0.disappearingMessagesConfig(threadId: .any, threadVariant: .any) } - .thenReturn(nil) - cache - .when { $0.isAdmin(groupSessionId: .any) } - .thenReturn(false) - cache - .when { try $0.withCustomBehaviour(.any, for: .any, variant: .any, change: { }) } - .then { args, untrackedArgs in - let callback: (() throws -> Void)? = (untrackedArgs[test: 0] as? () throws -> Void) - try? callback?() - } - .thenReturn(()) - cache.when { $0.isEmpty }.thenReturn(false) - cache - .when { try $0.pendingChanges(.any, swarmPublicKey: .any) } - .thenReturn(LibSession.PendingChanges()) - } + initialSetup: { $0.defaultInitialSetup() } ) @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( initialSetup: { crypto in @@ -152,8 +129,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { try SessionThread( id: user2Pubkey, variant: .contact, - creationDateTimestamp: 0, - using: dependencies + creationDateTimestamp: 0 ).insert(db) } @@ -185,8 +161,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { try SessionThread( id: userPubkey, variant: .contact, - creationDateTimestamp: 0, - using: dependencies + creationDateTimestamp: 0 ).insert(db) } @@ -232,8 +207,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { try SessionThread( id: user2Pubkey, variant: .contact, - creationDateTimestamp: 0, - using: dependencies + creationDateTimestamp: 0 ).insert(db) } @@ -355,8 +329,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { try SessionThread( id: legacyGroupPubkey, variant: .legacyGroup, - creationDateTimestamp: 0, - using: dependencies + creationDateTimestamp: 0 ).insert(db) try DisappearingMessagesConfiguration @@ -470,8 +443,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { try SessionThread( id: groupPubkey, variant: .group, - creationDateTimestamp: 0, - using: dependencies + creationDateTimestamp: 0 ).insert(db) try ClosedGroup( @@ -769,8 +741,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { try SessionThread( id: communityId, variant: .community, - creationDateTimestamp: 0, - using: dependencies + creationDateTimestamp: 0 ).insert(db) try OpenGroup( diff --git a/SessionTests/Database/DatabaseSpec.swift b/SessionTests/Database/DatabaseSpec.swift index 5ec773f985..6e0561e696 100644 --- a/SessionTests/Database/DatabaseSpec.swift +++ b/SessionTests/Database/DatabaseSpec.swift @@ -16,6 +16,7 @@ class DatabaseSpec: QuickSpec { fileprivate static let ignoredTables: Set = [ "sqlite_sequence", "grdb_migrations", "*_fts*" ] + private static var snapshotCache: [String: Result] = [:] override class func spec() { // MARK: Configuration @@ -62,6 +63,13 @@ class DatabaseSpec: QuickSpec { TableColumn(ConfigDump.self, .data): Data() ] + beforeSuite { + snapshotCache.removeAll() + } + afterSuite { + snapshotCache.removeAll() + } + // MARK: - a Database describe("a Database") { // MARK: -- can be created from an empty state @@ -130,18 +138,52 @@ class DatabaseSpec: QuickSpec { // MARK: -- can migrate from X to Y dynamicTests.forEach { test in it("can migrate from \(test.initialMigrationKey) to \(test.finalMigrationKey)") { - mockStorage.perform( - sortedMigrations: test.initialMigrations, - async: false, - onProgressUpdate: nil, - onComplete: { result in initialResult = result } - ) - expect(initialResult).to(beSuccess()) + let initialStateResult: Result = { + if let cachedResult: Result = snapshotCache[test.initialMigrationKey] { + return cachedResult + } + + do { + let dbQueue = try DatabaseQueue() + let storage = SynchronousStorage( + customWriter: dbQueue, + using: dependencies + ) + + // Generate dummy data (otherwise structural issues or invalid foreign keys won't error) + var initialResult: Result! + storage.perform( + sortedMigrations: test.initialMigrations, + async: false, + onProgressUpdate: nil, + onComplete: { result in initialResult = result } + ) + try initialResult.get() + + // Generate dummy data (otherwise structural issues or invalid foreign keys won't error) + try MigrationTest.generateDummyData(storage, nullsWherePossible: false) + + snapshotCache[test.initialMigrationKey] = .success(dbQueue) + return .success(dbQueue) + } catch { + snapshotCache[test.initialMigrationKey] = .failure(error) + return .failure(error) + } + }() - // Generate dummy data (otherwise structural issues or invalid foreign keys won't error) - expect(try MigrationTest.generateDummyData(mockStorage, nullsWherePossible: false)) - .toNot(throwError()) + var sourceDb: DatabaseQueue! + switch initialStateResult { + case .success(let db): sourceDb = db + case .failure(let error): + fail("Failed to prepare the initial state for '\(test.initialMigrationKey)'. Error: \(error)") + return + } + // Copy the cached initial state over to a new instance to run this test + let testDb = try! DatabaseQueue() + try! sourceDb.backup(to: testDb) + mockStorage = SynchronousStorage(customWriter: testDb, using: dependencies) + // Peform the target migrations to ensure the migrations themselves worked correctly mockStorage.perform( sortedMigrations: test.migrationsToTest, @@ -149,7 +191,14 @@ class DatabaseSpec: QuickSpec { onProgressUpdate: nil, onComplete: { result in finalResult = result } ) - expect(finalResult).to(beSuccess()) + + switch finalResult { + case .success: break + case .failure(let error): + fail("Failed to migrate from '\(test.initialMigrationKey)' to '\(test.finalMigrationKey)'. Error: \(error)") + case .none: + fail("Failed to migrate from '\(test.initialMigrationKey)' to '\(test.finalMigrationKey)'. Error: No result") + } } } } @@ -245,7 +294,7 @@ private class MigrationTest { if let error: Error = generationError { throw error } } - private static func generateDummyData(_ db: Database, nullsWherePossible: Bool) throws { + private static func generateDummyData(_ db: ObservingDatabase, nullsWherePossible: Bool) throws { // Fetch table schema information let disallowedPrefixes: Set = DatabaseSpec.ignoredTables .filter { $0.hasPrefix("*") && !$0.hasSuffix("*") } @@ -389,7 +438,7 @@ enum TestRequiresAllMigrationRequirementsReversedMigration: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws {} + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws {} } enum TestRequiresLibSessionStateMigration: Migration { @@ -398,7 +447,7 @@ enum TestRequiresLibSessionStateMigration: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws {} + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws {} } enum TestRequiresSessionIdCachedMigration: Migration { @@ -407,5 +456,5 @@ enum TestRequiresSessionIdCachedMigration: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws {} + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws {} } diff --git a/SessionTests/Onboarding/OnboardingSpec.swift b/SessionTests/Onboarding/OnboardingSpec.swift new file mode 100644 index 0000000000..637864b235 --- /dev/null +++ b/SessionTests/Onboarding/OnboardingSpec.swift @@ -0,0 +1,725 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Combine +import GRDB +import Quick +import Nimble +import SessionUtil +import SessionUIKit +import SessionUtilitiesKit + +@testable import Session +@testable import SessionSnodeKit +@testable import SessionMessagingKit + +class OnboardingSpec: AsyncSpec { + override class func spec() { + // MARK: Configuration + + @TestState var dependencies: TestDependencies! = TestDependencies { dependencies in + dependencies.forceSynchronous = true + dependencies[singleton: .scheduler] = .immediate + dependencies.uuid = .mock + dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) + } + @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + customWriter: try! DatabaseQueue(), + migrationTargets: [ + SNUtilitiesKit.self, + SNSnodeKit.self, + SNMessagingKit.self, + DeprecatedUIKitMigrationTarget.self + ], + 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(.ed25519KeyPair(seed: .any)) } + .thenReturn( + KeyPair( + publicKey: Array(Data(hex: TestConstants.edPublicKey)), + secretKey: Array(Data(hex: TestConstants.edSecretKey)) + ) + ) + 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: .any) }.thenReturn(true) + 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" + ) + ]) + + let cache: LibSession.Cache = LibSession.Cache( + userSessionId: SessionId(.standard, hex: TestConstants.publicKey), + using: dependencies + ) + cache.loadDefaultStateFor( + variant: .userProfile, + sessionId: cache.userSessionId, + userEd25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)), + 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: [ + ( + 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 + ) + } + .thenReturn(()) + } + ) + @TestState(cache: .snodeAPI, in: dependencies) var mockSnodeAPICache: MockSnodeAPICache! = MockSnodeAPICache( + initialSetup: { $0.defaultInitialSetup() } + ) + @TestState var disposables: [AnyCancellable]! = [] + @TestState var cache: Onboarding.Cache! + + // MARK: - an Onboarding Cache - Initialization + describe("an Onboarding Cache when initialising") { + beforeEach { + mockLibSession + .when { $0.profile(contactId: .any, threadId: .any, threadVariant: .any, visibleMessage: .any) } + .thenReturn(nil) + } + + justBeforeEach { + cache = Onboarding.Cache( + flow: .restore, + using: dependencies + ) + } + + // MARK: -- stores the initialFlow + it("stores the initialFlow") { + Onboarding.Flow.allCases.forEach { flow in + cache = Onboarding.Cache( + flow: flow, + using: dependencies + ) + expect(cache.initialFlow).to(equal(flow)) + } + } + + // MARK: -- without a stored key pair + context("without a stored key pair") { + beforeEach { + mockCrypto + .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .thenReturn(KeyPair(publicKey: [1, 2, 3], secretKey: [4, 5, 6])) + mockCrypto + .when { $0.generate(.x25519(ed25519Pubkey: .any)) } + .thenReturn([3, 2, 1]) + 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"))) + } + } + + // MARK: -- with a stored key pair + context("with a stored key pair") { + beforeEach { + mockStorage.write { db in + try Identity( + variant: .ed25519PublicKey, + data: Data(hex: TestConstants.edPublicKey) + ).insert(db) + try Identity( + variant: .ed25519SecretKey, + data: Data(hex: TestConstants.edSecretKey) + ).insert(db) + } + } + + // MARK: ---- does not generate a seed + it("does not generate a seed") { + expect(cache.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()) + .to(equal(TestConstants.edPublicKey)) + expect(cache.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)))) + }) + + expect(cache.x25519KeyPair.publicKey.toHexString()) + .to(equal(TestConstants.publicKey)) + expect(cache.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) + .to(equal(SessionId(.standard, hex: TestConstants.publicKey))) + } + + // MARK: ---- and failing to generate an x25519KeyPair + context("and failing to generate an x25519KeyPair") { + beforeEach { + mockStorage.write { db in + try Identity.deleteAll(db) + try Identity( + variant: .ed25519PublicKey, + data: Data(hex: "090807") + ).insert(db) + try Identity( + variant: .ed25519SecretKey, + data: Data(hex: TestConstants.edSecretKey) + ).insert(db) + } + mockCrypto + .when { $0.generate(.x25519(ed25519Pubkey: [9, 8, 7])) } + .thenReturn(nil) + mockCrypto + .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .thenReturn(KeyPair(publicKey: [1, 2, 3], secretKey: [4, 5, 6])) + mockCrypto + .when { $0.generate(.x25519(ed25519Pubkey: [1, 2, 3])) } + .thenReturn([4, 3, 2, 1]) + mockCrypto + .when { $0.generate(.x25519(ed25519Seckey: [4, 5, 6])) } + .thenReturn([7, 6, 5, 4]) + } + + // 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"))) + } + + // 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( + flow: .restore, + using: dependencies + ) + expect(cache.state).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) }) + } + } + + // MARK: ---- and an existing display name + context("and an existing display name") { + beforeEach { + mockLibSession + .when { + $0.profile( + contactId: .any, + threadId: .any, + threadVariant: .any, + visibleMessage: .any + ) + } + .thenReturn(Profile(id: "TestProfileId", name: "TestProfileName")) + } + + // 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 + ) + }) + } + + // MARK: ------ stores the loaded displayName + it("stores the loaded displayName") { + expect(cache.displayName).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()) + } + + // MARK: ------ after generating new credentials + context("after generating new credentials") { + beforeEach { + mockStorage.write { db in + try Identity.deleteAll(db) + try Identity( + variant: .ed25519PublicKey, + data: Data(hex: "090807") + ).insert(db) + try Identity( + variant: .ed25519SecretKey, + data: Data(hex: TestConstants.edSecretKey) + ).insert(db) + } + mockCrypto + .when { $0.generate(.x25519(ed25519Pubkey: [9, 8, 7])) } + .thenReturn(nil) + mockCrypto + .when { $0.generate(.x25519(ed25519Pubkey: [1, 2, 3])) } + .thenReturn([4, 3, 2, 1]) + mockCrypto + .when { $0.generate(.x25519(ed25519Seckey: [4, 5, 6])) } + .thenReturn([7, 6, 5, 4]) + } + + // MARK: -------- has an empty display name + it("has an empty display name") { + expect(cache.displayName).to(equal("")) + } + } + } + + // MARK: ---- and a missing display name + context("and a missing display name") { + // MARK: ------ has an empty display name + it("has an empty display name") { + expect(cache.displayName).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()) + } + } + } + } + + // MARK: - an Onboarding Cache - Seed Data + describe("an Onboarding Cache when setting seed data") { + beforeEach { + mockCrypto + .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .thenReturn( + KeyPair( + publicKey: Array(Data(hex: TestConstants.edPublicKey)), + secretKey: Array(Data(hex: TestConstants.edSecretKey)) + ) + ) + } + + justBeforeEach { + cache = Onboarding.Cache( + flow: .register, + using: dependencies + ) + cache.displayNamePublisher.sinkAndStore(in: &disposables) + try? cache.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)) + } + + // MARK: -- stores the seed + it("stores the seed") { + expect(cache.seed).to(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))) + } + + // 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( + LibSession.Snode( + ip: "1.2.3.4", + quicPort: 1234, + ed25519PubkeyHex: "" + ), + swarmPublicKey: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b" + ), + requestTimeout: 10, + requestAndPathBuildTimeout: 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")) + } + + // 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: - an Onboarding Cache - Setting values + describe("an Onboarding Cache when setting values") { + justBeforeEach { + cache = Onboarding.Cache( + flow: .register, + using: dependencies + ) + } + + // MARK: -- stores the useAPNs setting + it("stores the useAPNs setting") { + expect(cache.useAPNS).to(beFalse()) + cache.setUseAPNS(true) + expect(cache.useAPNS).to(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")) + } + } + + // MARK: - an Onboarding Cache - Complete Registration + describe("an Onboarding Cache when completing registration") { + justBeforeEach { + cache = Onboarding.Cache( + flow: .register, + using: dependencies + ) + cache.setDisplayName("TestCompleteName") + cache.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))) + }) + } + + // MARK: -- stores a new libSession cache instance + it("stores a new libSession cache instance") { + expect(dependencies[cache: .libSession]).toNot(beAKindOf(MockLibSessionCache.self)) + } + + // MARK: -- saves the identity data to the database + it("saves the identity data to the database") { + let result: [Identity]? = mockStorage.read { db in + try Identity.fetchAll(db) + } + + expect(result).to(equal([ + Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)), + Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)), + Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)), + Identity(variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey)) + ])) + } + + // MARK: -- creates a contact record for the current user + it("creates a contact record for the current user") { + let result: [Contact]? = mockStorage.read { db in + try Contact.fetchAll(db) + } + + expect(result).to(equal([ + Contact( + id: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b", + isTrusted: true, + isApproved: true, + isBlocked: false, + lastKnownClientVersion: nil, + didApproveMe: true, + hasBeenBlocked: false, + using: dependencies + ) + ])) + } + + // MARK: -- creates a profile record for the current user + it("creates a profile record for the current user") { + let result: [Profile]? = mockStorage.read { db in + try Profile.fetchAll(db) + } + + expect(result).to(equal([ + Profile( + id: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b", + name: "TestCompleteName", + lastNameUpdate: 1234567890, + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + displayPictureLastUpdated: nil, + blocksCommunityMessageRequests: nil, + lastBlocksCommunityMessageRequests: nil + ) + ])) + } + + // MARK: -- creates a thread for Note to Self + it("creates a thread for Note to Self") { + let result: [SessionThread]? = mockStorage.read { db in + try SessionThread.fetchAll(db) + } + + expect(result).to(equal([ + SessionThread( + id: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b", + variant: .contact, + creationDateTimestamp: 1234567890, + shouldBeVisible: false, + messageDraft: nil, + notificationSound: nil, + mutedUntilTimestamp: nil, + onlyNotifyForMentions: false, + markedAsUnread: false, + pinnedPriority: 0, + isDraft: false + ) + ])) + } + + // MARK: -- has the correct profile in libSession + it("has the correct profile in libSession") { + expect(dependencies.mutate(cache: .libSession) { $0.profile }).to(equal( + Profile( + id: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b", + name: "TestCompleteName", + lastNameUpdate: nil, + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + displayPictureLastUpdated: nil, + blocksCommunityMessageRequests: nil, + lastBlocksCommunityMessageRequests: nil + ) + )) + } + + // MARK: -- saves a config dump to the database + it("saves a config dump to the database") { + 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 + ) + ])) + } + + // MARK: -- updates the onboarding state to 'completed' + it("updates the onboarding state to 'completed'") { + expect(cache.state).to(equal(.completed)) + } + + // MARK: -- updates the hasViewedSeed value only when restoring + it("updates the hasViewedSeed value only when restoring") { + // Check for the `register` case first + await expect(dependencies[cache: .libSession]?.get(.hasViewedSeed)).toEventually(beFalse()) + + // Then the `restore` case + cache = Onboarding.Cache( + flow: .restore, + using: dependencies + ) + cache.setDisplayName("TestCompleteName") + cache.completeRegistration() + + await expect(dependencies[cache: .libSession]?.get(.hasViewedSeed)).toEventually(beTrue()) + } + + // MARK: -- replicates the user metadata + it("replicates the user metadata") { + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.saveUserMetadata( + sessionId: SessionId(.standard, hex: TestConstants.publicKey), + ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)), + unreadCount: 0 + ) + }) + } + + // MARK: -- stores the desired useAPNs value in the user defaults + it("stores the desired useAPNs value in the user defaults") { + expect(mockUserDefaults).to(call(.exactly(times: 1), matchingParameters: .all) { + $0.set(false, forKey: UserDefaults.BoolKey.isUsingFullAPNs.rawValue) + }) + } + + // MARK: -- emits an event from the completion publisher + it("emits an event from the completion publisher") { + var didEmitInPublisher: Bool = false + + cache = Onboarding.Cache( + flow: .register, + using: dependencies + ) + cache.setDisplayName("TestCompleteName") + cache.onboardingCompletePublisher + .sink(receiveValue: { _ in didEmitInPublisher = true }) + .store(in: &disposables) + cache.completeRegistration() + + await expect(didEmitInPublisher).toEventually(beTrue()) + } + + // MARK: -- calls the onComplete callback + it("calls the onComplete callback") { + var didCallOnComplete: Bool = false + + cache = Onboarding.Cache( + flow: .register, + using: dependencies + ) + cache.setDisplayName("TestCompleteName") + cache.completeRegistration { didCallOnComplete = true } + + await expect(didCallOnComplete).toEventually(beTrue()) + } + } + } +} diff --git a/SessionTests/Settings/NotificationContentViewModelSpec.swift b/SessionTests/Settings/NotificationContentViewModelSpec.swift index d9e972a35b..509e6bad23 100644 --- a/SessionTests/Settings/NotificationContentViewModelSpec.swift +++ b/SessionTests/Settings/NotificationContentViewModelSpec.swift @@ -4,6 +4,7 @@ import Combine import GRDB import Quick import Nimble +import SessionUtil import SessionUIKit import SessionSnodeKit import SessionMessagingKit @@ -11,7 +12,7 @@ import SessionUtilitiesKit @testable import Session -class NotificationContentViewModelSpec: QuickSpec { +class NotificationContentViewModelSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -28,6 +29,22 @@ class NotificationContentViewModelSpec: QuickSpec { ], using: dependencies ) + @TestState var secretKey: [UInt8]! = Array(Data(hex: TestConstants.edSecretKey)) + @TestState var localConfig: LibSession.Config! = { + var conf: UnsafeMutablePointer! + _ = user_groups_init(&conf, &secretKey, nil, 0, nil) + + return .local(conf) + }() + @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( + initialSetup: { + $0.defaultInitialSetup( + configs: [ + .local: localConfig + ] + ) + } + ) @TestState var viewModel: NotificationContentViewModel! = NotificationContentViewModel( using: dependencies ) @@ -40,6 +57,10 @@ class NotificationContentViewModelSpec: QuickSpec { // MARK: - a NotificationContentViewModel describe("a NotificationContentViewModel") { + beforeEach { + try await require { viewModel.tableData.count }.toEventually(beGreaterThan(0)) + } + // MARK: -- has the correct title it("has the correct title") { expect(viewModel.title).to(equal("notificationsContent".localized())) @@ -86,15 +107,16 @@ class NotificationContentViewModelSpec: QuickSpec { // MARK: -- starts with the correct item active if not default it("starts with the correct item active if not default") { - mockStorage.write { db in - db[.preferencesNotificationPreviewType] = Preferences.NotificationPreviewType.nameNoPreview - } + mockLibSessionCache + .when { $0.get(.preferencesNotificationPreviewType) } + .thenReturn(Preferences.NotificationPreviewType.nameNoPreview) viewModel = NotificationContentViewModel(using: dependencies) dataChangeCancellable = viewModel.tableDataPublisher .sink( receiveCompletion: { _ in }, receiveValue: { viewModel.updateTableData($0) } ) + try await require { viewModel.tableData.count }.toEventually(beGreaterThan(0)) expect(viewModel.tableData.first?.elements) .to( @@ -133,8 +155,9 @@ class NotificationContentViewModelSpec: QuickSpec { it("updates the saved preference") { viewModel.tableData.first?.elements.last?.onTap?() - expect(dependencies[singleton: .storage, key: .preferencesNotificationPreviewType]) - .to(equal(Preferences.NotificationPreviewType.noNameNoPreview)) + await expect(mockLibSessionCache).toEventually(call(.exactly(times: 1), matchingParameters: .all) { + $0.set(.preferencesNotificationPreviewType, Preferences.NotificationPreviewType.noNameNoPreview) + }) } // MARK: ---- dismisses the screen @@ -148,7 +171,7 @@ class NotificationContentViewModelSpec: QuickSpec { ) viewModel.tableData.first?.elements.last?.onTap?() - expect(didDismissScreen).to(beTrue()) + await expect(didDismissScreen).toEventually(beTrue()) } } } diff --git a/SessionUIKit/Components/ConfirmationModal.swift b/SessionUIKit/Components/ConfirmationModal.swift index 47ed868a86..0effabf609 100644 --- a/SessionUIKit/Components/ConfirmationModal.swift +++ b/SessionUIKit/Components/ConfirmationModal.swift @@ -501,7 +501,7 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { contentStackView.addArrangedSubview(radioButton) } - case .image(let identifier, let source, let placeholder, let icon, let style, let accessibility, let dataManager, let onClick): + case .image(let source, let placeholder, let icon, let style, let accessibility, let dataManager, let onClick): imageViewContainer.isAccessibilityElement = (accessibility != nil) imageViewContainer.accessibilityIdentifier = accessibility?.identifier imageViewContainer.accessibilityLabel = accessibility?.label @@ -511,7 +511,6 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { profileView.setDataManager(dataManager) profileView.update( ProfilePictureView.Info( - identifier: identifier, source: (source ?? placeholder), icon: icon ) @@ -633,12 +632,13 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { @objc private func imageViewTapped() { internalOnBodyTap?({ [weak self, info = self.info] valueUpdate in switch (valueUpdate, info.body) { - case (.image(let updatedIdentifier, let updatedData), .image(_, _, let placeholder, let icon, let style, let accessibility, let dataManager, let onClick)): + case (.image(let updatedIdentifier, let updatedData), .image(_, let placeholder, let icon, let style, let accessibility, let dataManager, let onClick)): self?.updateContent( with: info.with( body: .image( - identifier: updatedIdentifier, - source: updatedData.map { ImageDataManager.DataSource.data($0) }, + source: updatedData.map { + ImageDataManager.DataSource.data(updatedIdentifier, $0) + }, placeholder: placeholder, icon: icon, style: style, @@ -755,8 +755,8 @@ public extension ConfirmationModal { let hasCloseButton: Bool let dismissOnConfirm: Bool let dismissType: Modal.DismissType - public let onConfirm: ((ConfirmationModal) -> ())? - let onCancel: ((ConfirmationModal) -> ())? + public let onConfirm: (@MainActor (ConfirmationModal) -> ())? + let onCancel: (@MainActor (ConfirmationModal) -> ())? let afterClosed: (() -> ())? // MARK: - Initialization @@ -774,8 +774,8 @@ public extension ConfirmationModal { hasCloseButton: Bool = false, dismissOnConfirm: Bool = true, dismissType: Modal.DismissType = .recursive, - onConfirm: ((ConfirmationModal) -> ())? = nil, - onCancel: ((ConfirmationModal) -> ())? = nil, + onConfirm: (@MainActor (ConfirmationModal) -> ())? = nil, + onCancel: (@MainActor (ConfirmationModal) -> ())? = nil, afterClosed: (() -> ())? = nil ) { self.title = title @@ -1007,7 +1007,6 @@ public extension ConfirmationModal.Info { options: [RadioOptionInfo] ) case image( - identifier: String, source: ImageDataManager.DataSource?, placeholder: ImageDataManager.DataSource?, icon: ProfilePictureView.ProfileIcon = .none, @@ -1048,9 +1047,8 @@ public extension ConfirmationModal.Info { lhsOptions == rhsOptions ) - case (.image(let lhsIdentifier, let lhsSource, let lhsPlaceholder, let lhsIcon, let lhsStyle, let lhsAccessibility, _, _), .image(let rhsIdentifier, let rhsSource, let rhsPlaceholder, let rhsIcon, let rhsStyle, let rhsAccessibility, _, _)): + case (.image(let lhsSource, let lhsPlaceholder, let lhsIcon, let lhsStyle, let lhsAccessibility, _, _), .image(let rhsSource, let rhsPlaceholder, let rhsIcon, let rhsStyle, let rhsAccessibility, _, _)): return ( - lhsIdentifier == rhsIdentifier && lhsSource == rhsSource && lhsPlaceholder == rhsPlaceholder && lhsIcon == rhsIcon && @@ -1082,8 +1080,7 @@ public extension ConfirmationModal.Info { warning.hash(into: &hasher) options.hash(into: &hasher) - case .image(let identifier, let source, let placeholder, let icon, let style, let accessibility, _, _): - identifier.hash(into: &hasher) + case .image(let source, let placeholder, let icon, let style, let accessibility, _, _): source.hash(into: &hasher) placeholder.hash(into: &hasher) icon.hash(into: &hasher) diff --git a/SessionUIKit/Components/Modal.swift b/SessionUIKit/Components/Modal.swift index 349a229fee..f881db5e19 100644 --- a/SessionUIKit/Components/Modal.swift +++ b/SessionUIKit/Components/Modal.swift @@ -88,7 +88,6 @@ open class Modal: UIViewController, UIGestureRecognizerDelegate { navigationItem.backButtonTitle = "" view.themeBackgroundColor = .clear - ThemeManager.applyNavigationStylingIfNeeded(to: self) setNeedsStatusBarAppearanceUpdate() @@ -130,6 +129,14 @@ open class Modal: UIViewController, UIGestureRecognizerDelegate { populateContentView() } + open override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + /// Apply the nav styling in `viewWillAppear` instead of `viewDidLoad` as it's possible the nav stack isn't fully setup + /// and could crash when trying to access it (whereas by the time `viewWillAppear` is called it should be setup) + ThemeManager.applyNavigationStylingIfNeeded(to: self) + } + open override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) diff --git a/SessionUIKit/Components/PlaceholderIcon.swift b/SessionUIKit/Components/PlaceholderIcon.swift index c9eaaed060..5ff758c993 100644 --- a/SessionUIKit/Components/PlaceholderIcon.swift +++ b/SessionUIKit/Components/PlaceholderIcon.swift @@ -3,41 +3,39 @@ import UIKit import CryptoKit -public class PlaceholderIcon { +public enum PlaceholderIcon { private static let colors: [UIColor] = Theme.PrimaryColor.allCases.map { $0.color } - private let seed: Int - - // MARK: - Initialization - - init(seed: Int) { - self.seed = seed - } - // stringlint:ignore_contents - convenience init(seed: String) { - // Ensure we have a correct hash - var hash = seed - - if (hash.matches("^[0-9A-Fa-f]+$") && hash.count >= 12) { - // This is the same as the `SessionUtilitiesKit` `toHexString` function - hash = Data(SHA512.hash(data: Data(Array(seed.utf8))).makeIterator()) - .map { String(format: "%02x", $0) }.joined() - } + public static func generate(seed: String, text: String, size: CGFloat) -> UIImage { + let content: (intSeed: Int, initials: String) = content(seed: seed, text: text) + let layer = generateLayer( + with: size, + text: content.initials, + seed: content.intSeed + ) - guard let number = Int(String(hash.prefix(12)), radix: 16) else { - self.init(seed: 0) - return - } + let rect = CGRect(origin: CGPoint.zero, size: layer.frame.size) + let renderer = UIGraphicsImageRenderer(size: rect.size) - self.init(seed: number) + return renderer.image { layer.render(in: $0.cgContext) } } - // MARK: - Convenience + // MARK: - Internal - // stringlint:ignore_contents - public static func generate(seed: String, text: String, size: CGFloat) -> UIImage { - let icon = PlaceholderIcon(seed: seed) + internal static func content(seed: String, text: String) -> (intSeed: Int, initials: String) { + let intSeed: Int = { + var hash = seed + + if (hash.matches("^[0-9A-Fa-f]+$") && hash.count >= 12) { + // This is the same as the `SessionUtilitiesKit` `toHexString` function + hash = Data(SHA512.hash(data: Data(Array(seed.utf8))).makeIterator()) + .map { String(format: "%02x", $0) }.joined() + } + + + return (Int(String(hash.prefix(12)), radix: 16) ?? 0) + }() var content: String = { guard text.hasSuffix("\(String(seed.suffix(4))))") else { @@ -60,33 +58,17 @@ public class PlaceholderIcon { .compactMap { word in word.first.map { String($0) } } .joined() - return SNUIKit.placeholderIconCacher(cacheKey: "\(seed)-\(initials)-\(Int(floor(size)))") { - let layer = icon.generateLayer( - with: size, - text: (initials.count >= 2 ? - String(initials.prefix(2)).uppercased() : - String(content.prefix(2)).uppercased() - ) + return ( + intSeed, + (initials.count >= 2 ? + String(initials.prefix(2)).uppercased() : + String(content.prefix(2)).uppercased() ) - - let rect = CGRect(origin: CGPoint.zero, size: layer.frame.size) - let renderer = UIGraphicsImageRenderer(size: rect.size) - - return renderer.image { layer.render(in: $0.cgContext) } - } + ) } - // MARK: - Internal - - private func generateLayer(with diameter: CGFloat, text: String) -> CALayer { + private static func generateLayer(with diameter: CGFloat, text: String, seed: Int) -> CALayer { let color: UIColor = PlaceholderIcon.colors[seed % PlaceholderIcon.colors.count] - let base: CALayer = getTextLayer(with: diameter, color: color, text: text) - base.masksToBounds = true - - return base - } - - private func getTextLayer(with diameter: CGFloat, color: UIColor, text: String) -> CALayer { let font = UIFont.boldSystemFont(ofSize: diameter / 2) let height = NSString(string: text).boundingRect(with: CGSize(width: diameter, height: CGFloat.greatestFiniteMagnitude), options: .usesLineFragmentOrigin, attributes: [ NSAttributedString.Key.font : font ], context: nil).height @@ -106,6 +88,7 @@ public class PlaceholderIcon { let base = CALayer() base.frame = CGRect(x: 0, y: 0, width: diameter, height: diameter) + base.masksToBounds = true base.themeBackgroundColorForced = .color(color) base.addSublayer(layer) diff --git a/SessionUIKit/Components/ProfilePictureView.swift b/SessionUIKit/Components/ProfilePictureView.swift index 7608fc4064..65c4425021 100644 --- a/SessionUIKit/Components/ProfilePictureView.swift +++ b/SessionUIKit/Components/ProfilePictureView.swift @@ -5,7 +5,6 @@ import Combine public final class ProfilePictureView: UIView { public struct Info { - let identifier: String let source: ImageDataManager.DataSource? let renderingMode: UIImage.RenderingMode? let themeTintColor: ThemeValue? @@ -15,7 +14,6 @@ public final class ProfilePictureView: UIView { let forcedBackgroundColor: ForcedThemeValue? public init( - identifier: String, source: ImageDataManager.DataSource?, renderingMode: UIImage.RenderingMode? = nil, themeTintColor: ThemeValue? = nil, @@ -24,7 +22,6 @@ public final class ProfilePictureView: UIView { backgroundColor: ThemeValue? = nil, forcedBackgroundColor: ForcedThemeValue? = nil ) { - self.identifier = identifier self.source = source self.renderingMode = renderingMode self.themeTintColor = themeTintColor @@ -482,8 +479,7 @@ public final class ProfilePictureView: UIView { case (.some(let source), .some(let renderingMode)) where source.directImage != nil: imageView.image = source.directImage?.withRenderingMode(renderingMode) - case (.some(let source), _): - imageView.loadImage(identifier: info.identifier, from: source) + case (.some(let source), _): imageView.loadImage(source) default: imageView.image = nil } @@ -529,7 +525,7 @@ public final class ProfilePictureView: UIView { additionalImageContainerView.isHidden = false case (.some(let source), _): - additionalImageView.loadImage(identifier: additionalInfo.identifier, from: source) + additionalImageView.loadImage(source) additionalImageContainerView.isHidden = false default: diff --git a/SessionUIKit/Components/SessionButton.swift b/SessionUIKit/Components/SessionButton.swift index b96632d120..0cf81edbd8 100644 --- a/SessionUIKit/Components/SessionButton.swift +++ b/SessionUIKit/Components/SessionButton.swift @@ -23,7 +23,7 @@ public final class SessionButton: UIButton { public let isEnabled: Bool public let accessibility: Accessibility? public let minWidth: CGFloat - public let onTap: () -> () + public let onTap: @MainActor () -> () public init( style: Style, @@ -31,7 +31,7 @@ public final class SessionButton: UIButton { isEnabled: Bool, accessibility: Accessibility? = nil, minWidth: CGFloat = 0, - onTap: @escaping () -> () + onTap: @MainActor @escaping () -> () ) { self.style = style self.title = title diff --git a/SessionUIKit/Components/SessionHostingViewController.swift b/SessionUIKit/Components/SessionHostingViewController.swift index 0012eeb8b5..fdedd738df 100644 --- a/SessionUIKit/Components/SessionHostingViewController.swift +++ b/SessionUIKit/Components/SessionHostingViewController.swift @@ -58,12 +58,15 @@ open class SessionHostingViewController: UIHostingController Void)? = nil) { - /// Call through to the `url` loader so that the identifier would match regardless of whether the called used `path` or `url` - loadImage(identifier: identifier, from: URL(fileURLWithPath: path), onComplete: onComplete) - } - - @MainActor - public func loadImage(identifier: String? = nil, from url: URL, onComplete: (() -> Void)? = nil) { - loadImage(identifier: (identifier ?? url.absoluteString), source: .url(url), onComplete: onComplete) - } - - @MainActor - public func loadImage(identifier: String, from data: Data, onComplete: (() -> Void)? = nil) { - loadImage(identifier: identifier, source: .data(data), onComplete: onComplete) - } - - @MainActor - public func loadImage(identifier: String, from closure: @Sendable @escaping () -> Data?, onComplete: (() -> Void)? = nil) { - loadImage(identifier: identifier, source: .closure(closure), onComplete: onComplete) - } - - @MainActor - public func loadImage(identifier: String, from source: ImageDataManager.DataSource, onComplete: (() -> Void)? = nil) { - loadImage(identifier: identifier, source: source, onComplete: onComplete) + public func loadImage(_ source: ImageDataManager.DataSource, onComplete: ((Bool) -> Void)? = nil) { + /// If we are trying to load the image that is already displayed then no need to do anything + if currentLoadIdentifier == source.identifier && (self.image == nil || isAnimating()) { + /// If it was an animation that got paused then resume it + if let frames: [UIImage] = animationFrames, !frames.isEmpty, !isAnimating() { + startAnimationLoop() + } + return + } + + imageLoadTask?.cancel() + resetState(identifier: source.identifier) + + /// No need to kick of an async task if we were given an image directly + switch source { + case .image(_, .some(let image)): + imageSizeMetadata = image.size + return handleLoadedImageData( + ImageDataManager.ProcessedImageData(type: .staticImage(image)) + ) + + default: break + } + + /// Otherwise read the size of the image from the metadata (so we can layout prior to the image being loaded) and schedule the + /// background task for loading + imageSizeMetadata = source.sizeFromMetadata + + guard let dataManager: ImageDataManagerType = self.dataManager else { + #if DEBUG + preconditionFailure("Error! No `ImageDataManager` configured for `SessionImageView") + #else + return + #endif + } + + imageLoadTask = Task.detached(priority: .userInitiated) { [weak self, dataManager] in + let processedData: ImageDataManager.ProcessedImageData? = await dataManager.load(source) + + await MainActor.run { [weak self] in + guard !Task.isCancelled && self?.currentLoadIdentifier == source.identifier else { return } + + self?.handleLoadedImageData(processedData) + onComplete?(processedData != nil) + } + } } @MainActor @@ -176,6 +199,33 @@ public class SessionImageView: UIImageView { displayLink?.add(to: .main, forMode: .common) } + @MainActor + public func setAnimationPoint(index: Int, time: TimeInterval) { + guard index >= 0, index < animationFrames?.count ?? 0 else { return } + currentFrameIndex = index + self.image = animationFrames?[index] + + /// Stop animating if we don't have a valid animation state + guard + let frames: [UIImage] = animationFrames, + let durations = animationFrameDurations, + !frames.isEmpty, + frames.count == durations.count, + index >= 0, + index < durations.count, + time > 0, + time < durations.reduce(0, +) + else { return stopAnimationLoop() } + + /// Update the values + accumulatedTime = time + currentFrameIndex = index + + /// Set the image using `super.image` as `self.image` is overwritten to stop the animation (in case it gets called + /// to replace the current image with something else) + super.image = frames[currentFrameIndex] + } + @MainActor public func stopAnimationLoop() { displayLink?.invalidate() @@ -199,62 +249,6 @@ public class SessionImageView: UIImageView { // MARK: - Internal Functions - @MainActor - private func loadImage( - identifier: String, - source: ImageDataManager.DataSource, - onComplete: (() -> Void)? - ) { - /// If we are trying to load the image that is already displayed then no need to do anything - if currentLoadIdentifier == identifier && (self.image == nil || isAnimating()) { - /// If it was an animation that got paused then resume it - if let frames: [UIImage] = animationFrames, !frames.isEmpty, !isAnimating() { - startAnimationLoop() - } - return - } - - imageLoadTask?.cancel() - resetState(identifier: identifier) - - /// No need to kick of an async task if we were given an image directly - switch source { - case .image(_, .some(let image)): - imageSizeMetadata = image.size - return handleLoadedImageData( - ImageDataManager.ProcessedImageData(type: .staticImage(image)) - ) - - default: break - } - - /// Otherwise read the size of the image from the metadata (so we can layout prior to the image being loaded) and schedule the - /// background task for loading - imageSizeMetadata = source.sizeFromMetadata - - guard let dataManager: ImageDataManagerType = self.dataManager else { - #if DEBUG - preconditionFailure("Error! No `ImageDataManager` configured for `SessionImageView") - #else - return - #endif - } - - imageLoadTask = Task { [weak self, dataManager] in - let processedData: ImageDataManager.ProcessedImageData? = await dataManager.loadImageData( - identifier: identifier, - source: source - ) - - await MainActor.run { [weak self] in - guard !Task.isCancelled && self?.currentLoadIdentifier == identifier else { return } - - self?.handleLoadedImageData(processedData) - onComplete?() - } - } - } - @MainActor private func resetState(identifier: String?) { stopAnimationLoop() diff --git a/SessionUIKit/Components/SwiftUI/AttributedText.swift b/SessionUIKit/Components/SwiftUI/AttributedText.swift index e6b694e81c..89f2c9c2b3 100644 --- a/SessionUIKit/Components/SwiftUI/AttributedText.swift +++ b/SessionUIKit/Components/SwiftUI/AttributedText.swift @@ -27,8 +27,11 @@ public struct AttributedText: View { if let text = attributedText?.value { text.enumerateAttributes(in: NSMakeRange(0, text.length), options: [], using: { (attribute, range, stop) in let substring = (text.string as NSString).substring(with: range) - let font = (attribute[.font] as? UIFont).map { Font($0) } - let color = (attribute[.foregroundColor] as? UIColor).map { Color($0) } + let font = (attribute[.font] as? UIFont).map { Font($0) } + let color = ( + (attribute[.themeForegroundColor] as? ThemeValue).map { Color($0) } ?? + (attribute[.foregroundColor] as? UIColor).map { Color($0) } + ) let baselineOffset = (attribute[.baselineOffset] as? CGFloat) descriptions.append( AttributedTextBlock( diff --git a/SessionUIKit/Components/SwiftUI/SessionImageView_SwiftUI.swift b/SessionUIKit/Components/SwiftUI/SessionImageView_SwiftUI.swift new file mode 100644 index 0000000000..306fcc14e9 --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/SessionImageView_SwiftUI.swift @@ -0,0 +1,167 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI +import Combine +import NaturalLanguage + +public struct SessionAsyncImage: View { + @State private var loadedImage: UIImage? = nil + @State private var animationFrames: [UIImage]? + @State private var animationFrameDurations: [TimeInterval]? + @State private var isAnimating: Bool = false + + @State private var currentFrameIndex: Int = 0 + @State private var accumulatedTime: TimeInterval = 0.0 + @State private var lastFrameDate: Date? = nil + + private let source: ImageDataManager.DataSource + private let dataManager: ImageDataManagerType + + private let content: (Image) -> Content + private let placeholder: () -> Placeholder + + public init( + source: ImageDataManager.DataSource, + dataManager: ImageDataManagerType, + @ViewBuilder content: @escaping (Image) -> Content, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + self.source = source + self.dataManager = dataManager + self.content = content + self.placeholder = placeholder + } + + public var body: some View { + ZStack { + if let uiImage = loadedImage { + let imageView = content(Image(uiImage: uiImage)) + + if isAnimating { + TimelineView(.animation) { context in + imageView + .onChange(of: context.date) { newDate in + updateAnimationFrame(at: newDate) + } + } + } + else { + imageView + } + } else { + placeholder() + } + } + .task(id: source.identifier) { + await loadAndProcessData() + } + } + + // MARK: - Internal Functions + + private func loadAndProcessData() async { + let processedData = await dataManager.load(source) + + /// Reset the state before loading new data + await MainActor.run { + self.loadedImage = nil + self.animationFrames = nil + self.animationFrameDurations = nil + self.isAnimating = false + self.currentFrameIndex = 0 + self.accumulatedTime = 0.0 + self.lastFrameDate = .now + } + + switch processedData?.type { + case .staticImage(let image): + await MainActor.run { + self.loadedImage = image + } + + case .animatedImage(let frames, let durations) where frames.count > 1: + await MainActor.run { + self.animationFrames = frames + self.animationFrameDurations = durations + self.loadedImage = frames.first + self.isAnimating = true /// Activate the `TimelineView` + } + + case .animatedImage(let frames, _): + await MainActor.run { + self.loadedImage = frames.first + } + + default: + await MainActor.run { + self.loadedImage = nil + } + } + } + + private func updateAnimationFrame(at date: Date) { + guard + isAnimating, + let frames: [UIImage] = animationFrames, + let durations = animationFrameDurations, + !frames.isEmpty, + frames.count == durations.count, + currentFrameIndex < durations.count, + let lastDate = lastFrameDate + else { return } + + /// Calculate elapsed time since the last frame + let elapsed: TimeInterval = date.timeIntervalSince(lastDate) + self.lastFrameDate = date + accumulatedTime += elapsed + + let currentFrameDuration: TimeInterval = durations[currentFrameIndex] + + // Advance frames if the accumulated time exceeds the current frame's duration + while accumulatedTime >= currentFrameDuration { + accumulatedTime -= currentFrameDuration + currentFrameIndex = (currentFrameIndex + 1) % frames.count + + /// Check if we need to break after advancing to the next frame + if currentFrameIndex < durations.count, accumulatedTime < durations[currentFrameIndex] { + break + } + + /// Prevent an infinite loop for all zero durations + if + durations[currentFrameIndex] <= 0.001 && + currentFrameIndex == (currentFrameIndex + 1) % frames.count + { + break + } + } + + /// Make sure we don't cause an index-out-of-bounds somehow + guard currentFrameIndex < frames.count else { + isAnimating = false + return + } + + /// Update the displayed image only if the frame has changed + if loadedImage !== frames[currentFrameIndex] { + loadedImage = frames[currentFrameIndex] + } + } +} + +// MARK: - Convenience + +extension SessionAsyncImage where Content == Image, Placeholder == ProgressView { + init( + identifier: String, + source: ImageDataManager.DataSource, + dataManager: ImageDataManagerType + ) { + self.init( + source: source, + dataManager: dataManager, + content: { $0.resizable() }, + placeholder: { ProgressView() } + ) + } +} diff --git a/SessionUIKit/Configuration.swift b/SessionUIKit/Configuration.swift index 1a45060f04..41e8c9826f 100644 --- a/SessionUIKit/Configuration.swift +++ b/SessionUIKit/Configuration.swift @@ -1,10 +1,11 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import AVFoundation public typealias ThemeSettings = (theme: Theme?, primaryColor: Theme.PrimaryColor?, matchSystemNightModeSetting: Bool?) -public enum SNUIKit { +public actor SNUIKit { public protocol ConfigType { var maxFileSize: UInt { get } var isStorageValid: Bool { get } @@ -15,56 +16,25 @@ public enum SNUIKit { func cachedContextualActionInfo(tableViewHash: Int, sideKey: String) -> [Int: Any]? func cacheContextualActionInfo(tableViewHash: Int, sideKey: String, actionIndex: Int, actionInfo: Any) func removeCachedContextualActionInfo(tableViewHash: Int, keys: [String]) - func placeholderIconCacher(cacheKey: String, generator: @escaping () -> UIImage) -> UIImage func shouldShowStringKeys() -> Bool + func asset(for path: String, mimeType: String, sourceFilename: String?) -> (asset: AVURLAsset, cleanup: () -> Void)? } - private static var _mainWindow: UIWindow? = nil - private static var _unsafeConfig: ConfigType? = nil + @MainActor public static var mainWindow: UIWindow? = nil + internal static var config: ConfigType? = nil - /// The `mainWindow` of the application set during application startup - /// - /// **Note:** This should only be accessed on the main thread - internal static var mainWindow: UIWindow? { - assert(Thread.isMainThread) - - return _mainWindow + @MainActor public static func setMainWindow(_ mainWindow: UIWindow) { + self.mainWindow = mainWindow } - internal static var config: ConfigType? { - switch Thread.isMainThread { - case false: - // Don't allow config access off the main thread - print("SNUIKit Error: Attempted to access the 'SNUIKit.config' on the wrong thread") - return nil - - case true: return _unsafeConfig - } - } - - public static func setMainWindow(_ mainWindow: UIWindow) { - switch Thread.isMainThread { - case true: _mainWindow = mainWindow - case false: DispatchQueue.main.async { _mainWindow = mainWindow } - } - } - - public static func configure(with config: ConfigType, themeSettings: ThemeSettings?) { - guard Thread.isMainThread else { - return DispatchQueue.main.async { - configure(with: config, themeSettings: themeSettings) - } - } - - // Apply the theme settings before storing the config so we don't needlessly update - // the settings in the database + @MainActor public static func configure(with config: ConfigType, themeSettings: ThemeSettings?) { + /// Apply the theme settings before storing the config so we don't needlessly update the settings in the database ThemeManager.updateThemeState( theme: themeSettings?.theme, primaryColor: themeSettings?.primaryColor, matchSystemNightModeSetting: themeSettings?.matchSystemNightModeSetting ) - - _unsafeConfig = config + self.config = config } internal static func themeSettingsChanged( @@ -72,16 +42,10 @@ public enum SNUIKit { _ primaryColor: Theme.PrimaryColor, _ matchSystemNightModeSetting: Bool ) { - guard Thread.isMainThread else { - return DispatchQueue.main.async { - themeSettingsChanged(theme, primaryColor, matchSystemNightModeSetting) - } - } - config?.themeChanged(theme, primaryColor, matchSystemNightModeSetting) } - internal static func navBarSessionIcon() -> NavBarSessionIcon { + @MainActor internal static func navBarSessionIcon() -> NavBarSessionIcon { guard let config: ConfigType = self.config else { return NavBarSessionIcon() } return config.navBarSessionIcon() @@ -97,15 +61,15 @@ public enum SNUIKit { config?.persistentTopBannerChanged(warningKey: warning.rawValue) } - internal static func placeholderIconCacher(cacheKey: String, generator: @escaping () -> UIImage) -> UIImage { - guard let config: ConfigType = self.config else { return generator() } - - return config.placeholderIconCacher(cacheKey: cacheKey, generator: generator) - } - public static func shouldShowStringKeys() -> Bool { guard let config: ConfigType = self.config else { return false } return config.shouldShowStringKeys() } + + internal static func asset(for path: String, mimeType: String, sourceFilename: String?) -> (asset: AVURLAsset, cleanup: () -> Void)? { + guard let config: ConfigType = self.config else { return nil } + + return config.asset(for: path, mimeType: mimeType, sourceFilename: sourceFilename) + } } diff --git a/SessionUIKit/Style Guide/ThemeManager.swift b/SessionUIKit/Style Guide/ThemeManager.swift index 1f78e2044a..335da4baee 100644 --- a/SessionUIKit/Style Guide/ThemeManager.swift +++ b/SessionUIKit/Style Guide/ThemeManager.swift @@ -17,8 +17,8 @@ public enum ThemeManager { private static var uiRegistry: NSMapTable = NSMapTable.weakToStrongObjects() private static var _hasLoadedTheme: Bool = false - private static var _theme: Theme = .classicDark // Default to `classicDark` - private static var _primaryColor: Theme.PrimaryColor = .green // Default to `green` + private static var _theme: Theme = Theme.defaultTheme + private static var _primaryColor: Theme.PrimaryColor = Theme.PrimaryColor.defaultPrimaryColor private static var _matchSystemNightModeSetting: Bool = false // Default to `false` public static var hasLoadedTheme: Bool { _hasLoadedTheme } @@ -28,7 +28,7 @@ public enum ThemeManager { // MARK: - Styling - public static func updateThemeState( + @MainActor public static func updateThemeState( theme: Theme? = nil, primaryColor: Theme.PrimaryColor? = nil, matchSystemNightModeSetting: Bool? = nil @@ -62,9 +62,7 @@ public enum ThemeManager { // Note: We need to set this to 'unspecified' to force the UI to properly update as the // 'TraitObservingWindow' won't actually trigger the trait change otherwise - DispatchQueue.main.async { - SNUIKit.mainWindow?.overrideUserInterfaceStyle = .unspecified - } + SNUIKit.mainWindow?.overrideUserInterfaceStyle = .unspecified } // If the theme was changed then trigger the callback for the theme settings change (so it gets persisted) @@ -73,7 +71,7 @@ public enum ThemeManager { SNUIKit.themeSettingsChanged(targetTheme, targetPrimaryColor, targetMatchSystemNightModeSetting) } - public static func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + @MainActor public static func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { let currentUserInterfaceStyle: UIUserInterfaceStyle = UITraitCollection.current.userInterfaceStyle // Only trigger updates if the style changed and the device is set to match the system style @@ -92,7 +90,7 @@ public enum ThemeManager { } } - public static func applyNavigationStyling() { + @MainActor public static func applyNavigationStyling() { guard Thread.isMainThread else { return DispatchQueue.main.async { applyNavigationStyling() } } @@ -180,7 +178,7 @@ public enum ThemeManager { updateIfNeeded(viewController: SNUIKit.mainWindow?.rootViewController) } - public static func applyNavigationStylingIfNeeded(to viewController: UIViewController) { + @MainActor public static func applyNavigationStylingIfNeeded(to viewController: UIViewController) { // Will use the 'primary' style for all other cases guard let navController: UINavigationController = ((viewController as? UINavigationController) ?? viewController.navigationController), @@ -207,7 +205,7 @@ public enum ThemeManager { navController.navigationBar.scrollEdgeAppearance = appearance } - public static func applyWindowStyling() { + @MainActor public static func applyWindowStyling() { guard Thread.isMainThread else { return DispatchQueue.main.async { applyWindowStyling() } } @@ -234,6 +232,26 @@ public enum ThemeManager { ) } + // MARK: - Internal Functions + + @MainActor private static func updateAllUI() { + guard Thread.isMainThread else { + return DispatchQueue.main.async { updateAllUI() } + } + + ThemeManager.uiRegistry.objectEnumerator()?.forEach { applier in + (applier as? ThemeApplier)?.apply(theme: currentTheme) + } + + applyNavigationStyling() + applyWindowStyling() + + if !hasSetInitialSystemTrait { + traitCollectionDidChange(nil) + hasSetInitialSystemTrait = true + } + } + internal static func color(for value: ThemeValue, in theme: Theme) -> T? { switch value { case .value(let value, let alpha): return T.resolve(value, for: theme)?.alpha(alpha) @@ -264,26 +282,6 @@ public enum ThemeManager { } } - // MARK: - Internal Functions - - private static func updateAllUI() { - guard Thread.isMainThread else { - return DispatchQueue.main.async { updateAllUI() } - } - - ThemeManager.uiRegistry.objectEnumerator()?.forEach { applier in - (applier as? ThemeApplier)?.apply(theme: currentTheme) - } - - applyNavigationStyling() - applyWindowStyling() - - if !hasSetInitialSystemTrait { - traitCollectionDidChange(nil) - hasSetInitialSystemTrait = true - } - } - private static func retrieveNavigationController(from viewController: UIViewController) -> UINavigationController? { switch viewController { case let navController as UINavigationController: return navController diff --git a/SessionUIKit/Style Guide/Themes/SwiftUI+Theme.swift b/SessionUIKit/Style Guide/Themes/SwiftUI+Theme.swift index ef2d6409d5..a453b5bf1a 100644 --- a/SessionUIKit/Style Guide/Themes/SwiftUI+Theme.swift +++ b/SessionUIKit/Style Guide/Themes/SwiftUI+Theme.swift @@ -45,6 +45,14 @@ public extension Shape { } } +public extension Color { + init?(_ value: ThemeValue) { + guard let result = Color.resolve(value, for: ThemeManager.currentTheme) else { return nil } + + self = result + } +} + public extension Text { func foregroundColor(themeColor: ThemeValue) -> Text { return self.foregroundColor(ThemeManager.color(for: themeColor)) diff --git a/SessionUIKit/Style Guide/Themes/Theme+Colors.swift b/SessionUIKit/Style Guide/Themes/Theme+Colors.swift index eae6d0268e..92b555ad91 100644 --- a/SessionUIKit/Style Guide/Themes/Theme+Colors.swift +++ b/SessionUIKit/Style Guide/Themes/Theme+Colors.swift @@ -6,7 +6,9 @@ import SwiftUI // MARK: - Primary Colors public extension Theme { - enum PrimaryColor: String, Codable, CaseIterable { + enum PrimaryColor: Int, Codable, CaseIterable { + public static var defaultPrimaryColor: Theme.PrimaryColor = .green + case green case blue case yellow @@ -39,27 +41,13 @@ public extension Theme { // FIXME: Clean it when Xcode can show the color panel in "return Color(#colorLiteral())" public var colorSwiftUI: Color { switch self { - case .green: - let color: Color = Color(#colorLiteral(red: 0.1921568627, green: 0.9450980392, blue: 0.5882352941, alpha: 1)) // #31F196 - return color - case .blue: - let color: Color = Color(#colorLiteral(red: 0.3411764706, green: 0.7882352941, blue: 0.9803921569, alpha: 1)) // #57C9FA - return color - case .yellow: - let color: Color = Color(#colorLiteral(red: 0.9803921569, green: 0.8392156863, blue: 0.3411764706, alpha: 1)) // #FAD657 - return color - case .pink: - let color: Color = Color(#colorLiteral(red: 1, green: 0.5843137255, blue: 0.937254902, alpha: 1)) // #FF95EF - return color - case .purple: - let color: Color = Color(#colorLiteral(red: 0.7882352941, green: 0.5764705882, blue: 1, alpha: 1)) // #C993FF - return color - case .orange: - let color: Color = Color(#colorLiteral(red: 0.9882352941, green: 0.6941176471, blue: 0.3490196078, alpha: 1)) // #FCB159 - return color - case .red: - let color: Color = Color(#colorLiteral(red: 1, green: 0.6117647059, blue: 0.5568627451, alpha: 1)) // #FF9C8E - return color + case .green: return Color(#colorLiteral(red: 0.1921568627, green: 0.9450980392, blue: 0.5882352941, alpha: 1)) // #31F196 + case .blue: return Color(#colorLiteral(red: 0.3411764706, green: 0.7882352941, blue: 0.9803921569, alpha: 1)) // #57C9FA + case .yellow: return Color(#colorLiteral(red: 0.9803921569, green: 0.8392156863, blue: 0.3411764706, alpha: 1)) // #FAD657 + case .pink: return Color(#colorLiteral(red: 1, green: 0.5843137255, blue: 0.937254902, alpha: 1)) // #FF95EF + case .purple: return Color(#colorLiteral(red: 0.7882352941, green: 0.5764705882, blue: 1, alpha: 1)) // #C993FF + case .orange: return Color(#colorLiteral(red: 0.9882352941, green: 0.6941176471, blue: 0.3490196078, alpha: 1)) // #FCB159 + case .red: return Color(#colorLiteral(red: 1, green: 0.6117647059, blue: 0.5568627451, alpha: 1)) // #FF9C8E } } } diff --git a/SessionUIKit/Style Guide/Themes/Theme.swift b/SessionUIKit/Style Guide/Themes/Theme.swift index 5a4ad87765..f3ab1f69de 100644 --- a/SessionUIKit/Style Guide/Themes/Theme.swift +++ b/SessionUIKit/Style Guide/Themes/Theme.swift @@ -5,11 +5,13 @@ import SwiftUI // MARK: - Theme -public enum Theme: String, CaseIterable, Codable { - case classicDark = "classic_dark" - case classicLight = "classic_light" - case oceanDark = "ocean_dark" - case oceanLight = "ocean_light" +public enum Theme: Int, CaseIterable, Codable { + public static var defaultTheme: Theme = .classicDark + + case classicDark + case classicLight + case oceanDark + case oceanLight // MARK: - Properties diff --git a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift index e883bbd5e8..8d129139b9 100644 --- a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift +++ b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift @@ -131,7 +131,7 @@ public class ThemedAttributedString: Equatable, Hashable { value.addAttribute(name, value: attrValue, range: targetRange) return self } - + public func addAttributes(_ attrs: [NSAttributedString.Key: Any], range: NSRange? = nil) { #if DEBUG ThemedAttributedString.validateAttributes(attrs) diff --git a/SessionUIKit/Types/IconSize.swift b/SessionUIKit/Types/IconSize.swift index 29db4c8ec4..dae3f4ae86 100644 --- a/SessionUIKit/Types/IconSize.swift +++ b/SessionUIKit/Types/IconSize.swift @@ -11,20 +11,14 @@ public enum IconSize: Differentiable { case veryLarge case extraLarge - case mediumAspectFill - case smallAspectFill - - case fit - public var size: CGFloat { switch self { case .verySmall: return 12 - case .small, .smallAspectFill: return 20 - case .medium, .mediumAspectFill: return 24 + case .small: return 20 + case .medium: return 24 case .large: return 32 case .veryLarge: return 40 case .extraLarge: return 80 - case .fit: return 0 } } } diff --git a/SessionUIKit/Types/ImageDataManager.swift b/SessionUIKit/Types/ImageDataManager.swift index b1228773b7..84472c7ad0 100644 --- a/SessionUIKit/Types/ImageDataManager.swift +++ b/SessionUIKit/Types/ImageDataManager.swift @@ -27,7 +27,9 @@ public actor ImageDataManager: ImageDataManagerType { // MARK: - Functions - @discardableResult public func loadImageData(identifier: String, source: DataSource) async -> ProcessedImageData? { + @discardableResult public func load(_ source: DataSource) async -> ProcessedImageData? { + let identifier: String = source.identifier + if let cachedData: ProcessedImageData = cache.object(forKey: identifier as NSString) { return cachedData } @@ -35,25 +37,35 @@ public actor ImageDataManager: ImageDataManagerType { if let existingTask: Task = activeLoadTasks[identifier] { return await existingTask.value } - - let newTask: Task = Task { - let processedData: ProcessedImageData? = await self.processSourceOnQueue(source) - - if let data: ProcessedImageData = processedData { - self.cache.setObject(data, forKey: identifier as NSString, cost: data.estimatedCost) - } - - self.activeLoadTasks[identifier] = nil - return processedData - } + /// Kick off a new processing task in the background + let newTask: Task = Task.detached(priority: .userInitiated) { + await ImageDataManager.processSource(source) + } activeLoadTasks[identifier] = newTask - return await newTask.value + + /// Wait for the result then cache and return it + let processedData: ProcessedImageData? = await newTask.value + + if let data: ProcessedImageData = processedData { + self.cache.setObject(data, forKey: identifier as NSString, cost: data.estimatedCost) + } + + self.activeLoadTasks[identifier] = nil + return processedData } - public func cacheImage(_ image: UIImage, for identifier: String) async { - let data: ProcessedImageData = ProcessedImageData(type: .staticImage(image)) - cache.setObject(data, forKey: identifier as NSString, cost: data.estimatedCost) + nonisolated public func load( + _ source: ImageDataManager.DataSource, + onComplete: @escaping (ImageDataManager.ProcessedImageData?) -> Void + ) { + Task { [weak self] in + let result: ImageDataManager.ProcessedImageData? = await self?.load(source) + + await MainActor.run { + onComplete(result) + } + } } public func cachedImage(identifier: String) async -> ProcessedImageData? { @@ -70,128 +82,205 @@ public actor ImageDataManager: ImageDataManagerType { // MARK: - Internal Functions - private func processSourceOnQueue(_ dataSource: DataSource) async -> ProcessedImageData? { - return await withCheckedContinuation { continuation in - processingQueue.async { - switch dataSource { - /// If we were given a direct `UIImage` value then use it - case .image(_, let maybeImage): - guard let image: UIImage = maybeImage else { - return continuation.resume(returning: nil) - } - - let processedData: ProcessedImageData = ProcessedImageData( - type: .staticImage(image) - ) - continuation.resume(returning: processedData) - return + private static func processSource(_ dataSource: DataSource) async -> ProcessedImageData? { + switch dataSource { + /// If we were given a direct `UIImage` value then use it + case .image(_, let maybeImage): + guard let image: UIImage = maybeImage else { return nil } + + return ProcessedImageData( + type: .staticImage(image) + ) + + /// Custom handle `videoUrl` values since it requires thumbnail generation + case .videoUrl(let url, let mimeType, let sourceFilename, let thumbnailManager): + /// If we had already generated a thumbnail then use that + if let existingThumbnail: UIImage = thumbnailManager.existingThumbnailImage(url: url, size: .large) { + let decodedImage: UIImage = (existingThumbnail.predecodedImage() ?? existingThumbnail) + let processedData: ProcessedImageData = ProcessedImageData( + type: .staticImage(decodedImage) + ) - /// Custom handle `videoUrl` values since it requires thumbnail generation - case .videoUrl(let url): - let asset: AVURLAsset = AVURLAsset(url: url, options: nil) - - guard asset.isValidVideo else { return continuation.resume(returning: nil) } - - let time: CMTime = CMTimeMake(value: 1, timescale: 60) - let generator: AVAssetImageGenerator = AVAssetImageGenerator(asset: asset) - generator.appliesPreferredTrackTransform = true - - guard - let cgImage: CGImage = try? generator.copyCGImage(at: time, actualTime: nil) - else { return continuation.resume(returning: nil) } - - let image: UIImage = UIImage(cgImage: cgImage) - let decodedImage: UIImage = (image.predecodedImage() ?? image) - let processedData: ProcessedImageData = ProcessedImageData( - type: .staticImage(decodedImage) - ) - continuation.resume(returning: processedData) - return - - default: break + return processedData } - /// Otherwise load the data as either a static or animated image (do quick validation checks here - other checks - /// require loading the image source anyway so don't bother to include them) + /// Otherwise we need to generate a new one + let assetInfo: (asset: AVURLAsset, cleanup: () -> Void)? = SNUIKit.asset( + for: url.path, + mimeType: mimeType, + sourceFilename: sourceFilename + ) + guard - let imageData: Data = dataSource.imageData, - let imageFormat: SUIKImageFormat = imageData.suiKitGuessedImageFormat.nullIfUnknown, - (imageFormat != .gif || imageData.suiKitHasValidGifSize), - let source: CGImageSource = CGImageSourceCreateWithData(imageData as CFData, nil), - CGImageSourceGetCount(source) > 0 - else { return continuation.resume(returning: nil) } - - let count: Int = CGImageSourceGetCount(source) - - switch count { - /// Invalid image - case ..<1: return continuation.resume(returning: nil) - - /// Static image - case 1: - guard let cgImage: CGImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else { - return continuation.resume(returning: nil) - } - - /// Extract image orientation if present - var orientation: UIImage.Orientation = .up - - if - let imageProperties: [CFString: Any] = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], - let rawCgOrientation: UInt32 = imageProperties[kCGImagePropertyOrientation] as? UInt32, - let cgOrientation: CGImagePropertyOrientation = CGImagePropertyOrientation(rawValue: rawCgOrientation) - { - orientation = UIImage.Orientation(cgOrientation) - } - - let image: UIImage = UIImage(cgImage: cgImage, scale: 1, orientation: orientation) - let decodedImage: UIImage = (image.predecodedImage() ?? image) - let processedData: ProcessedImageData = ProcessedImageData( - type: .staticImage(decodedImage) - ) - continuation.resume(returning: processedData) - return - - /// Animated Image - default: - var framesArray: [UIImage] = [] - var durationsArray: [TimeInterval] = [] - - for i in 0.. 0 + else { return nil } + + let count: Int = CGImageSourceGetCount(source) + + switch count { + /// Invalid image + case ..<1: return nil + + /// Static image + case 1: + guard let cgImage: CGImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else { + return nil + } + + /// Extract image orientation if present + var orientation: UIImage.Orientation = .up + + if + let imageProperties: [CFString: Any] = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let rawCgOrientation: UInt32 = imageProperties[kCGImagePropertyOrientation] as? UInt32, + let cgOrientation: CGImagePropertyOrientation = CGImagePropertyOrientation(rawValue: rawCgOrientation) + { + orientation = UIImage.Orientation(cgOrientation) + } + + let image: UIImage = UIImage(cgImage: cgImage, scale: 1, orientation: orientation) + let decodedImage: UIImage = (image.predecodedImage() ?? image) + + return ProcessedImageData( + type: .staticImage(decodedImage) + ) + + /// Animated Image + default: + var framesArray: [UIImage] = [] + var durationsArray: [TimeInterval] = [] + + for i in 0.. Data?) + case videoUrl(URL, String, String?, ThumbnailManager) + case urlThumbnail(URL, ImageDataManager.ThumbnailSize, ThumbnailManager) + case closureThumbnail(String, ImageDataManager.ThumbnailSize, @Sendable () async -> UIImage?) + case placeholderIcon(seed: String, text: String, size: CGFloat) + + public var identifier: String { + switch self { + case .url(let url): return url.absoluteString + case .data(let identifier, _): return identifier + case .image(let identifier, _): return identifier + case .videoUrl(let url, _, _, _): return url.absoluteString + case .urlThumbnail(let url, let size, _): + return "\(url.absoluteString)-\(size)" + + case .closureThumbnail(let identifier, let size, _): + return "\(identifier)-\(size)" + + case .placeholderIcon(let seed, let text, let size): + let content: (intSeed: Int, initials: String) = PlaceholderIcon.content( + seed: seed, + text: text + ) + + return "\(seed)-\(content.initials)-\(Int(floor(size)))" + } + } public var imageData: Data? { switch self { case .url(let url): return try? Data(contentsOf: url, options: [.dataReadingMapped]) - case .data(let data): return data + case .data(_, let data): return data case .image(_, let image): return image?.pngData() case .videoUrl: return nil - case .closure(let dataRetriever): return dataRetriever() + case .urlThumbnail: return nil + case .closureThumbnail: return nil + case .placeholderIcon: return nil + } + } + + public var dataForGuessingImageFormat: Data? { + switch self { + case .url(let url), .urlThumbnail(let url, _, _): + guard let fileHandle: FileHandle = try? FileHandle(forReadingFrom: url) else { + return nil + } + + defer { fileHandle.closeFile() } + return fileHandle.readData(ofLength: 12) + + case .data(_, let data): return data + case .image, .videoUrl, .closureThumbnail, .placeholderIcon: return nil } } @@ -270,27 +414,73 @@ public extension ImageDataManager { public static func == (lhs: DataSource, rhs: DataSource) -> Bool { switch (lhs, rhs) { case (.url(let lhsUrl), .url(let rhsUrl)): return (lhsUrl == rhsUrl) - case (.data(let lhsData), .data(let rhsData)): return (lhsData == rhsData) + case (.data(let lhsIdentifier, let lhsData), .data(let rhsIdentifier, let rhsData)): + return ( + lhsIdentifier == rhsIdentifier && + lhsData == rhsData + ) case (.image(let lhsIdentifier, _), .image(let rhsIdentifier, _)): /// `UIImage` is not _really_ equatable so we need to use a separate identifier to use instead return (lhsIdentifier == rhsIdentifier) - case (.videoUrl(let lhsUrl), .videoUrl(let rhsUrl)): return (lhsUrl == rhsUrl) - case (.closure, .closure): return false + case (.videoUrl(let lhsUrl, let lhsMimeType, let lhsSourceFilename, _), .videoUrl(let rhsUrl, let rhsMimeType, let rhsSourceFilename, _)): + return ( + lhsUrl == rhsUrl && + lhsMimeType == rhsMimeType && + lhsSourceFilename == rhsSourceFilename + ) + + case (.urlThumbnail(let lhsUrl, let lhsSize, _), .urlThumbnail(let rhsUrl, let rhsSize, _)): + return ( + lhsUrl == rhsUrl && + lhsSize == rhsSize + ) + + case (.closureThumbnail(let lhsIdentifier, let lhsSize, _), .closureThumbnail(let rhsIdentifier, let rhsSize, _)): + return ( + lhsIdentifier == rhsIdentifier && + lhsSize == rhsSize + ) + + case (.placeholderIcon(let lhsSeed, let lhsText, let lhsSize), .placeholderIcon(let rhsSeed, let rhsText, let rhsSize)): + return ( + lhsSeed == rhsSeed && + lhsText == rhsText && + lhsSize == rhsSize + ) + default: return false } } public func hash(into hasher: inout Hasher) { switch self { - case .url(let url): return url.hash(into: &hasher) - case .data(let data): return data.hash(into: &hasher) + case .url(let url): url.hash(into: &hasher) + case .data(let identifier, let data): + identifier.hash(into: &hasher) + data.hash(into: &hasher) + case .image(let identifier, _): /// `UIImage` is not actually hashable so we need to provide a separate identifier to use instead - return identifier.hash(into: &hasher) + identifier.hash(into: &hasher) + + case .videoUrl(let url, let mimeType, let sourceFilename, _): + url.hash(into: &hasher) + mimeType.hash(into: &hasher) + sourceFilename.hash(into: &hasher) + + case .urlThumbnail(let url, let size, _): + url.hash(into: &hasher) + size.hash(into: &hasher) - case .videoUrl(let url): return url.hash(into: &hasher) - case .closure: break + case .closureThumbnail(let identifier, let size, _): + identifier.hash(into: &hasher) + size.hash(into: &hasher) + + case .placeholderIcon(let seed, let text, let size): + seed.hash(into: &hasher) + text.hash(into: &hasher) + size.hash(into: &hasher) } } } @@ -403,7 +593,26 @@ extension AVAsset { } public extension ImageDataManager.DataSource { + @MainActor var sizeFromMetadata: CGSize? { + /// There are a number of types which have fixed sizes, in those cases we should return the target size rather than try to + /// read it from data so we doncan avoid processing + switch self { + case .image(_, let image): + guard let image: UIImage = image else { break } + + return image.size + + case .urlThumbnail(_, let size, _), .closureThumbnail(_, let size, _): + let dimension: CGFloat = size.pixelDimension() + return CGSize(width: dimension, height: dimension) + + case .placeholderIcon(_, _, let size): return CGSize(width: size, height: size) + + case .url, .data, .videoUrl: break + } + + /// Since we don't have a direct size, try to extract it from the data guard let imageData: Data = imageData, let imageFormat: SUIKImageFormat = imageData.suiKitGuessedImageFormat.nullIfUnknown @@ -430,16 +639,47 @@ public extension ImageDataManager.DataSource { } } +// MARK: - ImageDataManager.ThumbnailSize + +public extension ImageDataManager { + enum ThumbnailSize: String, Sendable { + case small + case medium + case large + + @MainActor public func pixelDimension() -> CGFloat { + let scale: CGFloat = UIScreen.main.scale + + switch self { + case .small: return floor(200 * scale) + case .medium: return floor(450 * scale) + case .large: + /// This size is large enough to render full screen + let screenSizePoints: CGSize = UIScreen.main.bounds.size + + return floor(max(screenSizePoints.width, screenSizePoints.height) * scale) + } + } + } +} + // MARK: - ImageDataManagerType public protocol ImageDataManagerType { - @discardableResult func loadImageData( - identifier: String, - source: ImageDataManager.DataSource - ) async -> ImageDataManager.ProcessedImageData? + @discardableResult func load(_ source: ImageDataManager.DataSource) async -> ImageDataManager.ProcessedImageData? + nonisolated func load( + _ source: ImageDataManager.DataSource, + onComplete: @escaping (ImageDataManager.ProcessedImageData?) -> Void + ) - func cacheImage(_ image: UIImage, for identifier: String) async func cachedImage(identifier: String) async -> ImageDataManager.ProcessedImageData? func removeImage(identifier: String) async func clearCache() async } + +// MARK: - ThumbnailManager + +public protocol ThumbnailManager: Sendable { + func existingThumbnailImage(url: URL, size: ImageDataManager.ThumbnailSize) -> UIImage? + func saveThumbnail(data: Data, size: ImageDataManager.ThumbnailSize, url: URL) +} diff --git a/Session/Utilities/MentionUtilities.swift b/SessionUIKit/Utilities/MentionUtilities.swift similarity index 80% rename from Session/Utilities/MentionUtilities.swift rename to SessionUIKit/Utilities/MentionUtilities.swift index f2759f8f50..d6f3ba2494 100644 --- a/Session/Utilities/MentionUtilities.swift +++ b/SessionUIKit/Utilities/MentionUtilities.swift @@ -1,10 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB -import SessionUIKit -import SessionMessagingKit -import SessionUtilitiesKit +import UIKit public enum MentionUtilities { public enum MentionLocation { @@ -18,23 +15,17 @@ public enum MentionUtilities { public static func highlightMentionsNoAttributes( in string: String, - threadVariant: SessionThread.Variant, - currentUserSessionId: String, - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String?, - using dependencies: Dependencies + currentUserSessionIds: Set, + displayNameRetriever: (String) -> String? ) -> String { /// **Note:** We are returning the string here so the 'textColor' and 'primaryColor' values are irrelevant return highlightMentions( in: string, - threadVariant: threadVariant, - currentUserSessionId: currentUserSessionId, - currentUserBlinded15SessionId: currentUserBlinded15SessionId, - currentUserBlinded25SessionId: currentUserBlinded25SessionId, + currentUserSessionIds: currentUserSessionIds, location: .styleFree, textColor: .black, attributes: [:], - using: dependencies + displayNameRetriever: displayNameRetriever ) .string .deformatted() @@ -42,14 +33,11 @@ public enum MentionUtilities { public static func highlightMentions( in string: String, - threadVariant: SessionThread.Variant, - currentUserSessionId: String?, - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String?, + currentUserSessionIds: Set, location: MentionLocation, textColor: ThemeValue, attributes: [NSAttributedString.Key: Any], - using dependencies: Dependencies + displayNameRetriever: (String) -> String? ) -> ThemedAttributedString { guard let regex: NSRegularExpression = try? NSRegularExpression(pattern: "@[0-9a-fA-F]{66}", options: []) @@ -60,13 +48,6 @@ public enum MentionUtilities { var string = string var lastMatchEnd: Int = 0 var mentions: [(range: NSRange, isCurrentUser: Bool)] = [] - let currentUserSessionIds: Set = [ - currentUserSessionId, - currentUserBlinded15SessionId, - currentUserBlinded25SessionId - ] - .compactMap { $0 } - .asSet() while let match: NSTextCheckingResult = regex.firstMatch( in: string, @@ -80,8 +61,7 @@ public enum MentionUtilities { guard let targetString: String = { guard !isCurrentUser else { return "you".localized() } - // FIXME: This does a database query and is happening when populating UI - should try to refactor it somehow (ideally resolve a set of mentioned profiles as part of the database query) - guard let displayName: String = Profile.displayNameNoFallback(id: sessionId, threadVariant: threadVariant, using: dependencies) else { + guard let displayName: String = displayNameRetriever(sessionId) else { lastMatchEnd = (match.range.location + match.range.length) return nil } @@ -151,3 +131,16 @@ public enum MentionUtilities { return result } } + +public extension String { + func replacingMentions( + currentUserSessionIds: Set, + displayNameRetriever: (String) -> String? + ) -> String { + return MentionUtilities.highlightMentionsNoAttributes( + in: self, + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: displayNameRetriever + ) + } +} diff --git a/SessionUIKit/Utilities/SwiftUI+Utilities.swift b/SessionUIKit/Utilities/SwiftUI+Utilities.swift index a130c7b694..1f83f60164 100644 --- a/SessionUIKit/Utilities/SwiftUI+Utilities.swift +++ b/SessionUIKit/Utilities/SwiftUI+Utilities.swift @@ -8,7 +8,7 @@ struct ViewControllerHolder { } struct ViewControllerKey: EnvironmentKey { - static var defaultValue: ViewControllerHolder { + @MainActor static var defaultValue: ViewControllerHolder { return ViewControllerHolder(value: SNUIKit.mainWindow?.rootViewController) } } diff --git a/SessionUtilitiesKit/Configuration.swift b/SessionUtilitiesKit/Configuration.swift index ce693261c0..ce7ef571ee 100644 --- a/SessionUtilitiesKit/Configuration.swift +++ b/SessionUtilitiesKit/Configuration.swift @@ -33,7 +33,8 @@ public enum SNUtilitiesKit: MigratableTarget { // Just to make the external API ], [ _006_RenameTableSettingToKeyValueStore.self - ] + ], // Renamed `Setting` to `KeyValueStore` + [] ] ) } diff --git a/SessionUtilitiesKit/Crypto/CryptoError.swift b/SessionUtilitiesKit/Crypto/CryptoError.swift index 0194aa61f1..b5b821d651 100644 --- a/SessionUtilitiesKit/Crypto/CryptoError.swift +++ b/SessionUtilitiesKit/Crypto/CryptoError.swift @@ -11,4 +11,6 @@ public enum CryptoError: Error { case encryptionFailed case decryptionFailed case failedToGenerateOutput + case missingUserSecretKey + case invalidAuthentication } diff --git a/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift index 64bd5b9c97..038c6de166 100644 --- a/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -10,10 +10,10 @@ enum _001_InitialSetupMigration: Migration { static let identifier: String = "initialSetup" static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [ - Identity.self, Job.self, JobDependencies.self, Setting.self + Identity.self, Job.self, JobDependencies.self ] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.create(table: "identity") { t in t.column("variant", .text) .notNull() @@ -72,6 +72,6 @@ enum _001_InitialSetupMigration: Migration { t.column("value", .blob).notNull() } - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift index a51da22428..ca787c2c2c 100644 --- a/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -11,7 +11,7 @@ enum _002_SetupStandardJobs: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { /// This job exists in the 'Session' target but that doesn't have it's own migrations /// /// **Note:** We actually need this job to run both onLaunch and onActive as the logic differs slightly and there are cases @@ -31,6 +31,6 @@ enum _002_SetupStandardJobs: Migration { ) """) - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 04948899aa..565fd3e3f2 100644 --- a/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -9,13 +9,7 @@ enum _003_YDBToGRDBMigration: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { - guard - !SNUtilitiesKit.isRunningTests && - MigrationHelper.userExists(db) - else { return Storage.update(progress: 1, for: self, in: target, using: dependencies) } - - Log.error(.migration, "Attempted to perform legacy migation") - throw StorageError.migrationNoLongerSupported + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { + MigrationExecution.updateProgress(1) } } diff --git a/SessionUtilitiesKit/Database/Migrations/_004_AddJobPriority.swift b/SessionUtilitiesKit/Database/Migrations/_004_AddJobPriority.swift index aff17f907d..852033b582 100644 --- a/SessionUtilitiesKit/Database/Migrations/_004_AddJobPriority.swift +++ b/SessionUtilitiesKit/Database/Migrations/_004_AddJobPriority.swift @@ -9,7 +9,7 @@ enum _004_AddJobPriority: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { // Add `priority` to the job table try db.alter(table: "job") { t in t.add(column: "priority", .integer).defaults(to: 0) @@ -35,6 +35,6 @@ enum _004_AddJobPriority: Migration { """) } - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionUtilitiesKit/Database/Migrations/_005_AddJobUniqueHash.swift b/SessionUtilitiesKit/Database/Migrations/_005_AddJobUniqueHash.swift index 077313c092..e4a36701f5 100644 --- a/SessionUtilitiesKit/Database/Migrations/_005_AddJobUniqueHash.swift +++ b/SessionUtilitiesKit/Database/Migrations/_005_AddJobUniqueHash.swift @@ -9,12 +9,12 @@ enum _005_AddJobUniqueHash: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { // Add `uniqueHashValue` to the job table try db.alter(table: "job") { t in t.add(column: "uniqueHashValue", .integer) } - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionUtilitiesKit/Database/Migrations/_006_RenameTableSettingToKeyValueStore.swift b/SessionUtilitiesKit/Database/Migrations/_006_RenameTableSettingToKeyValueStore.swift index 1d5af28598..ffad28e128 100644 --- a/SessionUtilitiesKit/Database/Migrations/_006_RenameTableSettingToKeyValueStore.swift +++ b/SessionUtilitiesKit/Database/Migrations/_006_RenameTableSettingToKeyValueStore.swift @@ -9,9 +9,9 @@ enum _006_RenameTableSettingToKeyValueStore: Migration { static let minExpectedRunDuration: TimeInterval = 0.1 static let createdTables: [(TableRecord & FetchableRecord).Type] = [ KeyValueStore.self ] - static func migrate(_ db: Database, using dependencies: Dependencies) throws { + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.rename(table: "setting", to: "keyValueStore") - Storage.update(progress: 1, for: self, in: target, using: dependencies) + MigrationExecution.updateProgress(1) } } diff --git a/SessionUtilitiesKit/Database/Models/Identity.swift b/SessionUtilitiesKit/Database/Models/Identity.swift index 732dbb156d..945e0c7d3f 100644 --- a/SessionUtilitiesKit/Database/Models/Identity.swift +++ b/SessionUtilitiesKit/Database/Models/Identity.swift @@ -3,7 +3,7 @@ import Foundation import GRDB -public struct Identity: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct Identity: Codable, Equatable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "identity" } public typealias Columns = CodingKeys @@ -70,25 +70,14 @@ public extension Identity { ) } - static func store(_ db: Database, ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair) throws { + static func store(_ db: ObservingDatabase, ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair) throws { try Identity(variant: .ed25519SecretKey, data: Data(ed25519KeyPair.secretKey)).upsert(db) try Identity(variant: .ed25519PublicKey, data: Data(ed25519KeyPair.publicKey)).upsert(db) try Identity(variant: .x25519PrivateKey, data: Data(x25519KeyPair.secretKey)).upsert(db) try Identity(variant: .x25519PublicKey, data: Data(x25519KeyPair.publicKey)).upsert(db) } - static func userExists( - _ db: Database? = nil, - using dependencies: Dependencies - ) -> Bool { - guard let db: Database = db else { - return (dependencies[singleton: .storage].read { db in Identity.userExists(db, using: dependencies) } ?? false) - } - - return (fetchUserEd25519KeyPair(db) != nil) - } - - static func fetchUserKeyPair(_ db: Database) -> KeyPair? { + static func fetchUserKeyPair(_ db: ObservingDatabase) -> KeyPair? { guard let publicKey: Data = try? Identity.fetchOne(db, id: .x25519PublicKey)?.data, let secretKey: Data = try? Identity.fetchOne(db, id: .x25519PrivateKey)?.data @@ -100,7 +89,7 @@ public extension Identity { ) } - static func fetchUserEd25519KeyPair(_ db: Database) -> KeyPair? { + static func fetchUserEd25519KeyPair(_ db: ObservingDatabase) -> KeyPair? { guard let publicKey: Data = try? Identity.fetchOne(db, id: .ed25519PublicKey)?.data, let secretKey: Data = try? Identity.fetchOne(db, id: .ed25519SecretKey)?.data @@ -114,36 +103,45 @@ public extension Identity { static func mnemonic(using dependencies: Dependencies) throws -> String { guard - let ed25519KeyPair: KeyPair = dependencies[singleton: .storage] - .read({ db in Identity.fetchUserEd25519KeyPair(db) }), + let ed25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey.nullIfEmpty, let seedData: Data = dependencies[singleton: .crypto].generate( - .ed25519Seed(ed25519SecretKey: ed25519KeyPair.secretKey) + .ed25519Seed(ed25519SecretKey: ed25519SecretKey) ), seedData.count >= 16 // Just to be safe else { - let dbIsValid: Bool = dependencies[singleton: .storage].isValid - let dbHasRead: Bool = dependencies[singleton: .storage].hasSuccessfullyRead - let dbHasWritten: Bool = dependencies[singleton: .storage].hasSuccessfullyWritten - let dbIsSuspended: Bool = dependencies[singleton: .storage].isSuspended - let (hasStoredXKeyPair, hasStoredEdKeyPair) = dependencies[singleton: .storage].read { db -> (Bool, Bool) in - ( - (Identity.fetchUserKeyPair(db) != nil), - (Identity.fetchUserEd25519KeyPair(db) != nil) - ) - }.defaulting(to: (false, false)) + /// This log is for debugging purposes so doesn't need to run sycnrhonously + Task.detached(priority: .low) { + let dbIsValid: Bool = dependencies[singleton: .storage].isValid + let dbHasRead: Bool = dependencies[singleton: .storage].hasSuccessfullyRead + let dbHasWritten: Bool = dependencies[singleton: .storage].hasSuccessfullyWritten + let dbIsSuspended: Bool = dependencies[singleton: .storage].isSuspended + + dependencies[singleton: .storage].readAsync( + retrieve: { db in + ( + (Identity.fetchUserKeyPair(db) != nil), + (Identity.fetchUserEd25519KeyPair(db) != nil) + ) + }, + completion: { result in + let (hasStoredXKeyPair, hasStoredEdKeyPair) = ((try? result.successOrThrow()) ?? (false, false)) + + // stringlint:ignore_start + let dbStates: [String] = [ + "dbIsValid: \(dbIsValid)", + "dbHasRead: \(dbHasRead)", + "dbHasWritten: \(dbHasWritten)", + "dbIsSuspended: \(dbIsSuspended)", + "userXKeyPair: \(hasStoredXKeyPair)", + "userEdKeyPair: \(hasStoredEdKeyPair)" + ] + // stringlint:ignore_stop - // stringlint:ignore_start - let dbStates: [String] = [ - "dbIsValid: \(dbIsValid)", - "dbHasRead: \(dbHasRead)", - "dbHasWritten: \(dbHasWritten)", - "dbIsSuspended: \(dbIsSuspended)", - "userXKeyPair: \(hasStoredXKeyPair)", - "userEdKeyPair: \(hasStoredEdKeyPair)" - ] - // stringlint:ignore_stop - - Log.critical("Failed to retrieve keys for mnemonic generation (\(dbStates.joined(separator: ", ")))") + Log.critical("Failed to retrieve keys for mnemonic generation (\(dbStates.joined(separator: ", ")))") + } + ) + } + throw StorageError.objectNotFound } diff --git a/SessionUtilitiesKit/Database/Models/KeyValueStore.swift b/SessionUtilitiesKit/Database/Models/KeyValueStore.swift index a29d9dd73c..d7bac86112 100644 --- a/SessionUtilitiesKit/Database/Models/KeyValueStore.swift +++ b/SessionUtilitiesKit/Database/Models/KeyValueStore.swift @@ -141,31 +141,12 @@ public extension KeyValueStore { } } -public protocol EnumInt: RawRepresentable where RawValue == Int {} -public protocol EnumString: RawRepresentable where RawValue == String {} - // MARK: - GRDB Interactions -public extension Storage { - subscript(key: KeyValueStore.BoolKey) -> Bool { - // Default to false if it doesn't exist - return (read { db in db[key] } ?? false) - } - - subscript(key: KeyValueStore.DoubleKey) -> Double? { return read { db in db[key] } } - subscript(key: KeyValueStore.IntKey) -> Int? { return read { db in db[key] } } - subscript(key: KeyValueStore.Int64Key) -> Int64? { return read { db in db[key] } } - subscript(key: KeyValueStore.StringKey) -> String? { return read { db in db[key] } } - subscript(key: KeyValueStore.DateKey) -> Date? { return read { db in db[key] } } - - subscript(key: KeyValueStore.EnumKey) -> T? { return read { db in db[key] } } - subscript(key: KeyValueStore.EnumKey) -> T? { return read { db in db[key] } } -} - -public extension Database { +public extension ObservingDatabase { @discardableResult func unsafeSet(key: String, value: T?) -> KeyValueStore? { guard let value: T = value else { - _ = try? Setting.filter(id: key).deleteAll(self) + _ = try? KeyValueStore.filter(id: key).deleteAll(self) return nil } @@ -212,7 +193,7 @@ public extension Database { set { self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue) } } - subscript(key: KeyValueStore.EnumKey) -> T? { + subscript(key: KeyValueStore.EnumKey) -> T? where T.RawValue == Int { get { guard let rawValue: Int = self[key.rawValue]?.value(as: Int.self) else { return nil @@ -223,7 +204,7 @@ public extension Database { set { self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue?.rawValue) } } - subscript(key: KeyValueStore.EnumKey) -> T? { + subscript(key: KeyValueStore.EnumKey) -> T? where T.RawValue == String { get { guard let rawValue: String = self[key.rawValue]?.value(as: String.self) else { return nil @@ -279,13 +260,13 @@ public extension Database { return result } - func setting(key: KeyValueStore.EnumKey, to newValue: T?) -> KeyValueStore? { + func setting(key: KeyValueStore.EnumKey, to newValue: T?) -> KeyValueStore? where T.RawValue == Int { let result: KeyValueStore? = KeyValueStore(key: key.rawValue, value: newValue?.rawValue) self[key.rawValue] = result return result } - func setting(key: KeyValueStore.EnumKey, to newValue: T?) -> KeyValueStore? { + func setting(key: KeyValueStore.EnumKey, to newValue: T?) -> KeyValueStore? where T.RawValue == String { let result: KeyValueStore? = KeyValueStore(key: key.rawValue, value: newValue?.rawValue) self[key.rawValue] = result return result @@ -298,4 +279,3 @@ public extension Database { return result } } - diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index f12284561a..aad708bfb3 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -38,11 +38,6 @@ public extension KeychainStorage.DataKey { static let dbCipherKeySpec: Self = "G open class Storage { public static let base32: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" - public struct CurrentlyRunningMigration: ThreadSafeType { - public let identifier: TargetMigrations.Identifier - public let migration: Migration.Type - } - public static let queuePrefix: String = "SessionDatabase" public static let dbFileName: String = "Session.sqlite" private static let SQLCipherKeySpecLength: Int = 48 @@ -69,23 +64,13 @@ open class Storage { fileprivate var dbWriter: DatabaseWriter? internal var testDbWriter: DatabaseWriter? { dbWriter } - // MARK: - Migration Variables - - @ThreadSafeObject private var migrationProgressUpdater: ((String, CGFloat) -> ())? - @ThreadSafe private var internalCurrentlyRunningMigration: CurrentlyRunningMigration? = nil - @ThreadSafe private var migrationsCompleted: Bool = false - - public var hasCompletedMigrations: Bool { migrationsCompleted } - public var currentlyRunningMigration: CurrentlyRunningMigration? { - internalCurrentlyRunningMigration - } - // MARK: - Database State Variables private var startupError: Error? public private(set) var isValid: Bool = false public private(set) var isSuspended: Bool = false public var isDatabasePasswordAccessible: Bool { ((try? getDatabaseCipherKeySpec()) != nil) } + public private(set) var hasCompletedMigrations: Bool = false /// This property gets set the first time we successfully read from the database public private(set) var hasSuccessfullyRead: Bool = false @@ -164,7 +149,13 @@ open class Storage { /// **Note:** If we fail to get/generate the keySpec then don't bother continuing to setup the Database as it'll just be invalid, /// in this case the App/Extensions will have logic that checks the `isValid` flag of the database do { - var tmpKeySpec: Data = try getOrGenerateDatabaseKeySpec() + var tmpKeySpec: Data = try dependencies[singleton: .keychain].getOrGenerateEncryptionKey( + forKey: .dbCipherKeySpec, + length: Storage.SQLCipherKeySpecLength, + cat: .storage, + legacyKey: "GRDBDatabaseCipherKeySpec", + legacyService: "TSKeyChainService" + ) tmpKeySpec.resetBytes(in: 0.. Set { + public static func appliedMigrationIdentifiers(_ db: ObservingDatabase) -> Set { let migrator: DatabaseMigrator = DatabaseMigrator() - return (try? migrator.appliedIdentifiers(db)) + return (try? migrator.appliedIdentifiers(db.originalDb)) .defaulting(to: []) } @@ -334,7 +333,8 @@ open class Storage { .map { _, _, migration in migration.minExpectedRunDuration } let totalMinExpectedDuration: TimeInterval = migrationToDurationMap.values.reduce(0, +) - self._migrationProgressUpdater.set(to: { targetKey, progress in + // Store the logic to handle migration progress and completion + let progressUpdater: (String, CGFloat) -> Void = { (targetKey: String, progress: CGFloat) in guard let migrationIndex: Int = unperformedMigrations.firstIndex(where: { key, _, _ in key == targetKey }) else { return } @@ -348,18 +348,27 @@ open class Storage { DispatchQueue.main.async { onProgressUpdate?(totalProgress, totalMinExpectedDuration) } - }) - - // Store the logic to run when the migration completes - let migrationCompleted: (Result) -> () = { [weak self, migrator, dbWriter] result in + } + 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 lastMigrationKey: String = unperformedMigrations.last?.key { - self?.migrationProgressUpdater?(lastMigrationKey, 1) + MigrationExecution.current?.progressUpdater(lastMigrationKey, 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 { + Task(priority: .medium) { [dependencies] in + await dependencies[singleton: .observationManager].notify(events) + } } - self?.migrationsCompleted = true - self?._migrationProgressUpdater.set(to: nil) + 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 @@ -390,57 +399,35 @@ open class Storage { return } + // Create the `MigrationContext` + let migrationContext: MigrationExecution.Context = MigrationExecution.Context(progressUpdater: progressUpdater) + // If we have an unperformed migration then trigger the progress updater immediately if let firstMigrationKey: String = unperformedMigrations.first?.key { - self.migrationProgressUpdater?(firstMigrationKey, 0) + migrationContext.progressUpdater(firstMigrationKey, 0) } - // 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 = { - switch result { - case .failure(let error): return .failure(error) - case .success: return .success(()) - } - }() + 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) })) } - // 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) + migrator.asyncMigrate(dbWriter) { [dependencies] result in + let finalResult: Result = { + switch result { + case .failure(let error): return .failure(error) + case .success: return .success(()) + } + }() + + // 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) + } } } } - public func willStartMigration( - _ db: Database, - _ migration: Migration.Type, - _ identifier: TargetMigrations.Identifier - ) { - internalCurrentlyRunningMigration = CurrentlyRunningMigration( - identifier: identifier, - migration: migration - ) - } - - public func didCompleteMigration() { - internalCurrentlyRunningMigration = nil - } - - public static func update( - progress: CGFloat, - for migration: Migration.Type, - in target: TargetMigrations.Identifier, - using dependencies: Dependencies - ) { - // In test builds ignore any migration progress updates (we run in a custom database writer anyway) - guard !SNUtilitiesKit.isRunningTests else { return } - - dependencies[singleton: .storage].migrationProgressUpdater?(target.key(with: migration), progress) - } - // MARK: - Security private func getDatabaseCipherKeySpec() throws -> Data { @@ -452,65 +439,6 @@ open class Storage { return try dependencies[singleton: .keychain].data(forKey: .dbCipherKeySpec) } - private func getOrGenerateDatabaseKeySpec() throws -> Data { - do { - var keySpec: Data = try getDatabaseCipherKeySpec() - defer { keySpec.resetBytes(in: 0..( + _ info: CallInfo, + _ dbWriter: DatabaseWriter, + _ operation: @escaping (ObservingDatabase) throws -> T, + _ dependencies: Dependencies + ) async -> Result { + typealias DatabaseOutput = ( + result: T, + events: [ObservedEvent], + postCommitActions: [() -> Void] + ) + typealias DatabaseResult = (result: T, postCommitActions: [() -> Void]) + + return await withThrowingTaskGroup(of: DatabaseResult.self) { group in + /// Add the task to perform the actual database operation + group.addTask { + let trackedOperation: @Sendable (Database) throws -> DatabaseOutput = { db in + info.start() + guard info.storage?.isValid == true else { throw StorageError.databaseInvalid } + guard info.storage?.isSuspended == false else { + throw StorageError.databaseSuspended + } + + /// Create the `ObservingDatabase` and store it in the `ObservationContext` so objects can access + /// it while the operation is running (this allows us to use things like `aroundInsert` without having to resort + /// to hacks to give it access to the `ObservingDatabase` or `Dependencies` instances + let observingDatabase: ObservingDatabase = ObservingDatabase.create(db, using: dependencies) + let result: T = try ObservationContext.$observingDb.withValue(observingDatabase) { + try operation(observingDatabase) + } + + /// Update the state flags + switch info.isWrite { + case true: info.storage?.hasSuccessfullyWritten = true + case false: info.storage?.hasSuccessfullyRead = true + } + + return ( + result, + observingDatabase.events, + Array(observingDatabase.postCommitActions.values) + ) + } + + /// 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] { + try await Task.sleep(for: .seconds(1)) + } + + let output: DatabaseOutput = (info.isWrite ? + try await dbWriter.write(trackedOperation) : + try await dbWriter.read(trackedOperation) + ) + + /// Trigger the observations + Task(priority: .medium) { [dependencies] in + await dependencies[singleton: .observationManager].notify(output.events) + } + + return (output.result, output.postCommitActions) + } + + /// If this is a syncronous task then we want to the operation to timeout to ensure we don't unintentionally + /// create a deadlock + if !info.isAsync { + group.addTask { + /// If the debugger is attached then we want to have a lot of shorter sleep iterations as the clock doesn't get + /// paused when stopped on a breakpoint (and we don't want to end up having a bunch of false positive + /// database timeouts while debugging code) + /// + /// **Note:** `isDebuggerAttached` will always return `false` in production builds + if isDebuggerAttached() { + let numIterations: UInt64 = 50 + + for _ in (0.. = await ( + group.nextResult() ?? + .failure(StorageError.invalidQueryResult) + ) + group.cancelAll() + + /// If the database operation completed successfully we should trigger any of the `postCommitActions` + switch output { + case .failure: break + case .success(let result): result.postCommitActions.forEach { $0() } + } + + /// Return the actual result + return output.map { $0.result } + } + } + /// This function manually performs `read`/`write` operations in either a synchronous or asyncronous way using a semaphore to /// block the syncrhonous version because `GRDB` has an internal assertion when using it's built-in synchronous `read`/`write` /// functions to prevent reentrancy which is unsupported /// - /// Unfortunately this results in the code getting messy when trying to chain multiple database transactions (even - /// when using `db.afterNextTransaction`) which is somewhat unintuitive - /// /// The `async` variants don't need to worry about this reentrancy issue so instead we route we use those for all operations instead /// and just block the thread when we want to perform a synchronous operation /// /// **Note:** When running a synchronous operation the result will be returned and `asyncCompletion` will not be called, and /// vice-versa for an asynchronous operation - @discardableResult private static func performOperation( + @discardableResult private func performOperation( _ info: CallInfo, _ dependencies: Dependencies, - _ operation: @escaping (Database) throws -> T, + _ operation: @escaping (ObservingDatabase) throws -> T, _ asyncCompletion: ((Result) -> Void)? = nil ) -> Result { /// Ensure we are in a valid state let storageState: StorageState = StorageState(info.storage) guard case .valid(let dbWriter) = storageState else { - if info.isAsync { asyncCompletion?(.failure(storageState.forcedError)) } + info.errored(storageState.forcedError) return .failure(storageState.forcedError) } @@ -682,116 +728,39 @@ open class Storage { /// Log that we are scheduling the operation (so we have a log in case it's blocked for some reason) info.schedule() - /// We need to prevent the task from starting before it's been added to our tracking (otherwise it will never be removed - /// resulting in incorrect logs) so create an `AsyncStream` that the task can wait on - var startSignalContinuation: AsyncStream.Continuation? - let startSignalStream = AsyncStream { continuation in - startSignalContinuation = continuation - } - /// Kick off and store the task in case we want to cancel it later info.task = Task { - _ = await startSignalStream.first { _ in true } + info.storage?.addCall(info) + defer { info.storage?.removeCall(info) } - await withThrowingTaskGroup(of: T.self) { group in - /// Add the task to perform the actual database operation - group.addTask { - let trackedOperation: @Sendable (Database) throws -> T = { db in - info.start() - guard info.storage?.isValid == true else { throw StorageError.databaseInvalid } - guard info.storage?.isSuspended == false else { - throw StorageError.databaseSuspended - } - - if dependencies[feature: .forceSlowDatabaseQueries] { - Thread.sleep(forTimeInterval: 1) - } - - let result: T = try operation(db) - - // Update the state flags - switch info.isWrite { - case true: info.storage?.hasSuccessfullyWritten = true - case false: info.storage?.hasSuccessfullyRead = true - } - - return result - } - - return (info.isWrite ? - try await dbWriter.write(trackedOperation) : - try await dbWriter.read(trackedOperation) - ) - } + let result = await Storage.performOperation(info, dbWriter, operation, dependencies) - /// If this is a syncronous task then we want to the operation to timeout to ensure we don't unintentionally - /// create a deadlock - if !info.isAsync { - group.addTask { - let timeoutNanoseconds: UInt64 = UInt64(Storage.transactionDeadlockTimeoutSeconds * 1_000_000_000) - - /// If the debugger is attached then we want to have a lot of shorter sleep iterations as the clock doesn't get - /// paused when stopped on a breakpoint (and we don't want to end up having a bunch of false positive - /// database timeouts while debugging code) - /// - /// **Note:** `isDebuggerAttached` will always return `false` in production builds - if isDebuggerAttached() { - let numIterations: UInt64 = 50 - - for _ in (0.. = await ( - group.nextResult() ?? - .failure(StorageError.invalidQueryResult) - ) - group.cancelAll() - - /// Log the result - switch result { - case .success: info.complete() - case .failure(let error): info.errored(error) - } - - /// Now that we have completed the database operation we don't need to track the task anymore so we can - /// remove it - /// - /// **Note:** we want to remove it before `asyncCompletion` is called just in case that is a long running - /// process - info.storage?.removeCall(info) - - /// Send the result back - switch info.isAsync { - case true: asyncCompletion?(result) - case false: - syncResultContainer?.value = result - semaphore?.signal() - } + /// Log the result + switch result { + case .success: info.complete() + case .failure(let error): info.errored(error) + } + + /// Now that we have completed the database operation we don't need to track the task anymore so we can + /// remove it + /// + /// **Note:** we want to remove it before `asyncCompletion` is called just in case that is a long running + /// process + info.storage?.removeCall(info) + + /// Send the result back + switch info.isAsync { + case true: asyncCompletion?(result) + case false: + syncResultContainer?.value = result + semaphore?.signal() } } - info.storage?.addCall(info) - startSignalContinuation?.yield(()) - startSignalContinuation?.finish() /// For the `async` operation the returned value should be ignored so just return the `invalidQueryResult` error - guard !info.isAsync else { return .failure(StorageError.invalidQueryResult) } + if info.behaviour == .asyncRead || info.behaviour == .asyncWrite{ + return .failure(StorageError.invalidQueryResult) + } /// Block until we have a result semaphore?.wait() @@ -803,13 +772,16 @@ open class Storage { _ functionName: String, _ lineNumber: Int, isWrite: Bool, - _ operation: @escaping (Database) throws -> T + _ operation: @escaping (ObservingDatabase) throws -> T ) -> AnyPublisher { let info: CallInfo = CallInfo(self, fileName, functionName, lineNumber, (isWrite ? .asyncWrite : .asyncRead)) switch StorageState(self) { case .invalid(let error): return info.errored(error) case .valid: + /// Log that we are scheduling the operation (so we have a log in case it's blocked for some reason) + info.schedule() + /// **Note:** GRDB does have `readPublisher`/`writePublisher` functions but it appears to asynchronously /// trigger both the `output` and `complete` closures at the same time which causes a lot of unexpected /// behaviours (this behaviour is apparently expected but still causes a number of odd behaviours in our code @@ -822,35 +794,79 @@ open class Storage { return Deferred { [dependencies] in let subject: PassthroughSubject = PassthroughSubject() - Storage.performOperation(info, dependencies, operation) { [weak subject] result in - /// If the query was cancelled then we shouldn't try to propagate the result (as it may result in - /// interacting with deallocated objects) - guard !info.cancelledViaCombine else { return } + /// Kick off and store the task in case we want to cancel it later + info.task = Task { [weak subject] in + info.storage?.addCall(info) + defer { info.storage?.removeCall(info) } + /// Ensure we are in a valid state + let storageState: StorageState = StorageState(info.storage) + + guard case .valid(let dbWriter) = storageState else { + info.errored(storageState.forcedError) + subject?.send(completion: .failure(storageState.forcedError)) + return + } + + let result = await Storage.performOperation(info, dbWriter, operation, dependencies) + + /// Log and emit the result switch result { case .success(let value): + info.complete() subject?.send(value) subject?.send(completion: .finished) - - case .failure(let error): subject?.send(completion: .failure(error)) + + case .failure(let error): + /// If the query was cancelled then we shouldn't try to propagate the result (as it may result in + /// interacting with deallocated objects) + guard !info.cancelledViaCombine else { return } + + info.errored(error) + subject?.send(completion: .failure(error)) } } return subject } - .handleEvents(receiveCancel: { [weak self] in - info.cancel(cancelledViaCombine: true) + .handleEvents(receiveCancel: { [weak self, weak info] in + info?.cancel(cancelledViaCombine: true) self?.removeCall(info) }) .eraseToAnyPublisher() } } + private func performSwiftConcurrencyOperation( + _ fileName: String, + _ functionName: String, + _ lineNumber: Int, + isWrite: Bool, + _ operation: @escaping (ObservingDatabase) throws -> T + ) async throws -> T { + let info: CallInfo = CallInfo(self, fileName, functionName, lineNumber, (isWrite ? .swiftConcurrencyWrite : .swiftConcurrencyRead)) + let storageState: StorageState = StorageState(self) + + guard case .valid(let dbWriter) = storageState else { + info.errored(storageState.forcedError) + throw storageState.forcedError + } + + info.schedule() + addCall(info) + defer { removeCall(info) } + + return try await Storage.performOperation(info, dbWriter, operation, dependencies) + .successOrThrow() + } + private func addCall(_ call: CallInfo) { _currentCalls.performUpdate { $0.inserting(call) } } - private func removeCall(_ call: CallInfo) { + private func removeCall(_ call: CallInfo?) { + guard let call: CallInfo = call else { return } + _currentCalls.performUpdate { $0.removing(call) } } @@ -862,12 +878,12 @@ open class Storage { _currentObservers.performUpdate { $0.removing(observer) } } - private func stopAndRemoveObserver(forId id: String) { + private func stopAndRemoveObserver(forId id: String, explicitRemoval: Bool) { _currentObservers.performUpdate { $0.filter { info -> Bool in guard info.id == id else { return true } - info.stop() + info.stop(explicitRemoval: explicitRemoval) return false } } @@ -876,53 +892,81 @@ open class Storage { // MARK: - Functions @discardableResult public func write( - fileName file: String = #file, + fileName file: String = #fileID, functionName funcN: String = #function, lineNumber line: Int = #line, - updates: @escaping (Database) throws -> T? + updates: @escaping (ObservingDatabase) throws -> T? ) -> T? { - switch Storage.performOperation(CallInfo(self, file, funcN, line, .syncWrite), dependencies, updates) { + switch performOperation(CallInfo(self, file, funcN, line, .syncWrite), dependencies, updates) { case .failure: return nil case .success(let result): return result } } open func writeAsync( - fileName file: String = #file, + fileName file: String = #fileID, functionName funcN: String = #function, lineNumber line: Int = #line, - updates: @escaping (Database) throws -> T, + updates: @escaping (ObservingDatabase) throws -> T, completion: @escaping (Result) -> Void = { _ in } ) { - Storage.performOperation(CallInfo(self, file, funcN, line, .asyncWrite), dependencies, updates, completion) + performOperation(CallInfo(self, file, funcN, line, .asyncWrite), dependencies, updates, completion) + } + + @discardableResult public func writeAsync( + fileName file: String = #fileID, + functionName funcN: String = #function, + lineNumber line: Int = #line, + updates: @escaping (ObservingDatabase) throws -> T + ) async throws -> T { + return try await performSwiftConcurrencyOperation(file, funcN, line, isWrite: true, updates) } open func writePublisher( - fileName: String = #file, + fileName: String = #fileID, functionName: String = #function, lineNumber: Int = #line, - updates: @escaping (Database) throws -> T + updates: @escaping (ObservingDatabase) throws -> T ) -> AnyPublisher { return performPublisherOperation(fileName, functionName, lineNumber, isWrite: true, updates) } @discardableResult public func read( - fileName file: String = #file, + fileName file: String = #fileID, functionName funcN: String = #function, lineNumber line: Int = #line, - _ value: @escaping (Database) throws -> T? + _ value: @escaping (ObservingDatabase) throws -> T? ) -> T? { - switch Storage.performOperation(CallInfo(self, file, funcN, line, .syncRead), dependencies, value) { + switch performOperation(CallInfo(self, file, funcN, line, .syncRead), dependencies, value) { case .failure: return nil case .success(let result): return result } } + public func readAsync( + fileName file: String = #fileID, + functionName funcN: String = #function, + lineNumber line: Int = #line, + retrieve: @escaping (ObservingDatabase) throws -> T, + completion: @escaping (Result) -> Void + ) { + performOperation(CallInfo(self, file, funcN, line, .asyncRead), dependencies, retrieve, completion) + } + + @discardableResult public func readAsync( + fileName file: String = #fileID, + functionName funcN: String = #function, + lineNumber line: Int = #line, + value: @escaping (ObservingDatabase) throws -> T + ) async throws -> T { + return try await performSwiftConcurrencyOperation(file, funcN, line, isWrite: false, value) + } + open func readPublisher( - fileName: String = #file, + fileName: String = #fileID, functionName: String = #function, lineNumber: Int = #line, - value: @escaping (Database) throws -> T + value: @escaping (ObservingDatabase) throws -> T ) -> AnyPublisher { return performPublisherOperation(fileName, functionName, lineNumber, isWrite: false, value) } @@ -938,7 +982,7 @@ open class Storage { /// - returns: a DatabaseCancellable public func start( _ observation: ValueObservation, - fileName: String = #file, + fileName: String = #fileID, functionName: String = #function, lineNumber: Int = #line, scheduling scheduler: ValueObservationScheduler = .async(onQueue: .main), @@ -955,7 +999,7 @@ open class Storage { let cancellable: AnyDatabaseCancellable = observation .handleEvents(didCancel: { [weak self] in - info.stop() + info.stop(explicitRemoval: false) self?.removeObserver(info) }) .start( @@ -973,7 +1017,7 @@ open class Storage { /// /// **Note:** This function **MUST NOT** be called from the main thread public func addObserver( - fileName: String = #file, + fileName: String = #fileID, functionName: String = #function, lineNumber: Int = #line, _ observer: IdentifiableTransactionObserver? @@ -995,7 +1039,7 @@ open class Storage { /// /// **Note:** This function **MUST NOT** be called from the main thread public func removeObserver( - fileName: String = #file, + fileName: String = #fileID, functionName: String = #function, lineNumber: Int = #line, _ observer: IdentifiableTransactionObserver? @@ -1003,7 +1047,7 @@ open class Storage { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return } guard let observer: IdentifiableTransactionObserver = observer else { return } - stopAndRemoveObserver(forId: observer.id) + stopAndRemoveObserver(forId: observer.id, explicitRemoval: true) /// This actually triggers a write to the database so can be blocked by other writes so shouldn't be called on the main thread, /// we don't dispatch to an async thread in here because `TransactionObserver` isn't `Sendable` so instead just require @@ -1030,13 +1074,13 @@ public extension ValueObservation { } public extension Publisher where Failure == Error { - func flatMapStorageWritePublisher(using dependencies: Dependencies, updates: @escaping (Database, Output) throws -> T) -> AnyPublisher { + func flatMapStorageWritePublisher(using dependencies: Dependencies, updates: @escaping (ObservingDatabase, Output) throws -> T) -> AnyPublisher { return self.flatMap { output -> AnyPublisher in dependencies[singleton: .storage].writePublisher(updates: { db in try updates(db, output) }) }.eraseToAnyPublisher() } - func flatMapStorageReadPublisher(using dependencies: Dependencies, value: @escaping (Database, Output) throws -> T) -> AnyPublisher { + func flatMapStorageReadPublisher(using dependencies: Dependencies, value: @escaping (ObservingDatabase, Output) throws -> T) -> AnyPublisher { return self.flatMap { output -> AnyPublisher in dependencies[singleton: .storage].readPublisher(value: { db in try value(db, output) }) }.eraseToAnyPublisher() @@ -1057,6 +1101,8 @@ private extension Storage { case asyncRead case syncWrite case asyncWrite + case swiftConcurrencyRead + case swiftConcurrencyWrite } private enum Event { @@ -1091,14 +1137,14 @@ private extension Storage { var isWrite: Bool { switch behaviour { - case .syncWrite, .asyncWrite: return true - case .syncRead, .asyncRead: return false + case .syncWrite, .asyncWrite, .swiftConcurrencyWrite: return true + case .syncRead, .asyncRead, .swiftConcurrencyRead: return false } } var isAsync: Bool { switch behaviour { - case .asyncRead, .asyncWrite: return true - case .syncRead, .syncWrite: return false + case .asyncRead, .asyncWrite, .swiftConcurrencyWrite: return true + case .syncRead, .syncWrite, .swiftConcurrencyRead: return false } } @@ -1203,10 +1249,12 @@ private extension Storage { func errored(_ error: Error) { log(.errored(error)) + timer?.cancel() + timer = nil } func errored(_ error: Error) -> AnyPublisher { - log(.errored(error)) + errored(error) return Fail(error: error).eraseToAnyPublisher() } @@ -1214,6 +1262,8 @@ private extension Storage { /// Cancelling the task with result in a log being added self.cancelledViaCombine = cancelledViaCombine task?.cancel() + timer?.cancel() + timer = nil } // MARK: - Conformance @@ -1275,13 +1325,13 @@ private extension Storage { Log.verbose(.storage, "Started observer \(id) - [ \(callInfo) ]") } - func stop() { + func stop(explicitRemoval: Bool) { guard cancellable != nil || observer != nil else { return } cancellable?.cancel() cancellable = nil - if let observer: IdentifiableTransactionObserver = observer { + if let observer: IdentifiableTransactionObserver = observer, !explicitRemoval { /// Need to set to `nil` first to prevent infinite loop self.observer = nil storage?.removeObserver(observer) @@ -1351,7 +1401,7 @@ public extension Storage { var keySpec: Data = try self?.decryptSecureExportedKey( path: encryptedKeyPath, password: encryptedKeyPassword - ) ?? { throw StorageError.invalidKeySpec }() + ) ?? { throw KeychainStorageError.keySpecInvalid }() defer { keySpec.resetBytes(in: 0.. String { - var keySpec: Data = try getOrGenerateDatabaseKeySpec() + var keySpec: Data = try dependencies[singleton: .keychain].getOrGenerateEncryptionKey( + forKey: .dbCipherKeySpec, + length: Storage.SQLCipherKeySpecLength, + cat: .storage, + legacyKey: "GRDBDatabaseCipherKeySpec", + legacyService: "TSKeyChainService" + ) defer { keySpec.resetBytes(in: 0.. ((_ db: Database) throws -> ()) { - return { (db: Database) in + ) -> ((_ db: ObservingDatabase) throws -> ()) { + return { (db: ObservingDatabase) in Log.info(.migration, "Starting \(targetIdentifier.key(with: self))") - storage?.willStartMigration(db, self, targetIdentifier) - defer { storage?.didCompleteMigration() } + /// Store the `currentlyRunningMigration` in case it's useful + MigrationExecution.current?.currentlyRunningMigration = MigrationExecution.CurrentlyRunningMigration( + identifier: targetIdentifier, + migration: self + ) + defer { MigrationExecution.current?.currentlyRunningMigration = nil } + + /// Perform the migration try migrate(db, using: dependencies) + + /// If the migration was successful then we should add the `events` and `postCommitActions` to the context to be + /// run at the end of all migrations + MigrationExecution.current?.observedEvents.append(contentsOf: db.events) + MigrationExecution.current?.postCommitActions.merge(db.postCommitActions) { old, _ in old } + Log.info(.migration, "Completed \(targetIdentifier.key(with: self))") } } } + +// MARK: - MigrationExecution + +public enum MigrationExecution { + public struct CurrentlyRunningMigration: ThreadSafeType { + public let identifier: TargetMigrations.Identifier + public let migration: Migration.Type + + public var key: String { identifier.key(with: migration) } + } + + public final class Context { + let progressUpdater: (String, CGFloat) -> () + var currentlyRunningMigration: CurrentlyRunningMigration? + var observedEvents: [ObservedEvent] = [] + var postCommitActions: [String: () -> Void] = [:] + + init(progressUpdater: @escaping (String, CGFloat) -> Void) { + self.progressUpdater = progressUpdater + } + + // Helper method to add events safely. + func add(events: [ObservedEvent]) { + self.observedEvents.append(contentsOf: events) + } + + // Helper method to add actions with deduplication. + func add(postCommitActions: [String: () -> Void]) { + self.postCommitActions.merge(postCommitActions, uniquingKeysWith: { (current, _) in current }) + } + } + + @TaskLocal + public static var current: Context? + + public static func updateProgress(_ progress: CGFloat) { + // In test builds ignore any migration progress updates (we run in a custom database writer anyway) + guard !SNUtilitiesKit.isRunningTests else { return } + + let identifier: String = (MigrationExecution.current?.currentlyRunningMigration?.key ?? "Unknown Migration") + MigrationExecution.current?.progressUpdater(identifier, progress) + } +} diff --git a/SessionUtilitiesKit/Database/Types/MigrationHelper.swift b/SessionUtilitiesKit/Database/Types/MigrationHelper.swift index a2750c814c..d7dab7fd08 100644 --- a/SessionUtilitiesKit/Database/Types/MigrationHelper.swift +++ b/SessionUtilitiesKit/Database/Types/MigrationHelper.swift @@ -8,7 +8,7 @@ import GRDB /// Since we want to avoid using logic outside of the database during migrations wherever possible these functions provide funcitonality /// shared across multiple migrations public enum MigrationHelper { - public static func userExists(_ db: Database) -> Bool { + public static func userExists(_ db: ObservingDatabase) -> Bool { let numEdSecretKeys: Int? = try? Int.fetchOne( db, sql: "SELECT COUNT(*) FROM identity WHERE variant == 'ed25519SecretKey'" @@ -17,13 +17,13 @@ public enum MigrationHelper { return ((numEdSecretKeys ?? 0) > 0) } - public static func userSessionId(_ db: Database) -> SessionId { + public static func userSessionId(_ db: ObservingDatabase) -> SessionId { let pubkey: Data? = fetchIdentityValue(db, key: "x25519PublicKey") return SessionId(.standard, publicKey: (pubkey.map { Array($0) } ?? [])) } - public static func fetchIdentityValue(_ db: Database, key: String) -> Data? { + public static func fetchIdentityValue(_ db: ObservingDatabase, key: String) -> Data? { return try? Data.fetchOne( db, sql: "SELECT data FROM identity WHERE variant == ?", @@ -31,7 +31,7 @@ public enum MigrationHelper { ) } - public static func configDump(_ db: Database, for rawVariant: String) -> Data? { + public static func configDump(_ db: ObservingDatabase, for rawVariant: String) -> Data? { return try? Data.fetchOne( db, sql: "SELECT data FROM configDump WHERE variant == ?", diff --git a/SessionUtilitiesKit/Database/Types/PagedData.swift b/SessionUtilitiesKit/Database/Types/PagedData.swift new file mode 100644 index 0000000000..d3b0b40bf2 --- /dev/null +++ b/SessionUtilitiesKit/Database/Types/PagedData.swift @@ -0,0 +1,532 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public enum PagedData { + public static let autoLoadNextPageDelay: DispatchTimeInterval = .milliseconds(400) +} + +// MARK: - PagedData.PageInfo + +public extension PagedData { + struct LoadedInfo: Sendable, Equatable, ThreadSafeType { + fileprivate let queryInfo: QueryInfo + + public let pageSize: Int + public let totalCount: Int + public let firstPageOffset: Int + public let currentRowIds: [Int64] + private let idToRowIdMap: [ID: Int64] + + public var lastIndex: Int { firstPageOffset + currentRowIds.count - 1 } + public var hasPrevPage: Bool { firstPageOffset > 0 } + public var hasNextPage: Bool { (lastIndex + 1) < totalCount } + public var asResult: LoadResult { LoadResult(info: self, newRowIds: []) } + + // MARK: - Initialization + + public init( + record: FetchedRecord.Type, + pageSize: Int, + requiredJoinSQL: SQL?, + filterSQL: SQL, + groupSQL: SQL?, + orderSQL: SQL + ) { + self.queryInfo = QueryInfo( + tableName: record.PagedDataType.databaseTableName, + idColumnName: record.PagedDataType.idColumn.name, + requiredJoinSQL: requiredJoinSQL, + filterSQL: filterSQL, + groupSQL: groupSQL, + orderSQL: orderSQL + ) + self.pageSize = pageSize + self.totalCount = 0 + self.firstPageOffset = 0 + self.currentRowIds = [] + self.idToRowIdMap = [:] + } + + fileprivate init( + queryInfo: QueryInfo, + pageSize: Int, + totalCount: Int, + firstPageOffset: Int, + currentRowIds: [Int64], + idToRowIdMap: [ID: Int64] + ) { + self.queryInfo = queryInfo + self.pageSize = pageSize + self.totalCount = totalCount + self.firstPageOffset = firstPageOffset + self.currentRowIds = currentRowIds + self.idToRowIdMap = [:] + } + } + + struct LoadResult { + public let info: PagedData.LoadedInfo + public let newRowIds: [Int64] + + public init(info: PagedData.LoadedInfo, newRowIds: [Int64] = []) { + self.info = info + self.newRowIds = newRowIds + } + } + + @available(*, deprecated, message: "This type was used with the PagedDatabaseObserver but that is deprecated, use the ObservationBuilder instead and PagedData.LoadedInfo") + struct PageInfo: Equatable, ThreadSafeType { + public let pageSize: Int + public let pageOffset: Int + public let currentCount: Int + public let totalCount: Int + + // MARK: - Initizliation + + public init( + pageSize: Int, + pageOffset: Int = 0, + currentCount: Int = 0, + totalCount: Int = 0 + ) { + self.pageSize = pageSize + self.pageOffset = pageOffset + self.currentCount = currentCount + self.totalCount = totalCount + } + } + + fileprivate struct QueryInfo: Equatable { + fileprivate let tableName: String + fileprivate let idColumnName: String + fileprivate let requiredJoinSQL: SQL? + fileprivate let filterSQL: SQL + fileprivate let groupSQL: SQL? + fileprivate let orderSQL: SQL + fileprivate let hasResolvedQueries: Bool + + /// The `SQL` type isn't equatable so we use this type to generate `String` versions of the queries so that we can still + /// equate the values + let requiredJoin: String? + let requiredJoinArguments: StatementArguments? + let filter: String + let filterArguments: StatementArguments + let group: String? + let groupArguments: StatementArguments? + let order: String + let orderArguments: StatementArguments + + init( + tableName: String, + idColumnName: String, + requiredJoinSQL: SQL?, + filterSQL: SQL, + groupSQL: SQL?, + orderSQL: SQL + ) { + self.tableName = tableName + self.idColumnName = idColumnName + self.requiredJoinSQL = requiredJoinSQL + self.filterSQL = filterSQL + self.groupSQL = groupSQL + self.orderSQL = orderSQL + self.hasResolvedQueries = false + + self.requiredJoin = "" + self.requiredJoinArguments = nil + self.filter = "" + self.filterArguments = StatementArguments() + self.group = "" + self.groupArguments = nil + self.order = "" + self.orderArguments = StatementArguments() + } + + init( + _ db: ObservingDatabase, + tableName: String, + idColumnName: String, + requiredJoinSQL: SQL?, + filterSQL: SQL, + groupSQL: SQL?, + orderSQL: SQL + ) throws { + self.tableName = tableName + self.idColumnName = idColumnName + self.requiredJoinSQL = requiredJoinSQL + self.filterSQL = filterSQL + self.groupSQL = groupSQL + self.orderSQL = orderSQL + self.hasResolvedQueries = true + + /// Build the Queries + let requiredJoinResult = try requiredJoinSQL.map { try $0.build(db.originalDb) } + let filterResult = try filterSQL.build(db.originalDb) + let groupResult = try groupSQL.map { try $0.build(db.originalDb) } + let orderResult = try orderSQL.build(db.originalDb) + + /// Store the built versions + self.requiredJoin = requiredJoinResult?.sql + self.requiredJoinArguments = requiredJoinResult?.arguments + self.filter = filterResult.sql + self.filterArguments = filterResult.arguments + self.group = groupResult?.sql + self.groupArguments = groupResult?.arguments + self.order = orderResult.sql + self.orderArguments = orderResult.arguments + } + + fileprivate func resolveIfNeeded(_ db: ObservingDatabase) throws -> QueryInfo { + guard !hasResolvedQueries else { return self } + + return try QueryInfo( + db, + tableName: tableName, + idColumnName: idColumnName, + requiredJoinSQL: requiredJoinSQL, + filterSQL: filterSQL, + groupSQL: groupSQL, + orderSQL: orderSQL + ) + } + + // MARK: - Equatable Conformance + + public static func == (lhs: QueryInfo, rhs: QueryInfo) -> Bool { + return ( + lhs.tableName == rhs.tableName && + lhs.requiredJoin == rhs.requiredJoin && + lhs.requiredJoinArguments == rhs.requiredJoinArguments && + lhs.filter == rhs.filter && + lhs.filterArguments == rhs.filterArguments && + lhs.group == rhs.group && + lhs.groupArguments == rhs.groupArguments && + lhs.order == rhs.order && + lhs.orderArguments == rhs.orderArguments + ) + } + } +} + +// MARK: - PagedData.Target + +public extension PagedData { + enum Target: Sendable { + /// This will attempt to load the first page of data + case initial + + /// This will attempt to load a page of data around a specified id + /// + /// **Note:** This target will only work if there is no other data in the cache + case initialPageAround(id: ID) + + /// This will attempt to load a page of data before the first item in the cache + case pageBefore + + /// This will attempt to load a page of data after the last item in the cache + case pageAfter + + /// This will jump to the specified id, loading a page around it and clearing out any + /// data that was previously cached + /// + /// **Note:** If the id is already within the cache then this will do nothing, if it's within a single `pageSize` of the currently + /// cached data (plus the padding amount) then it'll load up to that data (plus padding) + case jumpTo(id: ID, padding: Int) + + /// This will refetched all of the currently fetched data + case reloadCurrent(insertedIds: Set, deletedIds: Set) + + public var reloadCurrent: Target { .reloadCurrent(insertedIds: [], deletedIds: []) } + public static func reloadCurrent(insertedIds: Set) -> Target { + return .reloadCurrent(insertedIds: insertedIds, deletedIds: []) + } + + public static func reloadCurrent(deletedIds: Set) -> Target { + return .reloadCurrent(insertedIds: [], deletedIds: deletedIds) + } + } +} + +// MARK: - PagableRecord + +public protocol PagableRecord: Identifiable { + associatedtype PagedDataType: IdentifiableTableRecord +} + +public protocol IdentifiableTableRecord: TableRecord & Identifiable { + static var idColumn: ColumnExpression { get } +} + +// MARK: - PagedData.LoadedInfo Convenience + +public extension PagedData.LoadedInfo { + func load( + _ db: ObservingDatabase, + _ target: PagedData.Target + ) throws -> PagedData.LoadResult { + var newOffset: Int + var newLimit: Int + var newFirstPageOffset: Int + var mergeStrategy: ([Int64], [ID: Int64], [Int64], [ID: Int64]) -> (ids: [Int64], map: [ID: Int64]) + let newTotalCount: Int = PagedData.totalCount( + db, + tableName: queryInfo.tableName, + requiredJoinSQL: queryInfo.requiredJoinSQL, + filterSQL: queryInfo.filterSQL + ) + + switch target { + case .initial: + newOffset = 0 + newLimit = pageSize + newFirstPageOffset = 0 + mergeStrategy = { _, _, new, newMap in + (new, newMap) // Replace old with new + } + + case .pageBefore: + newLimit = min(firstPageOffset, pageSize) + newOffset = max(0, firstPageOffset - newLimit) + newFirstPageOffset = newOffset + mergeStrategy = { old, oldMap, new, newMap in + (new + old, newMap.merging(oldMap, uniquingKeysWith: { $1 })) // Prepend new page + } + + case .pageAfter: + newOffset = firstPageOffset + currentRowIds.count + newLimit = pageSize + newFirstPageOffset = firstPageOffset + mergeStrategy = { old, oldMap, new, newMap in + (old + new, oldMap.merging(newMap, uniquingKeysWith: { $1 })) // Append new page + } + + case .initialPageAround(let id): + let maybeRowInfo: PagedData.RowInfo? = PagedData.rowInfo( + db, + for: id, + tableName: queryInfo.tableName, + idColumn: queryInfo.idColumnName, + requiredJoinSQL: queryInfo.requiredJoinSQL, + orderSQL: queryInfo.orderSQL, + filterSQL: queryInfo.filterSQL + ) + + guard let targetIndex: Int = maybeRowInfo?.rowIndex else { + return try self.load(db, .initial) + } + + let halfPage: Int = (pageSize / 2) + newOffset = max(0, targetIndex - halfPage) + newLimit = pageSize + newFirstPageOffset = newOffset + mergeStrategy = { _, _, new, newMap in + (new, newMap) // Replace old with new + } + + case .jumpTo(let targetId, let padding): + /// If we want to focus on a specific item then we need to find it's index in the queried data + let maybeRowInfo: PagedData.RowInfo? = PagedData.rowInfo( + db, + for: targetId, + tableName: queryInfo.tableName, + idColumn: queryInfo.idColumnName, + requiredJoinSQL: queryInfo.requiredJoinSQL, + orderSQL: queryInfo.orderSQL, + filterSQL: queryInfo.filterSQL + ) + + /// If the id doesn't exist then we can't jump to it so just return the current state + guard let targetIndex: Int = maybeRowInfo?.rowIndex else { + return PagedData.LoadResult(info: self) + } + + /// Check if the item is already loaded, if so then no need to load anything + guard + targetIndex < firstPageOffset || + targetIndex >= lastIndex + else { return PagedData.LoadResult(info: self) } + + /// If the `targetIndex` is over a page before the current content or more than a page after the current content + /// then we want to reload the entire content (to avoid loading an excessive amount of data), otherwise we should + /// load all messages between the current content and the `targetIndex` (plus padding) + let isCloseBefore = targetIndex >= (firstPageOffset - pageSize) + let isCloseAfter = targetIndex <= (lastIndex + pageSize) + + if isCloseBefore { + newOffset = max(0, targetIndex - padding) + newLimit = firstPageOffset - newOffset + newFirstPageOffset = newOffset + mergeStrategy = { old, oldMap, new, newMap in + (new + old, newMap.merging(oldMap, uniquingKeysWith: { $1 })) // Prepend new page + } + } + else if isCloseAfter { + newOffset = lastIndex + 1 + newLimit = (targetIndex - lastIndex) + padding + newFirstPageOffset = firstPageOffset + mergeStrategy = { old, oldMap, new, newMap in + (old + new, oldMap.merging(newMap, uniquingKeysWith: { $1 })) // Append new page + } + } + else { + /// The target is too far away so we need to do a new fetch + return try PagedData + .LoadedInfo( + queryInfo: queryInfo, + pageSize: pageSize, + totalCount: 0, + firstPageOffset: 0, + currentRowIds: [], + idToRowIdMap: [:] + ) + .load(db, .initialPageAround(id: targetId)) + } + + case .reloadCurrent(let insertedIds, let deletedIds): + newOffset = self.firstPageOffset + newLimit = max(pageSize, currentRowIds.count - deletedIds.count + insertedIds.count) + newFirstPageOffset = self.firstPageOffset + mergeStrategy = { _, _, new, newMap in + (new, newMap) // Replace old with new + } + } + + /// Now that we have the limit and offset actually load the data + let newRowIdPairs: [PagedData.RowIdPair] = try PagedData.rowIdPairs( + db, + tableName: queryInfo.tableName, + idColumn: queryInfo.idColumnName, + requiredJoinSQL: queryInfo.requiredJoinSQL, + filterSQL: queryInfo.filterSQL, + groupSQL: queryInfo.groupSQL, + orderSQL: queryInfo.orderSQL, + limit: newLimit, + offset: newOffset + ) + let (mergedIds, mergedMap) = mergeStrategy( + currentRowIds, + idToRowIdMap, + newRowIdPairs.map { $0.rowId }, + Dictionary(uniqueKeysWithValues: newRowIdPairs.map { ($0.id, $0.rowId) }) + ) + + return PagedData.LoadResult( + info: PagedData.LoadedInfo( + queryInfo: try queryInfo.resolveIfNeeded(db), + pageSize: pageSize, + totalCount: newTotalCount, + firstPageOffset: newFirstPageOffset, + currentRowIds: mergedIds, + idToRowIdMap: mergedMap + ), + newRowIds: newRowIdPairs.map { $0.rowId } + ) + } +} + +// MARK: - PagedData.LoadResult Convenience + +public extension PagedData.LoadResult { + func load(_ db: ObservingDatabase, target: PagedData.Target) throws -> PagedData.LoadResult { + let result: PagedData.LoadResult = try info.load(db, target) + + guard !newRowIds.isEmpty else { return result } + + return PagedData.LoadResult(info: info, newRowIds: (newRowIds + result.newRowIds)) + } +} + +// MARK: - PagedData Queries + +internal extension PagedData { + struct RowInfo: Codable, FetchableRecord { + let rowId: Int64 + let rowIndex: Int + } + struct RowIdPair: Codable, FetchableRecord { + let rowId: Int64 + let id: ID + } + + static func totalCount( + _ db: ObservingDatabase, + tableName: String, + requiredJoinSQL: SQL?, + filterSQL: SQL + ) -> Int { + let tableNameLiteral: SQL = SQL(stringLiteral: tableName) + let finalJoinSQL: SQL = (requiredJoinSQL ?? "") + let request: SQLRequest = """ + SELECT \(tableNameLiteral).rowId + FROM \(tableNameLiteral) + \(finalJoinSQL) + WHERE \(filterSQL) + """ + + return (try? request.fetchCount(db)) + .defaulting(to: 0) + } + + fileprivate static func rowIdPairs( + _ db: ObservingDatabase, + tableName: String, + idColumn: String, + requiredJoinSQL: SQL?, + filterSQL: SQL, + groupSQL: SQL?, + orderSQL: SQL, + limit: Int, + offset: Int + ) throws -> [RowIdPair] { + let tableNameLiteral: SQL = SQL(stringLiteral: tableName) + let idColumnLiteral: SQL = SQL(stringLiteral: idColumn) + let finalJoinSQL: SQL = (requiredJoinSQL ?? "") + let finalGroupSQL: SQL = (groupSQL ?? "") + let request: SQLRequest> = """ + SELECT + \(tableNameLiteral).rowId, + \(tableNameLiteral).\(idColumnLiteral) as id + FROM \(tableNameLiteral) + \(finalJoinSQL) + WHERE \(filterSQL) + \(finalGroupSQL) + ORDER BY \(orderSQL) + LIMIT \(limit) OFFSET \(offset) + """ + + return try request.fetchAll(db) + } + + static func rowInfo( + _ db: ObservingDatabase, + for id: ID, + tableName: String, + idColumn: String, + requiredJoinSQL: SQL?, + orderSQL: SQL, + filterSQL: SQL + ) -> RowInfo? { + let tableNameLiteral: SQL = SQL(stringLiteral: tableName) + let idColumnLiteral: SQL = SQL(stringLiteral: idColumn) + let finalJoinSQL: SQL = (requiredJoinSQL ?? "") + let request: SQLRequest = """ + SELECT + data.rowId AS rowId, + (data.rowIndex - 1) AS rowIndex -- Converting from 1-Indexed to 0-indexed + FROM ( + SELECT + \(tableNameLiteral).rowId AS rowId, + \(tableNameLiteral).\(idColumnLiteral) AS \(idColumnLiteral), + ROW_NUMBER() OVER (ORDER BY \(orderSQL)) AS rowIndex + FROM \(tableNameLiteral) + \(finalJoinSQL) + WHERE \(filterSQL) + ) AS data + WHERE \(SQL("data.\(idColumnLiteral) = \(id)")) + """ + + return try? request.fetchOne(db) + } +} diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index 40d7fd15e0..04d44b8178 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -18,6 +18,7 @@ private extension Log.Category { /// This type manages observation and paging for the provided dataQuery /// /// **Note:** We **MUST** have accurate `filterSQL` and `orderSQL` values otherwise the indexing won't work +@available(*, deprecated, message: "This type is now deprecated since we store data in both the database and libSession (and this type only observes database changes). Use the `ObservationBuilder` approach in the HomeViewModel instead") public class PagedDatabaseObserver: IdentifiableTransactionObserver where ObservedTable: TableRecord & ColumnExpressible & Identifiable, T: FetchableRecordWithRowId & Identifiable { private let commitProcessingQueue: DispatchQueue = DispatchQueue( label: "PagedDatabaseObserver.commitProcessingQueue", @@ -49,7 +50,12 @@ public class PagedDatabaseObserver: IdentifiableTransactionObs @ThreadSafe private var dataCache: DataCache = DataCache() @ThreadSafe private var isLoadingMoreData: Bool = false @ThreadSafe private var isSuspended: Bool = false + @ThreadSafe private var isProcessingCommit: Bool = false @ThreadSafeObject private var changesInCommit: Set = [] + @ThreadSafeObject private var pendingCommits: [Set] = [] + + /// This is a cache of `relatedRowId -> pagedRowId` grouped by `relatedTableName` + @ThreadSafeObject private var relationshipCache: [String: [Int64: [Int64]]] = [:] private let onChangeUnsorted: (([T], PagedData.PageInfo) -> ()) // MARK: - Initialization @@ -137,29 +143,15 @@ public class PagedDatabaseObserver: IdentifiableTransactionObs // there won't be a way to associated the deleted related record to the // original so we need to retrieve the association in here) let trackedChange: PagedData.TrackedChange = { - guard - event.tableName != pagedTableName, - event.kind == .delete, - let observedChange: PagedData.ObservedChanges = observedTableChangeTypes[event.tableName], - let joinToPagedType: SQL = observedChange.joinToPagedType - else { return PagedData.TrackedChange(event: event) } - - // Retrieve the pagedRowId for the related value that is - // getting deleted - let pagedTableName: String = self.pagedTableName - let pagedRowIds: [Int64] = dependencies[singleton: .storage] - .read { db in - PagedData.pagedRowIdsForRelatedRowIds( - db, - tableName: event.tableName, - pagedTableName: pagedTableName, - relatedRowIds: [event.rowID], - joinToPagedType: joinToPagedType - ) - } - .defaulting(to: []) + guard event.tableName != pagedTableName && event.kind == .delete else { + return PagedData.TrackedChange(event: event) + } - return PagedData.TrackedChange(event: event, pagedRowIdsForRelatedDeletion: pagedRowIds) + // Retrieve the pagedRowId for the related value that is getting deleted + return PagedData.TrackedChange( + event: event, + pagedRowIdsForRelatedDeletion: relationshipCache[event.tableName]?[event.rowID] + ) }() // The 'event' object only exists during this method so we need to copy the info @@ -196,17 +188,36 @@ public class PagedDatabaseObserver: IdentifiableTransactionObs committedChanges = cachedChanges return [] } - - // Dispatch to the `commitProcessingQueue` so we don't block the database `write` queue - // when updatind the data + _pendingCommits.performUpdate { $0.appending(committedChanges) } + triggerNextCommitProcessing() + } + + private func triggerNextCommitProcessing() { commitProcessingQueue.async { [weak self] in - self?.processDatabaseCommit(committedChanges: committedChanges) + guard let self = self else { return } + guard !self.isProcessingCommit && !pendingCommits.isEmpty else { return } + + self.isProcessingCommit = true + let changesToProcess: Set = self._pendingCommits.performUpdateAndMap { pending in + var remainingChanges: [Set] = pending + let nextCommit: Set = remainingChanges.removeFirst() + + return (remainingChanges, nextCommit) + } + + self.processDatabaseCommit(committedChanges: changesToProcess) } } private func processDatabaseCommit(committedChanges: Set) { typealias AssociatedDataInfo = [(hasChanges: Bool, data: ErasedAssociatedRecord)] - typealias UpdatedData = (cache: DataCache, pageInfo: PagedData.PageInfo, hasChanges: Bool, associatedData: AssociatedDataInfo) + typealias UpdatedData = ( + cache: DataCache, + pageInfo: PagedData.PageInfo, + hasChanges: Bool, + updatedRelationships: [String: [Int64: [Int64]]], + associatedData: AssociatedDataInfo + ) // Store the instance variables locally to avoid unwrapping let dataCache: DataCache = self.dataCache @@ -218,7 +229,11 @@ public class PagedDatabaseObserver: IdentifiableTransactionObs let dataQuery: ([Int64]) -> any FetchRequest = self.dataQuery let associatedRecords: [ErasedAssociatedRecord] = self.associatedRecords let observedTableChangeTypes: [String: PagedData.ObservedChanges] = self.observedTableChangeTypes - let getAssociatedDataInfo: (Database, PagedData.PageInfo) -> AssociatedDataInfo = { db, updatedPageInfo in + let relatedTables: [PagedData.ObservedChanges] = self.observedTableChangeTypes.values.filter { change in + change.databaseTableName != pagedTableName && + change.joinToPagedType != nil + } + let getAssociatedDataInfo: (ObservingDatabase, PagedData.PageInfo) -> AssociatedDataInfo = { db, updatedPageInfo in associatedRecords.map { associatedRecord in let hasChanges: Bool = associatedRecord.tryUpdateForDatabaseCommit( db, @@ -244,24 +259,33 @@ public class PagedDatabaseObserver: IdentifiableTransactionObs result[next.tableName] = (result[next.tableName] ?? []).appending(next) } + let deletionChanges: [Int64] = directChanges + .filter { $0.kind == .delete } + .map { $0.rowId } let relatedDeletions: [PagedData.TrackedChange] = committedChanges .filter { $0.tableName != pagedTableName } .filter { $0.kind == .delete } + let pagedRowIdsForRelatedChanges: Set = { + guard !relatedChanges.isEmpty else { return [] } + + return Set(relatedChanges.values.flatMap { changes in + changes.flatMap { change in + (self.relationshipCache[change.tableName]?[change.rowId] ?? []) + } + }) + }() // Process and retrieve the updated data - let updatedData: UpdatedData = dependencies[singleton: .storage] - .read { [dependencies] db -> UpdatedData in + dependencies[singleton: .storage].readAsync( + retrieve: { [dependencies] db -> UpdatedData in // If there aren't any direct or related changes then early-out guard !directChanges.isEmpty || !relatedChanges.isEmpty || !relatedDeletions.isEmpty else { - return (dataCache, pageInfo, false, getAssociatedDataInfo(db, pageInfo)) + return (dataCache, pageInfo, false, [:], getAssociatedDataInfo(db, pageInfo)) } // Store a mutable copies of the dataCache and pageInfo for updating var updatedDataCache: DataCache = dataCache var updatedPageInfo: PagedData.PageInfo = pageInfo - let deletionChanges: [Int64] = directChanges - .filter { $0.kind == .delete } - .map { $0.rowId } let oldDataCount: Int = dataCache.count // First remove any items which have been deleted @@ -287,37 +311,14 @@ public class PagedDatabaseObserver: IdentifiableTransactionObs guard !changesToQuery.isEmpty || !relatedChanges.isEmpty || !relatedDeletions.isEmpty else { let associatedData: AssociatedDataInfo = getAssociatedDataInfo(db, updatedPageInfo) - return (updatedDataCache, updatedPageInfo, !deletionChanges.isEmpty, associatedData) + return (updatedDataCache, updatedPageInfo, !deletionChanges.isEmpty, [:], associatedData) } // Next we need to determine if any related changes were associated to the pagedData we are // observing, if they aren't (and there were no other direct changes) we can early-out - let pagedRowIdsForRelatedChanges: Set = { - guard !relatedChanges.isEmpty else { return [] } - - return relatedChanges - .reduce(into: []) { result, next in - guard - let observedChange: PagedData.ObservedChanges = observedTableChangeTypes[next.key], - let joinToPagedType: SQL = observedChange.joinToPagedType - else { return } - - let pagedRowIds: [Int64] = PagedData.pagedRowIdsForRelatedRowIds( - db, - tableName: next.key, - pagedTableName: pagedTableName, - relatedRowIds: Array(next.value.map { $0.rowId }.asSet()), - joinToPagedType: joinToPagedType - ) - - result.append(contentsOf: pagedRowIds) - } - .asSet() - }() - guard !changesToQuery.isEmpty || !pagedRowIdsForRelatedChanges.isEmpty || !relatedDeletions.isEmpty else { let associatedData: AssociatedDataInfo = getAssociatedDataInfo(db, updatedPageInfo) - return (updatedDataCache, updatedPageInfo, !deletionChanges.isEmpty, associatedData) + return (updatedDataCache, updatedPageInfo, !deletionChanges.isEmpty, [:], associatedData) } // Fetch the indexes of the rowIds so we can determine whether they should be added to the screen @@ -438,7 +439,7 @@ public class PagedDatabaseObserver: IdentifiableTransactionObs // so we want to flat 'hasChanges' as true) guard !validChangeRowIds.isEmpty || !validRelatedChangeRowIds.isEmpty || !validRelatedDeletionRowIds.isEmpty else { let associatedData: AssociatedDataInfo = getAssociatedDataInfo(db, updatedPageInfo) - return (updatedDataCache, updatedPageInfo, true, associatedData) + return (updatedDataCache, updatedPageInfo, true, [:], associatedData) } // Fetch the inserted/updated rows @@ -466,40 +467,125 @@ public class PagedDatabaseObserver: IdentifiableTransactionObs totalCount: updatedPageInfo.totalCount ) + let updatedRelationships: [String: [Int64: [Int64]]] = relatedTables.reduce(into: [:]) { result, change in + guard let joinToPagedType: SQL = change.joinToPagedType else { return } + + let relationshipIds: [(pagedRowId: Int64, relatedRowId: Int64)] = PagedData.relatedRowIdsForPagedRowIds( + db, + tableName: change.databaseTableName, + pagedTableName: pagedTableName, + pagedRowIds: validChangeRowIds, + joinToPagedType: joinToPagedType + ) + result[change.databaseTableName] = relationshipIds + .grouped(by: { $0.relatedRowId }) + .mapValues { value in value.map { $0.pagedRowId } } + } + // Return the final updated data let associatedData: AssociatedDataInfo = getAssociatedDataInfo(db, updatedPageInfo) - return (updatedDataCache, updatedPageInfo, true, associatedData) - } - .defaulting(to: (cache: dataCache, pageInfo: pageInfo, hasChanges: false, associatedData: [])) - - // Now that we have all of the changes, check if there were actually any changes - guard updatedData.hasChanges || updatedData.associatedData.contains(where: { hasChanges, _ in hasChanges }) else { - return - } - - // If the associated data changed then update the updatedCachedData with the updated associated data - var finalUpdatedDataCache: DataCache = updatedData.cache + return (updatedDataCache, updatedPageInfo, true, updatedRelationships, associatedData) + }, + completion: { [weak self] result in + self?.commitProcessingQueue.async { + switch result { + case .failure: + self?.isProcessingCommit = false + self?.triggerNextCommitProcessing() + + case .success(let updatedData): + // Now that we have all of the changes, check if there were actually any changes + guard + updatedData.hasChanges || + updatedData.associatedData.contains(where: { hasChanges, _ in hasChanges }) + else { + self?.isProcessingCommit = false + self?.triggerNextCommitProcessing() + return + } + + // If the associated data changed then update the updatedCachedData with the updated associated data + var finalUpdatedDataCache: DataCache = updatedData.cache - updatedData.associatedData.forEach { hasChanges, associatedData in - guard updatedData.hasChanges || hasChanges else { return } + updatedData.associatedData.forEach { hasChanges, associatedData in + guard updatedData.hasChanges || hasChanges else { return } - finalUpdatedDataCache = associatedData.updateAssociatedData(to: finalUpdatedDataCache) - } + finalUpdatedDataCache = associatedData.updateAssociatedData(to: finalUpdatedDataCache) + } + + // Update the relationshipCache records for the paged values + self?._relationshipCache.performUpdate { cache in + var updatedCache: [String: [Int64: [Int64]]] = cache + + // Add the updated relationships + updatedData.updatedRelationships.forEach { key, value in + if updatedCache[key] == nil { + updatedCache[key] = [:] + } + updatedCache[key]?.merge(value, uniquingKeysWith: { current, _ in current }) + } + + // Delete any removed relationships + if !relatedDeletions.isEmpty || !relatedDeletions.isEmpty { + let deletionPagedIdSet: Set = Set(deletionChanges) + + cache.forEach { key, relationships in + var updatedRelationships: [Int64: [Int64]] = relationships + + // Remove any deleted related rows first + relatedDeletions.forEach { change in + updatedRelationships.removeValue(forKey: change.rowId) + } + + // Remove deleted paged ids + guard !deletionPagedIdSet.isEmpty else { return } + + let allRelatedRowIds: [Int64] = Array(updatedRelationships.keys) + + allRelatedRowIds.forEach { relatedRowId in + guard let pagedIds: [Int64] = updatedRelationships[relatedRowId] else { + return + } + + let updatedPagedIds: [Int64] = Array(Set(pagedIds) + .subtracting(deletionPagedIdSet)) + + if updatedPagedIds.isEmpty { + updatedRelationships.removeValue(forKey: relatedRowId) + } + else { + updatedRelationships[relatedRowId] = updatedPagedIds + } + } + + updatedCache[key] = updatedRelationships + } + } + + return updatedCache + } - // Update the cache, pageInfo and the change callback - self.dataCache = finalUpdatedDataCache - self.pageInfo = updatedData.pageInfo + // Update the cache, pageInfo and the change callback + self?.dataCache = finalUpdatedDataCache + self?.pageInfo = updatedData.pageInfo - // Trigger the unsorted change callback (the actual UI update triggering should eventually be run on - // the main thread via the `PagedData.processAndTriggerUpdates` function) - self.onChangeUnsorted(finalUpdatedDataCache.values, updatedData.pageInfo) + // Trigger the unsorted change callback (the actual UI update triggering + // should eventually be run on the main thread via the + // `PagedData.processAndTriggerUpdates` function) + self?.onChangeUnsorted(finalUpdatedDataCache.values, updatedData.pageInfo) + self?.isProcessingCommit = false + self?.triggerNextCommitProcessing() + } + } + } + ) } public func databaseDidRollback(_ db: Database) {} // MARK: - Functions - fileprivate func load(_ target: PagedData.PageInfo.InternalTarget) { + fileprivate func load(_ target: PagedData.InternalTarget) { // Only allow a single page load at a time guard !self.isLoadingMoreData else { return } @@ -521,282 +607,318 @@ public class PagedDatabaseObserver: IdentifiableTransactionObs let groupSQL: SQL? = self.groupSQL let orderSQL: SQL = self.orderSQL let dataQuery: ([Int64]) -> any FetchRequest = self.dataQuery + let relatedTables: [PagedData.ObservedChanges] = self.observedTableChangeTypes.values.filter { change in + change.databaseTableName != pagedTableName && + change.joinToPagedType != nil + } - let loadedPage: (data: [T]?, pageInfo: PagedData.PageInfo, failureCallback: (() -> ())?)? = dependencies[singleton: .storage].read { [weak self] db in - typealias QueryInfo = (limit: Int, offset: Int, updatedCacheOffset: Int) - let totalCount: Int = PagedData.totalCount( - db, - tableName: pagedTableName, - requiredJoinSQL: joinSQL, - filterSQL: filterSQL - ) - - let (queryInfo, callback): (QueryInfo?, (() -> ())?) = { - switch target { - case .initialPageAround(let targetId): - // If we want to focus on a specific item then we need to find it's index in - // the queried data - let maybeIndex: Int? = PagedData.index( - db, - for: targetId, - tableName: pagedTableName, - idColumn: idColumnName, - requiredJoinSQL: joinSQL, - orderSQL: orderSQL, - filterSQL: filterSQL - ) - - // If we couldn't find the targetId then just load the first page - guard let targetIndex: Int = maybeIndex else { - return ((currentPageInfo.pageSize, 0, 0), nil) - } - - let updatedOffset: Int = { - // If the focused item is within the first or last half of the page - // then we still want to retrieve a full page so calculate the offset - // needed to do so (snapping to the ends) - let halfPageSize: Int = Int(floor(Double(currentPageInfo.pageSize) / 2)) + typealias QueryInfo = (limit: Int, offset: Int, updatedCacheOffset: Int) + typealias LoadedPage = (data: [T]?, pageInfo: PagedData.PageInfo, failureCallback: (() -> ())?) + + dependencies[singleton: .storage].readAsync( + retrieve: { [weak self] (db: ObservingDatabase) -> LoadedPage in + let totalCount: Int = PagedData.totalCount( + db, + tableName: pagedTableName, + requiredJoinSQL: joinSQL, + filterSQL: filterSQL + ) + + let (queryInfo, callback): (QueryInfo?, (() -> ())?) = { + switch target { + case .initialPageAround(let targetId): + // If we want to focus on a specific item then we need to find it's index in + // the queried data + let maybeRowInfo: PagedData.RowInfo? = PagedData.rowInfo( + db, + for: targetId, + tableName: pagedTableName, + idColumn: idColumnName, + requiredJoinSQL: joinSQL, + orderSQL: orderSQL, + filterSQL: filterSQL + ) - guard targetIndex > halfPageSize else { return 0 } - guard targetIndex < (totalCount - halfPageSize) else { - return max(0, (totalCount - currentPageInfo.pageSize)) + // If we couldn't find the targetId then just load the first page + guard let targetIndex: Int = maybeRowInfo?.rowIndex else { + return ((currentPageInfo.pageSize, 0, 0), nil) } + + let updatedOffset: Int = { + // If the focused item is within the first or last half of the page + // then we still want to retrieve a full page so calculate the offset + // needed to do so (snapping to the ends) + let halfPageSize: Int = Int(floor(Double(currentPageInfo.pageSize) / 2)) + + guard targetIndex > halfPageSize else { return 0 } + guard targetIndex < (totalCount - halfPageSize) else { + return max(0, (totalCount - currentPageInfo.pageSize)) + } - return (targetIndex - halfPageSize) - }() + return (targetIndex - halfPageSize) + }() - return ((currentPageInfo.pageSize, updatedOffset, updatedOffset), nil) - - case .pageBefore: - let updatedOffset: Int = max(0, (currentPageInfo.pageOffset - currentPageInfo.pageSize)) - - return ( - ( - currentPageInfo.pageSize, - updatedOffset, - updatedOffset - ), - nil - ) - - case .pageAfter: - return ( - ( - currentPageInfo.pageSize, - (currentPageInfo.pageOffset + currentPageInfo.currentCount), - currentPageInfo.pageOffset - ), - nil - ) - - case .untilInclusive(let targetId, let padding): - // If we want to focus on a specific item then we need to find it's index in - // the queried data - let maybeIndex: Int? = PagedData.index( - db, - for: targetId, - tableName: pagedTableName, - idColumn: idColumnName, - requiredJoinSQL: joinSQL, - orderSQL: orderSQL, - filterSQL: filterSQL - ) - let cacheCurrentEndIndex: Int = (currentPageInfo.pageOffset + currentPageInfo.currentCount) - - // If we couldn't find the targetId or it's already in the cache then do nothing - guard - let targetIndex: Int = maybeIndex.map({ max(0, min(totalCount, $0)) }), - ( - targetIndex < currentPageInfo.pageOffset || - targetIndex >= cacheCurrentEndIndex + return ((currentPageInfo.pageSize, updatedOffset, updatedOffset), nil) + + case .pageBefore: + let updatedOffset: Int = max(0, (currentPageInfo.pageOffset - currentPageInfo.pageSize)) + + return ( + ( + currentPageInfo.pageSize, + updatedOffset, + updatedOffset + ), + nil ) - else { return (nil, nil) } - - // If the target is before the cached data then load before - if targetIndex < currentPageInfo.pageOffset { - let finalIndex: Int = max(0, (targetIndex - abs(padding))) + case .pageAfter: return ( ( - (currentPageInfo.pageOffset - finalIndex), - finalIndex, - finalIndex + currentPageInfo.pageSize, + (currentPageInfo.pageOffset + currentPageInfo.currentCount), + currentPageInfo.pageOffset ), nil ) - } - - // Otherwise load after (targetIndex is 0-indexed so we need to add 1 for this to - // have the correct 'limit' value) - let finalIndex: Int = min(totalCount, (targetIndex + 1 + abs(padding))) - - return ( - ( - (finalIndex - cacheCurrentEndIndex), - cacheCurrentEndIndex, - currentPageInfo.pageOffset - ), - nil - ) - case .jumpTo(let targetId, let paddingForInclusive): - // If we want to focus on a specific item then we need to find it's index in - // the queried data - let maybeIndex: Int? = PagedData.index( - db, - for: targetId, - tableName: pagedTableName, - idColumn: idColumnName, - requiredJoinSQL: joinSQL, - orderSQL: orderSQL, - filterSQL: filterSQL - ) - let cacheCurrentEndIndex: Int = (currentPageInfo.pageOffset + currentPageInfo.currentCount) - - // If we couldn't find the targetId or it's already in the cache then do nothing - guard - let targetIndex: Int = maybeIndex.map({ max(0, min(totalCount, $0)) }), - ( - targetIndex < currentPageInfo.pageOffset || - targetIndex >= cacheCurrentEndIndex + case .untilInclusive(let targetId, let padding): + // If we want to focus on a specific item then we need to find it's index in + // the queried data + let maybeRowInfo: PagedData.RowInfo? = PagedData.rowInfo( + db, + for: targetId, + tableName: pagedTableName, + idColumn: idColumnName, + requiredJoinSQL: joinSQL, + orderSQL: orderSQL, + filterSQL: filterSQL ) - else { return (nil, nil) } - - // If the targetIndex is over a page before the current content or more than a page - // after the current content then we want to reload the entire content (to avoid - // loading an excessive amount of data), otherwise we should load all messages between - // the current content and the targetIndex (plus padding) - guard - (targetIndex < (currentPageInfo.pageOffset - currentPageInfo.pageSize)) || - (targetIndex > (cacheCurrentEndIndex + currentPageInfo.pageSize)) - else { + let cacheCurrentEndIndex: Int = (currentPageInfo.pageOffset + currentPageInfo.currentCount) + + // If we couldn't find the targetId or it's already in the cache then do nothing + guard + let targetIndex: Int = maybeRowInfo.map({ max(0, min(totalCount, $0.rowIndex)) }), + ( + targetIndex < currentPageInfo.pageOffset || + targetIndex >= cacheCurrentEndIndex + ) + else { return (nil, nil) } + + // If the target is before the cached data then load before + if targetIndex < currentPageInfo.pageOffset { + let finalIndex: Int = max(0, (targetIndex - abs(padding))) + + return ( + ( + (currentPageInfo.pageOffset - finalIndex), + finalIndex, + finalIndex + ), + nil + ) + } + + // Otherwise load after (targetIndex is 0-indexed so we need to add 1 for this to + // have the correct 'limit' value) + let finalIndex: Int = min(totalCount, (targetIndex + 1 + abs(padding))) + + return ( + ( + (finalIndex - cacheCurrentEndIndex), + cacheCurrentEndIndex, + currentPageInfo.pageOffset + ), + nil + ) + + case .jumpTo(let targetId, let paddingForInclusive): + // If we want to focus on a specific item then we need to find it's index in + // the queried data + let maybeRowInfo: PagedData.RowInfo? = PagedData.rowInfo( + db, + for: targetId, + tableName: pagedTableName, + idColumn: idColumnName, + requiredJoinSQL: joinSQL, + orderSQL: orderSQL, + filterSQL: filterSQL + ) + let cacheCurrentEndIndex: Int = (currentPageInfo.pageOffset + currentPageInfo.currentCount) + + // If we couldn't find the targetId or it's already in the cache then do nothing + guard + let targetIndex: Int = maybeRowInfo.map({ max(0, min(totalCount, $0.rowIndex)) }), + ( + targetIndex < currentPageInfo.pageOffset || + targetIndex >= cacheCurrentEndIndex + ) + else { return (nil, nil) } + + // If the targetIndex is over a page before the current content or more than a page + // after the current content then we want to reload the entire content (to avoid + // loading an excessive amount of data), otherwise we should load all messages between + // the current content and the targetIndex (plus padding) + guard + (targetIndex < (currentPageInfo.pageOffset - currentPageInfo.pageSize)) || + (targetIndex > (cacheCurrentEndIndex + currentPageInfo.pageSize)) + else { + let callback: () -> () = { + self?.load(.untilInclusive(id: targetId, padding: paddingForInclusive)) + } + return (nil, callback) + } + + // If the targetId is further than 1 pageSize away then discard the current + // cached data and trigger a fresh `initialPageAround` let callback: () -> () = { - self?.load(.untilInclusive(id: targetId, padding: paddingForInclusive)) + self?.dataCache = DataCache() + self?.associatedRecords.forEach { $0.clearCache() } + self?.pageInfo = PagedData.PageInfo(pageSize: currentPageInfo.pageSize) + self?.load(.initialPageAround(id: targetId)) } + return (nil, callback) - } + + case .reloadCurrent: + return ( + ( + currentPageInfo.currentCount, + currentPageInfo.pageOffset, + currentPageInfo.pageOffset + ), + nil + ) + } + }() + + // If there is no queryOffset then we already have the data we need so + // early-out (may as well update the 'totalCount' since it may be relevant) + guard let queryInfo: QueryInfo = queryInfo else { + return ( + nil, + PagedData.PageInfo( + pageSize: currentPageInfo.pageSize, + pageOffset: currentPageInfo.pageOffset, + currentCount: currentPageInfo.currentCount, + totalCount: totalCount + ), + callback + ) + } + + // Fetch the desired data + let pageRowIds: [Int64] + let newData: [T] + let updatedLimitInfo: PagedData.PageInfo + + do { + pageRowIds = try PagedData.rowIds( + db, + tableName: pagedTableName, + requiredJoinSQL: joinSQL, + filterSQL: filterSQL, + groupSQL: groupSQL, + orderSQL: orderSQL, + limit: queryInfo.limit, + offset: queryInfo.offset + ) + newData = try dataQuery(pageRowIds).fetchAll(db) + updatedLimitInfo = PagedData.PageInfo( + pageSize: currentPageInfo.pageSize, + pageOffset: queryInfo.updatedCacheOffset, + currentCount: { + switch target { + case .reloadCurrent: return currentPageInfo.currentCount + default: return (currentPageInfo.currentCount + newData.count) + } + }(), + totalCount: totalCount + ) + let updatedRelationships: [String: [Int64: [Int64]]] = relatedTables.reduce(into: [:]) { result, change in + guard let joinToPagedType: SQL = change.joinToPagedType else { return } - // If the targetId is further than 1 pageSize away then discard the current - // cached data and trigger a fresh `initialPageAround` - let callback: () -> () = { - self?.dataCache = DataCache() - self?.associatedRecords.forEach { $0.clearCache(db) } - self?.pageInfo = PagedData.PageInfo(pageSize: currentPageInfo.pageSize) - self?.load(.initialPageAround(id: targetId)) + let relationshipIds: [(pagedRowId: Int64, relatedRowId: Int64)] = PagedData.relatedRowIdsForPagedRowIds( + db, + tableName: change.databaseTableName, + pagedTableName: pagedTableName, + pagedRowIds: pageRowIds, + joinToPagedType: joinToPagedType + ) + result[change.databaseTableName] = relationshipIds + .grouped(by: { $0.relatedRowId }) + .mapValues { value in value.map { $0.pagedRowId } } + } + + // Update the relationshipCache records for the paged values + self?._relationshipCache.performUpdate { cache in + var updatedCache: [String: [Int64: [Int64]]] = cache + updatedRelationships.forEach { key, value in + if updatedCache[key] == nil { + updatedCache[key] = [:] + } + updatedCache[key]?.merge(value, uniquingKeysWith: { current, _ in current }) } - return (nil, callback) - - case .reloadCurrent: - return ( - ( - currentPageInfo.currentCount, - currentPageInfo.pageOffset, - currentPageInfo.pageOffset + return updatedCache + } + + // Update the associatedRecords for the newly retrieved data + let newDataRowIds: [Int64] = newData.map { $0.rowId } + try self?.associatedRecords.forEach { record in + record.updateCache( + db, + rowIds: try PagedData.associatedRowIds( + db, + tableName: record.databaseTableName, + pagedTableName: pagedTableName, + pagedTypeRowIds: newDataRowIds, + joinToPagedType: record.joinToPagedType ), - nil + hasOtherChanges: false ) + } } - }() - - // If there is no queryOffset then we already have the data we need so - // early-out (may as well update the 'totalCount' since it may be relevant) - guard let queryInfo: QueryInfo = queryInfo else { - return ( - nil, - PagedData.PageInfo( - pageSize: currentPageInfo.pageSize, - pageOffset: currentPageInfo.pageOffset, - currentCount: currentPageInfo.currentCount, - totalCount: totalCount - ), - callback - ) - } - - // Fetch the desired data - let pageRowIds: [Int64] - let newData: [T] - let updatedLimitInfo: PagedData.PageInfo - - do { - pageRowIds = try PagedData.rowIds( - db, - tableName: pagedTableName, - requiredJoinSQL: joinSQL, - filterSQL: filterSQL, - groupSQL: groupSQL, - orderSQL: orderSQL, - limit: queryInfo.limit, - offset: queryInfo.offset - ) - newData = try dataQuery(pageRowIds).fetchAll(db) - updatedLimitInfo = PagedData.PageInfo( - pageSize: currentPageInfo.pageSize, - pageOffset: queryInfo.updatedCacheOffset, - currentCount: { - switch target { - case .reloadCurrent: return currentPageInfo.currentCount - default: return (currentPageInfo.currentCount + newData.count) - } - }(), - totalCount: totalCount - ) - - // Update the associatedRecords for the newly retrieved data - let newDataRowIds: [Int64] = newData.map { $0.rowId } - try self?.associatedRecords.forEach { record in - record.updateCache( - db, - rowIds: try PagedData.associatedRowIds( - db, - tableName: record.databaseTableName, - pagedTableName: pagedTableName, - pagedTypeRowIds: newDataRowIds, - joinToPagedType: record.joinToPagedType - ), - hasOtherChanges: false - ) + catch { + Log.error(.cat, "Error loading data: \(error)") + throw error } - } - catch { - Log.error(.cat, "Error loading data: \(error)") - throw error - } - return (newData, updatedLimitInfo, nil) - } - - // Unwrap the updated data - guard - let loadedPageData: [T] = loadedPage?.data, - let updatedPageInfo: PagedData.PageInfo = loadedPage?.pageInfo - else { - // It's possible to get updated page info without having updated data, in that case - // we do want to update the cache but probably don't need to trigger the change callback - if let updatedPageInfo: PagedData.PageInfo = loadedPage?.pageInfo { - self.pageInfo = updatedPageInfo + return (newData, updatedLimitInfo, nil) + }, + completion: { [weak self] result in + guard + let self = self, + case .success(let loadedPage) = result + else { return } + + // Unwrap the updated data + guard let loadedPageData: [T] = loadedPage.data else { + /// It's possible to get updated page info without having updated data, in that case we do want to update the + /// cache but probably don't need to trigger the change callback + self.pageInfo = loadedPage.pageInfo + self.isLoadingMoreData = false + loadedPage.failureCallback?() + return + } + + // Attach any associated data to the loadedPageData + var associatedLoadedData: DataCache = DataCache(items: loadedPageData) + + self.associatedRecords.forEach { record in + associatedLoadedData = record.updateAssociatedData(to: associatedLoadedData) + } + + // Update the cache and pageInfo + self.dataCache = self.dataCache.upserting(items: associatedLoadedData.values) + self.pageInfo = loadedPage.pageInfo + + /// Trigger the unsorted change callback (the actual UI update triggering should eventually be run on the main thread + /// via the `PagedData.processAndTriggerUpdates` function) + self.onChangeUnsorted(self.dataCache.values, loadedPage.pageInfo) + self.isLoadingMoreData = false } - self.isLoadingMoreData = false - loadedPage?.failureCallback?() - return - } - - // Attach any associated data to the loadedPageData - var associatedLoadedData: DataCache = DataCache(items: loadedPageData) - - self.associatedRecords.forEach { record in - associatedLoadedData = record.updateAssociatedData(to: associatedLoadedData) - } - - // Update the cache and pageInfo - self.dataCache = self.dataCache.upserting(items: associatedLoadedData.values) - self.pageInfo = updatedPageInfo - - // Trigger the unsorted change callback (the actual UI update triggering should eventually be run on - // the main thread via the `PagedData.processAndTriggerUpdates` function) - self.onChangeUnsorted(self.dataCache.values, updatedPageInfo) - self.isLoadingMoreData = false + ) } public func suspend() { @@ -811,12 +933,46 @@ public class PagedDatabaseObserver: IdentifiableTransactionObs // MARK: - Convenience +private extension PagedData { + /// This type is identical to the 'Target' type but has it's 'SQLExpressible' requirement removed + enum InternalTarget { + case initialPageAround(id: SQLExpression) + case pageBefore + case pageAfter + case jumpTo(id: SQLExpression, paddingForInclusive: Int) + case reloadCurrent + + /// This will be used when `jumpTo` is called and the `id` is within a single `pageSize` of the currently + /// cached data (plus the padding amount) + /// + /// **Note:** If the id is already within the cache then this will do nothing (even if + /// the padding would mean more data should be loaded) + case untilInclusive(id: SQLExpression, padding: Int) + } +} + +private extension PagedData.Target { + var internalTarget: PagedData.InternalTarget { + switch self { + case .initial: return .pageBefore + case .initialPageAround(let id): return .initialPageAround(id: id.sqlExpression) + case .pageBefore: return .pageBefore + case .pageAfter: return .pageAfter + + case .jumpTo(let id, let padding): + return .jumpTo(id: id.sqlExpression, paddingForInclusive: padding) + + case .reloadCurrent: return .reloadCurrent + } + } +} + public extension PagedDatabaseObserver { - func load(_ target: PagedData.PageInfo.Target) where ObservedTable.ID: SQLExpressible { + func load(_ target: PagedData.Target) where ObservedTable.ID: SQLExpressible { self.load(target.internalTarget) } - func load(_ target: PagedData.PageInfo.Target) where ObservedTable.ID == Optional, ID: SQLExpressible { + func load(_ target: PagedData.Target) where ObservedTable.ID == Optional, ID: SQLExpressible { self.load(target.internalTarget) } } @@ -837,15 +993,15 @@ public protocol ErasedAssociatedRecord { func settingPagedTableName(pagedTableName: String) -> Self func tryUpdateForDatabaseCommit( - _ db: Database, + _ db: ObservingDatabase, changes: Set, joinSQL: SQL?, orderSQL: SQL, filterSQL: SQL, pageInfo: PagedData.PageInfo ) -> Bool - @discardableResult func updateCache(_ db: Database, rowIds: [Int64], hasOtherChanges: Bool) -> Bool - func clearCache(_ db: Database) + @discardableResult func updateCache(_ db: ObservingDatabase, rowIds: [Int64], hasOtherChanges: Bool) -> Bool + func clearCache() func updateAssociatedData(to unassociatedCache: DataCache) -> DataCache } @@ -918,79 +1074,7 @@ public struct DataCache: ThreadSafeT // MARK: - PagedData -public enum PagedData { - public static let autoLoadNextPageDelay: DispatchTimeInterval = .milliseconds(400) - - // MARK: - PageInfo - - public struct PageInfo: ThreadSafeType { - /// This type is identical to the 'Target' type but has it's 'SQLExpressible' requirement removed - fileprivate enum InternalTarget { - case initialPageAround(id: SQLExpression) - case pageBefore - case pageAfter - case jumpTo(id: SQLExpression, paddingForInclusive: Int) - case reloadCurrent - - /// This will be used when `jumpTo` is called and the `id` is within a single `pageSize` of the currently - /// cached data (plus the padding amount) - /// - /// **Note:** If the id is already within the cache then this will do nothing (even if - /// the padding would mean more data should be loaded) - case untilInclusive(id: SQLExpression, padding: Int) - } - - public enum Target { - /// This will attempt to load a page of data around a specified id - /// - /// **Note:** This target will only work if there is no other data in the cache - case initialPageAround(id: ID) - - /// This will attempt to load a page of data before the first item in the cache - case pageBefore - - /// This will attempt to load a page of data after the last item in the cache - case pageAfter - - /// This will jump to the specified id, loading a page around it and clearing out any - /// data that was previously cached - /// - /// **Note:** If the id is within 1 pageSize of the currently cached data then this - /// will behave as per the `untilInclusive(id:padding:)` type - case jumpTo(id: ID, paddingForInclusive: Int) - - fileprivate var internalTarget: InternalTarget { - switch self { - case .initialPageAround(let id): return .initialPageAround(id: id.sqlExpression) - case .pageBefore: return .pageBefore - case .pageAfter: return .pageAfter - - case .jumpTo(let id, let paddingForInclusive): - return .jumpTo(id: id.sqlExpression, paddingForInclusive: paddingForInclusive) - } - } - } - - public let pageSize: Int - public let pageOffset: Int - public let currentCount: Int - public let totalCount: Int - - // MARK: - Initizliation - - public init( - pageSize: Int, - pageOffset: Int = 0, - currentCount: Int = 0, - totalCount: Int = 0 - ) { - self.pageSize = pageSize - self.pageOffset = pageOffset - self.currentCount = currentCount - self.totalCount = totalCount - } - } - +public extension PagedData { // MARK: - ObservedChanges /// This type contains the information needed to define what changes should be included when observing @@ -1000,7 +1084,7 @@ public enum PagedData { /// - table: The table whose changes should be observed /// - events: The database events which should be observed /// - columns: The specific columns which should trigger changes (**Note:** These only apply to `update` changes) - public struct ObservedChanges { + struct ObservedChanges { public let databaseTableName: String public let events: [DatabaseEvent.Kind] public let columns: [String] @@ -1021,7 +1105,7 @@ public enum PagedData { // MARK: - TrackedChange - public struct TrackedChange: Hashable { + struct TrackedChange: Hashable { let tableName: String let kind: DatabaseEvent.Kind let rowId: Int64 @@ -1043,7 +1127,7 @@ public enum PagedData { // MARK: - Convenience Functions // FIXME: Would be good to clean this up further in the future (should be able to do more processing on BG threads) - public static func processAndTriggerUpdates( + static func processAndTriggerUpdates( updatedData: [SectionModel]?, currentDataRetriever: @escaping (() -> [SectionModel]?), onDataChangeRetriever: @escaping (() -> (([SectionModel], StagedChangeset<[SectionModel]>) -> ())?), @@ -1092,7 +1176,7 @@ public enum PagedData { } } - public static func processAndTriggerUpdates( + static func processAndTriggerUpdates( updatedData: [SectionModel]?, currentDataRetriever: @escaping (() -> [SectionModel]?), valueSubject: CurrentValueSubject<([SectionModel], StagedChangeset<[SectionModel]>), Never>? @@ -1131,27 +1215,8 @@ public enum PagedData { // MARK: - Internal Functions - fileprivate static func totalCount( - _ db: Database, - tableName: String, - requiredJoinSQL: SQL? = nil, - filterSQL: SQL - ) -> Int { - let tableNameLiteral: SQL = SQL(stringLiteral: tableName) - let finalJoinSQL: SQL = (requiredJoinSQL ?? "") - let request: SQLRequest = """ - SELECT \(tableNameLiteral).rowId - FROM \(tableNameLiteral) - \(finalJoinSQL) - WHERE \(filterSQL) - """ - - return (try? request.fetchCount(db)) - .defaulting(to: 0) - } - fileprivate static func rowIds( - _ db: Database, + _ db: ObservingDatabase, tableName: String, requiredJoinSQL: SQL? = nil, filterSQL: SQL, @@ -1176,40 +1241,11 @@ public enum PagedData { return try request.fetchAll(db) } - fileprivate static func index( - _ db: Database, - for id: ID, - tableName: String, - idColumn: String, - requiredJoinSQL: SQL? = nil, - orderSQL: SQL, - filterSQL: SQL - ) -> Int? { - let tableNameLiteral: SQL = SQL(stringLiteral: tableName) - let idColumnLiteral: SQL = SQL(stringLiteral: idColumn) - let finalJoinSQL: SQL = (requiredJoinSQL ?? "") - let request: SQLRequest = """ - SELECT - (data.rowIndex - 1) AS rowIndex -- Converting from 1-Indexed to 0-indexed - FROM ( - SELECT - \(tableNameLiteral).\(idColumnLiteral) AS \(idColumnLiteral), - ROW_NUMBER() OVER (ORDER BY \(orderSQL)) AS rowIndex - FROM \(tableNameLiteral) - \(finalJoinSQL) - WHERE \(filterSQL) - ) AS data - WHERE \(SQL("data.\(idColumnLiteral) = \(id)")) - """ - - return try? request.fetchOne(db) - } - /// Returns the indexes the requested rowIds will have in the paged query /// /// **Note:** If the `associatedRecord` is null then the index for the rowId of the paged data type will be returned fileprivate static func indexes( - _ db: Database, + _ db: ObservingDatabase, rowIds: [Int64], tableName: String, requiredJoinSQL: SQL? = nil, @@ -1241,7 +1277,7 @@ public enum PagedData { /// Returns the rowIds for the associated types based on the specified pagedTypeRowIds fileprivate static func associatedRowIds( - _ db: Database, + _ db: ObservingDatabase, tableName: String, pagedTableName: String, pagedTypeRowIds: [Int64], @@ -1261,9 +1297,40 @@ public enum PagedData { return try request.fetchAll(db) } - /// Returns the rowIds for the paged type based on the specified relatedRowIds + /// Returns the relatedRowIds for the specified pagedRowIds + fileprivate static func relatedRowIdsForPagedRowIds( + _ db: ObservingDatabase, + tableName: String, + pagedTableName: String, + pagedRowIds: [Int64], + joinToPagedType: SQL + ) -> [(pagedRowId: Int64, relatedRowId: Int64)] { + guard !pagedRowIds.isEmpty else { return [] } + + struct RowIdPair: Decodable, FetchableRecord { + let pagedRowId: Int64 + let relatedRowId: Int64 + } + + let tableNameLiteral: SQL = SQL(stringLiteral: tableName) + let pagedTableNameLiteral: SQL = SQL(stringLiteral: pagedTableName) + let request: SQLRequest = """ + SELECT + \(pagedTableNameLiteral).rowid AS pagedRowId, + \(tableNameLiteral).rowid AS relatedRowId + FROM \(pagedTableNameLiteral) + \(joinToPagedType) + WHERE \(pagedTableNameLiteral).rowId IN \(pagedRowIds) + """ + + return (try? request.fetchAll(db)) + .defaulting(to: []) + .map { ($0.pagedRowId, $0.relatedRowId) } + } + + /// Returns the pagedRowIds for the specified relatedRowIds fileprivate static func pagedRowIdsForRelatedRowIds( - _ db: Database, + _ db: ObservingDatabase, tableName: String, pagedTableName: String, relatedRowIds: [Int64], @@ -1324,7 +1391,7 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet } public func tryUpdateForDatabaseCommit( - _ db: Database, + _ db: ObservingDatabase, changes: Set, joinSQL: SQL?, orderSQL: SQL, @@ -1422,7 +1489,7 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet ) } - @discardableResult public func updateCache(_ db: Database, rowIds: [Int64], hasOtherChanges: Bool = false) -> Bool { + @discardableResult public func updateCache(_ db: ObservingDatabase, rowIds: [Int64], hasOtherChanges: Bool = false) -> Bool { // If there are no rowIds then stop here guard !rowIds.isEmpty else { return hasOtherChanges } @@ -1449,7 +1516,7 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet } } - public func clearCache(_ db: Database) { + public func clearCache() { dataCache = DataCache() } diff --git a/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift deleted file mode 100644 index add7923f46..0000000000 --- a/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift +++ /dev/null @@ -1,151 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -// -// stringlint:disable - -import Foundation -import GRDB - -// MARK: - Cache - -internal extension Cache { - static let transactionObserver: CacheConfig = Dependencies.create( - identifier: "transactionObserver", - createInstance: { dependencies in Storage.TransactionObserverCache(using: dependencies) }, - mutableInstance: { $0 }, - immutableInstance: { $0 } - ) -} - -public extension Database { - func makeFTS5Pattern(rawPattern: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible { - return try makeFTS5Pattern(rawPattern: rawPattern, forTable: table.databaseTableName) - } - - /// This is a custom implementation of the `afterNextTransaction` method which executes the closures within their own - /// transactions to allow for nesting of 'afterNextTransaction' actions - /// - /// **Note:** GRDB doesn't notify read-only transactions to transaction observers - func afterNextTransactionNested( - using dependencies: Dependencies, - onCommit: @escaping (Database) -> Void, - onRollback: @escaping (Database) -> Void = { _ in } - ) { - dependencies.mutate(cache: .transactionObserver) { - $0.add(self, dedupeId: UUID().uuidString, onCommit: onCommit, onRollback: onRollback) - } - } - - func afterNextTransactionNestedOnce( - dedupeId: String, - using dependencies: Dependencies, - onCommit: @escaping (Database) -> Void, - onRollback: @escaping (Database) -> Void = { _ in } - ) { - dependencies.mutate(cache: .transactionObserver) { - $0.add(self, dedupeId: dedupeId, onCommit: onCommit, onRollback: onRollback) - } - } -} - -internal class TransactionHandler: TransactionObserver { - private let dependencies: Dependencies - private let identifier: String - private let onCommit: (Database) -> Void - private let onRollback: (Database) -> Void - - init( - identifier: String, - onCommit: @escaping (Database) -> Void, - onRollback: @escaping (Database) -> Void, - using dependencies: Dependencies - ) { - self.dependencies = dependencies - self.identifier = identifier - self.onCommit = onCommit - self.onRollback = onRollback - } - - // Ignore changes - func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool { false } - func databaseDidChange(with event: DatabaseEvent) { } - - func databaseDidCommit(_ db: Database) { - dependencies.mutate(cache: .transactionObserver) { $0.remove(for: identifier) } - - do { - try db.inTransaction { - onCommit(db) - return .commit - } - } - catch { - Log.warn(.storage, "afterNextTransactionNested onCommit failed") - } - } - - func databaseDidRollback(_ db: Database) { - dependencies.mutate(cache: .transactionObserver) { $0.remove(for: identifier) } - onRollback(db) - } -} - -// MARK: - TransactionObserver Cache - -internal extension Storage { - class TransactionObserverCache: TransactionObserverCacheType { - private let dependencies: Dependencies - public var registeredHandlers: [String: TransactionHandler] = [:] - - // MARK: - Initialization - - public init(using dependencies: Dependencies) { - self.dependencies = dependencies - } - - // MARK: - Functions - - public func add( - _ db: Database, - dedupeId: String, - onCommit: @escaping (Database) -> Void, - onRollback: @escaping (Database) -> Void - ) { - // Only allow a single observer per `dedupeId` per transaction, this allows us to - // schedule an action to run at most once per transaction (eg. auto-scheduling a ConfigSyncJob - // when receiving messages) - guard registeredHandlers[dedupeId] == nil else { return } - - let observer: TransactionHandler = TransactionHandler( - identifier: dedupeId, - onCommit: onCommit, - onRollback: onRollback, - using: dependencies - ) - db.add(transactionObserver: observer, extent: .nextTransaction) - registeredHandlers[dedupeId] = observer - } - - public func remove(for identifier: String) { - registeredHandlers.removeValue(forKey: identifier) - } - } -} - -// MARK: - TransactionObserverCacheType - -/// This is a read-only version of the Cache designed to avoid unintentionally mutating the instance in a non-thread-safe way -internal protocol TransactionObserverImmutableCacheType: ImmutableCacheType { - var registeredHandlers: [String: TransactionHandler] { get } -} - -internal protocol TransactionObserverCacheType: TransactionObserverImmutableCacheType, MutableCacheType { - var registeredHandlers: [String: TransactionHandler] { get } - - func add( - _ db: Database, - dedupeId: String, - onCommit: @escaping (Database) -> Void, - onRollback: @escaping (Database) -> Void - ) - func remove(for identifier: String) -} diff --git a/SessionUtilitiesKit/Database/Utilities/DatabaseMigrator+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/DatabaseMigrator+Utilities.swift index 5d14f9e73e..748175ca8d 100644 --- a/SessionUtilitiesKit/Database/Utilities/DatabaseMigrator+Utilities.swift +++ b/SessionUtilitiesKit/Database/Utilities/DatabaseMigrator+Utilities.swift @@ -13,7 +13,10 @@ public extension DatabaseMigrator { ) { self.registerMigration( targetIdentifier.key(with: migration), - migrate: migration.loggedMigrate(storage, targetIdentifier: targetIdentifier, using: dependencies) + migrate: { db in + let migration = migration.loggedMigrate(storage, targetIdentifier: targetIdentifier, using: dependencies) + try migration(ObservingDatabase.create(db, using: dependencies)) + } ) } } diff --git a/SessionUtilitiesKit/Database/Utilities/FetchRequest+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/FetchRequest+Utilities.swift index b1816ba7ec..de27607d19 100644 --- a/SessionUtilitiesKit/Database/Utilities/FetchRequest+Utilities.swift +++ b/SessionUtilitiesKit/Database/Utilities/FetchRequest+Utilities.swift @@ -3,7 +3,7 @@ import GRDB public extension FetchRequest where RowDecoder: FetchableRecord { - func fetchOne(_ db: Database, orThrow error: Error) throws -> RowDecoder { + func fetchOne(_ db: ObservingDatabase, orThrow error: Error) throws -> RowDecoder { guard let result: RowDecoder = try fetchOne(db) else { throw error } return result @@ -11,7 +11,7 @@ public extension FetchRequest where RowDecoder: FetchableRecord { } public extension FetchRequest where RowDecoder: DatabaseValueConvertible { - func fetchOne(_ db: Database, orThrow error: Error) throws -> RowDecoder { + func fetchOne(_ db: ObservingDatabase, orThrow error: Error) throws -> RowDecoder { guard let result: RowDecoder = try fetchOne(db) else { throw error } return result diff --git a/SessionUtilitiesKit/Database/Utilities/PersistableRecord+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/PersistableRecord+Utilities.swift index 9e6809bfd9..8b967f33d7 100644 --- a/SessionUtilitiesKit/Database/Utilities/PersistableRecord+Utilities.swift +++ b/SessionUtilitiesKit/Database/Utilities/PersistableRecord+Utilities.swift @@ -4,7 +4,7 @@ import Foundation import GRDB public extension MutablePersistableRecord where Self: FetchableRecord { - @discardableResult func upserted(_ db: Database) throws -> Self { + @discardableResult func upserted(_ db: ObservingDatabase) throws -> Self { var mutableSelf: Self = self try mutableSelf.upsert(db) return mutableSelf diff --git a/SessionUtilitiesKit/Database/Utilities/QueryInterfaceRequest+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/QueryInterfaceRequest+Utilities.swift index e2f74f3658..f17204059a 100644 --- a/SessionUtilitiesKit/Database/Utilities/QueryInterfaceRequest+Utilities.swift +++ b/SessionUtilitiesKit/Database/Utilities/QueryInterfaceRequest+Utilities.swift @@ -14,7 +14,7 @@ public extension QueryInterfaceRequest { /// - returns: Whether the request matches a row in the database. /// /// stringlint:ignore_contents - func isNotEmpty(_ db: Database) -> Bool { + func isNotEmpty(_ db: ObservingDatabase) -> Bool { return ((try? SQLRequest("SELECT \(exists())").fetchOne(db)) ?? false) } } diff --git a/SessionUtilitiesKit/Dependency Injection/Dependencies.swift b/SessionUtilitiesKit/Dependency Injection/Dependencies.swift index 2b523fb78d..e329b5574e 100644 --- a/SessionUtilitiesKit/Dependency Injection/Dependencies.swift +++ b/SessionUtilitiesKit/Dependency Injection/Dependencies.swift @@ -86,7 +86,11 @@ public class Dependencies { } } - // MARK: - Random Access Functions + // MARK: - Random + + public func randomUUID() -> UUID { + return UUID() + } public func randomElement(_ collection: T) -> T.Element? { return collection.randomElement() @@ -107,22 +111,16 @@ public class Dependencies { } public func set(singleton: SingletonConfig, to instance: S) { - threadSafeChange(for: singleton.identifier, of: .singleton) { - setValue(instance, typedStorage: .singleton(instance), key: singleton.identifier) - } + setValue(instance, typedStorage: .singleton(instance), key: singleton.identifier) } public func set(cache: CacheConfig, to instance: M) { - threadSafeChange(for: cache.identifier, of: .cache) { - let value: ThreadSafeObject = ThreadSafeObject(cache.mutableInstance(instance)) - setValue(value, typedStorage: .cache(value), key: cache.identifier) - } + let value: ThreadSafeObject = ThreadSafeObject(cache.mutableInstance(instance)) + setValue(value, typedStorage: .cache(value), key: cache.identifier) } public func remove(cache: CacheConfig) { - threadSafeChange(for: cache.identifier, of: .cache) { - removeValue(cache.identifier, of: .cache) - } + removeValue(cache.identifier, of: .cache) } public static func setIsRTLRetriever(requiresMainThread: Bool, isRTLRetriever: @escaping () -> Bool) { @@ -175,77 +173,50 @@ public extension Dependencies { } func hasSet(feature: FeatureConfig) -> Bool { - return threadSafeChange(for: feature.identifier, of: .feature) { - guard let instance: Feature = getValue(feature.identifier, of: .feature) else { - return false - } - - return instance.hasStoredValue(using: self) - } + let key: Dependencies.DependencyStorage.Key = DependencyStorage.Key.Variant.feature + .key(feature.identifier) + + /// Use a `readLock` to check if a value has been set + guard + let typedValue: DependencyStorage.Value = storage.instances[key], + let existingValue: Feature = typedValue.value(as: Feature.self) + else { return false } + + return existingValue.hasStoredValue(using: self) } func set(feature: FeatureConfig, to updatedFeature: T?) { - threadSafeChange(for: feature.identifier, of: .feature) { - /// Update the cached & in-memory values - let instance: Feature = ( - getValue(feature.identifier, of: .feature) ?? - feature.createInstance(self) - ) - instance.setValue(to: updatedFeature, using: self) - setValue(instance, typedStorage: .feature(instance), key: feature.identifier) - } + let key: Dependencies.DependencyStorage.Key = DependencyStorage.Key.Variant.feature + .key(feature.identifier) + let typedValue: DependencyStorage.Value? = storage.instances[key] + + /// Update the cached & in-memory values + let instance: Feature = ( + typedValue?.value(as: Feature.self) ?? + feature.createInstance(self) + ) + instance.setValue(to: updatedFeature, using: self) + setValue(instance, typedStorage: .feature(instance), key: feature.identifier) /// Notify observers featureChangeSubject.send((feature.identifier, feature.groupIdentifier, updatedFeature)) } func reset(feature: FeatureConfig) { - threadSafeChange(for: feature.identifier, of: .feature) { - /// Reset the cached and in-memory values - let instance: Feature? = getValue(feature.identifier, of: .feature) - instance?.setValue(to: nil, using: self) - removeValue(feature.identifier, of: .feature) - } + let key: Dependencies.DependencyStorage.Key = DependencyStorage.Key.Variant.feature + .key(feature.identifier) + + /// Reset the cached and in-memory values + storage.instances[key]? + .value(as: Feature.self)? + .setValue(to: nil, using: self) + removeValue(feature.identifier, of: .feature) /// Notify observers featureChangeSubject.send((feature.identifier, feature.groupIdentifier, nil)) } } -// MARK: - Storage Setting Convenience - -public extension Dependencies { - subscript(singleton singleton: SingletonConfig, key key: Setting.BoolKey) -> Bool { - return self[singleton: singleton] - .read { db in db[key] } - .defaulting(to: false) // Default to false if it doesn't exist - } - - subscript(singleton singleton: SingletonConfig, key key: Setting.DoubleKey) -> Double? { - return self[singleton: singleton].read { db in db[key] } - } - - subscript(singleton singleton: SingletonConfig, key key: Setting.IntKey) -> Int? { - return self[singleton: singleton].read { db in db[key] } - } - - subscript(singleton singleton: SingletonConfig, key key: Setting.StringKey) -> String? { - return self[singleton: singleton].read { db in db[key] } - } - - subscript(singleton singleton: SingletonConfig, key key: Setting.DateKey) -> Date? { - return self[singleton: singleton].read { db in db[key] } - } - - subscript(singleton singleton: SingletonConfig, key key: Setting.EnumKey) -> T? { - return self[singleton: singleton].read { db in db[key] } - } - - subscript(singleton singleton: SingletonConfig, key key: Setting.EnumKey) -> T? { - return self[singleton: singleton].read { db in db[key] } - } -} - // MARK: - UserDefaults Convenience public extension Dependencies { @@ -372,38 +343,41 @@ private extension Dependencies { identifier: String, constructor: DependencyStorage.Constructor ) -> Value { - /// If we already have an instance then just return that - if let existingValue: Value = getValue(identifier, of: constructor.variant) { + let key: Dependencies.DependencyStorage.Key = constructor.variant.key(identifier) + + /// If we already have an instance then just return that (using a `readLock` rather than a `writeLock`) + if let existingValue: Value = storage.instances[key]?.value(as: Value.self) { return existingValue } - return threadSafeChange(for: identifier, of: constructor.variant) { - /// Now that we are within a synchronized group, check to make sure an instance wasn't created while we were waiting to - /// enter the group - if let existingValue: Value = getValue(identifier, of: constructor.variant) { - return existingValue + /// Otherwise we need to prevent multiple threads initialising **this** dependency, this is done with it's own + /// separate lock + let initializationLock: NSLock = _storage.performUpdateAndMap { storage in + if let existingLock = storage.initializationLocks[key] { + return (storage, existingLock) } - let result: (typedStorage: DependencyStorage.Value, value: Value) = constructor.create() - setValue(result.value, typedStorage: result.typedStorage, key: identifier) - return result.value + let newLock = NSLock() + storage.initializationLocks[key] = newLock + return (storage, newLock) } - } - - /// Convenience method to retrieve the existing dependency instance from memory in a thread-safe way - private func getValue(_ key: String, of variant: DependencyStorage.Key.Variant) -> T? { - return _storage.performMap { storage in - guard let typedValue: DependencyStorage.Value = storage.instances[variant.key(key)] else { - return nil - } - guard let result: T = typedValue.value(as: T.self) else { - /// If there is a value stored for the key, but it's not the right type then something has gone wrong, and we should log - Log.critical("Failed to convert stored dependency '\(variant.key(key))' to expected type: \(T.self)") - return nil - } - - return result + + /// Acquire the `initializationLock` + initializationLock.lock() + defer { initializationLock.unlock() } + + /// Now that we have acquired the `initializationLock` we need to check if an instance was created on another + /// thread while we were waiting + if let existingValue: Value = storage.instances[key]?.value(as: Value.self) { + return existingValue } + + /// Create an instance of the dependency **outside** of the `storage` lock (to prevent the initialiser of another + /// dependency from causing a deadlock) + let instance: (typedStorage: DependencyStorage.Value, value: Value) = constructor.create() + + /// Finally we can store the newly created dependency (this will acquire a `storage` lock again + return setValue(instance.value, typedStorage: instance.typedStorage, key: identifier) } /// Convenience method to store a dependency instance in memory in a thread-safe way @@ -421,27 +395,6 @@ private extension Dependencies { return storage } } - - /// This function creates an `NSLock` for the given identifier which allows us to block instance creation on a per-identifier basis - /// and avoid situations where multithreading could result in multiple instances of the same dependency being created concurrently - /// - /// **Note:** This `NSLock` is an additional mechanism on top of the `ThreadSafeObject` because the interface is a little - /// simpler and we don't need to wrap every instance within `ThreadSafeObject` this way - @discardableResult private func threadSafeChange(for identifier: String, of variant: DependencyStorage.Key.Variant, change: () -> T) -> T { - let lock: NSLock = _storage.performUpdateAndMap { storage in - if let existing = storage.initializationLocks[variant.key(identifier)] { - return (storage, existing) - } - - let lock: NSLock = NSLock() - storage.initializationLocks[variant.key(identifier)] = lock - return (storage, lock) - } - lock.lock() - defer { lock.unlock() } - - return change() - } } // MARK: - DSL diff --git a/SessionUtilitiesKit/General/AppContext.swift b/SessionUtilitiesKit/General/AppContext.swift index 0a12dac908..6982ddf7a3 100644 --- a/SessionUtilitiesKit/General/AppContext.swift +++ b/SessionUtilitiesKit/General/AppContext.swift @@ -24,7 +24,7 @@ public protocol AppContext: AnyObject { var frontMostViewController: UIViewController? { get } var backgroundTimeRemaining: TimeInterval { get } - func setMainWindow(_ mainWindow: UIWindow) + @MainActor func setMainWindow(_ mainWindow: UIWindow) func ensureSleepBlocking(_ shouldBeBlocking: Bool, blockingObjects: [Any]) func beginBackgroundTask(expirationHandler: @escaping () -> ()) -> UIBackgroundTaskIdentifier func endBackgroundTask(_ backgroundTaskIdentifier: UIBackgroundTaskIdentifier) diff --git a/SessionUtilitiesKit/General/Array+Utilities.swift b/SessionUtilitiesKit/General/Array+Utilities.swift index 9e04c99451..0b76ceb95c 100644 --- a/SessionUtilitiesKit/General/Array+Utilities.swift +++ b/SessionUtilitiesKit/General/Array+Utilities.swift @@ -11,6 +11,12 @@ public extension Array where Element: CustomStringConvertible { } public extension Array { + var nullIfEmpty: [Element]? { + guard !isEmpty else { return nil } + + return self + } + func appending(_ other: Element?) -> [Element] { guard let other: Element = other else { return self } @@ -70,12 +76,6 @@ public extension Array { Array(self[$0.. [Element]? { - guard !isEmpty else { return nil } - - return self - } } public extension Array where Element: Hashable { diff --git a/SessionUtilitiesKit/General/Authentication.swift b/SessionUtilitiesKit/General/Authentication.swift index 03d512f145..62b3662bde 100644 --- a/SessionUtilitiesKit/General/Authentication.swift +++ b/SessionUtilitiesKit/General/Authentication.swift @@ -10,9 +10,13 @@ public protocol AuthenticationMethod: SignatureGenerator { public extension AuthenticationMethod { var swarmPublicKey: String { - switch info { - case .standard(let sessionId, _), .groupAdmin(let sessionId, _), .groupMember(let sessionId, _): - return sessionId.hexString + get throws { + switch info { + case .standard(let sessionId, _), .groupAdmin(let sessionId, _), .groupMember(let sessionId, _): + return sessionId.hexString + + case .community: throw CryptoError.invalidAuthentication + } } } } @@ -51,14 +55,23 @@ public extension Authentication { public extension Authentication { enum Info: Equatable { - /// Used for when interacting as the current user - case standard(sessionId: SessionId, ed25519KeyPair: KeyPair) + /// Used when interacting as the current user + case standard(sessionId: SessionId, ed25519PublicKey: [UInt8]) - /// Used for when interacting as a group admin + /// Used when interacting as a group admin case groupAdmin(groupSessionId: SessionId, ed25519SecretKey: [UInt8]) - /// Used for when interacting as a group member + /// Used when interacting as a group member case groupMember(groupSessionId: SessionId, authData: Data) + + /// Used when interacting with a community + case community( + server: String, + publicKey: String, + hasCapabilities: Bool, + supportsBlinding: Bool, + forceBlinded: Bool + ) } } diff --git a/SessionUtilitiesKit/General/Dictionary+Utilities.swift b/SessionUtilitiesKit/General/Dictionary+Utilities.swift index 3f6c0704f5..5bc1657e57 100644 --- a/SessionUtilitiesKit/General/Dictionary+Utilities.swift +++ b/SessionUtilitiesKit/General/Dictionary+Utilities.swift @@ -30,6 +30,12 @@ public extension Dictionary.Values { // MARK: - Functional Convenience public extension Dictionary { + var nullIfEmpty: [Key: Value]? { + guard !isEmpty else { return nil } + + return self + } + subscript(_ key: Key?) -> Value? { guard let key: Key = key else { return nil } @@ -76,12 +82,6 @@ public extension Dictionary { return updatedDictionary } - - func nullIfEmpty() -> [Key: Value]? { - guard !isEmpty else { return nil } - - return self - } mutating func append(_ value: T?, toArrayOn key: Key?) where Value == [T] { guard let key: Key = key, let value: T = value else { return } diff --git a/SessionUtilitiesKit/General/General.swift b/SessionUtilitiesKit/General/General.swift index 9d56ab0cd5..1c7f764c4e 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: { _ in General.Cache() }, + createInstance: { dependencies in General.Cache(using: dependencies) }, mutableInstance: { $0 }, immutableInstance: { $0 } ) @@ -18,14 +18,44 @@ public extension Cache { public enum General { public class Cache: GeneralCacheType { + private let dependencies: Dependencies public var sessionId: SessionId = SessionId.invalid + public var ed25519SecretKey: [UInt8] = [] public var recentReactionTimestamps: [Int64] = [] public var contextualActionLookupMap: [Int: [String: [Int: Any]]] = [:] + public var userExists: Bool { !ed25519SecretKey.isEmpty } + public var ed25519Seed: [UInt8] { + guard ed25519SecretKey.count >= 32 else { return [] } + + return Array(ed25519SecretKey.prefix(upTo: 32)) + } + + // MARK: - Initialization + + init(using dependencies: Dependencies) { + self.dependencies = dependencies + } + // MARK: - Functions - public func setCachedSessionId(sessionId: SessionId) { - self.sessionId = sessionId + public func setSecretKey(ed25519SecretKey: [UInt8]) { + guard + ed25519SecretKey.count >= 32, + let ed25519KeyPair: KeyPair = dependencies[singleton: .crypto].generate( + .ed25519KeyPair(seed: Array(ed25519SecretKey.prefix(upTo: 32))) + ), + let x25519PublicKey: [UInt8] = dependencies[singleton: .crypto].generate( + .x25519(ed25519Pubkey: ed25519KeyPair.publicKey) + ) + else { + self.sessionId = .invalid + self.ed25519SecretKey = [] + return + } + + self.sessionId = SessionId(.standard, publicKey: x25519PublicKey) + self.ed25519SecretKey = ed25519SecretKey } } } @@ -34,15 +64,21 @@ public enum General { /// This is a read-only version of the Cache designed to avoid unintentionally mutating the instance in a non-thread-safe way public protocol ImmutableGeneralCacheType: ImmutableCacheType { + var userExists: Bool { get } var sessionId: SessionId { get } + var ed25519Seed: [UInt8] { get } + var ed25519SecretKey: [UInt8] { get } var recentReactionTimestamps: [Int64] { get } var contextualActionLookupMap: [Int: [String: [Int: Any]]] { get } } public protocol GeneralCacheType: ImmutableGeneralCacheType, MutableCacheType { + var userExists: Bool { get } var sessionId: SessionId { get } + var ed25519Seed: [UInt8] { get } + var ed25519SecretKey: [UInt8] { get } var recentReactionTimestamps: [Int64] { get set } var contextualActionLookupMap: [Int: [String: [Int: Any]]] { get set } - func setCachedSessionId(sessionId: SessionId) + func setSecretKey(ed25519SecretKey: [UInt8]) } diff --git a/SessionUtilitiesKit/General/Logging.swift b/SessionUtilitiesKit/General/Logging.swift index f0050ae23e..1cecbaf7ea 100644 --- a/SessionUtilitiesKit/General/Logging.swift +++ b/SessionUtilitiesKit/General/Logging.swift @@ -114,6 +114,10 @@ public enum Log { @ThreadSafeObject private static var logger: Logger? = nil @ThreadSafeObject private static var pendingStartupLogs: [LogInfo] = [] + public static func loggerExists(withPrefix prefix: String) -> Bool { + return (Log._logger.wrappedValue?.primaryPrefix == prefix) + } + public static func setup(with logger: Logger) { logger.setPendingLogsRetriever { _pendingStartupLogs.performUpdateAndMap { ([], $0) } @@ -126,11 +130,10 @@ public enum Log { } public static func logFilePath(using dependencies: Dependencies) -> String? { - guard let logger: Logger = logger else { return nil } - - let logFiles: [String] = logger.fileLogger.logFileManager.sortedLogFilePaths - - guard !logFiles.isEmpty else { return nil } + guard + let logFiles: [String] = logger?.fileLogger?.logFileManager.sortedLogFilePaths, + !logFiles.isEmpty + else { return nil } /// If the latest log file is too short (ie. less that ~1MB) then we want to create a temporary file which contains the previous /// log file logs plus the logs from the newest file so we don't miss info that might be relevant for debugging @@ -207,126 +210,126 @@ public enum Log { // FIXME: Would be nice to properly require a category for all logs public static func verbose( _ msg: String, - file: StaticString = #file, + file: StaticString = #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 = #file, + file: StaticString = #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 = #file, + file: StaticString = #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 = #file, + file: StaticString = #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 = #file, + file: StaticString = #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 = #file, + file: StaticString = #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 = #file, + file: StaticString = #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 = #file, + file: StaticString = #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 = #file, + file: StaticString = #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 = #file, + file: StaticString = #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 = #file, + file: StaticString = #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 = #file, + file: StaticString = #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 = #file, + file: StaticString = #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 = #file, + file: StaticString = #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 = #file, + file: StaticString = #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 = #file, + file: StaticString = #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 = #file, + file: StaticString = #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 = #file, + file: StaticString = #fileID, function: StaticString = #function, line: UInt = #line ) { custom(.critical, cats, msg, file: file, function: function, line: line) } @@ -334,7 +337,7 @@ public enum Log { public static func assert( _ condition: Bool, _ message: @autoclosure () -> String = String(), - file: StaticString = #file, + file: StaticString = #fileID, function: StaticString = #function, line: UInt = #line ) { @@ -349,7 +352,7 @@ public enum Log { } public static func assertOnMainThread( - file: StaticString = #file, + file: StaticString = #fileID, function: StaticString = #function, line: UInt = #line ) { @@ -364,7 +367,7 @@ public enum Log { } public static func assertNotOnMainThread( - file: StaticString = #file, + file: StaticString = #fileID, function: StaticString = #function, line: UInt = #line ) { @@ -382,7 +385,7 @@ public enum Log { _ level: Level, _ categories: [Category], _ message: String, - file: StaticString = #file, + file: StaticString = #fileID, function: StaticString = #function, line: UInt = #line ) { @@ -392,20 +395,32 @@ public enum Log { } } - logger.log(level, categories, message, file: file, function: function, line: line) + logger._internalLog(level, categories, message, file: file, function: function, line: line) } } // MARK: - Logger -public class Logger { +open class Logger { private let dependencies: Dependencies - private let primaryPrefix: String + fileprivate let primaryPrefix: String @ThreadSafeObject private var systemLoggers: [String: SystemLoggerType] = [:] - fileprivate let fileLogger: DDFileLogger + fileprivate let fileLogger: DDFileLogger? @ThreadSafe fileprivate var isSuspended: Bool = true @ThreadSafeObject fileprivate var pendingLogsRetriever: (() -> [Log.LogInfo])? = nil + internal init( + primaryPrefix: String, + fileLogger: DDFileLogger?, + isSuspended: Bool, + using dependencies: Dependencies + ) { + self.dependencies = dependencies + self.primaryPrefix = primaryPrefix + self.fileLogger = fileLogger + self.isSuspended = isSuspended + } + public init( primaryPrefix: String, customDirectory: String? = nil, @@ -431,11 +446,15 @@ public class Logger { dateFormatter.timeZone = NSTimeZone.local dateFormatter.dateFormat = "yyyy/MM/dd HH:mm:ss:SSSa ZZZZZ" - self.fileLogger.logFormatter = DDLogFileFormatterDefault(dateFormatter: dateFormatter) - self.fileLogger.rollingFrequency = (24 * 60 * 60) // Refresh everyday - self.fileLogger.maximumFileSize = (1024 * 1024 * 5) // Max log file size of 5MB - self.fileLogger.logFileManager.maximumNumberOfLogFiles = 3 // Save 3 days' log files - DDLog.add(self.fileLogger) + switch self.fileLogger { + case .none: break + case .some(let logger): + logger.logFormatter = DDLogFileFormatterDefault(dateFormatter: dateFormatter) + logger.rollingFrequency = (24 * 60 * 60) // Refresh everyday + logger.maximumFileSize = (1024 * 1024 * 5) // Max log file size of 5MB + logger.logFileManager.maximumNumberOfLogFiles = 3 // Save 3 days' log files + DDLog.add(logger) + } // Now that we are setup we should load the extension logs which will then // complete the startup process when completed @@ -445,7 +464,10 @@ public class Logger { deinit { // Need to ensure we remove the `fileLogger` from `DDLog` otherwise we will get duplicate // log entries - DDLog.remove(fileLogger) + switch fileLogger { + case .none: break + case .some(let logger): DDLog.remove(logger) + } } // MARK: - Functions @@ -462,8 +484,8 @@ public class Logger { // to a local directory (so they can be exported via XCode) - the below code reads any // logs from the shared directly and attempts to add them to the main app logs to make // debugging user issues in extensions easier - DispatchQueue.global(qos: .utility).async { [weak self, dependencies] in - guard let currentLogFileInfo: DDLogFileInfo = self?.fileLogger.currentLogFileInfo else { + DispatchQueue.global(qos: .utility).async(using: dependencies) { [weak self, dependencies] in + guard let currentLogFileInfo: DDLogFileInfo = self?.fileLogger?.currentLogFileInfo else { self?.completeResumeLogging(error: "Unable to retrieve current log file.") return } @@ -560,7 +582,7 @@ public class Logger { // If we had an error loading the extension logs then actually log it if let error: String = error { Log.empty() - log(.error, [], error, file: #file, function: #function, line: #line) + _internalLog(.error, [], error, file: #fileID, function: #function, line: #line) } // After creating a new logger we want to log two empty lines to make it easier to read @@ -569,11 +591,11 @@ public class Logger { // Add any logs that were pending during the startup process pendingLogs.forEach { level, categories, message, file, function, line in - log(level, categories, message, file: file, function: function, line: line) + _internalLog(level, categories, message, file: file, function: function, line: line) } } - fileprivate func log( + internal func _internalLog( _ level: Log.Level, _ categories: [Log.Category], _ message: String, diff --git a/SessionUtilitiesKit/General/SessionId.swift b/SessionUtilitiesKit/General/SessionId.swift index 39fb00d510..5039b82efd 100644 --- a/SessionUtilitiesKit/General/SessionId.swift +++ b/SessionUtilitiesKit/General/SessionId.swift @@ -2,11 +2,11 @@ import Foundation -public struct SessionId: Equatable, Hashable, CustomStringConvertible { +public struct SessionId: Codable, Equatable, Hashable, CustomStringConvertible { public static let byteCount: Int = 33 public static let invalid: SessionId = SessionId(.standard, publicKey: []) - public enum Prefix: String, CaseIterable, Hashable { + public enum Prefix: String, Codable, CaseIterable, Hashable { case standard = "05" // Used for identified users, open groups, etc. case blinded15 = "15" // Used for authentication and participants in open groups with blinding enabled case blinded25 = "25" // Used for authentication and participants in open groups with blinding enabled diff --git a/SessionUtilitiesKit/General/String+Utilities.swift b/SessionUtilitiesKit/General/String+Utilities.swift index 39e3eb2e09..9a33c9ad01 100644 --- a/SessionUtilitiesKit/General/String+Utilities.swift +++ b/SessionUtilitiesKit/General/String+Utilities.swift @@ -65,17 +65,6 @@ public extension String { return ranges } - static func filterNotificationText(_ text: String?) -> String? { - guard let text = text?.filteredForDisplay else { return nil } - - // iOS strips anything that looks like a printf formatting character from - // the notification body, so if we want to dispay a literal "%" in a notification - // it must be escaped. - // see https://developer.apple.com/documentation/uikit/uilocalnotification/1616646-alertbody - // for more details. - return text.replacingOccurrences(of: "%", with: "%%") - } - func appending(_ other: String?) -> String { guard let value: String = other else { return self } @@ -185,7 +174,19 @@ private extension CharacterSet { static let bidiPopDirectionalIsolate: String.UTF16View.Element = 0x2069 static let bidiControlCharacterSet: CharacterSet = { - return CharacterSet(charactersIn: "\(bidiLeftToRightIsolate)\(bidiRightToLeftIsolate)\(bidiFirstStrongIsolate)\(bidiLeftToRightEmbedding)\(bidiRightToLeftEmbedding)\(bidiLeftToRightOverride)\(bidiRightToLeftOverride)\(bidiPopDirectionalFormatting)\(bidiPopDirectionalIsolate)") + let bidiCodeUnits: [String.UTF16View.Element] = [ + bidiLeftToRightIsolate, bidiRightToLeftIsolate, bidiFirstStrongIsolate, + bidiLeftToRightEmbedding, bidiRightToLeftEmbedding, + bidiLeftToRightOverride, bidiRightToLeftOverride, + bidiPopDirectionalFormatting, bidiPopDirectionalIsolate + ] + + return CharacterSet( + charactersIn: bidiCodeUnits + .compactMap { UnicodeScalar($0) } + .map { String($0) } + .joined() + ) }() static let unsafeFilenameCharacterSet: CharacterSet = CharacterSet(charactersIn: "\u{202D}\u{202E}") @@ -222,6 +223,15 @@ public extension String { return self.trimmingCharacters(in: .whitespacesAndNewlines) } + /// iOS strips anything that looks like a printf formatting character from the notification body, so if we want to dispay a literal "%" in + /// a notification it must be escaped. + /// + /// See https://developer.apple.com/documentation/usernotifications/unnotificationcontent/body for + /// more details. + var filteredForNotification: String { + self.replacingOccurrences(of: "%", with: "%%") + } + private var hasExcessiveDiacriticals: Bool { for char in self.enumerated() { let scalarCount = String(char.element).unicodeScalars.count @@ -265,15 +275,19 @@ public extension String { var balancedString: String = "" + func charStr(_ utf16: String.UTF16View.Element) -> String { + return String(UnicodeScalar(utf16)!) + } + // If we have too many isolate pops, prepend FSI to balance while isolatePopCount > isolateStartsCount { - balancedString.append("\(CharacterSet.bidiFirstStrongIsolate)") + balancedString.append(charStr(CharacterSet.bidiFirstStrongIsolate)) isolateStartsCount += 1 } // If we have too many formatting pops, prepend LRE to balance while formattingPopCount > formattingStartsCount { - balancedString.append("\(CharacterSet.bidiLeftToRightEmbedding)") + balancedString.append(charStr(CharacterSet.bidiLeftToRightEmbedding)) formattingStartsCount += 1 } @@ -281,13 +295,13 @@ public extension String { // If we have too many formatting starts, append PDF to balance while formattingStartsCount > formattingPopCount { - balancedString.append("\(CharacterSet.bidiPopDirectionalFormatting)") + balancedString.append(charStr(CharacterSet.bidiPopDirectionalFormatting)) formattingPopCount += 1 } // If we have too many isolate starts, append PDI to balance while isolateStartsCount > isolatePopCount { - balancedString.append("\(CharacterSet.bidiPopDirectionalIsolate)") + balancedString.append(charStr(CharacterSet.bidiPopDirectionalIsolate)) isolatePopCount += 1 } diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 8c3f6317d5..b39e825901 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -46,9 +46,9 @@ public protocol JobRunnerType: AnyObject { // MARK: - Job Scheduling - @discardableResult func add(_ db: Database, job: Job?, dependantJob: Job?, canStartJob: Bool) -> Job? - @discardableResult func upsert(_ db: Database, job: Job?, canStartJob: Bool) -> Job? - @discardableResult func insert(_ db: Database, job: Job?, before otherJob: Job) -> (Int64, Job)? + @discardableResult func add(_ db: ObservingDatabase, job: Job?, dependantJob: Job?, canStartJob: Bool) -> Job? + @discardableResult func upsert(_ db: ObservingDatabase, job: Job?, canStartJob: Bool) -> Job? + @discardableResult func insert(_ db: ObservingDatabase, job: Job?, before otherJob: Job) -> (Int64, Job)? func enqueueDependenciesIfNeeded(_ jobs: [Job]) func manuallyTriggerResult(_ job: Job?, result: JobRunner.JobResult) func afterJob(_ job: Job?, state: JobRunner.JobState) -> AnyPublisher @@ -111,7 +111,7 @@ public extension JobRunnerType { // MARK: -- Job Scheduling - @discardableResult func add(_ db: Database, job: Job?, canStartJob: Bool) -> Job? { + @discardableResult func add(_ db: ObservingDatabase, job: Job?, canStartJob: Bool) -> Job? { return add(db, job: job, dependantJob: nil, canStartJob: canStartJob) } @@ -723,7 +723,7 @@ public final class JobRunner: JobRunnerType { // MARK: - Execution @discardableResult public func add( - _ db: Database, + _ db: ObservingDatabase, job: Job?, dependantJob: Job?, canStartJob: Bool @@ -759,7 +759,7 @@ public final class JobRunner: JobRunnerType { guard canStartJob else { return updatedJob } // Start the job runner if needed - db.afterNextTransactionNestedOnce(dedupeId: "JobRunner-Start: \(jobQueue?.queueContext ?? "N/A")", using: dependencies) { _ in + db.afterCommit(dedupeId: "JobRunner-Start: \(jobQueue?.queueContext ?? "N/A")") { jobQueue?.start() } @@ -767,7 +767,7 @@ public final class JobRunner: JobRunnerType { } public func upsert( - _ db: Database, + _ db: ObservingDatabase, job: Job?, canStartJob: Bool ) -> Job? { @@ -798,7 +798,7 @@ public final class JobRunner: JobRunnerType { guard canStartJob else { return updatedJob } // Start the job runner if needed - db.afterNextTransactionNestedOnce(dedupeId: "JobRunner-Start: \(jobQueue?.queueContext ?? "N/A")", using: dependencies) { _ in + db.afterCommit(dedupeId: "JobRunner-Start: \(jobQueue?.queueContext ?? "N/A")") { jobQueue?.start() } @@ -806,7 +806,7 @@ public final class JobRunner: JobRunnerType { } @discardableResult public func insert( - _ db: Database, + _ db: ObservingDatabase, job: Job?, before otherJob: Job ) -> (Int64, Job)? { @@ -961,7 +961,7 @@ public final class JobRunner: JobRunnerType { ) } - private func validatedJob(_ db: Database, job: Job?, validation: Validation) -> Job? { + private func validatedJob(_ db: ObservingDatabase, job: Job?, validation: Validation) -> Job? { guard let job: Job = job else { return nil } switch (validation, job.uniqueHashValue) { @@ -1194,7 +1194,7 @@ public final class JobQueue: Hashable { } fileprivate func add( - _ db: Database, + _ db: ObservingDatabase, job: Job, canStartJob: Bool ) { @@ -1216,7 +1216,7 @@ public final class JobQueue: Hashable { // Ensure that the database commit has completed and then trigger the next job to run (need // to ensure any interactions have been correctly inserted first) - db.afterNextTransactionNestedOnce(dedupeId: "JobRunner-Add: \(job.variant)", using: dependencies) { [weak self] _ in + db.afterCommit(dedupeId: "JobRunner-Add: \(job.variant)") { [weak self] in self?.runNextJob() } } @@ -1227,7 +1227,7 @@ public final class JobQueue: Hashable { /// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp` /// is in the future then the job won't be started fileprivate func upsert( - _ db: Database, + _ db: ObservingDatabase, job: Job, canStartJob: Bool ) -> Bool { @@ -1689,80 +1689,81 @@ public final class JobQueue: Hashable { /// This function is called when a job succeeds fileprivate func handleJobSucceeded(_ job: Job, shouldStop: Bool) { - /// Retrieve the dependant jobs first (the `JobDependecies` table has cascading deletion when the original `Job` is - /// removed so we need to retrieve these records before that happens) - let dependantJobs: [Job] = dependencies[singleton: .storage] - .read { db in try job.dependantJobs.fetchAll(db) } - .defaulting(to: []) - - switch job.behaviour { - case .runOnce, .runOnceNextLaunch, .runOnceAfterConfigSyncIgnoringPermanentFailure: - dependencies[singleton: .storage].write { db in - /// Since this job has been completed we can update the dependencies so other job that were dependant - /// on this one can be run - _ = try JobDependencies - .filter(JobDependencies.Columns.dependantId == job.id) - .deleteAll(db) - - _ = try job.delete(db) - } + dependencies[singleton: .storage].writeAsync( + 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) - case .recurring where shouldStop == true: - dependencies[singleton: .storage].write { db in - /// Since this job has been completed we can update the dependencies so other job that were dependant - /// on this one can be run - _ = try JobDependencies - .filter(JobDependencies.Columns.dependantId == job.id) - .deleteAll(db) + switch job.behaviour { + case .runOnce, .runOnceNextLaunch, .runOnceAfterConfigSyncIgnoringPermanentFailure: + /// Since this job has been completed we can update the dependencies so other job that were dependant + /// on this one can be run + _ = try JobDependencies + .filter(JobDependencies.Columns.dependantId == job.id) + .deleteAll(db) + + _ = try job.delete(db) + + case .recurring where shouldStop == true: + /// Since this job has been completed we can update the dependencies so other job that were dependant + /// on this one can be run + _ = try JobDependencies + .filter(JobDependencies.Columns.dependantId == job.id) + .deleteAll(db) + + _ = try job.delete(db) + + /// For `recurring` jobs which have already run, they should automatically run again but we want at least 1 second + /// to pass before doing so - the job itself should really update it's own `nextRunTimestamp` (this is just a safety net) + case .recurring where job.nextRunTimestamp <= dependencies.dateNow.timeIntervalSince1970: + guard let jobId: Int64 = job.id else { break } + + _ = try Job + .filter(id: jobId) + .updateAll( + db, + Job.Columns.failureCount.set(to: 0), + Job.Columns.nextRunTimestamp.set(to: (dependencies.dateNow.timeIntervalSince1970 + 1)) + ) + + /// For `recurringOnLaunch/Active` jobs which have already run but failed once, we need to clear their + /// `failureCount` and `nextRunTimestamp` to prevent them from endlessly running over and over again + case .recurringOnLaunch, .recurringOnActive: + guard + let jobId: Int64 = job.id, + job.failureCount != 0 && + job.nextRunTimestamp > TimeInterval.leastNonzeroMagnitude + else { break } + + _ = try Job + .filter(id: jobId) + .updateAll( + db, + Job.Columns.failureCount.set(to: 0), + Job.Columns.nextRunTimestamp.set(to: 0) + ) - _ = try job.delete(db) + default: break } - /// For `recurring` jobs which have already run, they should automatically run again but we want at least 1 second - /// to pass before doing so - the job itself should really update it's own `nextRunTimestamp` (this is just a safety net) - case .recurring where job.nextRunTimestamp <= dependencies.dateNow.timeIntervalSince1970: - guard let jobId: Int64 = job.id else { break } - - dependencies[singleton: .storage].write { [dependencies] db in - _ = try Job - .filter(id: jobId) - .updateAll( - db, - Job.Columns.failureCount.set(to: 0), - Job.Columns.nextRunTimestamp.set(to: (dependencies.dateNow.timeIntervalSince1970 + 1)) - ) + return dependantJobs + }, + completion: { [weak self, dependencies] result in + switch result { + case .failure: break + case .success(let dependantJobs): + /// Now that the job has been completed we want to enqueue any jobs that were dependant on it + dependencies[singleton: .jobRunner].enqueueDependenciesIfNeeded(dependantJobs) } - /// For `recurringOnLaunch/Active` jobs which have already run but failed once, we need to clear their - /// `failureCount` and `nextRunTimestamp` to prevent them from endlessly running over and over again - case .recurringOnLaunch, .recurringOnActive: - guard - let jobId: Int64 = job.id, - job.failureCount != 0 && - job.nextRunTimestamp > TimeInterval.leastNonzeroMagnitude - else { break } - - dependencies[singleton: .storage].write { db in - _ = try Job - .filter(id: jobId) - .updateAll( - db, - Job.Columns.failureCount.set(to: 0), - Job.Columns.nextRunTimestamp.set(to: 0) - ) + /// Perform job cleanup and start the next job + self?.performCleanUp(for: job, result: .succeeded) + self?.internalQueue.async(using: dependencies) { [weak self] in + self?.runNextJob() } - - default: break - } - - /// Now that the job has been completed we want to enqueue any jobs that were dependant on it - dependencies[singleton: .jobRunner].enqueueDependenciesIfNeeded(dependantJobs) - - // Perform job cleanup and start the next job - performCleanUp(for: job, result: .succeeded) - internalQueue.async(using: dependencies) { [weak self] in - self?.runNextJob() - } + } + ) } /// This function is called when a job fails, if it's wasn't a permanent failure then the 'failureCount' for the job will be incremented and it'll diff --git a/SessionUtilitiesKit/JobRunner/JobRunnerError.swift b/SessionUtilitiesKit/JobRunner/JobRunnerError.swift index 02e3f9fdb2..e9a69b5986 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunnerError.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunnerError.swift @@ -4,7 +4,7 @@ import Foundation -public enum JobRunnerError: Error, CustomStringConvertible { +public enum JobRunnerError: Error, Equatable, CustomStringConvertible { case executorMissing case jobIdMissing case requiredThreadIdMissing @@ -13,7 +13,7 @@ public enum JobRunnerError: Error, CustomStringConvertible { case missingRequiredDetails case missingDependencies - case possibleDuplicateJob + case possibleDuplicateJob(permanentFailure: Bool) case possibleDeferralLoop var wasPossibleDeferralLoop: Bool { diff --git a/SessionUtilitiesKit/LibSession/LibSessionError.swift b/SessionUtilitiesKit/LibSession/LibSessionError.swift index 698168230c..02067fa416 100644 --- a/SessionUtilitiesKit/LibSession/LibSessionError.swift +++ b/SessionUtilitiesKit/LibSession/LibSessionError.swift @@ -7,7 +7,7 @@ import SessionUtil public enum LibSessionError: Error, CustomStringConvertible { case unableToCreateConfigObject(String) - case invalidConfigObject + case invalidConfigObject(String, String) case invalidDataProvided case invalidConfigAccess case userDoesNotExist @@ -120,7 +120,7 @@ public enum LibSessionError: Error, CustomStringConvertible { public var description: String { switch self { case .unableToCreateConfigObject(let pubkey): return "Unable to create config object for: \(pubkey) (LibSessionError.unableToCreateConfigObject)." - case .invalidConfigObject: return "Invalid config object (LibSessionError.invalidConfigObject)." + case .invalidConfigObject(let wanted, let got): return "Invalid config object, wanted '\(wanted)' but got '\(got)' (LibSessionError.invalidConfigObject)." case .invalidDataProvided: return "Invalid data provided (LibSessionError.invalidDataProvided)." case .invalidConfigAccess: return "Invalid config access (LibSessionError.invalidConfigAccess)." case .userDoesNotExist: return "User does not exist (LibSessionError.userDoesNotExist)." diff --git a/SessionUtilitiesKit/LibSession/Types/ObservingDatabase.swift b/SessionUtilitiesKit/LibSession/Types/ObservingDatabase.swift new file mode 100644 index 0000000000..03c554ee3d --- /dev/null +++ b/SessionUtilitiesKit/LibSession/Types/ObservingDatabase.swift @@ -0,0 +1,416 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +// MARK: - ObservingDatabase + +public class ObservingDatabase { + public let dependencies: Dependencies + internal let originalDb: Database + internal var events: [ObservedEvent] = [] + internal var postCommitActions: [String: () -> Void] = [:] + + // MARK: - Initialization + + /// 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) + } + + private init(_ db: Database, using dependencies: Dependencies) { + self.dependencies = dependencies + self.originalDb = db + } + + // MARK: - Functions + + public func addEvent(_ event: ObservedEvent) { + events.append(event) + } + + public func afterCommit( + dedupeId: String = UUID().uuidString, + closure: @escaping () -> Void + ) { + /// If there is already an entry for `dedupeId` then don't do anything (this allows us to schedule an action to run at most once + /// per commit (eg. scheduling a job to run after receiving messages) + guard postCommitActions[dedupeId] == nil else { return } + + postCommitActions[dedupeId] = closure + } +} + +public extension ObservingDatabase { + func addEvent(_ key: ObservableKey) { + addEvent(ObservedEvent(key: key, value: nil)) + } + + func addEvent(_ value: AnyHashable?, forKey key: ObservableKey) { + addEvent(ObservedEvent(key: key, value: value)) + } + + func addEventIfNotNull(_ value: AnyHashable?, forKey key: ObservableKey) { + guard let value: AnyHashable = value else { return } + + addEvent(ObservedEvent(key: key, value: value)) + } +} + +// MARK: - ObservationContext + +public enum ObservationContext { + /// This `TaskLocal` variable is set and accessible within the context of a single `Task` and allows any code running within + /// the task to access the isntance without running into threading issues or needing to manage multiple instances + @TaskLocal + public static var observingDb: ObservingDatabase? +} + +// MARK: - Convenience + +public extension FetchableRecord where Self: TableRecord { + static func fetchAll(_ db: ObservingDatabase) throws -> [Self] { + return try self.fetchAll(db.originalDb) + } + + static func fetchOne(_ db: ObservingDatabase) throws -> Self? { + return try self.fetchOne(db.originalDb) + } + + static func fetchCount(_ db: ObservingDatabase) throws -> Int { + return try self.fetchCount(db.originalDb) + } +} + +public extension FetchableRecord where Self: TableRecord, Self: Hashable { + static func fetchSet(_ db: ObservingDatabase) throws -> Set { + return try self.fetchSet(db.originalDb) + } +} + +public extension FetchableRecord where Self: TableRecord, Self: Identifiable, Self.ID: DatabaseValueConvertible { + static func fetchAll(_ db: ObservingDatabase, ids: some Collection) throws -> [Self] { + return try self.fetchAll(db.originalDb, ids: ids) + } + + static func fetchOne(_ db: ObservingDatabase, id: Self.ID) throws -> Self? { + return try self.fetchOne(db.originalDb, id: id) + } +} + +public extension FetchRequest where Self.RowDecoder: FetchableRecord { + func fetchCursor(_ db: ObservingDatabase) throws -> RecordCursor { + return try self.fetchCursor(db.originalDb) + } + + func fetchAll(_ db: ObservingDatabase) throws -> [Self.RowDecoder] { + return try self.fetchAll(db.originalDb) + } + + func fetchOne(_ db: ObservingDatabase) throws -> Self.RowDecoder? { + return try self.fetchOne(db.originalDb) + } +} + +public extension FetchRequest where Self.RowDecoder: FetchableRecord, Self.RowDecoder: Hashable { + func fetchSet(_ db: ObservingDatabase) throws -> Set { + return try self.fetchSet(db.originalDb) + } +} + +public extension FetchRequest where Self.RowDecoder: DatabaseValueConvertible { + func fetchAll(_ db: ObservingDatabase) throws -> [Self.RowDecoder] { + return try self.fetchAll(db.originalDb) + } + + func fetchOne(_ db: ObservingDatabase) throws -> Self.RowDecoder? { + return try self.fetchOne(db.originalDb) + } +} + +public extension FetchRequest where Self.RowDecoder: DatabaseValueConvertible, Self.RowDecoder: StatementColumnConvertible { + func fetchAll(_ db: ObservingDatabase) throws -> [Self.RowDecoder] { + return try self.fetchAll(db.originalDb) + } + + func fetchOne(_ db: ObservingDatabase) throws -> Self.RowDecoder? { + return try self.fetchOne(db.originalDb) + } +} + +public extension FetchRequest where Self.RowDecoder: DatabaseValueConvertible, Self.RowDecoder: Hashable { + func fetchSet(_ db: ObservingDatabase) throws -> Set { + return try self.fetchSet(db.originalDb) + } +} + +public extension FetchRequest where Self.RowDecoder: DatabaseValueConvertible, Self.RowDecoder : StatementColumnConvertible, Self.RowDecoder: Hashable { + func fetchSet(_ db: ObservingDatabase) throws -> Set { + return try self.fetchSet(db.originalDb) + } +} + +public extension PersistableRecord { + func insert(_ db: ObservingDatabase, onConflict conflictResolution: Database.ConflictResolution? = nil) throws { + try self.insert(db.originalDb, onConflict: conflictResolution) + } + + func upsert(_ db: ObservingDatabase) throws { + return try self.upsert(db.originalDb) + } + + func save(_ db: ObservingDatabase, onConflict conflictResolution: Database.ConflictResolution? = nil) throws { + try self.save(db.originalDb, onConflict: conflictResolution) + } +} + +public extension SQLRequest { + func fetchCount(_ db: ObservingDatabase) throws -> Int { + return try self.fetchCount(db.originalDb) + } +} + +public extension MutablePersistableRecord { + mutating func insert(_ db: ObservingDatabase, onConflict conflictResolution: Database.ConflictResolution? = nil) throws { + try self.insert(db.originalDb, onConflict: conflictResolution) + } + + func inserted(_ db: ObservingDatabase, onConflict conflictResolution: Database.ConflictResolution? = nil) throws -> Self { + return try self.inserted(db.originalDb, onConflict: conflictResolution) + } + + mutating func upsert(_ db: ObservingDatabase) throws { + try self.upsert(db.originalDb) + } + + func update(_ db: ObservingDatabase, onConflict conflictResolution: Database.ConflictResolution? = nil) throws { + try self.update(db.originalDb, onConflict: conflictResolution) + } + + mutating func save(_ db: ObservingDatabase, onConflict conflictResolution: Database.ConflictResolution? = nil) throws { + try self.save(db.originalDb, onConflict: conflictResolution) + } + + func saved(_ db: ObservingDatabase, onConflict conflictResolution: Database.ConflictResolution? = nil) throws -> Self { + return try self.saved(db.originalDb, onConflict: conflictResolution) + } + + @discardableResult + func delete(_ db: ObservingDatabase) throws -> Bool { + return try self.delete(db.originalDb) + } +} + +public extension AdaptedFetchRequest { + func fetchCount(_ db: ObservingDatabase) throws -> Int { + return try self.fetchCount(db.originalDb) + } +} + +public extension QueryInterfaceRequest { + func fetchCount(_ db: ObservingDatabase) throws -> Int { + return try self.fetchCount(db.originalDb) + } + + func isEmpty(_ db: ObservingDatabase) throws -> Bool { + return try self.isEmpty(db.originalDb) + } + + func updateAndFetchAll( + _ db: ObservingDatabase, + onConflict conflictResolution: Database.ConflictResolution? = nil, + _ assignments: [ColumnAssignment] + ) throws -> [RowDecoder] where RowDecoder: FetchableRecord, RowDecoder: TableRecord { + return try self.updateAndFetchAll(db.originalDb, onConflict: conflictResolution, assignments) + } + + @discardableResult + func updateAll( + _ db: ObservingDatabase, + onConflict conflictResolution: Database.ConflictResolution? = nil, + _ assignments: [ColumnAssignment] + ) throws -> Int { + return try self.updateAll(db.originalDb, onConflict: conflictResolution, assignments) + } + + @discardableResult + func updateAll( + _ db: ObservingDatabase, + onConflict conflictResolution: Database.ConflictResolution? = nil, + _ assignments: ColumnAssignment... + ) throws -> Int { + return try self.updateAll(db.originalDb, onConflict: conflictResolution, assignments) + } + + @discardableResult + func deleteAll(_ db: ObservingDatabase) throws -> Int { + return try self.deleteAll(db.originalDb) + } +} + +public extension Row { + static func fetchAll( + _ db: ObservingDatabase, + sql: String, + arguments: StatementArguments = StatementArguments(), + adapter: (any RowAdapter)? = nil + ) throws -> [Row] { + return try self.fetchAll(db.originalDb, sql: sql, arguments: arguments, adapter: adapter) + } + + static func fetchOne( + _ db: ObservingDatabase, + sql: String, + arguments: StatementArguments = StatementArguments(), + adapter: (any RowAdapter)? = nil + ) throws -> Row? { + return try self.fetchOne(db.originalDb, sql: sql, arguments: arguments, adapter: adapter) + } +} + +public extension DatabaseValueConvertible where Self: StatementColumnConvertible { + static func fetchAll( + _ db: ObservingDatabase, + sql: String, + arguments: StatementArguments = StatementArguments(), + adapter: (any RowAdapter)? = nil + ) throws -> [Self] { + return try self.fetchAll(db.originalDb, sql: sql, arguments: arguments, adapter: adapter) + } + + static func fetchOne( + _ db: ObservingDatabase, + sql: String, + arguments: StatementArguments = StatementArguments(), + adapter: (any RowAdapter)? = nil + ) throws -> Self? { + return try self.fetchOne(db.originalDb, sql: sql, arguments: arguments, adapter: adapter) + } +} + +public extension DatabaseValueConvertible where Self: StatementColumnConvertible, Self: Hashable { + static func fetchSet( + _ db: ObservingDatabase, + sql: String, + arguments: StatementArguments = StatementArguments(), + adapter: (any RowAdapter)? = nil + ) throws -> Set { + return try self.fetchSet(db.originalDb, sql: sql, arguments: arguments, adapter: adapter) + } +} + +public extension TableRecord { + @discardableResult + static func updateAll( + _ db: ObservingDatabase, + onConflict conflictResolution: Database.ConflictResolution? = nil, + _ assignments: ColumnAssignment... + ) throws -> Int { + return try self.updateAll(db.originalDb, onConflict: conflictResolution, assignments) + } + + @discardableResult + static func deleteAll(_ db: ObservingDatabase) throws -> Int { + return try self.deleteAll(db.originalDb) + } +} + +public extension TableRecord where Self: Identifiable, Self.ID: DatabaseValueConvertible { + static func exists(_ db: ObservingDatabase, id: Self.ID) throws -> Bool { + return try self.exists(db.originalDb, id: id) + } + + @discardableResult + static func deleteAll(_ db: ObservingDatabase, ids: some Collection) throws -> Int { + return try self.deleteAll(db.originalDb, ids: ids) + } + + @discardableResult + static func deleteOne(_ db: ObservingDatabase, id: Self.ID) throws -> Bool { + return try self.deleteOne(db.originalDb, id: id) + } +} + +public extension ObservingDatabase { + func create( + table name: String, + options: TableOptions = [], + body: (TableDefinition) throws -> Void + ) throws { + try self.originalDb.create(table: name, options: options, body: body) + } + + func tableExists(_ name: String, in schemaName: String? = nil) throws -> Bool { + try self.originalDb.tableExists(name, in: schemaName) + } + + func triggerExists(_ name: String, in schemaName: String? = nil) throws -> Bool { + try self.originalDb.triggerExists(name, in: schemaName) + } + + func rename(table name: String, to newName: String) throws { + try self.originalDb.rename(table: name, to: newName) + } + + func alter(table name: String, body: (TableAlteration) -> Void) throws { + try self.originalDb.alter(table: name, body: body) + } + + func drop(table name: String) throws { + try self.originalDb.drop(table: name) + } + + func create( + indexOn table: String, + columns: [String], + options: IndexOptions = [], + condition: (any SQLExpressible)? = nil + ) throws { + try self.originalDb.create(indexOn: table, columns: columns, options: options, condition: condition) + } + + func create( + virtualTable tableName: String, + options: VirtualTableOptions = [], + using module: Module, + _ body: ((Module.TableDefinition) throws -> Void)? = nil + ) throws where Module: VirtualTableModule { + try self.originalDb.create(virtualTable: tableName, options: options, using: module, body) + } + + func makeFTS5Pattern(rawPattern: String, forTable table: String) throws -> FTS5Pattern { + try self.originalDb.makeFTS5Pattern(rawPattern: rawPattern, forTable: table) + } + + func makeFTS5Pattern(rawPattern: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible { + return try makeFTS5Pattern(rawPattern: rawPattern, forTable: table.databaseTableName) + } + + func dropFTS5SynchronizationTriggers(forTable tableName: String) throws { + try self.originalDb.dropFTS5SynchronizationTriggers(forTable: tableName) + } + + func execute(sql: String, arguments: StatementArguments = StatementArguments()) throws { + try self.originalDb.execute(sql: sql, arguments: arguments) + } + + func execute(literal sqlLiteral: SQL) throws { + try self.originalDb.execute(literal: sqlLiteral) + } + + func makeStatement(sql: String) throws -> Statement { + try self.originalDb.makeStatement(sql: sql) + } + + func add( + transactionObserver: some TransactionObserver, + extent: Database.TransactionObservationExtent = .observerLifetime + ) { + self.originalDb.add(transactionObserver: transactionObserver, extent: extent) + } + + func checkForeignKeys() throws { + try self.originalDb.checkForeignKeys() + } +} diff --git a/SessionUtilitiesKit/LibSession/Types/Setting.swift b/SessionUtilitiesKit/LibSession/Types/Setting.swift new file mode 100644 index 0000000000..31fad54bcb --- /dev/null +++ b/SessionUtilitiesKit/LibSession/Types/Setting.swift @@ -0,0 +1,31 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum Setting {} + +// MARK: - Setting Keys + +public extension Setting { + 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 } + } + + struct EnumKey: Key { + public let rawValue: String + public init(_ rawValue: String) { self.rawValue = rawValue } + } +} + +public extension Setting.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) } +} diff --git a/SessionUtilitiesKit/Media/Data+Image.swift b/SessionUtilitiesKit/Media/Data+Image.swift index 7626772226..028a20930b 100644 --- a/SessionUtilitiesKit/Media/Data+Image.swift +++ b/SessionUtilitiesKit/Media/Data+Image.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import AVKit import ImageIO import UniformTypeIdentifiers @@ -82,9 +83,10 @@ public extension Data { } var sizeForWebpData: CGSize { - guard let source: CGImageSource = CGImageSourceCreateWithData(self as CFData, nil) else { - return .zero - } + guard + guessedImageFormat == .webp, + let source: CGImageSource = CGImageSourceCreateWithData(self as CFData, nil) + else { return .zero } // Check if there's at least one image let count: Int = CGImageSourceGetCount(source) @@ -232,16 +234,29 @@ public extension Data { return ImageDimensions(pixelSize: CGSize(width: width, height: height), depthBytes: depthBytes) } - static func imageSize(for path: String, type: UTType?, using dependencies: Dependencies) -> CGSize { + static func mediaSize( + for path: String, + type: UTType?, + mimeType: String?, + sourceFilename: String?, + using dependencies: Dependencies + ) -> CGSize { let fileUrl: URL = URL(fileURLWithPath: path) - let isAnimated: Bool = (type?.isAnimated ?? false) + let maybePixelSize: CGSize? = extractSize( + from: path, + type: type, + mimeType: mimeType, + sourceFilename: sourceFilename, + using: dependencies + ) - guard - let data: Data = try? Data(validImageDataAt: path, type: type, using: dependencies), - let pixelSize: CGSize = imageSize(at: path, with: data, type: type, isAnimated: isAnimated) - else { return .zero } + guard let pixelSize: CGSize = maybePixelSize else { return .zero } - guard type != .webP else { return pixelSize } + // WebP and videos shouldn't have orientations so no need for any logic to rotate the size + switch (type, type?.isVideo, type?.isAnimated) { + case (.webP, _, _), (_, true, _), (_, _, true): return pixelSize + default: break + } // With CGImageSource we avoid loading the whole image into memory. let options: [String: Any] = [kCGImageSourceShouldCache as String: NSNumber(booleanLiteral: false)] @@ -253,73 +268,102 @@ public extension Data { let height: CGFloat = properties[kCGImagePropertyPixelHeight as String] as? CGFloat else { return .zero } - guard let orientation: UIImage.Orientation = (properties[kCGImagePropertyOrientation as String] as? Int).map({ UIImage.Orientation(exif: $0) }) else { + guard + let rawCgOrientation: UInt32 = properties[kCGImagePropertyOrientation] as? UInt32, + let cgOrientation: CGImagePropertyOrientation = CGImagePropertyOrientation(rawValue: rawCgOrientation) + else { return CGSize(width: width, height: height) } - return apply(orientation: orientation, to: CGSize(width: width, height: height)) + return apply( + orientation: UIImage.Orientation(cgOrientation), + to: CGSize(width: width, height: height) + ) } private static func apply(orientation: UIImage.Orientation, to imageSize: CGSize) -> CGSize { switch orientation { - case .up, // EXIF = 1 - .upMirrored, // EXIF = 2 - .down, // EXIF = 3 - .downMirrored: // EXIF = 4 - return imageSize - - case .leftMirrored, // EXIF = 5 - .left, // EXIF = 6 - .rightMirrored, // EXIF = 7 - .right: // EXIF = 8 + case .up, .upMirrored, .down, .downMirrored: return imageSize + case .leftMirrored, .left, .rightMirrored, .right: return CGSize(width: imageSize.height, height: imageSize.width) - @unknown default: return imageSize } } - private static func imageSize(at path: String, with data: Data?, type: UTType?, isAnimated: Bool) -> CGSize? { + private static func extractSize( + from path: String, + type: UTType?, + mimeType: String?, + sourceFilename: String?, + using dependencies: Dependencies + ) -> CGSize? { let fileUrl: URL = URL(fileURLWithPath: path) - // Need to custom handle WebP images via libwebp - guard type != .webP else { - guard let targetData: Data = (data ?? (try? Data(contentsOf: fileUrl, options: [.dataReadingMapped]))) else { - return nil - } - - let imageSize: CGSize = targetData.sizeForWebpData - - guard imageSize.width > 0, imageSize.height > 0 else { return nil } - - return imageSize + switch (type, type?.isVideo) { + case (.webP, _): + // Need to custom handle WebP images + guard let targetData: Data = try? Data(contentsOf: fileUrl, options: [.dataReadingMapped]) else { + return nil + } + + let imageSize: CGSize = targetData.sizeForWebpData + + guard imageSize.width > 0, imageSize.height > 0 else { return nil } + + return imageSize + + case (_, true): + // Videos don't have the same metadata as images so also need custom handling + let assetInfo: (asset: AVURLAsset, cleanup: () -> Void)? = AVURLAsset.asset( + for: path, + mimeType: mimeType, + sourceFilename: sourceFilename, + using: dependencies + ) + + guard + let asset: AVURLAsset = assetInfo?.asset, + let track: AVAssetTrack = asset.tracks(withMediaType: .video).first + else { return nil } + + let size: CGSize = track.naturalSize + let transformedSize: CGSize = size.applying(track.preferredTransform) + let videoSize: CGSize = CGSize( + width: abs(transformedSize.width), + height: abs(transformedSize.height) + ) + + guard videoSize.width > 0, videoSize.height > 0 else { return nil } + + return videoSize + + default: + // Otherwise use our custom code + guard + let imageSource = CGImageSourceCreateWithURL(fileUrl as CFURL, nil), + let dimensions: ImageDimensions = imageDimensions(source: imageSource), + dimensions.pixelSize.width > 0, + dimensions.pixelSize.height > 0, + dimensions.depthBytes > 0 + else { return nil } + + return dimensions.pixelSize } - - // Otherwise use our custom code - guard - let imageSource = CGImageSourceCreateWithURL(fileUrl as CFURL, nil), - let dimensions: ImageDimensions = imageDimensions(source: imageSource), - dimensions.pixelSize.width > 0, - dimensions.pixelSize.height > 0, - dimensions.depthBytes > 0 - else { return nil } - - return dimensions.pixelSize } } private extension UIImage.Orientation { - init?(exif: Int) { - switch exif { - case 1: self = .up - case 2: self = .upMirrored - case 3: self = .down - case 4: self = .downMirrored - case 5: self = .leftMirrored - case 6: self = .left - case 7: self = .rightMirrored - case 8: self = .right - default: return nil + init(_ cgOrientation: CGImagePropertyOrientation) { + switch cgOrientation { + case .up: self = .up + case .upMirrored: self = .upMirrored + case .down: self = .down + case .downMirrored: self = .downMirrored + case .left: self = .left + case .leftMirrored: self = .leftMirrored + case .right: self = .right + case .rightMirrored: self = .rightMirrored } } } diff --git a/SessionUtilitiesKit/Media/DataSource.swift b/SessionUtilitiesKit/Media/DataSource.swift index 9e817c0762..31730867dc 100644 --- a/SessionUtilitiesKit/Media/DataSource.swift +++ b/SessionUtilitiesKit/Media/DataSource.swift @@ -82,7 +82,12 @@ public class DataSourceValue: DataSource { public var isValidVideo: Bool { guard let dataUrl: URL = self.dataUrl else { return false } - return MediaUtils.isValidVideo(path: dataUrl.path, using: dependencies) + return MediaUtils.isValidVideo( + path: dataUrl.path, + mimeType: UTType.sessionMimeType(for: fileExtension), + sourceFilename: sourceFilename, + using: dependencies + ) } // MARK: - Initialization @@ -211,7 +216,12 @@ public class DataSourcePath: DataSource { public var isValidVideo: Bool { guard let dataUrl: URL = self.dataUrl else { return false } - return MediaUtils.isValidVideo(path: dataUrl.path, using: dependencies) + return MediaUtils.isValidVideo( + path: dataUrl.path, + mimeType: mimeType, + sourceFilename: sourceFilename, + using: dependencies + ) } // MARK: - Initialization diff --git a/SessionUtilitiesKit/Media/MediaUtils.swift b/SessionUtilitiesKit/Media/MediaUtils.swift index dd4ae7d122..dee568b0ed 100644 --- a/SessionUtilitiesKit/Media/MediaUtils.swift +++ b/SessionUtilitiesKit/Media/MediaUtils.swift @@ -27,70 +27,13 @@ public enum MediaUtils { public static let maxAnimatedImageDimensions: UInt = 1 * 1024 public static let maxStillImageDimensions: UInt = 8 * 1024 public static let maxVideoDimensions: CGFloat = 3 * 1024 - - public static func thumbnail(forImageAtPath path: String, maxDimension: CGFloat, type: String, using dependencies: Dependencies) throws -> UIImage { - Log.verbose(.media, "Thumbnailing image: \(path)") - - guard dependencies[singleton: .fileManager].fileExists(atPath: path) else { - throw MediaError.failure(description: "Media file missing.") - } - guard Data.isValidImage(at: path, type: UTType(sessionMimeType: type), using: dependencies) else { - throw MediaError.failure(description: "Invalid image.") - } - guard let originalImage = UIImage(contentsOfFile: path) else { - throw MediaError.failure(description: "Could not load original image.") - } - guard let thumbnailImage = originalImage.resized(maxDimensionPoints: maxDimension) else { - throw MediaError.failure(description: "Could not thumbnail image.") - } - return thumbnailImage - } - - public static func thumbnail(forVideoAtPath path: String, maxDimension: CGFloat, using dependencies: Dependencies) throws -> UIImage { - Log.verbose(.media, "Thumbnailing video: \(path)") - - guard isVideoOfValidContentTypeAndSize(path: path, using: dependencies) else { - throw MediaError.failure(description: "Media file has missing or invalid length.") - } - - let maxSize = CGSize(width: maxDimension, height: maxDimension) - let url = URL(fileURLWithPath: path) - let asset = AVURLAsset(url: url, options: nil) - guard isValidVideo(asset: asset) else { - throw MediaError.failure(description: "Invalid video.") - } - - let generator = AVAssetImageGenerator(asset: asset) - generator.maximumSize = maxSize - generator.appliesPreferredTrackTransform = true - let time: CMTime = CMTimeMake(value: 1, timescale: 60) - let cgImage = try generator.copyCGImage(at: time, actualTime: nil) - let image = UIImage(cgImage: cgImage) - return image - } - - public static func isValidVideo(path: String, using dependencies: Dependencies) -> Bool { - guard isVideoOfValidContentTypeAndSize(path: path, using: dependencies) else { - Log.error(.media, "Media file has missing or invalid length.") - return false - } - let url = URL(fileURLWithPath: path) - let asset = AVURLAsset(url: url, options: nil) - return isValidVideo(asset: asset) - } - - private static func isVideoOfValidContentTypeAndSize(path: String, using dependencies: Dependencies) -> Bool { + public static func isVideoOfValidContentTypeAndSize(path: String, type: String?, using dependencies: Dependencies) -> Bool { guard dependencies[singleton: .fileManager].fileExists(atPath: path) else { Log.error(.media, "Media file missing.") return false } - let fileExtension = URL(fileURLWithPath: path).pathExtension - guard let contentType: String = UTType.sessionMimeType(for: fileExtension) else { - Log.error(.media, "Media file has unknown content type.") - return false - } - guard UTType.isVideo(contentType) else { + guard let type: String = type, UTType.isVideo(type) else { Log.error(.media, "Media file has invalid content type.") return false } @@ -102,7 +45,7 @@ public enum MediaUtils { return UInt(fileSize) <= SNUtilitiesKit.maxFileSize } - private static func isValidVideo(asset: AVURLAsset) -> Bool { + public static func isValidVideo(asset: AVURLAsset) -> Bool { var maxTrackSize = CGSize.zero for track: AVAssetTrack in asset.tracks(withMediaType: .video) { let trackSize: CGSize = track.naturalSize @@ -119,4 +62,22 @@ public enum MediaUtils { } return true } + + /// Use `isValidVideo(asset: AVURLAsset)` if the `AVURLAsset` needs to be generated elsewhere in the code, + /// otherwise this will be inefficient as it can create a temporary file for the `AVURLAsset` on old iOS versions + public static func isValidVideo(path: String, mimeType: String?, sourceFilename: String?, using dependencies: Dependencies) -> Bool { + guard + let assetInfo: (asset: AVURLAsset, cleanup: () -> Void) = AVURLAsset.asset( + for: path, + mimeType: mimeType, + sourceFilename: sourceFilename, + using: dependencies + ) + else { return false } + + let result: Bool = isValidVideo(asset: assetInfo.asset) + assetInfo.cleanup() + + return result + } } diff --git a/SessionUtilitiesKit/Media/UTType+Utilities.swift b/SessionUtilitiesKit/Media/UTType+Utilities.swift index 18ca7f2b9d..f9f8684b1f 100644 --- a/SessionUtilitiesKit/Media/UTType+Utilities.swift +++ b/SessionUtilitiesKit/Media/UTType+Utilities.swift @@ -106,6 +106,15 @@ public extension UTType { var isText: Bool { UTType.supportedTextTypes.contains(self) } var isMicrosoftDoc: Bool { UTType.supportedMicrosoftDocTypes.contains(self) } var isVisualMedia: Bool { isImage || isVideo || isAnimated } + var sessionMimeType: String? { + guard + let mimeType: String = preferredMIMEType, + let fileExtension: String = UTType.genericMimeTypesToExtensionTypes[mimeType], + let targetMimeType: String = UTType.genericExtensionTypesToMimeTypes[fileExtension] + else { return preferredMIMEType } + + return targetMimeType + } // MARK: - Initialization diff --git a/SessionUtilitiesKit/Observations/DebounceTaskManager.swift b/SessionUtilitiesKit/Observations/DebounceTaskManager.swift new file mode 100644 index 0000000000..6c8963af29 --- /dev/null +++ b/SessionUtilitiesKit/Observations/DebounceTaskManager.swift @@ -0,0 +1,78 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public actor DebounceTaskManager { + private let debounceInterval: DispatchTimeInterval + private var debounceTask: Task? = nil + private var pendingEvents: [Event] = [] + private var pendingEventSet: Set = [] + private var action: (@Sendable ([Event]) async -> Void)? + + public init(debounceInterval: DispatchTimeInterval) { + self.debounceInterval = debounceInterval + } + + public func setAction(_ newAction: @Sendable @escaping ([Event]) async -> Void) { + self.action = newAction + } + + public func reset() { + debounceTask?.cancel() + debounceTask = nil + pendingEvents.removeAll() + pendingEventSet.removeAll() + action = nil + } + + // MARK: - Internal Functions + + fileprivate func scheduleSignal() { + debounceTask?.cancel() + debounceTask = Task { [weak self] in + guard let self = self else { return } + + do { + try await Task.sleep(for: self.debounceInterval) + guard !Task.isCancelled else { return } + + let eventsToProcess: [Event] = await self.clearPendingEvents() + await self.action?(eventsToProcess) + } catch { + // Task was cancelled so no need to do anything + } + } + } + + private func clearPendingEvents() -> [Event] { + let events: [Event] = pendingEvents + pendingEvents.removeAll() + pendingEventSet.removeAll() + return events + } +} + +public extension DebounceTaskManager where Event == Void { + func signal() { + pendingEvents.append(()) + scheduleSignal() + } +} + +public extension DebounceTaskManager { + func signal(event: Event) { + pendingEvents.append(event) + scheduleSignal() + } +} + +public extension DebounceTaskManager where Event: Hashable { + func signal(event: Event) { + /// Ignore duplicate events + guard !pendingEventSet.contains(event) else { return } + + pendingEvents.append(event) + pendingEventSet.insert(event) + scheduleSignal() + } +} diff --git a/SessionUtilitiesKit/Observations/ObservableKey.swift b/SessionUtilitiesKit/Observations/ObservableKey.swift new file mode 100644 index 0000000000..065514acfc --- /dev/null +++ b/SessionUtilitiesKit/Observations/ObservableKey.swift @@ -0,0 +1,34 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public struct GenericObservableKey: Setting.Key, Sendable { + public let rawValue: String + public init(_ rawValue: String) { self.rawValue = rawValue } + public init(_ original: ObservableKey) { self.rawValue = original.rawValue } +} + +public struct ObservableKey: Setting.Key, Sendable { + public let rawValue: String + public let generic: GenericObservableKey + + public init(_ rawValue: String) { + self.rawValue = rawValue + self.generic = GenericObservableKey(rawValue) + } + + public init(_ rawValue: String, _ generic: GenericObservableKey?) { + self.rawValue = rawValue + self.generic = (generic ?? GenericObservableKey(rawValue)) + } +} + +public struct ObservedEvent: Hashable { + public let key: ObservableKey + public let value: AnyHashable? + + public init(key: ObservableKey, value: AnyHashable?) { + self.key = key + self.value = value + } +} diff --git a/SessionUtilitiesKit/Observations/ObservationBuilder.swift b/SessionUtilitiesKit/Observations/ObservationBuilder.swift new file mode 100644 index 0000000000..440f6d0a3a --- /dev/null +++ b/SessionUtilitiesKit/Observations/ObservationBuilder.swift @@ -0,0 +1,202 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine + +// MARK: - ObservableKeyProvider + +public protocol ObservableKeyProvider: Sendable, Equatable { + var observedKeys: Set { get } +} + +// MARK: - ObservationBuilder DSL + +public enum ObservationBuilder { + public static func debounce(for interval: DispatchTimeInterval) -> ObservationDebounceBuilder { + return ObservationDebounceBuilder(debounceInterval: interval) + } + + public static func using(manager: ObservationManager) -> ObservationManagerBuilder { + return ObservationManagerBuilder( + debounceInterval: .milliseconds(250), + observationManager: manager + ) + } +} + +public struct ObservationDebounceBuilder { + fileprivate let debounceInterval: DispatchTimeInterval + + public func using(manager: ObservationManager) -> ObservationManagerBuilder { + return ObservationManagerBuilder( + debounceInterval: debounceInterval, + observationManager: manager + ) + } +} + +public struct ObservationManagerBuilder { + fileprivate let debounceInterval: DispatchTimeInterval + fileprivate let observationManager: ObservationManager + + public func query( + _ query: @escaping (_ previousValue: Output?, _ events: [ObservedEvent]) async -> Output + ) -> ConfiguredObservationBuilder { + return ConfiguredObservationBuilder( + debounceInterval: debounceInterval, + observationManager: observationManager, + query: query + ) + } +} + +// MARK: - ConfiguredObservationBuilder + +public struct ConfiguredObservationBuilder { + fileprivate let debounceInterval: DispatchTimeInterval + fileprivate let observationManager: ObservationManager + fileprivate let query: (_ previousValue: Output?, _ events: [ObservedEvent]) async -> Output + + // MARK: - Outputs + + public func stream() -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream(of: Output.self) + let runner: QueryRunner = QueryRunner( + observationManager: observationManager, + debounceInterval: debounceInterval, + continuation: continuation, + query: query + ) + let observationTask: Task = Task.detached { + await runner.run() + } + + continuation.onTermination = { @Sendable _ in + observationTask.cancel() + } + + return stream + } + + public func publisher(initialValue: Output) -> AnyPublisher { + let stream: AsyncStream = stream() + let subject: CurrentValueSubject = CurrentValueSubject(initialValue) + let streamConsumingTask: Task = Task { + for await value in stream { + if Task.isCancelled { break } + subject.send(value) + } + } + + /// When the publisher subscription is cancelled, we cancel the task that's consuming the stream + return subject.handleEvents( + receiveCancel: { + streamConsumingTask.cancel() + } + ).eraseToAnyPublisher() + } + + public func assign(using update: @escaping @MainActor (Output) -> Void) -> Task { + let stream: AsyncStream = stream() + + return Task { + for await value in stream { + if Task.isCancelled { break } + + await update(value) + } + } + } +} + +// MARK: - QueryRunner + +private actor QueryRunner { + private let observationManager: ObservationManager + private let debouncer: DebounceTaskManager + private let continuation: AsyncStream.Continuation + private let query: (_ previousValue: Output?, _ events: [ObservedEvent]) async -> Output + + private var activeKeys: Set = [] + private var listenerTask: Task? + private var lastValue: Output? + + // MARK: - Initialization + + init( + observationManager: ObservationManager, + debounceInterval: DispatchTimeInterval, + continuation: AsyncStream.Continuation, + query: @escaping (_ previousValue: Output?, _ events: [ObservedEvent]) async -> Output + ) { + self.query = query + self.observationManager = observationManager + self.continuation = continuation + self.debouncer = DebounceTaskManager(debounceInterval: debounceInterval) + } + + // MARK: - Functions + + func run() async { + /// Setup the debouncer to trigger a requery when events come through + await debouncer.setAction { [weak self] events in + await self?.requery(changes: events) + } + + /// Perform initial query + await requery(changes: []) + + /// Keep the `QueryRunner` alive until it's parent task is cancelled + await TaskCancellation.wait() + } + + private func requery(changes: [ObservedEvent]) async { + let previousValue: Output? = self.lastValue + + /// Capture the updated data and new keys to observe + let newResult: Output = await self.query(previousValue, changes) + let newKeys: Set = newResult.observedKeys + + /// If the keys have changed then we need to restart the observation + if newKeys != activeKeys { + let oldListenerTask: Task? = self.listenerTask + + listenerTask = Task { [weak self] in + await self?.observe(keys: newKeys) + } + activeKeys = newKeys + oldListenerTask?.cancel() + } + + /// Prevent redundant updates if the output hasn't changed. + guard newResult != previousValue else { return } + + /// Publish the new result + self.lastValue = newResult + continuation.yield(newResult) + } + + private func observe(keys: Set) async { + await withTaskGroup(of: Void.self) { group in + for key in keys { + group.addTask { [weak self] in + guard let self = self else { return } + + do { + let stream = await self.observationManager.observe(key) + + for await event in stream { + try Task.checkCancellation() + await self.debouncer.signal(event: event) + } + } + catch { + // A CancellationError could be thrown here but we just ignore it because + // it'll generally just be the result of observing a new set of keys while + // there are pending changes in the debouncer + } + } + } + } + } +} diff --git a/SessionUtilitiesKit/Observations/ObservationManager.swift b/SessionUtilitiesKit/Observations/ObservationManager.swift new file mode 100644 index 0000000000..0f02b8f15a --- /dev/null +++ b/SessionUtilitiesKit/Observations/ObservationManager.swift @@ -0,0 +1,68 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +// MARK: - Singleton + +public extension Singleton { + static let observationManager: SingletonConfig = Dependencies.create( + identifier: "observationManager", + createInstance: { dependencies in ObservationManager() } + ) +} + +// MARK: - ObservationManager + +public actor ObservationManager { + private var store: [ObservableKey: [UUID: AsyncStream.Continuation]] = [:] + + deinit { + store.values.forEach { $0.values.forEach { $0.finish() } } + } + + // MARK: - Functions + + public func observe(_ key: ObservableKey) -> AsyncStream { + let id: UUID = UUID() + + return AsyncStream { continuation in + Task { self.addContinuation(continuation, for: key, id: id) } + + continuation.onTermination = { _ in + Task { await self.removeContinuation(for: key, id: id) } + } + } + } + + public func notify(_ changes: [ObservedEvent]) async { + changes.forEach { event in + store[event.key]?.values.forEach { $0.yield(event) } + } + } + + // MARK: - Internal Functions + + private func addContinuation(_ continuation: AsyncStream.Continuation, for key: ObservableKey, id: UUID) { + store[key, default: [:]][id] = continuation + } + + private func removeContinuation(for key: ObservableKey, id: UUID) { + store[key]?.removeValue(forKey: id) + + if store[key]?.isEmpty == true { + store.removeValue(forKey: key) + } + } +} + +// MARK: - Convenience + +public extension ObservationManager { + func notify(_ change: ObservedEvent) async { + await notify([change]) + } + + func notify(_ key: ObservableKey, value: AnyHashable? = nil) async { + await notify([ObservedEvent(key: key, value: value)]) + } +} diff --git a/SessionUtilitiesKit/Observations/TaskCancellation.swift b/SessionUtilitiesKit/Observations/TaskCancellation.swift new file mode 100644 index 0000000000..4f4f4f2766 --- /dev/null +++ b/SessionUtilitiesKit/Observations/TaskCancellation.swift @@ -0,0 +1,11 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +/// This is a convenience method to force an async process to run indefinitely until it's parent task gets cancelled +public enum TaskCancellation { + public static func wait() async { + let (stream, _) = AsyncStream.makeStream(of: Void.self) + for await _ in stream {} + } +} diff --git a/SessionUtilitiesKit/Types/BencodeDecoder.swift b/SessionUtilitiesKit/Types/BencodeDecoder.swift index 9aa56138b5..6db7fe98bd 100644 --- a/SessionUtilitiesKit/Types/BencodeDecoder.swift +++ b/SessionUtilitiesKit/Types/BencodeDecoder.swift @@ -309,13 +309,11 @@ extension _BencodeDecoder.KeyedContainer: KeyedDecodingContainerProtocol { func contains(_ key: Key) -> Bool { nestedContainers.keys.contains(key.stringValue) } func decodeNil(forKey key: Key) throws -> Bool { - throw DecodingError.typeMismatch( - Any?.self, - DecodingError.Context( - codingPath: codingPath, - debugDescription: "cannot decode nil for key: \(key) (Null values are not supported)" - ) - ) + /// In Bencode, if a key is present, its value is never `nil` + /// + /// If `decodeIfPresent` calls this, it means `contains(key)` was true (the key is present and has a non-nil value) so + /// we should just return `false` + return false } func decode(_ type: T.Type, forKey key: Key) throws -> T where T: Decodable { @@ -443,13 +441,11 @@ extension _BencodeDecoder.UnkeyedContainer: _BencodeDecodingContainer {} extension _BencodeDecoder.UnkeyedContainer: UnkeyedDecodingContainer { func decodeNil() throws -> Bool { - throw DecodingError.typeMismatch( - Any?.self, - DecodingError.Context( - codingPath: codingPath, - debugDescription: "cannot decode nil for index: \(currentIndex) (Null values are not supported)" - ) - ) + /// In Bencode, if a key is present, its value is never `nil` + /// + /// If `decodeIfPresent` calls this, it means `contains(key)` was true (the key is present and has a non-nil value) so + /// we should just return `false` + return false } func decode(_ type: T.Type) throws -> T where T: Decodable { @@ -538,7 +534,7 @@ extension _BencodeDecoder { extension _BencodeDecoder.SingleValueContainer: _BencodeDecodingContainer {} extension _BencodeDecoder.SingleValueContainer: SingleValueDecodingContainer { - func decodeNil() -> Bool { return true } // Nil values are omitted in Bencoded data + func decodeNil() -> Bool { return false } // Nil values are omitted in Bencoded data func decode(_ type: Bool.Type) throws -> Bool { throw DecodingError.typeMismatch( diff --git a/SessionUtilitiesKit/Types/CurrentValueAsyncStream.swift b/SessionUtilitiesKit/Types/CurrentValueAsyncStream.swift new file mode 100644 index 0000000000..26c6b9245e --- /dev/null +++ b/SessionUtilitiesKit/Types/CurrentValueAsyncStream.swift @@ -0,0 +1,35 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public actor CurrentValueAsyncStream { + private var _currentValue: Element + private let continuation: AsyncStream.Continuation + public let stream: AsyncStream + + public var currentValue: Element { _currentValue } + + // MARK: - Initialization + + public init(_ initialValue: Element) { + self._currentValue = initialValue + + /// We use `.bufferingNewest(1)` to ensure that the stream always holds the most recent value. When a new iterator is + /// created for the stream, it will receive this buffered value first. + let (stream, continuation) = AsyncStream.makeStream(of: Element.self, bufferingPolicy: .bufferingNewest(1)) + self.stream = stream + self.continuation = continuation + self.continuation.yield(initialValue) + } + + // MARK: - Functions + + public func send(_ newValue: Element) { + _currentValue = newValue + continuation.yield(newValue) + } + + public func finish() { + continuation.finish() + } +} diff --git a/SessionUtilitiesKit/Types/EquatableIgnoring.swift b/SessionUtilitiesKit/Types/EquatableIgnoring.swift deleted file mode 100644 index 644c0b8329..0000000000 --- a/SessionUtilitiesKit/Types/EquatableIgnoring.swift +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -public struct EquatableIgnoring: Equatable { - public let value: T - - public init(value: T) { - self.value = value - } - - public static func == (lhs: EquatableIgnoring, rhs: EquatableIgnoring) -> Bool { - return true - } -} diff --git a/SessionUtilitiesKit/Types/FileManager.swift b/SessionUtilitiesKit/Types/FileManager.swift index e77f92853d..d1193a467f 100644 --- a/SessionUtilitiesKit/Types/FileManager.swift +++ b/SessionUtilitiesKit/Types/FileManager.swift @@ -17,6 +17,7 @@ public extension Singleton { public protocol FileManagerType { var temporaryDirectory: String { get } + var documentsDirectoryPath: String { get } var appSharedDataDirectoryPath: String { get } var temporaryDirectoryAccessibleAfterFirstAuth: String { get } @@ -47,12 +48,16 @@ public protocol FileManagerType { func contents(atPath: String) -> Data? func contentsOfDirectory(at url: URL) throws -> [URL] func contentsOfDirectory(atPath path: String) throws -> [String] + func isDirectoryEmpty(at url: URL) -> Bool + func isDirectoryEmpty(atPath path: String) -> Bool func createFile(atPath: String, contents: Data?, attributes: [FileAttributeKey: Any]?) -> Bool func createDirectory(at url: URL, withIntermediateDirectories: Bool, attributes: [FileAttributeKey: Any]?) throws func createDirectory(atPath: String, withIntermediateDirectories: Bool, attributes: [FileAttributeKey: Any]?) throws func copyItem(atPath: String, toPath: String) throws func copyItem(at fromUrl: URL, to toUrl: URL) throws + func moveItem(atPath: String, toPath: String) throws + func moveItem(at fromUrl: URL, to toUrl: URL) throws func removeItem(atPath: String) throws func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] @@ -122,6 +127,11 @@ public class SessionFileManager: FileManagerType { private let fileManager: FileManager = .default public var temporaryDirectory: String + public var documentsDirectoryPath: String { + return (fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.path) + .defaulting(to: "") + } + public var appSharedDataDirectoryPath: String { return (fileManager.containerURL(forSecurityApplicationGroupIdentifier: UserDefaults.applicationGroup)?.path) .defaulting(to: "") @@ -288,6 +298,22 @@ public class SessionFileManager: FileManagerType { public func contentsOfDirectory(atPath path: String) throws -> [String] { return try fileManager.contentsOfDirectory(atPath: path) } + + public func isDirectoryEmpty(at url: URL) -> Bool { + guard + let enumerator = fileManager.enumerator( + at: url, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { return false } + + /// If `nextObject()` returns `nil` immediately, there were no items + return enumerator.nextObject() == nil + } + + public func isDirectoryEmpty(atPath path: String) -> Bool { + return isDirectoryEmpty(at: URL(fileURLWithPath: path)) + } public func createFile(atPath: String, contents: Data?, attributes: [FileAttributeKey: Any]?) -> Bool { return fileManager.createFile(atPath: atPath, contents: contents, attributes: attributes) @@ -317,6 +343,14 @@ public class SessionFileManager: FileManagerType { return try fileManager.copyItem(at: fromUrl, to: toUrl) } + public func moveItem(atPath: String, toPath: String) throws { + try fileManager.moveItem(atPath: atPath, toPath: toPath) + } + + public func moveItem(at fromUrl: URL, to toUrl: URL) throws { + try fileManager.moveItem(at: fromUrl, to: toUrl) + } + public func removeItem(atPath: String) throws { return try fileManager.removeItem(atPath: atPath) } diff --git a/SessionUtilitiesKit/Types/KeychainStorage.swift b/SessionUtilitiesKit/Types/KeychainStorage.swift index f9e60fbb2a..49a62e3e14 100644 --- a/SessionUtilitiesKit/Types/KeychainStorage.swift +++ b/SessionUtilitiesKit/Types/KeychainStorage.swift @@ -2,7 +2,7 @@ // // stringlint:disable -import Foundation +import UIKit import KeychainSwift // MARK: - Singleton @@ -10,7 +10,7 @@ import KeychainSwift public extension Singleton { static let keychain: SingletonConfig = Dependencies.create( identifier: "keychain", - createInstance: { _ in KeychainStorage() } + createInstance: { dependencies in KeychainStorage(using: dependencies) } ) } @@ -23,11 +23,15 @@ public extension Log.Category { // MARK: - KeychainStorageError public enum KeychainStorageError: Error { + case keySpecInvalid + case keySpecCreationFailed + case keySpecInaccessible case failure(code: Int32?, logCategory: Log.Category, description: String) public var code: Int32? { switch self { case .failure(let code, _, _): return code + default: return nil } } } @@ -46,11 +50,35 @@ public protocol KeychainStorageType: AnyObject { func removeAll() throws func migrateLegacyKeyIfNeeded(legacyKey: String, legacyService: String?, toKey key: KeychainStorage.DataKey) throws + @discardableResult func getOrGenerateEncryptionKey( + forKey key: KeychainStorage.DataKey, + length: Int, + cat: Log.Category, + legacyKey: String?, + legacyService: String? + ) throws -> Data +} + +public extension KeychainStorageType { + @discardableResult func getOrGenerateEncryptionKey( + forKey key: KeychainStorage.DataKey, + length: Int, + cat: Log.Category + ) throws -> Data { + return try getOrGenerateEncryptionKey( + forKey: key, + length: length, + cat: cat, + legacyKey: nil, + legacyService: nil + ) + } } // MARK: - KeychainStorage public class KeychainStorage: KeychainStorageType { + private let dependencies: Dependencies private let keychain: KeychainSwift = { let result: KeychainSwift = KeychainSwift() result.synchronizable = false // This is the default but better to be explicit @@ -58,6 +86,14 @@ public class KeychainStorage: KeychainStorageType { return result }() + // MARK: - Initialization + + init(using dependencies: Dependencies) { + self.dependencies = dependencies + } + + // MARK: - Functions + public func string(forKey key: KeychainStorage.StringKey) throws -> String { guard let result: String = keychain.get(key.rawValue) else { throw KeychainStorageError.failure( @@ -170,6 +206,62 @@ public class KeychainStorage: KeychainStorageType { // Remove the data from the old location SecItemDelete(query as CFDictionary) } + + @discardableResult public func getOrGenerateEncryptionKey( + forKey key: KeychainStorage.DataKey, + length: Int, + cat: Log.Category, + legacyKey: String?, + legacyService: String? + ) throws -> Data { + do { + if let legacyKey: String = legacyKey { + try? migrateLegacyKeyIfNeeded( + legacyKey: legacyKey, + legacyService: legacyService, + toKey: key + ) + } + + var encryptionKey: Data = try data(forKey: key) + defer { encryptionKey.resetBytes(in: 0.. { + private var tasks: [Task] = [] + + public init() {} + + public func add(_ task: Task) { + tasks.append(task) + } + + public func cancelAll() { + tasks.forEach { $0.cancel() } + tasks.removeAll() + } +} diff --git a/SessionUtilitiesKit/Types/UserDefaultsType.swift b/SessionUtilitiesKit/Types/UserDefaultsType.swift index 291c78b7bc..d4b3c1c6ae 100644 --- a/SessionUtilitiesKit/Types/UserDefaultsType.swift +++ b/SessionUtilitiesKit/Types/UserDefaultsType.swift @@ -37,9 +37,18 @@ public protocol UserDefaultsType: AnyObject { func set(_ value: Bool, forKey defaultName: String) func set(_ url: URL?, forKey defaultName: String) + func removeObject(forKey defaultName: String) func removeAll() } +public extension UserDefaultsType { + func removeObject(forKey key: UserDefaults.BoolKey) { self.removeObject(forKey: key.rawValue) } + func removeObject(forKey key: UserDefaults.DateKey) { self.removeObject(forKey: key.rawValue) } + func removeObject(forKey key: UserDefaults.DoubleKey) { self.removeObject(forKey: key.rawValue) } + func removeObject(forKey key: UserDefaults.IntKey) { self.removeObject(forKey: key.rawValue) } + func removeObject(forKey key: UserDefaults.StringKey) { self.removeObject(forKey: key.rawValue) } +} + extension UserDefaults: UserDefaultsType {} // MARK: - Convenience @@ -77,9 +86,6 @@ public extension UserDefaults { // MARK: - UserDefault Values public extension UserDefaults.BoolKey { - /// Indicates whether the user has synced an initial config message from this device - static let hasSyncedInitialConfiguration: UserDefaults.BoolKey = "hasSyncedConfiguration" - /// Indicates whether the user has seen the suggestion to enable link previews static let hasSeenLinkPreviewSuggestion: UserDefaults.BoolKey = "hasSeenLinkPreviewSuggestion" @@ -143,6 +149,9 @@ public extension UserDefaults.IntKey { /// The latest softfork value returned when interacting with a service node static let softfork: UserDefaults.IntKey = "softfork" + + /// The id of the message that was just shared to + static let lastSharedMessageId: UserDefaults.IntKey = "lastSharedMessageId" } public extension UserDefaults.StringKey { @@ -151,6 +160,9 @@ public extension UserDefaults.StringKey { /// The warning to show at the top of the app static let topBannerWarningToShow: UserDefaults.StringKey = "topBannerWarningToShow" + + /// The id of the thread that a message was just shared to + static let lastSharedThreadId: UserDefaults.StringKey = "lastSharedThreadId" } // MARK: - Keys diff --git a/SessionUtilitiesKit/Utilities/AVURLAsset+Utilities.swift b/SessionUtilitiesKit/Utilities/AVURLAsset+Utilities.swift new file mode 100644 index 0000000000..962970910e --- /dev/null +++ b/SessionUtilitiesKit/Utilities/AVURLAsset+Utilities.swift @@ -0,0 +1,71 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import AVFoundation + +public extension AVURLAsset { + static func asset(for path: String, mimeType: String?, sourceFilename: String?, using dependencies: Dependencies) -> (asset: AVURLAsset, cleanup: () -> Void)? { + if #available(iOS 17.0, *) { + /// Since `mimeType` can be null we need to try to resolve it to a value + let finalMimeType: String + + switch (mimeType, sourceFilename) { + case (.none, .none): return nil + case (.some(let mimeType), _): finalMimeType = mimeType + case (.none, .some(let sourceFilename)): + guard + let type: UTType = UTType( + sessionFileExtension: URL(fileURLWithPath: sourceFilename).pathExtension + ), + let mimeType: String = type.sessionMimeType + else { return nil } + + finalMimeType = mimeType + } + + return ( + AVURLAsset( + url: URL(fileURLWithPath: path), + options: [AVURLAssetOverrideMIMETypeKey: finalMimeType] + ), + {} + ) + } + else { + /// Since `mimeType` and/or `sourceFilename` can be null we need to try to resolve them both to values + let finalExtension: String + + switch (mimeType, sourceFilename) { + case (.none, .none): return nil + case (.none, .some(let sourceFilename)): + guard + let type: UTType = UTType( + sessionFileExtension: URL(fileURLWithPath: sourceFilename).pathExtension + ), + let fileExtension: String = type.sessionFileExtension(sourceFilename: sourceFilename) + else { return nil } + + finalExtension = fileExtension + + case (.some(let mimeType), let sourceFilename): + guard + let fileExtension: String = UTType(sessionMimeType: mimeType)? + .sessionFileExtension(sourceFilename: sourceFilename) + else { return nil } + + finalExtension = fileExtension + } + + let tmpPath: String = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(URL(fileURLWithPath: path).lastPathComponent) + .appendingPathExtension(finalExtension) + .path + + try? dependencies[singleton: .fileManager].copyItem(atPath: path, toPath: tmpPath) + + return ( + AVURLAsset(url: URL(fileURLWithPath: tmpPath), options: nil), + { [dependencies] in try? dependencies[singleton: .fileManager].removeItem(atPath: tmpPath) } + ) + } + } +} diff --git a/SessionUtilitiesKit/Utilities/AsyncSequence+Utilities.swift b/SessionUtilitiesKit/Utilities/AsyncSequence+Utilities.swift new file mode 100644 index 0000000000..3992824965 --- /dev/null +++ b/SessionUtilitiesKit/Utilities/AsyncSequence+Utilities.swift @@ -0,0 +1,30 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +public extension AsyncSequence where Element: Equatable { + func removeDuplicates() -> AsyncThrowingStream { + return AsyncThrowingStream { continuation in + let task = Task { + var previousElement: Element? = nil + + do { + for try await element in self { + if Task.isCancelled { break } + + if element != previousElement { + continuation.yield(element) + previousElement = element + } + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } +} diff --git a/SessionUtilitiesKit/Utilities/Combine+Utilities.swift b/SessionUtilitiesKit/Utilities/Combine+Utilities.swift new file mode 100644 index 0000000000..b25abe864f --- /dev/null +++ b/SessionUtilitiesKit/Utilities/Combine+Utilities.swift @@ -0,0 +1,13 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine + +public extension Subscribers.Completion where Failure == Error { + var errorOrNull: Error? { + switch self { + case .finished: return nil + case .failure(let error): return error + } + } +} diff --git a/SessionUtilitiesKit/Utilities/Task+Utilities.swift b/SessionUtilitiesKit/Utilities/Task+Utilities.swift new file mode 100644 index 0000000000..2509ecd539 --- /dev/null +++ b/SessionUtilitiesKit/Utilities/Task+Utilities.swift @@ -0,0 +1,12 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension Task where Success == Never, Failure == Never { + /// Suspends the current task until the given deadline (compatibility version). + @available(iOS, introduced: 13.0, obsoleted: 16.0, message: "Use built-in Task.sleep(for:) accepting Swift.Duration on iOS 16+") + static func sleep(for interval: DispatchTimeInterval) async throws { + let nanosecondsToSleep: UInt64 = (UInt64(interval.milliseconds) * 1_000_000) + try await Task.sleep(nanoseconds: nanosecondsToSleep) + } +} diff --git a/SessionUtilitiesKit/Utilities/ThreadSafe.swift b/SessionUtilitiesKit/Utilities/ThreadSafe.swift index d53c9f34ea..e09e71ab3c 100644 --- a/SessionUtilitiesKit/Utilities/ThreadSafe.swift +++ b/SessionUtilitiesKit/Utilities/ThreadSafe.swift @@ -80,7 +80,11 @@ public class ThreadSafe { public final class ThreadSafeObject { private var value: Value private let lock: ReadWriteLock = ReadWriteLock() - @ThreadSafe private var mutationThreadId: UInt32? = nil + + /// Since this value is a `UInt32` it aligns with the size of a memory address and can't result in a "Torn Read" (which is where + /// a crash occurs when one thread reads while another thread is writing), this is because the data change is atomic at the hardware + /// level so the reader would always get either the value from before or after the write, and never a partial value + private var mutationThreadId: UInt32? = nil public var wrappedValue: Value { guard mutationThreadId != Thread.current.threadId else { return value } diff --git a/SessionUtilitiesKitTests/Database/Models/IdentitySpec.swift b/SessionUtilitiesKitTests/Database/Models/IdentitySpec.swift index c9aac99409..a6c60c58b8 100644 --- a/SessionUtilitiesKitTests/Database/Models/IdentitySpec.swift +++ b/SessionUtilitiesKitTests/Database/Models/IdentitySpec.swift @@ -56,19 +56,6 @@ class IdentitySpec: QuickSpec { .to(equal("Test6".data(using: .utf8)?.bytes)) } } - - // MARK: -- correctly determines if the user exists - it("correctly determines if the user exists") { - mockStorage.write { db in - try Identity(variant: .ed25519PublicKey, data: "Test3".data(using: .utf8)!).insert(db) - try Identity(variant: .ed25519SecretKey, data: "Test4".data(using: .utf8)!).insert(db) - } - - mockStorage.read { db in - expect(Identity.userExists(db, using: dependencies)) - .to(equal(true)) - } - } } } } diff --git a/SessionUtilitiesKitTests/General/GeneralCacheSpec.swift b/SessionUtilitiesKitTests/General/GeneralCacheSpec.swift new file mode 100644 index 0000000000..4eaa6a0480 --- /dev/null +++ b/SessionUtilitiesKitTests/General/GeneralCacheSpec.swift @@ -0,0 +1,104 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionUtilitiesKit + +class GeneralCacheSpec: QuickSpec { + 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)) + ) + ) + crypto + .when { $0.generate(.x25519(ed25519Pubkey: .any)) } + .thenReturn(Array(Data(hex: TestConstants.publicKey))) + } + ) + + // MARK: - a General Cache + describe("a General Cache") { + // MARK: -- starts with an invalid state + it("starts with an invalid state") { + let cache: General.Cache = General.Cache(using: dependencies) + + expect(cache.userExists).to(beFalse()) + expect(cache.sessionId).to(equal(.invalid)) + expect(cache.ed25519SecretKey).to(beEmpty()) + } + + // MARK: -- correctly indicates whether the user exists + it("correctly indicates whether the user exists") { + let cache: General.Cache = General.Cache(using: dependencies) + cache.setSecretKey(ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey))) + + expect(cache.userExists).to(beTrue()) + } + + // MARK: -- generates the correct sessionId + it("generates the correct sessionId") { + let cache: General.Cache = General.Cache(using: dependencies) + cache.setSecretKey(ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey))) + + expect(cache.sessionId).to(equal(SessionId(.standard, hex: TestConstants.publicKey))) + } + + // 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) + let cache: General.Cache = General.Cache(using: dependencies) + cache.setSecretKey(ed25519SecretKey: [1, 2, 3]) + + expect(cache.userExists).to(beFalse()) + expect(cache.sessionId).to(equal(.invalid)) + expect(cache.ed25519SecretKey).to(beEmpty()) + } + + // 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) + let cache: General.Cache = General.Cache(using: dependencies) + cache.setSecretKey(ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey))) + + expect(cache.userExists).to(beFalse()) + expect(cache.sessionId).to(equal(.invalid)) + expect(cache.ed25519SecretKey).to(beEmpty()) + } + + // 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) + let cache: General.Cache = General.Cache(using: dependencies) + cache.setSecretKey(ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey))) + + expect(cache.userExists).to(beFalse()) + expect(cache.sessionId).to(equal(.invalid)) + expect(cache.ed25519SecretKey).to(beEmpty()) + } + + // MARK: -- changes back to an invalid state if updated with an invalid value + it("changes back to an invalid state if updated with an invalid value") { + let cache: General.Cache = General.Cache(using: dependencies) + cache.setSecretKey(ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey))) + expect(cache.userExists).to(beTrue()) + + cache.setSecretKey(ed25519SecretKey: []) + expect(cache.userExists).to(beFalse()) + expect(cache.sessionId).to(equal(.invalid)) + expect(cache.ed25519SecretKey).to(beEmpty()) + } + } + } +} diff --git a/SessionUtilitiesKitTests/LibSession/Utilities/TypeConversionUtilitiesSpec.swift b/SessionUtilitiesKitTests/LibSession/Utilities/TypeConversionUtilitiesSpec.swift index c331fb4085..c3e22514d1 100644 --- a/SessionUtilitiesKitTests/LibSession/Utilities/TypeConversionUtilitiesSpec.swift +++ b/SessionUtilitiesKitTests/LibSession/Utilities/TypeConversionUtilitiesSpec.swift @@ -304,7 +304,7 @@ class TypeConversionUtilitiesSpec: QuickSpec { context("when initialised with a 2D C array") { // MARK: ---- returns the correct array it("returns the correct array") { - var test: [String] = ["Test1", "Test2", "Test3AndExtra"] + let test: [String] = ["Test1", "Test2", "Test3AndExtra"] let result = try! test.withUnsafeCStrArray { ptr in return [String](cStringArray: ptr.baseAddress, count: 3) @@ -314,7 +314,7 @@ class TypeConversionUtilitiesSpec: QuickSpec { // MARK: ---- returns an empty array if given one it("returns an empty array if given one") { - var test: [String] = [] + let test: [String] = [] let result = try! test.withUnsafeCStrArray { ptr in return [String](cStringArray: ptr.baseAddress, count: 0) @@ -324,7 +324,7 @@ class TypeConversionUtilitiesSpec: QuickSpec { // MARK: ---- handles empty strings without issues it("handles empty strings without issues") { - var test: [String] = ["Test1", "", "Test2"] + let test: [String] = ["Test1", "", "Test2"] let result = try! test.withUnsafeCStrArray { ptr in return [String](cStringArray: ptr.baseAddress, count: 3) @@ -342,7 +342,7 @@ class TypeConversionUtilitiesSpec: QuickSpec { // MARK: ---- returns null when given a null count it("returns null when given a null count") { - var test: [String] = ["Test1"] + let test: [String] = ["Test1"] let result = try! test.withUnsafeCStrArray { ptr in return [String](cStringArray: ptr.baseAddress, count: nil) diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift index 03f5edadb3..9120cb79b4 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift @@ -244,6 +244,9 @@ public class AttachmentPrepViewController: OWSViewController { @objc public func playButtonTapped() { guard let fileUrl: URL = attachment.dataUrl else { return Log.error(.media, "Missing video file") } + /// The `attachment` here is a `SignalAttachment` which is pointing to a file outside of the app (which would have a + /// proper file extension) so no need to create a temporary copy of the video, or clean it up by using our custom + /// `DismissCallbackAVPlayerViewController` callback logic let player: AVPlayer = AVPlayer(url: fileUrl) let viewController: AVPlayerViewController = AVPlayerViewController() viewController.player = player diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift index b1ddd4473b..8f0478b432 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift @@ -69,8 +69,14 @@ public class ImageEditorModel { Log.error("[ImageEditorModel] Invalid MIME type: \(type.preferredMIMEType ?? "unknown").") throw ImageEditorError.invalidInput } - - let srcImageSizePixels = Data.imageSize(for: srcImagePath, type: type, using: dependencies) + + let srcImageSizePixels = Data.mediaSize( + for: srcImagePath, + type: type, + mimeType: nil, + sourceFilename: srcFileName, + using: dependencies + ) guard srcImageSizePixels.width > 0, srcImageSizePixels.height > 0 else { Log.error("[ImageEditorModel] Couldn't determine image size.") throw ImageEditorError.invalidInput diff --git a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift index ac5b60e0fd..b05c930517 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift @@ -57,7 +57,7 @@ public class MediaMessageView: UIView { return nil }() - private lazy var duration: TimeInterval? = attachment.duration() + private lazy var duration: TimeInterval? = attachment.duration(using: dependencies) private var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)? // MARK: Initializers @@ -142,7 +142,7 @@ public class MediaMessageView: UIView { if let imageData: Data = validImageData, let dataUrl: URL = attachment.dataUrl { view.layer.minificationFilter = .trilinear view.layer.magnificationFilter = .trilinear - view.loadImage(identifier: dataUrl.absoluteString, from: imageData) + view.loadImage(.data(dataUrl.absoluteString, imageData)) } else { view.contentMode = .scaleAspectFit diff --git a/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift b/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift index 19375fa9a0..b081a21e9f 100644 --- a/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift +++ b/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift @@ -84,7 +84,7 @@ public class ModalActivityIndicatorViewController: OWSViewController { fatalError("init(coder:) has not been implemented") } - public required init( + @MainActor public required init( canCancel: Bool = false, message: String? = nil, onAppear: @escaping (ModalActivityIndicatorViewController) -> Void @@ -100,7 +100,7 @@ public class ModalActivityIndicatorViewController: OWSViewController { self.modalTransitionStyle = .crossDissolve } - public class func present( + @MainActor public class func present( fromViewController: UIViewController?, canCancel: Bool = false, message: String? = nil, @@ -108,8 +108,6 @@ public class ModalActivityIndicatorViewController: OWSViewController { ) { guard let fromViewController: UIViewController = fromViewController else { return } - Log.assertOnMainThread() - fromViewController.present( ModalActivityIndicatorViewController(canCancel: canCancel, message: message, onAppear: onAppear), animated: false @@ -210,43 +208,38 @@ public extension Publisher { return self.eraseToAnyPublisher() } - var modalActivityIndicator: ModalActivityIndicatorViewController? - - switch Thread.isMainThread { - case true: modalActivityIndicator = ModalActivityIndicatorViewController(onAppear: { _ in }) - case false: - DispatchQueue.main.sync { - modalActivityIndicator = ModalActivityIndicatorViewController(onAppear: { _ in }) + return Deferred { + Future { promise in + Task { @MainActor in + promise(.success(ModalActivityIndicatorViewController(onAppear: { _ in }))) } - + } } - - return self - .handleEvents( - receiveSubscription: { [weak viewController] _ in - guard let indicator: ModalActivityIndicatorViewController = modalActivityIndicator else { - return - } - - switch Thread.isMainThread { - case true: viewController?.present(indicator, animated: true) - case false: - DispatchQueue.main.async { - viewController?.present(indicator, animated: true) - } + .flatMap { indicator -> AnyPublisher in + self + .handleEvents( + receiveSubscription: { [weak viewController] _ in + switch Thread.isMainThread { + case true: viewController?.present(indicator, animated: true) + case false: + DispatchQueue.main.async { + viewController?.present(indicator, animated: true) + } + } } + ) + .asResult() + .flatMap { result -> AnyPublisher in + Deferred { + Future { resolver in + indicator.dismiss(completion: { + resolver(result) + }) + } + }.eraseToAnyPublisher() } - ) - .asResult() - .flatMap { result -> AnyPublisher in - Deferred { - Future { resolver in - modalActivityIndicator?.dismiss(completion: { - resolver(result) - }) - } - }.eraseToAnyPublisher() - } - .eraseToAnyPublisher() + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() } } diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift index cdd78c43c9..0238102468 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.swift +++ b/SignalUtilitiesKit/Utilities/AppSetup.swift @@ -72,39 +72,64 @@ public enum AppSetup { SNMessagingKit.self ]), onProgressUpdate: migrationProgressChanged, - onComplete: { result in + onComplete: { originalResult in // Now that the migrations are complete there are a few more states which need // to be setup - dependencies[singleton: .storage].read { db in - guard - Identity.userExists(db, using: dependencies), - let userKeyPair: KeyPair = Identity.fetchUserKeyPair(db) - else { return } - - let userSessionId: SessionId = SessionId(.standard, publicKey: userKeyPair.publicKey) - - /// Cache the users session id so we don't need to fetch it from the database every time - dependencies.mutate(cache: .general) { - $0.setCachedSessionId(sessionId: userSessionId) + typealias UserInfo = (sessionId: SessionId, ed25519SecretKey: [UInt8], 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, + 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 + ) + } + } + + /// 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 } } - - /// Load the `libSession` state into memory - let cache: LibSession.Cache = LibSession.Cache( - userSessionId: userSessionId, - using: dependencies - ) - cache.loadState(db, requestId: requestId) - dependencies.set(cache: .libSession, to: cache) - } - - /// Ensure any recurring jobs are properly scheduled - dependencies[singleton: .jobRunner].scheduleRecurringJobsIfNeeded() - - /// Callback that the migrations have completed - migrationsCompletion(result) - - /// The 'if' is only there to prevent the "variable never read" warning from showing - if backgroundTask != nil { backgroundTask = nil } + ) } ) } diff --git a/SignalUtilitiesKit/Utilities/ShareViewDelegate.swift b/SignalUtilitiesKit/Utilities/ShareViewDelegate.swift deleted file mode 100644 index 887dabcda8..0000000000 --- a/SignalUtilitiesKit/Utilities/ShareViewDelegate.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -import Foundation -// All Observer methods will be invoked from the main thread. -@objc -public protocol ShareViewDelegate: AnyObject { - func shareViewWasUnlocked() - func shareViewWasCompleted() - func shareViewWasCancelled() - func shareViewFailed(error: Error) -} diff --git a/_SharedTestUtilities/GRDBExtensions.swift b/_SharedTestUtilities/GRDBExtensions.swift index aae01cf3c7..29e2281825 100644 --- a/_SharedTestUtilities/GRDBExtensions.swift +++ b/_SharedTestUtilities/GRDBExtensions.swift @@ -7,7 +7,7 @@ import GRDB public extension MutablePersistableRecord where Self: MutableIdentifiable { /// This is a test method which allows for inserting with a pre-defined id - mutating func insert(_ db: Database, withRowId rowId: ID) throws { + 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 index 296b444e20..741cf8c1ad 100644 --- a/_SharedTestUtilities/Mock.swift +++ b/_SharedTestUtilities/Mock.swift @@ -1,8 +1,8 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import SessionUtilitiesKit import Combine +import SessionUtilitiesKit // MARK: - MockError @@ -12,12 +12,13 @@ public enum MockError: Error { // MARK: - Mock -public class Mock: DependenciesSettable { +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 @@ -27,7 +28,7 @@ public class Mock: DependenciesSettable { ) { self.functionConsumer = FunctionConsumer() self.functionHandler = (functionHandler ?? self.functionConsumer) - initialSetup?(self) + self.initialSetup = initialSetup } // MARK: - DependenciesSettable @@ -36,47 +37,70 @@ public class Mock: DependenciesSettable { self._dependencies = dependencies } + // MARK: - InitialSetupable + + func performInitialSetup() { + self.initialSetup?(self) + self.initialSetup = nil + } + // MARK: - MockFunctionHandler - @discardableResult internal func mock(funcName: String = #function, args: [Any?] = [], untrackedArgs: [Any?] = []) -> Output { + @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, args: [Any?] = [], untrackedArgs: [Any?] = []) { + 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, args: [Any?] = [], untrackedArgs: [Any?] = []) throws -> Output { + @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, args: [Any?] = [], untrackedArgs: [Any?] = []) throws { + 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 ) @@ -85,22 +109,24 @@ public class Mock: DependenciesSettable { // MARK: - Functions internal func reset() { - functionConsumer.trackCalls = true - functionConsumer.functionBuilders = [] - functionConsumer.functionHandlers = [:] - functionConsumer.clearCalls() + 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.functionBuilders.append(builder.build) + 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.functionBuilders.append(builder.build) + functionConsumer.addBuilder(builder.build) return builder } @@ -240,6 +266,7 @@ protocol MockFunctionHandler { parameterCount: Int, parameterSummary: String, allParameterSummaryCombinations: [ParameterCombination], + generics: [Any.Type], args: [Any?], untrackedArgs: [Any?] ) -> Output @@ -249,6 +276,7 @@ protocol MockFunctionHandler { parameterCount: Int, parameterSummary: String, allParameterSummaryCombinations: [ParameterCombination], + generics: [Any.Type], args: [Any?], untrackedArgs: [Any?] ) @@ -258,6 +286,7 @@ protocol MockFunctionHandler { parameterCount: Int, parameterSummary: String, allParameterSummaryCombinations: [ParameterCombination], + generics: [Any.Type], args: [Any?], untrackedArgs: [Any?] ) throws -> Output @@ -267,6 +296,7 @@ protocol MockFunctionHandler { parameterCount: Int, parameterSummary: String, allParameterSummaryCombinations: [ParameterCombination], + generics: [Any.Type], args: [Any?], untrackedArgs: [Any?] ) throws @@ -277,6 +307,11 @@ protocol MockFunctionHandler { 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 @@ -293,10 +328,13 @@ internal class MockFunction { var parameterCount: Int var parameterSummary: String var allParameterSummaryCombinations: [ParameterCombination] - var args: [Any?]? - var untrackedArgs: [Any?]? + 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?)? @@ -305,10 +343,13 @@ internal class MockFunction { 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?)? ) { @@ -316,8 +357,13 @@ internal class MockFunction { 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 } @@ -332,16 +378,19 @@ internal class MockFunctionBuilder: MockFunctionHandler { 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, Int, String, [ParameterCombination]) -> R?)? + internal var returnValueGenerator: ((String, [Any.Type], Int, String, [ParameterCombination]) -> R?)? // MARK: - Initialization @@ -358,18 +407,39 @@ internal class MockFunctionBuilder: MockFunctionHandler { 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 = closure + dynamicReturnValueRetriever = { args, untrackedArgs in + let result = closure(args, untrackedArgs) + (result as? (any InitialSetupable))?.performInitialSetup() + return result + } } func thenThrow(_ error: Error) { @@ -383,6 +453,7 @@ internal class MockFunctionBuilder: MockFunctionHandler { parameterCount: Int, parameterSummary: String, allParameterSummaryCombinations: [ParameterCombination], + generics: [Any.Type], args: [Any?], untrackedArgs: [Any?] ) -> Output { @@ -390,13 +461,14 @@ internal class MockFunctionBuilder: MockFunctionHandler { 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, parameterCount, parameterSummary, allParameterSummaryCombinations) + returnValueGenerator?(functionName, generics, parameterCount, parameterSummary, allParameterSummaryCombinations) ) switch result { @@ -418,6 +490,7 @@ internal class MockFunctionBuilder: MockFunctionHandler { parameterCount: Int, parameterSummary: String, allParameterSummaryCombinations: [ParameterCombination], + generics: [Any.Type], args: [Any?], untrackedArgs: [Any?] ) { @@ -425,6 +498,7 @@ internal class MockFunctionBuilder: MockFunctionHandler { self.parameterCount = parameterCount self.parameterSummary = parameterSummary self.allParameterSummaryCombinations = allParameterSummaryCombinations + self.generics = generics self.args = args self.untrackedArgs = untrackedArgs } @@ -434,6 +508,7 @@ internal class MockFunctionBuilder: MockFunctionHandler { parameterCount: Int, parameterSummary: String, allParameterSummaryCombinations: [ParameterCombination], + generics: [Any.Type], args: [Any?], untrackedArgs: [Any?] ) throws -> Output { @@ -441,6 +516,7 @@ internal class MockFunctionBuilder: MockFunctionHandler { self.parameterCount = parameterCount self.parameterSummary = parameterSummary self.allParameterSummaryCombinations = allParameterSummaryCombinations + self.generics = generics self.args = args self.untrackedArgs = untrackedArgs @@ -449,7 +525,7 @@ internal class MockFunctionBuilder: MockFunctionHandler { let result: Any? = ( returnValue ?? dynamicReturnValueRetriever?(args, untrackedArgs) ?? - returnValueGenerator?(functionName, parameterCount, parameterSummary, allParameterSummaryCombinations) + returnValueGenerator?(functionName, generics, parameterCount, parameterSummary, allParameterSummaryCombinations) ) switch result { @@ -471,6 +547,7 @@ internal class MockFunctionBuilder: MockFunctionHandler { parameterCount: Int, parameterSummary: String, allParameterSummaryCombinations: [ParameterCombination], + generics: [Any.Type], args: [Any?], untrackedArgs: [Any?] ) throws { @@ -478,6 +555,7 @@ internal class MockFunctionBuilder: MockFunctionHandler { self.parameterCount = parameterCount self.parameterSummary = parameterSummary self.allParameterSummaryCombinations = allParameterSummaryCombinations + self.generics = generics self.args = args self.untrackedArgs = untrackedArgs @@ -486,15 +564,33 @@ internal class MockFunctionBuilder: MockFunctionHandler { // MARK: - Build - func build() async throws -> MockFunction { + func build() throws -> MockFunction { var completionMock = mockInit(self, nil) as! T - _ = try? await callBlock(&completionMock) + 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..: MockFunctionHandler { parameterCount: parameterCount, parameterSummary: parameterSummary, allParameterSummaryCombinations: allParameterSummaryCombinations, + generics: generics, args: args, untrackedArgs: untrackedArgs, actions: actions, + asyncActions: asyncActions, returnError: returnError, + closureCallArgs: closureCallArgs, returnValue: returnValue, dynamicReturnValueRetriever: dynamicReturnValueRetriever.map { closure in { args, untrackedArgs in closure(args, untrackedArgs) } @@ -552,44 +651,57 @@ protocol DependenciesSettable { func setDependencies(_ dependencies: Dependencies?) } +// MARK: - InitialSetupable + +protocol InitialSetupable { + func performInitialSetup() +} + // MARK: - FunctionConsumer internal class FunctionConsumer: MockFunctionHandler { - struct Key: Equatable, Hashable { + 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 - var functionBuilders: [() async throws -> MockFunction?] = [] - var functionHandlers: [Key: [String: MockFunction]] = [:] + @ThreadSafeObject var functionBuilders: [() throws -> MockFunction?] = [] + @ThreadSafeObject var functionHandlers: [Key: [String: MockFunction]] = [:] @ThreadSafeObject var calls: [Key: [CallDetails]] = [:] - private func getExpectation( + 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, paramCount: parameterCount) + let key: Key = Key(name: functionName, generics: generics, paramCount: parameterCount) if !functionBuilders.isEmpty { functionBuilders - .compactMap { builder in - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - var result: MockFunction? - - Task { - result = try? await builder() - semaphore.signal() - } - semaphore.wait() - return result - } + .compactMap { builder in try? builder() } .forEach { function in - let key: Key = Key(name: function.name, paramCount: function.parameterCount) + 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 @@ -602,16 +714,45 @@ internal class FunctionConsumer: MockFunctionHandler { updatedHandlers[combination.summary] = function } - functionHandlers[key] = updatedHandlers + _functionHandlers.performUpdate { $0.setting(key, updatedHandlers) } } - functionBuilders.removeAll() + _functionBuilders.performUpdate { _ in [] } } - guard let expectation: MockFunction = firstFunction(for: key, matchingParameterSummaryIfPossible: parameterSummary, allParameterSummaryCombinations: allParameterSummaryCombinations) else { - preconditionFailure("No expectations found for \(functionName)") + 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 { @@ -628,6 +769,18 @@ internal class FunctionConsumer: MockFunctionHandler { 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 } @@ -636,14 +789,16 @@ internal class FunctionConsumer: MockFunctionHandler { parameterCount: Int, parameterSummary: String, allParameterSummaryCombinations: [ParameterCombination], + generics: [Any.Type], args: [Any?], untrackedArgs: [Any?] ) -> Output { - let expectation: MockFunction = getExpectation( + let expectation: MockFunction = getAndTrackExpectation( functionName, parameterCount: parameterCount, parameterSummary: parameterSummary, allParameterSummaryCombinations: allParameterSummaryCombinations, + generics: generics, args: args, untrackedArgs: untrackedArgs ) @@ -668,14 +823,16 @@ internal class FunctionConsumer: MockFunctionHandler { parameterCount: Int, parameterSummary: String, allParameterSummaryCombinations: [ParameterCombination], + generics: [Any.Type], args: [Any?], untrackedArgs: [Any?] ) { - _ = getExpectation( + _ = getAndTrackExpectation( functionName, parameterCount: parameterCount, parameterSummary: parameterSummary, allParameterSummaryCombinations: allParameterSummaryCombinations, + generics: generics, args: args, untrackedArgs: untrackedArgs ) @@ -686,14 +843,16 @@ internal class FunctionConsumer: MockFunctionHandler { parameterCount: Int, parameterSummary: String, allParameterSummaryCombinations: [ParameterCombination], + generics: [Any.Type], args: [Any?], untrackedArgs: [Any?] ) throws -> Output { - let expectation: MockFunction = getExpectation( + let expectation: MockFunction = getAndTrackExpectation( functionName, parameterCount: parameterCount, parameterSummary: parameterSummary, allParameterSummaryCombinations: allParameterSummaryCombinations, + generics: generics, args: args, untrackedArgs: untrackedArgs ) @@ -719,14 +878,16 @@ internal class FunctionConsumer: MockFunctionHandler { parameterCount: Int, parameterSummary: String, allParameterSummaryCombinations: [ParameterCombination], + generics: [Any.Type], args: [Any?], untrackedArgs: [Any?] ) throws { - let expectation: MockFunction = getExpectation( + let expectation: MockFunction = getAndTrackExpectation( functionName, parameterCount: parameterCount, parameterSummary: parameterSummary, allParameterSummaryCombinations: allParameterSummaryCombinations, + generics: generics, args: args, untrackedArgs: untrackedArgs ) @@ -768,6 +929,41 @@ internal class FunctionConsumer: MockFunctionHandler { 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: [:]) } diff --git a/_SharedTestUtilities/MockFileManager.swift b/_SharedTestUtilities/MockFileManager.swift index 61c9300981..2eb20cfd17 100644 --- a/_SharedTestUtilities/MockFileManager.swift +++ b/_SharedTestUtilities/MockFileManager.swift @@ -5,6 +5,7 @@ import SessionUtilitiesKit class MockFileManager: Mock, FileManagerType { var temporaryDirectory: String { mock() } + var documentsDirectoryPath: String { mock() } var appSharedDataDirectoryPath: String { mock() } var temporaryDirectoryAccessibleAfterFirstAuth: String { mock() } @@ -54,8 +55,10 @@ class MockFileManager: Mock, FileManagerType { } func contents(atPath: String) -> Data? { return mock(args: [atPath]) } - func contentsOfDirectory(at url: URL) throws -> [URL] { return mock(args: [url]) } - func contentsOfDirectory(atPath path: String) throws -> [String] { return mock(args: [path]) } + 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]) @@ -71,6 +74,8 @@ class MockFileManager: Mock, FileManagerType { 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 removeItem(atPath: String) throws { return try mockThrowing(args: [atPath]) } func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] { @@ -81,3 +86,31 @@ class MockFileManager: Mock, FileManagerType { 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.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 index 88a7cdd407..18d30ffe9f 100644 --- a/_SharedTestUtilities/MockGeneralCache.swift +++ b/_SharedTestUtilities/MockGeneralCache.swift @@ -4,11 +4,26 @@ 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]) } @@ -19,7 +34,7 @@ class MockGeneralCache: Mock, GeneralCacheType { set { mockNoReturn(args: [newValue]) } } - func setCachedSessionId(sessionId: SessionId) { - mockNoReturn(args: [sessionId]) + func setSecretKey(ed25519SecretKey: [UInt8]) { + mockNoReturn(args: [ed25519SecretKey]) } } diff --git a/_SharedTestUtilities/MockJobRunner.swift b/_SharedTestUtilities/MockJobRunner.swift index 728f68737f..7f6ccdb393 100644 --- a/_SharedTestUtilities/MockJobRunner.swift +++ b/_SharedTestUtilities/MockJobRunner.swift @@ -44,15 +44,15 @@ class MockJobRunner: Mock, JobRunnerType { // MARK: - Job Scheduling - @discardableResult func add(_ db: Database, job: Job?, dependantJob: Job?, canStartJob: Bool) -> Job? { + @discardableResult func add(_ db: ObservingDatabase, job: Job?, dependantJob: Job?, canStartJob: Bool) -> Job? { return mock(args: [job, dependantJob, canStartJob], untrackedArgs: [db]) } - func upsert(_ db: Database, job: Job?, canStartJob: Bool) -> Job? { + func upsert(_ db: ObservingDatabase, job: Job?, canStartJob: Bool) -> Job? { return mock(args: [job, canStartJob], untrackedArgs: [db]) } - func insert(_ db: Database, job: Job?, before otherJob: Job) -> (Int64, Job)? { + func insert(_ db: ObservingDatabase, job: Job?, before otherJob: Job) -> (Int64, Job)? { return mock(args: [job, otherJob], untrackedArgs: [db]) } diff --git a/_SharedTestUtilities/MockKeychain.swift b/_SharedTestUtilities/MockKeychain.swift index 1790334404..8b92b49522 100644 --- a/_SharedTestUtilities/MockKeychain.swift +++ b/_SharedTestUtilities/MockKeychain.swift @@ -33,5 +33,14 @@ class MockKeychain: Mock, KeychainStorageType { 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/MockLogger.swift b/_SharedTestUtilities/MockLogger.swift new file mode 100644 index 0000000000..76a17ec07a --- /dev/null +++ b/_SharedTestUtilities/MockLogger.swift @@ -0,0 +1,51 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +@testable import SessionUtilitiesKit + +public class MockLogger: Logger { + public struct LogOutput: Equatable { + let level: Log.Level + let categories: [Log.Category] + let message: String + let file: String + let function: String + + /// We don't include the `line` because it'd make test maintenance a pain + /// `let line: UInt` + } + + public var logs: [LogOutput] = [] + + public init( + primaryPrefix: String, + using dependencies: Dependencies + ) { + super.init( + primaryPrefix: primaryPrefix, + fileLogger: nil, /// Don't setup the `fileLogger` + isSuspended: false, + using: dependencies + ) + } + + public override func _internalLog( + _ level: Log.Level, + _ categories: [Log.Category], + _ message: String, + file: StaticString, + function: StaticString, + line: UInt + ) { + logs.append( + LogOutput( + level: level, + categories: categories, + message: message, + file: "\(file)", + function: "\(function)" + ) + ) + } +} diff --git a/_SharedTestUtilities/MockUserDefaults.swift b/_SharedTestUtilities/MockUserDefaults.swift index f4cffe6e71..28ead9e1eb 100644 --- a/_SharedTestUtilities/MockUserDefaults.swift +++ b/_SharedTestUtilities/MockUserDefaults.swift @@ -25,5 +25,9 @@ class MockUserDefaults: Mock, UserDefaultsType { 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 index 2b3337b6ba..fd085ebcaa 100644 --- a/_SharedTestUtilities/Mocked.swift +++ b/_SharedTestUtilities/Mocked.swift @@ -59,14 +59,26 @@ extension Dependencies { // MARK: - Conformance -extension Database: Mocked { - static var mock: Database { +extension ObservingDatabase: Mocked { + static var mock: Self { var result: Database! try! DatabaseQueue().read { result = $0 } - return result! + 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")) } @@ -104,6 +116,18 @@ 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" +} + // MARK: - Encodable Convenience extension Mocked where Self: Encodable { diff --git a/_SharedTestUtilities/NimbleExtensions.swift b/_SharedTestUtilities/NimbleExtensions.swift index 07cc83dd7f..b6dfe2beb4 100644 --- a/_SharedTestUtilities/NimbleExtensions.swift +++ b/_SharedTestUtilities/NimbleExtensions.swift @@ -2,7 +2,6 @@ import Foundation import Nimble -import CwlPreconditionTesting import SessionUtilitiesKit public enum CallAmount { @@ -73,13 +72,13 @@ public func call( .joined(separator: " ") }() - /// If an exception was thrown when generating call info then fail (mock value likely invalid) - guard callInfo.caughtException == nil else { + /// 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: "a thrown assertion (invalid mock param, not called or no mocked return value)" + actual: "an error (invalid mock param, not called or no mocked return value)" ) ) } @@ -249,7 +248,7 @@ public func call( fileprivate struct CallInfo { let didError: Bool - let caughtException: BadInstructionException? + let caughtError: Error? let targetFunction: MockFunction? let allFunctionsCalled: [FunctionConsumer.Key] let allCallDetails: [CallDetails] @@ -261,7 +260,7 @@ fileprivate struct CallInfo { static var error: CallInfo { CallInfo( didError: true, - caughtException: nil, + caughtError: nil, targetFunction: nil, allFunctionsCalled: [], allCallDetails: [] @@ -270,13 +269,13 @@ fileprivate struct CallInfo { init( didError: Bool = false, - caughtException: BadInstructionException?, + caughtError: Error?, targetFunction: MockFunction?, allFunctionsCalled: [FunctionConsumer.Key], allCallDetails: [CallDetails] ) { self.didError = didError - self.caughtException = caughtException + self.caughtError = caughtError self.targetFunction = targetFunction self.allFunctionsCalled = allFunctionsCalled self.allCallDetails = allCallDetails @@ -290,12 +289,13 @@ fileprivate func generateCallInfo( var maybeTargetFunction: MockFunction? var allFunctionsCalled: [FunctionConsumer.Key] = [] var allCallDetails: [CallDetails] = [] + var caughtError: Error? = nil let builderCreator: ((M) -> MockFunctionBuilder) = { validInstance in let builder: MockFunctionBuilder = MockFunctionBuilder(functionBlock, mockInit: type(of: validInstance).init) - builder.returnValueGenerator = { name, parameterCount, parameterSummary, allParameterSummaryCombinations in + builder.returnValueGenerator = { name, generics, parameterCount, parameterSummary, allParameterSummaryCombinations in validInstance.functionConsumer .firstFunction( - for: FunctionConsumer.Key(name: name, paramCount: parameterCount), + for: FunctionConsumer.Key(name: name, generics: generics, paramCount: parameterCount), matchingParameterSummaryIfPossible: parameterSummary, allParameterSummaryCombinations: allParameterSummaryCombinations )? @@ -305,90 +305,39 @@ fileprivate func generateCallInfo( return builder } - #if (arch(x86_64) || arch(arm64)) && (canImport(Darwin) || canImport(Glibc)) - var didError: Bool = false - let caughtException: BadInstructionException? = catchBadInstruction { - do { - guard let validInstance: M = try actualExpression.evaluate() else { - didError = true - return - } - - 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 { - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - let builder: MockFunctionBuilder = builderCreator(validInstance) - validInstance.functionConsumer.trackCalls = false - - Task { - maybeTargetFunction = try? await builder.build() - semaphore.signal() - } - semaphore.wait() - - let key: FunctionConsumer.Key = FunctionConsumer.Key( - name: (maybeTargetFunction?.name ?? ""), - paramCount: (maybeTargetFunction?.parameterCount ?? 0) - ) - allCallDetails = validInstance.functionConsumer.calls[key] - .defaulting(to: []) - validInstance.functionConsumer.trackCalls = true - } - else { - allCallDetails = [] - } - } - catch { - didError = true - } - } - - // Make sure to switch this back on in case an assertion was thrown (which would meant this - // wouldn't have been reset) - (try? actualExpression.evaluate())?.functionConsumer.trackCalls = true - - guard !didError else { return CallInfo.error } - #else - let caughtException: BadInstructionException? = nil - // Just hope for the best and if there is a force-cast there's not much we can do - guard let validInstance: M = try? actualExpression.evaluate() else { return CallInfo.error } - - 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 { - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - let builder: MockFunctionBuilder = builderCreator(validInstance) - validInstance.functionConsumer.trackCalls = false - - Task { - maybeTargetFunction = try? await builder.build() - semaphore.signal() + do { + guard let validInstance: M = try actualExpression.evaluate() else { + throw TestError.unableToEvaluateExpression } - semaphore.wait() - let key: FunctionConsumer.Key = FunctionConsumer.Key( - name: (maybeTargetFunction?.name ?? ""), - paramCount: (maybeTargetFunction?.parameterCount ?? 0) - ) - allCallDetails = validInstance.functionConsumer.calls[key] - .defaulting(to: []) - validInstance.functionConsumer.trackCalls = true - } - else { - allCallDetails = [] + 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 { + let builder: MockFunctionBuilder = builderCreator(validInstance) + validInstance.functionConsumer.trackCalls = false + maybeTargetFunction = try builder.build() + + 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 = [] + } } - #endif + catch { caughtError = error } return CallInfo( - caughtException: caughtException, + caughtError: caughtError, targetFunction: maybeTargetFunction, allFunctionsCalled: allFunctionsCalled, allCallDetails: allCallDetails diff --git a/_SharedTestUtilities/SynchronousStorage.swift b/_SharedTestUtilities/SynchronousStorage.swift index fbfac3bc08..393f25d0c7 100644 --- a/_SharedTestUtilities/SynchronousStorage.swift +++ b/_SharedTestUtilities/SynchronousStorage.swift @@ -5,17 +5,19 @@ import GRDB @testable import SessionUtilitiesKit -class SynchronousStorage: Storage { - private let dependencies: Dependencies +class SynchronousStorage: Storage, DependenciesSettable, InitialSetupable { + public var dependencies: Dependencies + private let initialData: ((ObservingDatabase) throws -> ())? public init( customWriter: DatabaseWriter? = nil, migrationTargets: [MigratableTarget.Type]? = nil, migrations: [Storage.KeyedMigration]? = nil, using dependencies: Dependencies, - initialData: ((Database) throws -> ())? = nil + initialData: ((ObservingDatabase) throws -> ())? = nil ) { self.dependencies = dependencies + self.initialData = initialData super.init(customWriter: customWriter, using: dependencies) @@ -38,15 +40,31 @@ class SynchronousStorage: Storage { onComplete: { _ in } ) } + } + + // MARK: - DependenciesSettable + + func setDependencies(_ dependencies: Dependencies?) { + guard let dependencies: Dependencies = dependencies else { return } - write { db in try initialData?(db) } + self.dependencies = dependencies } + // MARK: - InitialSetupable + + func performInitialSetup() { + guard let closure: ((ObservingDatabase) throws -> ()) = initialData else { return } + + write { db in try closure(db) } + } + + // MARK: - Overwritten Functions + @discardableResult override func write( - fileName: String = #file, + fileName: String = #fileID, functionName: String = #function, lineNumber: Int = #line, - updates: @escaping (Database) throws -> T? + updates: @escaping (ObservingDatabase) throws -> T? ) -> T? { guard isValid, let dbWriter: DatabaseWriter = testDbWriter else { return nil } @@ -55,32 +73,29 @@ class SynchronousStorage: Storage { // database without worrying about reentrant access during tests because we can be // confident that the tests are running on the correct thread guard !dependencies.forceSynchronous else { - let result: T? - let didThrow: Bool - do { - result = try dbWriter.unsafeReentrantWrite(updates) - didThrow = false - } - catch { - result = nil - didThrow = true - } + var result: T? + var events: [ObservedEvent] = [] + var actions: [String: () -> Void] = [:] - dbWriter.unsafeReentrantWrite { db in - // Forcibly call the transaction observer when forcing synchronous logic - dependencies.mutate(cache: .transactionObserver) { cache in - let handlers = cache.registeredHandlers - handlers.forEach { identifier, observer in - if didThrow { - observer.databaseDidRollback(db) - } - else { - observer.databaseDidCommit(db) - } - cache.remove(for: identifier) + do { + try dbWriter.unsafeReentrantWrite { [dependencies] db in + let observingDatabase: ObservingDatabase = ObservingDatabase.create(db, using: dependencies) + result = try ObservationContext.$observingDb.withValue(observingDatabase) { + try updates(observingDatabase) } + + events = observingDatabase.events + actions = observingDatabase.postCommitActions + } + + /// Forcibly trigger `ObservableEvent` and `postCommitActions` when forcing synchronous logic + Task(priority: .medium) { [dependencies] in + await dependencies[singleton: .observationManager].notify(events) } + + actions.values.forEach { $0() } } + catch {} return result } @@ -94,10 +109,10 @@ class SynchronousStorage: Storage { } @discardableResult override func read( - fileName: String = #file, + fileName: String = #fileID, functionName: String = #function, lineNumber: Int = #line, - _ value: @escaping (Database) throws -> T? + _ value: @escaping (ObservingDatabase) throws -> T? ) -> T? { guard isValid, let dbWriter: DatabaseWriter = testDbWriter else { return nil } @@ -106,32 +121,29 @@ class SynchronousStorage: Storage { // database without worrying about reentrant access during tests because we can be // confident that the tests are running on the correct thread guard !dependencies.forceSynchronous else { - let result: T? - let didThrow: Bool - do { - result = try dbWriter.unsafeReentrantRead(value) - didThrow = false - } - catch { - result = nil - didThrow = true - } + var result: T? + var events: [ObservedEvent] = [] + var actions: [String: () -> Void] = [:] - try? dbWriter.unsafeReentrantRead { db in - // Forcibly call the transaction observer when forcing synchronous logic - dependencies.mutate(cache: .transactionObserver) { cache in - let handlers = cache.registeredHandlers - handlers.forEach { identifier, observer in - if didThrow { - observer.databaseDidRollback(db) - } - else { - observer.databaseDidCommit(db) - } - cache.remove(for: identifier) + do { + try dbWriter.unsafeReentrantRead { [dependencies] db in + let observingDatabase: ObservingDatabase = ObservingDatabase.create(db, using: dependencies) + result = try ObservationContext.$observingDb.withValue(observingDatabase) { + try value(observingDatabase) } + + events = observingDatabase.events + actions = observingDatabase.postCommitActions + } + + /// Forcibly trigger `ObservableEvent` and `postCommitActions` when forcing synchronous logic + Task(priority: .medium) { [dependencies] in + await dependencies[singleton: .observationManager].notify(events) } + + actions.values.forEach { $0() } } + catch {} return result } @@ -147,10 +159,10 @@ class SynchronousStorage: Storage { // MARK: - Async Methods override func readPublisher( - fileName: String = #file, + fileName: String = #fileID, functionName: String = #function, lineNumber: Int = #line, - value: @escaping (Database) throws -> T + value: @escaping (ObservingDatabase) throws -> T ) -> AnyPublisher { guard isValid, let dbWriter: DatabaseWriter = testDbWriter else { return Fail(error: StorageError.generic) @@ -162,25 +174,32 @@ class SynchronousStorage: Storage { // database without worrying about reentrant access during tests because we can be // confident that the tests are running on the correct thread guard !dependencies.forceSynchronous else { + var events: [ObservedEvent] = [] + var actions: [String: () -> Void] = [:] + return Just(()) .setFailureType(to: Error.self) - .tryMap { _ in try dbWriter.unsafeReentrantRead(value) } + .tryMap { [dependencies] _ in + try dbWriter.unsafeReentrantRead { [dependencies] db in + let observingDatabase: ObservingDatabase = ObservingDatabase.create(db, using: dependencies) + let result: T = try ObservationContext.$observingDb.withValue(observingDatabase) { + try value(observingDatabase) + } + + events = observingDatabase.events + actions = observingDatabase.postCommitActions + + return result + } + } .handleEvents( receiveCompletion: { [dependencies] result in - try? dbWriter.unsafeReentrantRead { db in - // Forcibly call the transaction observer when forcing synchronous logic - dependencies.mutate(cache: .transactionObserver) { cache in - let handlers = cache.registeredHandlers - handlers.forEach { identifier, observer in - switch result { - case .finished: observer.databaseDidCommit(db) - case .failure: observer.databaseDidRollback(db) - } - cache.remove(for: identifier) - } - } + /// Forcibly trigger `ObservableEvent` and `postCommitActions` when forcing synchronous logic + Task(priority: .medium) { [dependencies] in + await dependencies[singleton: .observationManager].notify(events) } + actions.values.forEach { $0() } } ) .eraseToAnyPublisher() @@ -190,10 +209,10 @@ class SynchronousStorage: Storage { } override func writeAsync( - fileName: String = #file, + fileName: String = #fileID, functionName: String = #function, lineNumber: Int = #line, - updates: @escaping (Database) throws -> T, + updates: @escaping (ObservingDatabase) throws -> T, completion: @escaping (Result) -> Void ) { do { @@ -206,10 +225,10 @@ class SynchronousStorage: Storage { } override func writePublisher( - fileName: String = #file, + fileName: String = #fileID, functionName: String = #function, lineNumber: Int = #line, - updates: @escaping (Database) throws -> T + updates: @escaping (ObservingDatabase) throws -> T ) -> AnyPublisher { guard isValid, let dbWriter: DatabaseWriter = testDbWriter else { return Fail(error: StorageError.generic) @@ -221,25 +240,32 @@ class SynchronousStorage: Storage { // database without worrying about reentrant access during tests because we can be // confident that the tests are running on the correct thread guard !dependencies.forceSynchronous else { + var events: [ObservedEvent] = [] + var actions: [String: () -> Void] = [:] + return Just(()) .setFailureType(to: Error.self) - .tryMap { _ in try dbWriter.unsafeReentrantWrite(updates) } + .tryMap { [dependencies] _ in + try dbWriter.unsafeReentrantWrite { [dependencies] db in + let observingDatabase: ObservingDatabase = ObservingDatabase.create(db, using: dependencies) + let result: T = try ObservationContext.$observingDb.withValue(observingDatabase) { + try updates(observingDatabase) + } + + events = observingDatabase.events + actions = observingDatabase.postCommitActions + + return result + } + } .handleEvents( receiveCompletion: { [dependencies] result in - dbWriter.unsafeReentrantWrite { db in - // Forcibly call the transaction observer when forcing synchronous logic - dependencies.mutate(cache: .transactionObserver) { cache in - let handlers = cache.registeredHandlers - handlers.forEach { identifier, observer in - switch result { - case .finished: observer.databaseDidCommit(db) - case .failure: observer.databaseDidRollback(db) - } - cache.remove(for: identifier) - } - } + /// Forcibly trigger `ObservableEvent` and `postCommitActions` when forcing synchronous logic + Task(priority: .medium) { [dependencies] in + await dependencies[singleton: .observationManager].notify(events) } + actions.values.forEach { $0() } } ) .eraseToAnyPublisher() diff --git a/_SharedTestUtilities/TestConstants.swift b/_SharedTestUtilities/TestConstants.swift index fa88148391..0bd76aa6b7 100644 --- a/_SharedTestUtilities/TestConstants.swift +++ b/_SharedTestUtilities/TestConstants.swift @@ -38,4 +38,6 @@ enum TestConstants { public enum TestError: Error, Equatable { case mock + case timeout + case unableToEvaluateExpression } diff --git a/_SharedTestUtilities/TestDependencies.swift b/_SharedTestUtilities/TestDependencies.swift index 6ae988b351..bc45f43072 100644 --- a/_SharedTestUtilities/TestDependencies.swift +++ b/_SharedTestUtilities/TestDependencies.swift @@ -6,17 +6,17 @@ import Quick @testable import SessionUtilitiesKit public class TestDependencies: Dependencies { - private var singletonInstances: [String: Any] = [:] - private var cacheInstances: [String: MutableCacheType] = [:] - private var defaultsInstances: [String: (any UserDefaultsType)] = [:] - private var featureInstances: [String: (any FeatureType)] = [:] + @ThreadSafeObject private var singletonInstances: [String: Any] = [:] + @ThreadSafeObject private var cacheInstances: [String: MutableCacheType] = [:] + @ThreadSafeObject private var defaultsInstances: [String: (any UserDefaultsType)] = [:] + @ThreadSafeObject private var featureInstances: [String: (any FeatureType)] = [:] // 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) - singletonInstances[singleton.identifier] = value + _singletonInstances.performUpdate { $0.setting(singleton.identifier, value) } return value } @@ -25,14 +25,14 @@ public class TestDependencies: Dependencies { public subscript(singleton singleton: SingletonConfig) -> S? { get { return (singletonInstances[singleton.identifier] as? S) } - set { singletonInstances[singleton.identifier] = newValue } + set { _singletonInstances.performUpdate { $0.setting(singleton.identifier, newValue) } } } override public subscript(cache cache: CacheConfig) -> I { guard let value: M = (cacheInstances[cache.identifier] as? M) else { let value: M = cache.createInstance(self) let mutableInstance: MutableCacheType = cache.mutableInstance(value) - cacheInstances[cache.identifier] = mutableInstance + _cacheInstances.performUpdate { $0.setting(cache.identifier, mutableInstance) } return cache.immutableInstance(value) } @@ -41,13 +41,13 @@ public class TestDependencies: Dependencies { public subscript(cache cache: CacheConfig) -> M? { get { return (cacheInstances[cache.identifier] as? M) } - set { cacheInstances[cache.identifier] = newValue.map { cache.mutableInstance($0) } } + set { _cacheInstances.performUpdate { $0.setting(cache.identifier, newValue.map { cache.mutableInstance($0) }) } } } override public subscript(defaults defaults: UserDefaultsConfig) -> UserDefaultsType { guard let value: UserDefaultsType = defaultsInstances[defaults.identifier] else { let value: UserDefaultsType = defaults.createInstance(self) - defaultsInstances[defaults.identifier] = value + _defaultsInstances.performUpdate { $0.setting(defaults.identifier, value) } return value } @@ -57,7 +57,7 @@ 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) - featureInstances[feature.identifier] = value + _featureInstances.performUpdate { $0.setting(feature.identifier, value) } return value.currentValue(using: self) } @@ -68,7 +68,7 @@ public class TestDependencies: Dependencies { get { return (featureInstances[feature.identifier] as? T) } set { if featureInstances[feature.identifier] == nil { - featureInstances[feature.identifier] = feature.createInstance(self) + _featureInstances.performUpdate { $0.setting(feature.identifier, feature.createInstance(self)) } } set(feature: feature, to: newValue) @@ -77,7 +77,7 @@ public class TestDependencies: Dependencies { public subscript(defaults defaults: UserDefaultsConfig) -> UserDefaultsType? { get { return defaultsInstances[defaults.identifier] } - set { defaultsInstances[defaults.identifier] = newValue } + set { _defaultsInstances.performUpdate { $0.setting(defaults.identifier, newValue) } } } // MARK: - Timing and Async Handling @@ -122,7 +122,7 @@ public class TestDependencies: Dependencies { ) -> R { let value: M = ((cacheInstances[cache.identifier] as? M) ?? cache.createInstance(self)) let mutableInstance: MutableCacheType = cache.mutableInstance(value) - cacheInstances[cache.identifier] = mutableInstance + _cacheInstances.performUpdate { $0.setting(cache.identifier, mutableInstance) } return mutation(value) } @@ -132,7 +132,7 @@ public class TestDependencies: Dependencies { ) throws -> R { let value: M = ((cacheInstances[cache.identifier] as? M) ?? cache.createInstance(self)) let mutableInstance: MutableCacheType = cache.mutableInstance(value) - cacheInstances[cache.identifier] = mutableInstance + _cacheInstances.performUpdate { $0.setting(cache.identifier, mutableInstance) } return try mutation(value) } @@ -153,7 +153,12 @@ public class TestDependencies: Dependencies { } } - // MARK: - Random Access Functions + // MARK: - Random + + public var uuid: UUID? = nil + public override func randomUUID() -> UUID { + return (uuid ?? UUID()) + } public override func randomElement(_ collection: T) -> T.Element? { return collection.first @@ -178,15 +183,15 @@ public class TestDependencies: Dependencies { // MARK: - Instance replacing public override func set(singleton: SingletonConfig, to instance: S) { - singletonInstances[singleton.identifier] = instance + _singletonInstances.performUpdate { $0.setting(singleton.identifier, instance) } } public override func set(cache: CacheConfig, to instance: M) { - cacheInstances[cache.identifier] = cache.mutableInstance(instance) + _cacheInstances.performUpdate { $0.setting(cache.identifier, cache.mutableInstance(instance)) } } public override func remove(cache: CacheConfig) { - cacheInstances[cache.identifier] = nil + _cacheInstances.performUpdate { $0.setting(cache.identifier, nil) } } } @@ -203,6 +208,7 @@ internal extension TestState { let value: T? = wrappedValue() (value as? DependenciesSettable)?.setDependencies(dependencies) dependencies?[cache: cache] = (value as! M) + (value as? (any InitialSetupable))?.performInitialSetup() return value }()) @@ -218,6 +224,7 @@ internal extension TestState { let value: T? = wrappedValue() (value as? DependenciesSettable)?.setDependencies(dependencies) dependencies?[singleton: singleton] = (value as! S) + (value as? (any InitialSetupable))?.performInitialSetup() return value }()) @@ -233,6 +240,7 @@ internal extension TestState { let value: T? = wrappedValue() (value as? DependenciesSettable)?.setDependencies(dependencies) dependencies?[defaults: defaults] = value + (value as? (any InitialSetupable))?.performInitialSetup() return value }())