diff --git a/Scripts/build_ci.sh b/Scripts/build_ci.sh index 9ff7932d3d..0034642f7b 100755 --- a/Scripts/build_ci.sh +++ b/Scripts/build_ci.sh @@ -56,8 +56,8 @@ if [[ "$MODE" == "test" ]]; then xcresultparser --output-format cli --no-test-result --coverage ./build/artifacts/testResults.xcresult parser_output=$(xcresultparser --output-format cli --no-test-result ./build/artifacts/testResults.xcresult) - build_errors_count=$(echo "$parser_output" | grep "Number of errors" | awk '{print $NF}') - failed_tests_count=$(echo "$parser_output" | grep "Number of failed tests" | awk '{print $NF}') + build_errors_count=$(echo "$parser_output" | grep "Number of errors" | awk '{print $NF}' | grep -o '[0-9]*' || echo "0") + failed_tests_count=$(echo "$parser_output" | grep "Number of failed tests" | awk '{print $NF}' | grep -o '[0-9]*' || echo "0") if [ "${build_errors_count:-0}" -gt 0 ] || [ "${failed_tests_count:-0}" -gt 0 ]; then echo "" diff --git a/Scripts/build_libSession_util.sh b/Scripts/build_libSession_util.sh index df2d64b325..3a8e445a9c 100755 --- a/Scripts/build_libSession_util.sh +++ b/Scripts/build_libSession_util.sh @@ -12,15 +12,63 @@ 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>&- +# Modify the platform detection to handle archive builds +if [ "${ACTION}" = "install" ] || [ "${CONFIGURATION}" = "Release" ]; then + # Archive builds typically use 'install' action + if [ -z "$PLATFORM_NAME" ]; then + # During archive, PLATFORM_NAME might not be set correctly + # Default to device build for archives + PLATFORM_NAME="iphoneos" + echo "Missing 'PLATFORM_NAME' value, manually set to ${PLATFORM_NAME}" + fi +fi + +sync_headers() { + local source_dir="$1" + echo "- Syncing headers from ${source_dir}" + + local destinations=( + "${TARGET_BUILD_DIR}/include" + "${INDEX_DIR}/include" + "${BUILT_PRODUCTS_DIR}/include" + "${CONFIGURATION_BUILD_DIR}/include" + ) + + # For archive builds, add the archive-specific path + if [ "${ACTION}" = "install" ]; then + local ARCHIVE_PRODUCTS_PATH="${BUILD_DIR}/../../BuildProductsPath/${CONFIGURATION}-${PLATFORM_NAME}/include" + destinations+=("${ARCHIVE_PRODUCTS_PATH}") + fi + + for dest in "${destinations[@]}"; do + if [ -n "$dest" ]; then + local temp_dest="${dest}.tmp-$(uuidgen)" + rm -rf "$temp_dest" + mkdir -p "$temp_dest" + + rsync -rtc --delete --exclude='.DS_Store' "${source_dir}/" "$temp_dest/" + + # Atomically move the old directory out of the way + local old_dest="${dest}.old-$(uuidgen)" + if [ -d "$dest" ]; then + mv "$dest" "$old_dest" + fi + + # Atomically move the new, correct directory into place + mv "$temp_dest" "$dest" + + # Clean up the old directory + if [ -d "$old_dest" ]; then + # Clear any immutable flags (work around Xcode's indexer file locking) + chflags -R nouchg "${dir_to_remove}" &>/dev/null || true + rm -rf "$old_dest" + fi + + echo " Synced to: $dest" + fi + done } -trap finish EXIT ERR SIGINT SIGTERM # Determine whether we want to build from source TARGET_ARCH_DIR="" @@ -35,11 +83,21 @@ else fi if [ "${COMPILE_LIB_SESSION}" != "YES" ]; then - echo "Restoring original headers to Xcode Indexer cache from backup..." - rm -rf "${INDEX_DIR}/include" - rsync -rt --exclude='.DS_Store' "${PRE_BUILT_FRAMEWORK_DIR}/${FRAMEWORK_DIR}/${TARGET_ARCH_DIR}/Headers/" "${INDEX_DIR}/include" - echo "Using pre-packaged SessionUtil" + + if [ "$CI" = "true" ] || [ "$DRONE" = "true" ]; then + # In CI, Xcode's SPM integration is reliable. Skip manual header sync + # to avoid the 'redefinition of module' error. + echo "- CI environment detected, skipping manual header sync to rely on SPM" + else + echo "- Local build detected, syncing headers to assist Xcode indexer" + sync_headers "${PRE_BUILT_FRAMEWORK_DIR}/${FRAMEWORK_DIR}/${TARGET_ARCH_DIR}/Headers/" + fi + + # Create the placeholder in the FINAL products directory to satisfy dependency + touch "${BUILT_PRODUCTS_DIR}/libsession-util.a" + + echo "- Revert to SPM complete." exit 0 fi @@ -83,20 +141,22 @@ fi echo "- Checking if libSession changed..." REQUIRES_BUILD=0 -# Generate a hash to determine whether any source files have changed -SOURCE_HASH=$(find "${LIB_SESSION_SOURCE_DIR}/src" -type f -not -name '.DS_Store' -exec md5 {} + | awk '{print $NF}' | sort | md5 | awk '{print $NF}') -HEADER_HASH=$(find "${LIB_SESSION_SOURCE_DIR}/include" -type f -not -name '.DS_Store' -exec md5 {} + | awk '{print $NF}' | sort | md5 | awk '{print $NF}') -EXTERNAL_HASH=$(find "${LIB_SESSION_SOURCE_DIR}/external" -type f -not -name '.DS_Store' -exec md5 {} + | awk '{print $NF}' | sort | md5 | awk '{print $NF}') -MAKE_LISTS_HASH=$(md5 -q "${LIB_SESSION_SOURCE_DIR}/CMakeLists.txt") -STATIC_BUNDLE_HASH=$(md5 -q "${LIB_SESSION_SOURCE_DIR}/utils/static-bundle.sh") - -CURRENT_SOURCE_TREE_HASH=$( ( - echo "${SOURCE_HASH}" - echo "${HEADER_HASH}" - echo "${EXTERNAL_HASH}" - echo "${MAKE_LISTS_HASH}" - echo "${STATIC_BUNDLE_HASH}" -) | sort | md5 -q) +# Generate a hash to determine whether any source files have changed (by using git we automatically +# respect .gitignore) +CURRENT_SOURCE_TREE_HASH=$( \ + ( \ + cd "${LIB_SESSION_SOURCE_DIR}" && git ls-files --recurse-submodules \ + ) \ + | grep -vE '/(tests?|docs?|examples?)/|\.md$|/(\.DS_Store|\.gitignore)$' \ + | sort \ + | tr '\n' '\0' \ + | ( \ + cd "${LIB_SESSION_SOURCE_DIR}" && xargs -0 md5 -r \ + ) \ + | awk '{print $1}' \ + | sort \ + | md5 -q \ +) PREVIOUS_BUILT_FRAMEWORK_SLICE_DIR="" if [ -f "$LAST_BUILT_FRAMEWORK_SLICE_DIR_FILE" ]; then @@ -131,10 +191,6 @@ if [ "${REQUIRES_BUILD}" == 1 ]; then VALID_SIM_ARCH_PLATFORMS=(SIMULATORARM64 SIMULATOR64) VALID_DEVICE_ARCH_PLATFORMS=(OS64) - OUTPUT_DIR="${TARGET_BUILD_DIR}" - IPHONEOS_DEPLOYMENT_TARGET=${IPHONEOS_DEPLOYMENT_TARGET} - ENABLE_BITCODE=${ENABLE_BITCODE} - # Generate the target architectures we want to build for TARGET_ARCHS=() TARGET_PLATFORMS=() @@ -204,69 +260,31 @@ if [ "${REQUIRES_BUILD}" == 1 ]; then log_file="${COMPILE_DIR}/libsession_util_output.log" 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 - cd "${LIB_SESSION_SOURCE_DIR}" - env -i PATH="$PATH" SDKROOT="$(xcrun --sdk macosx --show-sdk-path)" \ - ./utils/static-bundle.sh "$build" "" \ - -DCMAKE_TOOLCHAIN_FILE="${LIB_SESSION_SOURCE_DIR}/external/ios-cmake/ios.toolchain.cmake" \ - -DPLATFORM=$platform \ - -DDEPLOYMENT_TARGET=$IPHONEOS_DEPLOYMENT_TARGET \ - -DENABLE_BITCODE=$ENABLE_BITCODE \ - -DBUILD_TESTS=OFF \ - -DBUILD_STATIC_DEPS=ON \ - -DENABLE_VISIBILITY=ON \ - -DSUBMODULE_CHECK=$submodule_check \ - -DCMAKE_BUILD_TYPE=$build_type \ - -DLOCAL_MIRROR=https://oxen.rocks/deps + { + env -i PATH="$PATH" SDKROOT="$(xcrun --sdk macosx --show-sdk-path)" \ + ./utils/static-bundle.sh "$build" "" \ + -DCMAKE_TOOLCHAIN_FILE="${LIB_SESSION_SOURCE_DIR}/external/ios-cmake/ios.toolchain.cmake" \ + -DPLATFORM=$platform \ + -DDEPLOYMENT_TARGET=$IPHONEOS_DEPLOYMENT_TARGET \ + -DENABLE_BITCODE=$ENABLE_BITCODE \ + -DBUILD_TESTS=OFF \ + -DBUILD_STATIC_DEPS=ON \ + -DENABLE_VISIBILITY=ON \ + -DSUBMODULE_CHECK=$submodule_check \ + -DCMAKE_BUILD_TYPE=$build_type \ + -DLOCAL_MIRROR=https://oxen.rocks/deps + } 2>&1 | tee "$log_file" | grep --line-buffered -E '^\[.*%\]|:[0-9]+:[0-9]+: error:|^make.*\*\*\*|^error:|^CMake Error' # Capture the exit status of the ./utils/static-bundle.sh command - EXIT_STATUS=$? - - # Flush the tee buffer (ensure any errors have been properly written to the log before continuing) and - # restore stdout - echo "" - exec 1>&3 - - # Retrieve and log any submodule errors/warnings - ALL_CMAKE_ERROR_LINES=($(grep -nE "CMake Error" "$log_file" | cut -d ":" -f 1)) - ALL_SUBMODULE_ISSUE_LINES=($(grep -nE "\s*Submodule '([^']+)' is not up-to-date" "$log_file" | cut -d ":" -f 1)) - ALL_CMAKE_ERROR_LINES_STR=" ${ALL_CMAKE_ERROR_LINES[*]} " - ALL_SUBMODULE_ISSUE_LINES_STR=" ${ALL_SUBMODULE_ISSUE_LINES[*]} " - - for i in "${!ALL_SUBMODULE_ISSUE_LINES[@]}"; do - line="${ALL_SUBMODULE_ISSUE_LINES[$i]}" - prev_line=$((line - 1)) - value=$(sed "${line}q;d" "$log_file" | sed -E "s/.*Submodule '([^']+)'.*/Submodule '\1' is not up-to-date./") - - if [[ "$ALL_CMAKE_ERROR_LINES_STR" == *" $prev_line "* ]]; then - echo "error: $value" - else - echo "warning: $value" - fi - done - + EXIT_STATUS=${PIPESTATUS[0]} + if [ $EXIT_STATUS -ne 0 ]; then - ALL_ERROR_LINES=($(grep -n "error:" "$log_file" | cut -d ":" -f 1)) - - # Log any other errors - for e in "${!ALL_ERROR_LINES[@]}"; do - error_line="${ALL_ERROR_LINES[$e]}" - error=$(sed "${error_line}q;d" "$log_file") - - # If it was a CMake Error then the actual error will be on the next line so we want to append that info - if [[ $error == *'CMake Error'* ]]; then - actual_error_line=$((error_line + 1)) - error="${error}$(sed "${actual_error_line}q;d" "$log_file")" - fi - - # Exclude the 'ALL_ERROR_LINES' line and the 'grep' line - if [[ ! $error == *'grep -n "error'* ]] && [[ ! $error == *'grep -n error'* ]]; then - echo "error: $error" - fi + # Extract and display CMake/make errors from the log in Xcode error format + grep -E '^CMake Error' "$log_file" | sort -u | while IFS= read -r line; do + echo "error: $line" done - + # If the build failed we still want to copy files across because it'll help errors appear correctly echo "- Replacing build dir files" @@ -276,9 +294,14 @@ if [ "${REQUIRES_BUILD}" == 1 ]; then 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" + if [ -f "${COMPILE_DIR}/libsession-util.a" ]; then + rsync -rt "${COMPILE_DIR}/libsession-util.a" "${TARGET_BUILD_DIR}/libsession-util.a" + fi + + if [ -d "${COMPILE_DIR}/Headers" ]; then + sync_headers "${COMPILE_DIR}/Headers/" + fi + exit 1 fi done @@ -309,24 +332,23 @@ if [ "${REQUIRES_BUILD}" == 1 ]; then echo "${TARGET_ARCH_DIR}" > "${LAST_BUILT_FRAMEWORK_SLICE_DIR_FILE}" echo "${CURRENT_SOURCE_TREE_HASH}" > "${LAST_SUCCESSFUL_HASH_FILE}" - echo "- Touching timestamp file to signal update to Xcode" - touch "${BUILT_LIB_FINAL_TIMESTAMP_FILE}" - cp "${BUILT_LIB_FINAL_TIMESTAMP_FILE}" "${SPM_TIMESTAMP_FILE}" - echo "- Build complete" fi echo "- Replacing build dir files" -# Remove the current files (might be "newer") -rm -rf "${TARGET_BUILD_DIR}/libsession-util.a" -rm -rf "${TARGET_BUILD_DIR}/include" -rm -rf "${INDEX_DIR}/include" - # Rsync the compiled ones (maintaining timestamps) +rm -rf "${TARGET_BUILD_DIR}/libsession-util.a" rsync -rt "${COMPILE_DIR}/libsession-util.a" "${TARGET_BUILD_DIR}/libsession-util.a" -rsync -rt --exclude='.DS_Store' "${COMPILE_DIR}/Headers/" "${TARGET_BUILD_DIR}/include" -rsync -rt --exclude='.DS_Store' "${COMPILE_DIR}/Headers/" "${INDEX_DIR}/include" + +if [ "${TARGET_BUILD_DIR}" != "${BUILT_PRODUCTS_DIR}" ]; then + echo "- TARGET_BUILD_DIR and BUILT_PRODUCTS_DIR are different. Copying library." + rm -f "${BUILT_PRODUCTS_DIR}/libsession-util.a" + rsync -rt "${COMPILE_DIR}/libsession-util.a" "${BUILT_PRODUCTS_DIR}/libsession-util.a" +fi + +sync_headers "${COMPILE_DIR}/Headers/" +echo "- Sync complete." # Output to XCode just so the output is good echo "LibSession is Ready" diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index cd344a81ee..2731c09df5 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -15,7 +15,6 @@ 3496955C219B605E00DCFE74 /* ImagePickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34969559219B605E00DCFE74 /* ImagePickerController.swift */; }; 3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3496955B219B605E00DCFE74 /* PhotoLibrary.swift */; }; 3496956021A2FC8100DCFE74 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3496955F21A2FC8100DCFE74 /* CloudKit.framework */; }; - 34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */; }; 34A8B3512190A40E00218A25 /* MediaAlbumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A8B3502190A40E00218A25 /* MediaAlbumView.swift */; }; 34B6A903218B3F63007C4606 /* TypingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */; }; 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */; }; @@ -72,7 +71,6 @@ 4C4AE6A1224AF35700D4AF6F /* SendMediaNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4AE69F224AF21900D4AF6F /* SendMediaNavigationController.swift */; }; 4C63CC00210A620B003AE45C /* SignalTSan.supp in Resources */ = {isa = PBXBuildFile; fileRef = 4C63CBFF210A620B003AE45C /* SignalTSan.supp */; }; 4C6F527C20FFE8400097DEEE /* SignalUBSan.supp in Resources */ = {isa = PBXBuildFile; fileRef = 4C6F527B20FFE8400097DEEE /* SignalUBSan.supp */; }; - 4CA46F4C219CCC630038ABDE /* CaptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA46F4B219CCC630038ABDE /* CaptionView.swift */; }; 4CA485BB2232339F004B9E7D /* PhotoCaptureViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA485BA2232339F004B9E7D /* PhotoCaptureViewController.swift */; }; 4CC613362227A00400E21A3A /* ConversationSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC613352227A00400E21A3A /* ConversationSearch.swift */; }; 70377AAB1918450100CAF501 /* MobileCoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 70377AAA1918450100CAF501 /* MobileCoreServices.framework */; }; @@ -151,6 +149,10 @@ 940943402C7ED62300D9D2E0 /* StartupError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9409433F2C7ED62300D9D2E0 /* StartupError.swift */; }; 941375BB2D5184C20058F244 /* HTTPHeader+SessionNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = 941375BA2D5184B60058F244 /* HTTPHeader+SessionNetwork.swift */; }; 941375BD2D5195F30058F244 /* KeyValueStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 941375BC2D5195F30058F244 /* KeyValueStore.swift */; }; + 9420CAC62E584B5800F738F6 /* GroupAdminCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 9420CAC42E584B5800F738F6 /* GroupAdminCTA.webp */; }; + 9420CAC72E584B5800F738F6 /* GroupNonAdminCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 9420CAC52E584B5800F738F6 /* GroupNonAdminCTA.webp */; }; + 9420CAC82E584B5800F738F6 /* GroupAdminCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 9420CAC42E584B5800F738F6 /* GroupAdminCTA.webp */; }; + 9420CAC92E584B5800F738F6 /* GroupNonAdminCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 9420CAC52E584B5800F738F6 /* GroupNonAdminCTA.webp */; }; 942256802C23F8BB00C0FDBF /* StartConversationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422567D2C23F8BB00C0FDBF /* StartConversationScreen.swift */; }; 942256812C23F8BB00C0FDBF /* NewMessageScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422567E2C23F8BB00C0FDBF /* NewMessageScreen.swift */; }; 942256822C23F8BB00C0FDBF /* InviteAFriendScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422567F2C23F8BB00C0FDBF /* InviteAFriendScreen.swift */; }; @@ -172,8 +174,11 @@ 9422EE2B2B8C3A97004C740D /* String+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422EE2A2B8C3A97004C740D /* String+Utilities.swift */; }; 942ADDD42D9F9613006E0BB0 /* NewTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942ADDD32D9F960C006E0BB0 /* NewTagView.swift */; }; 942BA9412E4487F7007C4595 /* LightBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9402E4487EE007C4595 /* LightBox.swift */; }; - 942BA9BF2E4ABBA1007C4595 /* _045_LastProfileUpdateTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9BE2E4ABB9F007C4595 /* _045_LastProfileUpdateTimestamp.swift */; }; + 942BA9C12E4EA5CB007C4595 /* SessionLabelWithProBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9C02E4EA5BE007C4595 /* SessionLabelWithProBadge.swift */; }; + 942BA9C22E53F694007C4595 /* SRCopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */; }; + 942BA9C42E55AB54007C4595 /* UILabel+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9C32E55AB51007C4595 /* UILabel+Utilities.swift */; }; 943C6D822B75E061004ACE64 /* Message+DisappearingMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */; }; + 94519A932E84C20700F02723 /* _045_LastProfileUpdateTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9BE2E4ABB9F007C4595 /* _045_LastProfileUpdateTimestamp.swift */; }; 946F5A732D5DA3AC00A5ADCE /* Punycode in Frameworks */ = {isa = PBXBuildFile; productRef = 946F5A722D5DA3AC00A5ADCE /* Punycode */; }; 9473386E2BDF5F3E00B9E169 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 9473386D2BDF5F3E00B9E169 /* InfoPlist.xcstrings */; }; 9479981C2DD44ADC008F5CD5 /* ThreadNotificationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9479981B2DD44AC5008F5CD5 /* ThreadNotificationSettingsViewModel.swift */; }; @@ -186,7 +191,6 @@ 947D7FE92D51837200E8E413 /* Text+CopyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947D7FE62D51837200E8E413 /* Text+CopyButton.swift */; }; 9499E6032DDD9BF900091434 /* ExpandableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9499E6022DDD9BEE00091434 /* ExpandableLabel.swift */; }; 9499E68B2DF92F4E00091434 /* ThreadNotificationSettingsViewModelSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9499E68A2DF92F3B00091434 /* ThreadNotificationSettingsViewModelSpec.swift */; }; - 949D91222E822D5A0074F595 /* String+SessionProBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 949D91212E822D520074F595 /* String+SessionProBadge.swift */; }; 94A6B9DB2DD6BF7C00DB4B44 /* Constants+Apple.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A6B9DA2DD6BF6E00DB4B44 /* Constants+Apple.swift */; }; 94AAB14B2E1E198200A6FA18 /* Modal+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94AAB14A2E1E197800A6FA18 /* Modal+SwiftUI.swift */; }; 94AAB14D2E1F39B500A6FA18 /* ProCTAModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94AAB14C2E1F39AB00A6FA18 /* ProCTAModal.swift */; }; @@ -217,6 +221,10 @@ 94CD96412E1BABE90097754D /* HigherCharLimitCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */; }; 94CD96432E1BAC0F0097754D /* GenericCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963C2E1BABE90097754D /* GenericCTA.webp */; }; 94CD96452E1BAC0F0097754D /* HigherCharLimitCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */; }; + 94D716802E8F6363008294EE /* HighlightMentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D7167F2E8F6362008294EE /* HighlightMentionView.swift */; }; + 94D716822E8FA1A0008294EE /* AttributedLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D716812E8FA19D008294EE /* AttributedLabel.swift */; }; + 94D716862E933958008294EE /* SessionProBadge+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D716852E93394B008294EE /* SessionProBadge+Utilities.swift */; }; + 94D716912E9379BA008294EE /* MentionUtilities+Attributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D716902E9379A4008294EE /* MentionUtilities+Attributes.swift */; }; 94E9BC0D2C7BFBDA006984EA /* Localization+Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */; }; A11CD70D17FA230600A2D1B1 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */; }; A163E8AB16F3F6AA0094D68B /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A163E8AA16F3F6A90094D68B /* Security.framework */; }; @@ -237,7 +245,6 @@ B835247925C38D880089A44F /* MessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B835247825C38D880089A44F /* MessageCell.swift */; }; 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 */; }; 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 */; }; @@ -246,7 +253,6 @@ B86BD08623399CEF000F5AE3 /* SeedModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86BD08523399CEF000F5AE3 /* SeedModal.swift */; }; B877E24226CA12910007970A /* CallVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B877E24126CA12910007970A /* CallVC.swift */; }; B877E24626CA13BA0007970A /* CallVC+Camera.swift in Sources */ = {isa = PBXBuildFile; fileRef = B877E24526CA13BA0007970A /* CallVC+Camera.swift */; }; - B8783E9E23EB948D00404FB8 /* UILabel+Interaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */; }; B879D449247E1BE300DB3608 /* PathVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B879D448247E1BE300DB3608 /* PathVC.swift */; }; B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */; }; @@ -318,8 +324,6 @@ C33FD9C2255A54EF00E217F9 /* SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; }; C33FD9C4255A54EF00E217F9 /* SessionNetworkingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionNetworkingKit.framework */; }; C33FD9C5255A54EF00E217F9 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; }; - C33FDC58255A582000E217F9 /* ReverseDispatchQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA9E255A57FF00E217F9 /* ReverseDispatchQueue.swift */; }; - C33FDD8D255A582000E217F9 /* OWSSignalAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */; }; C3402FE52559036600EA6424 /* SessionUIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C331FF1B2558F9D300070591 /* SessionUIKit.framework */; }; C34C8F7423A7830B00D82669 /* SpaceMono-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = C34C8F7323A7830A00D82669 /* SpaceMono-Bold.ttf */; }; C3548F0824456AB6009433A8 /* UIView+Wrapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3548F0724456AB6009433A8 /* UIView+Wrapping.swift */; }; @@ -335,7 +339,6 @@ 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 */; }; - C38EF32E255B6DBF007E1867 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF304255B6DBE007E1867 /* ImageCache.swift */; }; C38EF363255B6DCC007E1867 /* ModalActivityIndicatorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF349255B6DC7007E1867 /* ModalActivityIndicatorViewController.swift */; }; C38EF372255B6DCC007E1867 /* MediaMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF358255B6DCC007E1867 /* MediaMessageView.swift */; }; C38EF385255B6DD2007E1867 /* AttachmentTextToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF37C255B6DCF007E1867 /* AttachmentTextToolbar.swift */; }; @@ -392,7 +395,6 @@ C3CA3AC8255CDB2900F4C6D4 /* spanish.txt in Resources */ = {isa = PBXBuildFile; fileRef = C3CA3AC7255CDB2900F4C6D4 /* spanish.txt */; }; C3D0972B2510499C00F6E3E4 /* BackgroundPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D0972A2510499C00F6E3E4 /* BackgroundPoller.swift */; }; C3D90A5C25773A25002C9DF5 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; }; - C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */; }; D2179CFC16BB0B3A0006F3AB /* CoreTelephony.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2179CFB16BB0B3A0006F3AB /* CoreTelephony.framework */; }; D2179CFE16BB0B480006F3AB /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2179CFD16BB0B480006F3AB /* SystemConfiguration.framework */; }; D221A08E169C9E5E00537ABF /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D221A08D169C9E5E00537ABF /* UIKit.framework */; }; @@ -449,7 +451,6 @@ FD09799527FE7B8E00936362 /* Interaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799227FE693200936362 /* Interaction.swift */; }; FD09799927FFC1A300936362 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799827FFC1A300936362 /* Attachment.swift */; }; FD09799B27FFC82D00936362 /* Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799A27FFC82D00936362 /* Quote.swift */; }; - FD09B7E328865FDA00ED0B66 /* HighlightMentionBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09B7E228865FDA00ED0B66 /* HighlightMentionBackgroundView.swift */; }; FD09B7E5288670BB00ED0B66 /* _017_EmojiReacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09B7E4288670BB00ED0B66 /* _017_EmojiReacts.swift */; }; FD09B7E7288670FD00ED0B66 /* Reaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09B7E6288670FD00ED0B66 /* Reaction.swift */; }; FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */; }; @@ -506,7 +507,7 @@ FD22727C2C32911C004D8A6C /* GroupPromoteMemberJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272642C32911B004D8A6C /* GroupPromoteMemberJob.swift */; }; FD22727E2C32911C004D8A6C /* GarbageCollectionJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272662C32911B004D8A6C /* GarbageCollectionJob.swift */; }; FD22727F2C32911C004D8A6C /* GetExpirationJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272672C32911B004D8A6C /* GetExpirationJob.swift */; }; - FD2272812C32911C004D8A6C /* UpdateProfilePictureJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD22726A2C32911C004D8A6C /* UpdateProfilePictureJob.swift */; }; + FD2272812C32911C004D8A6C /* ReuploadUserDisplayPictureJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD22726A2C32911C004D8A6C /* ReuploadUserDisplayPictureJob.swift */; }; FD2272832C337830004D8A6C /* GroupPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272822C337830004D8A6C /* GroupPoller.swift */; }; FD2272A92C33E337004D8A6C /* ContentProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272952C33E335004D8A6C /* ContentProxy.swift */; }; FD2272AA2C33E337004D8A6C /* UpdatableTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272962C33E335004D8A6C /* UpdatableTimestamp.swift */; }; @@ -537,7 +538,6 @@ FD2272D82C34EDE7004D8A6C /* SnodeAPIEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272D72C34EDE6004D8A6C /* SnodeAPIEndpoint.swift */; }; FD2272DD2C34EFFA004D8A6C /* AppSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272DC2C34EFFA004D8A6C /* AppSetup.swift */; }; FD2272E02C3502BE004D8A6C /* Setting+Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272DF2C3502BE004D8A6C /* Setting+Theme.swift */; }; - FD2272E62C351378004D8A6C /* SUIKImageFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272E52C351378004D8A6C /* SUIKImageFormat.swift */; }; FD2272EA2C351CA7004D8A6C /* Threading.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272E92C351CA7004D8A6C /* Threading.swift */; }; FD2272EC2C352155004D8A6C /* Feature.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272EB2C352155004D8A6C /* Feature.swift */; }; FD2272EE2C3521D6004D8A6C /* FeatureConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272ED2C3521D6004D8A6C /* FeatureConfig.swift */; }; @@ -570,7 +570,6 @@ FD23EA6328ED0B260058676E /* CombineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23EA6028ED0B260058676E /* CombineExtensions.swift */; }; FD245C50285065C700B966DD /* VisibleMessage+Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7552553A3AB00C340D1 /* VisibleMessage+Quote.swift */; }; FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5FB2554B0A000555489 /* MessageReceiver.swift */; }; - FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF224255B6D5D007E1867 /* SignalAttachment.swift */; }; FD245C53285065DB00B966DD /* ProximityMonitoringManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */; }; FD245C55285065E500B966DD /* OpenGroupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */; }; FD245C56285065EA00B966DD /* SNProto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7822553AAF200C340D1 /* SNProto.swift */; }; @@ -687,7 +686,6 @@ FD49E2492B05C1D500FFBBB5 /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD49E2452B05C1D500FFBBB5 /* MockKeychain.swift */; }; FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200D283492210034334B /* InsetLockableTableView.swift */; }; FD4BB22B2D63F20700D0DC3D /* MigrationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4BB22A2D63F20600D0DC3D /* MigrationHelper.swift */; }; - FD4C4E9C2B02E2A300C72199 /* DisplayPictureError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4C4E9B2B02E2A300C72199 /* DisplayPictureError.swift */; }; FD4C53AF2CC1D62E003B10F4 /* _035_ReworkRecipientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4C53AE2CC1D61E003B10F4 /* _035_ReworkRecipientState.swift */; }; FD52090328B4680F006098F6 /* RadioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52090228B4680F006098F6 /* RadioButton.swift */; }; FD52090528B4915F006098F6 /* PrivacySettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52090428B4915F006098F6 /* PrivacySettingsViewModel.swift */; }; @@ -712,6 +710,11 @@ FD5E93D12C100FD70038C25A /* FileUploadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387127B5BB3B00C60D73 /* FileUploadResponse.swift */; }; FD5E93D22C12B0580038C25A /* AppVersionResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383727B3863200C60D73 /* AppVersionResponse.swift */; }; FD61FCF92D308CC9005752DE /* GroupMemberSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD61FCF82D308CC5005752DE /* GroupMemberSpec.swift */; }; + FD636C672E9DAC4100965D56 /* HTTPFragmentParam+FileServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD636C662E9DAC4100965D56 /* HTTPFragmentParam+FileServer.swift */; }; + FD636C692E9F0D1400965D56 /* MockMediaDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD636C682E9F0D1100965D56 /* MockMediaDecoder.swift */; }; + FD636C6A2E9F0D1400965D56 /* MockMediaDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD636C682E9F0D1100965D56 /* MockMediaDecoder.swift */; }; + FD636C6B2E9F0D1400965D56 /* MockMediaDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD636C682E9F0D1100965D56 /* MockMediaDecoder.swift */; }; + FD636C6C2E9F0D1400965D56 /* MockMediaDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD636C682E9F0D1100965D56 /* MockMediaDecoder.swift */; }; FD65318A2AA025C500DFEEAA /* TestDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6531892AA025C500DFEEAA /* TestDependencies.swift */; }; FD65318B2AA025C500DFEEAA /* TestDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6531892AA025C500DFEEAA /* TestDependencies.swift */; }; FD65318C2AA025C500DFEEAA /* TestDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6531892AA025C500DFEEAA /* TestDependencies.swift */; }; @@ -865,7 +868,6 @@ FD778B6429B189FF001BAC6B /* _028_GenerateInitialUserConfigDumps.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD778B6329B189FF001BAC6B /* _028_GenerateInitialUserConfigDumps.swift */; }; FD78E9EE2DD6D32500D55B50 /* ImageDataManager+Singleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A622DD5BDDD00BEF49F /* ImageDataManager+Singleton.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 /* _042_MoveSettingsToLibSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78E9F72DDD742100D55B50 /* _042_MoveSettingsToLibSession.swift */; }; FD78E9FD2DDD97F200D55B50 /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78E9FC2DDD97F000D55B50 /* Setting.swift */; }; @@ -943,6 +945,7 @@ FD9DD2712A72516D00ECB68E /* TestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9DD2702A72516D00ECB68E /* TestExtensions.swift */; }; FD9DD2722A72516D00ECB68E /* TestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9DD2702A72516D00ECB68E /* TestExtensions.swift */; }; FD9DD2732A72516D00ECB68E /* TestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9DD2702A72516D00ECB68E /* TestExtensions.swift */; }; + FD9E26AF2EA5DC7D00404C7F /* _046_RemoveQuoteUnusedColumnsAndForeignKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9E26AE2EA5DC7100404C7F /* _046_RemoveQuoteUnusedColumnsAndForeignKeys.swift */; }; FDA335F52D91157A007E0EB6 /* SessionImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA335F42D911576007E0EB6 /* SessionImageView.swift */; }; FDAA16762AC28A3B00DDBF77 /* UserDefaultsType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA16752AC28A3B00DDBF77 /* UserDefaultsType.swift */; }; FDAA167B2AC28E2F00DDBF77 /* SnodeRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA167A2AC28E2F00DDBF77 /* SnodeRequestSpec.swift */; }; @@ -1036,6 +1039,17 @@ FDDF074429C3E3D000E5E8B5 /* FetchRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */; }; FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */; }; FDE125232A837E4E002DA685 /* MainAppContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE125222A837E4E002DA685 /* MainAppContext.swift */; }; + FDE287532E94C5CB00442E03 /* Update.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE287522E94C5C900442E03 /* Update.swift */; }; + FDE287552E94CFDB00442E03 /* URL+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE287542E94CFD400442E03 /* URL+Utilities.swift */; }; + FDE287572E94D7B800442E03 /* DeveloperSettingsFileServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE287562E94D7B200442E03 /* DeveloperSettingsFileServerViewModel.swift */; }; + FDE287592E95BBAF00442E03 /* HTTPFragmentParam.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE287582E95BBA900442E03 /* HTTPFragmentParam.swift */; }; + FDE2875D2E95CD3500442E03 /* StringCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE2875C2E95CD3300442E03 /* StringCache.swift */; }; + FDE2875F2E96061E00442E03 /* ExtendExpirationResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE2875E2E96061A00442E03 /* ExtendExpirationResponse.swift */; }; + FDE287612E970D5C00442E03 /* Async+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE287602E970D5900442E03 /* Async+Utilities.swift */; }; + FDE287622E970D5C00442E03 /* Async+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE287602E970D5900442E03 /* Async+Utilities.swift */; }; + FDE287632E970D5C00442E03 /* Async+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE287602E970D5900442E03 /* Async+Utilities.swift */; }; + FDE287642E970D5C00442E03 /* Async+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE287602E970D5900442E03 /* Async+Utilities.swift */; }; + FDE287662E970D9E00442E03 /* AsyncStream+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE287652E970D9A00442E03 /* AsyncStream+Utilities.swift */; }; FDE33BBC2D5C124900E56F42 /* DispatchTimeInterval+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE33BBB2D5C124300E56F42 /* DispatchTimeInterval+Utilities.swift */; }; FDE33BBE2D5C3AF100E56F42 /* _037_GroupsExpiredFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE33BBD2D5C3AE800E56F42 /* _037_GroupsExpiredFlag.swift */; }; FDE519F72AB7CDC700450C53 /* Result+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE519F62AB7CDC700450C53 /* Result+Utilities.swift */; }; @@ -1043,7 +1057,6 @@ 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 /* SessionAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE5219B2E08E76600061B8E /* SessionAsyncImage.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 */; }; FDE521A62E0E6C8C00061B8E /* MockNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5C2CAA28CF00C0B51B /* MockNotificationsManager.swift */; }; @@ -1073,8 +1086,6 @@ FDE754C02C9BAEF6002A2623 /* Array+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754BF2C9BAEF6002A2623 /* Array+Utilities.swift */; }; FDE754CC2C9BAF37002A2623 /* MediaUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754C72C9BAF36002A2623 /* MediaUtils.swift */; }; FDE754CD2C9BAF37002A2623 /* UTType+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754C82C9BAF36002A2623 /* UTType+Utilities.swift */; }; - FDE754CE2C9BAF37002A2623 /* ImageFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754C92C9BAF36002A2623 /* ImageFormat.swift */; }; - FDE754CF2C9BAF37002A2623 /* DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754CA2C9BAF37002A2623 /* DataSource.swift */; }; FDE754D22C9BAF53002A2623 /* JobDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754D12C9BAF53002A2623 /* JobDependencies.swift */; }; FDE754D42C9BAF6B002A2623 /* UICollectionView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754D32C9BAF6B002A2623 /* UICollectionView+ReusableView.swift */; }; FDE754DB2C9BAF8A002A2623 /* Crypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754D52C9BAF89002A2623 /* Crypto.swift */; }; @@ -1107,6 +1118,7 @@ FDE755242C9BC1D1002A2623 /* Publisher+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE755232C9BC1D1002A2623 /* Publisher+Utilities.swift */; }; FDEF573E2C40F2A100131302 /* GroupUpdateMemberLeftNotificationMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDEF573D2C40F2A100131302 /* GroupUpdateMemberLeftNotificationMessage.swift */; }; FDEF57712C44D2D300131302 /* GeoLite2-Country-Blocks-IPv4 in Resources */ = {isa = PBXBuildFile; fileRef = FDEF57702C44D2D300131302 /* GeoLite2-Country-Blocks-IPv4 */; }; + FDEFDC732E8B9F3300EBCD81 /* SDWebImageWebPCoder in Frameworks */ = {isa = PBXBuildFile; productRef = FDEFDC722E8B9F3300EBCD81 /* SDWebImageWebPCoder */; }; FDF01FAD2A9ECC4200CAF969 /* SingletonConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF01FAC2A9ECC4200CAF969 /* SingletonConfig.swift */; }; FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */; }; FDF0B7422804EA4F004C14C5 /* _007_SMK_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7412804EA4F004C14C5 /* _007_SMK_SetupStandardJobs.swift */; }; @@ -1393,7 +1405,6 @@ 34969559219B605E00DCFE74 /* ImagePickerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePickerController.swift; sourceTree = ""; }; 3496955B219B605E00DCFE74 /* PhotoLibrary.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoLibrary.swift; sourceTree = ""; }; 3496955F21A2FC8100DCFE74 /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; }; - 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSImagePickerController.swift; sourceTree = ""; }; 34A8B3502190A40E00218A25 /* MediaAlbumView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaAlbumView.swift; sourceTree = ""; }; 34B0796E1FD07B1E00E248C2 /* SignalShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SignalShareExtension.entitlements; sourceTree = ""; }; 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorView.swift; sourceTree = ""; }; @@ -1452,7 +1463,6 @@ 4C4AE69F224AF21900D4AF6F /* SendMediaNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMediaNavigationController.swift; sourceTree = ""; }; 4C63CBFF210A620B003AE45C /* SignalTSan.supp */ = {isa = PBXFileReference; lastKnownFileType = text; path = SignalTSan.supp; sourceTree = ""; }; 4C6F527B20FFE8400097DEEE /* SignalUBSan.supp */ = {isa = PBXFileReference; lastKnownFileType = text; path = SignalUBSan.supp; sourceTree = ""; }; - 4CA46F4B219CCC630038ABDE /* CaptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptionView.swift; sourceTree = ""; }; 4CA485BA2232339F004B9E7D /* PhotoCaptureViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoCaptureViewController.swift; sourceTree = ""; }; 4CC613352227A00400E21A3A /* ConversationSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationSearch.swift; sourceTree = ""; }; 70377AAA1918450100CAF501 /* MobileCoreServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MobileCoreServices.framework; path = System/Library/Frameworks/MobileCoreServices.framework; sourceTree = SDKROOT; }; @@ -1538,6 +1548,8 @@ 9409433F2C7ED62300D9D2E0 /* StartupError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupError.swift; sourceTree = ""; }; 941375BA2D5184B60058F244 /* HTTPHeader+SessionNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HTTPHeader+SessionNetwork.swift"; sourceTree = ""; }; 941375BC2D5195F30058F244 /* KeyValueStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyValueStore.swift; sourceTree = ""; }; + 9420CAC42E584B5800F738F6 /* GroupAdminCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = GroupAdminCTA.webp; sourceTree = ""; }; + 9420CAC52E584B5800F738F6 /* GroupNonAdminCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = GroupNonAdminCTA.webp; sourceTree = ""; }; 9422567D2C23F8BB00C0FDBF /* StartConversationScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StartConversationScreen.swift; sourceTree = ""; }; 9422567E2C23F8BB00C0FDBF /* NewMessageScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewMessageScreen.swift; sourceTree = ""; }; 9422567F2C23F8BB00C0FDBF /* InviteAFriendScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InviteAFriendScreen.swift; sourceTree = ""; }; @@ -1560,6 +1572,8 @@ 942ADDD32D9F960C006E0BB0 /* NewTagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTagView.swift; sourceTree = ""; }; 942BA9402E4487EE007C4595 /* LightBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightBox.swift; sourceTree = ""; }; 942BA9BE2E4ABB9F007C4595 /* _045_LastProfileUpdateTimestamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _045_LastProfileUpdateTimestamp.swift; sourceTree = ""; }; + 942BA9C02E4EA5BE007C4595 /* SessionLabelWithProBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionLabelWithProBadge.swift; sourceTree = ""; }; + 942BA9C32E55AB51007C4595 /* UILabel+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Utilities.swift"; sourceTree = ""; }; 94367C422C6C828500814252 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+DisappearingMessages.swift"; sourceTree = ""; }; 943C6D832B86B5F1004ACE64 /* Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Localization.swift; sourceTree = ""; }; @@ -1580,7 +1594,6 @@ 947D7FE62D51837200E8E413 /* Text+CopyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Text+CopyButton.swift"; sourceTree = ""; }; 9499E6022DDD9BEE00091434 /* ExpandableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandableLabel.swift; sourceTree = ""; }; 9499E68A2DF92F3B00091434 /* ThreadNotificationSettingsViewModelSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadNotificationSettingsViewModelSpec.swift; sourceTree = ""; }; - 949D91212E822D520074F595 /* String+SessionProBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+SessionProBadge.swift"; sourceTree = ""; }; 94A6B9DA2DD6BF6E00DB4B44 /* Constants+Apple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Constants+Apple.swift"; sourceTree = ""; }; 94AAB14A2E1E197800A6FA18 /* Modal+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Modal+SwiftUI.swift"; sourceTree = ""; }; 94AAB14C2E1F39AB00A6FA18 /* ProCTAModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProCTAModal.swift; sourceTree = ""; }; @@ -1606,6 +1619,10 @@ 94CD96312E1B88C20097754D /* ExpandingAttachmentsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandingAttachmentsButton.swift; sourceTree = ""; }; 94CD963C2E1BABE90097754D /* GenericCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = GenericCTA.webp; sourceTree = ""; }; 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = HigherCharLimitCTA.webp; sourceTree = ""; }; + 94D7167F2E8F6362008294EE /* HighlightMentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightMentionView.swift; sourceTree = ""; }; + 94D716812E8FA19D008294EE /* AttributedLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedLabel.swift; sourceTree = ""; }; + 94D716852E93394B008294EE /* SessionProBadge+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProBadge+Utilities.swift"; sourceTree = ""; }; + 94D716902E9379A4008294EE /* MentionUtilities+Attributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MentionUtilities+Attributes.swift"; sourceTree = ""; }; 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Localization+Style.swift"; sourceTree = ""; }; A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; A163E8AA16F3F6A90094D68B /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; @@ -1629,7 +1646,6 @@ B835247825C38D880089A44F /* MessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCell.swift; sourceTree = ""; }; B835249A25C3AB650089A44F /* VisibleMessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibleMessageCell.swift; sourceTree = ""; }; B83524A425C3BA4B0089A44F /* InfoMessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoMessageCell.swift; sourceTree = ""; }; - B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Scaling.swift"; sourceTree = ""; }; B84664F4235022F30083A1CD /* MentionUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionUtilities.swift; sourceTree = ""; }; B849789525D4A2F500D0D0B3 /* LinkPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewView.swift; sourceTree = ""; }; B84A89BB25DE328A0040017D /* ProfilePictureVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePictureVC.swift; sourceTree = ""; }; @@ -1640,7 +1656,6 @@ B86BD08523399CEF000F5AE3 /* SeedModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeedModal.swift; sourceTree = ""; }; B877E24126CA12910007970A /* CallVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVC.swift; sourceTree = ""; }; B877E24526CA13BA0007970A /* CallVC+Camera.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CallVC+Camera.swift"; sourceTree = ""; }; - B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Interaction.swift"; sourceTree = ""; }; B879D448247E1BE300DB3608 /* PathVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathVC.swift; sourceTree = ""; }; B879D44A247E1D9200DB3608 /* PathStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathStatusView.swift; sourceTree = ""; }; B885D5F52334A32100EE0D8E /* UIView+Constraints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Constraints.swift"; sourceTree = ""; }; @@ -1695,12 +1710,10 @@ C33FD9AE255A548A00E217F9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinkPreviewDraft.swift; sourceTree = ""; }; - C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSSignalAddress.swift; sourceTree = ""; }; C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PushNotificationAPI.swift; sourceTree = ""; }; C34C8F7323A7830A00D82669 /* SpaceMono-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SpaceMono-Bold.ttf"; sourceTree = ""; }; C352A30825574D8400338F3E /* Message+Destination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+Destination.swift"; sourceTree = ""; }; @@ -1712,7 +1725,6 @@ C374EEF325DB31D40073A857 /* VoiceMessageRecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingView.swift; sourceTree = ""; }; 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; }; 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; }; @@ -1725,7 +1737,6 @@ C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSAudioPlayer.h; path = SessionMessagingKit/Utilities/OWSAudioPlayer.h; sourceTree = SOURCE_ROOT; }; C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSAudioPlayer.m; path = SessionMessagingKit/Utilities/OWSAudioPlayer.m; sourceTree = SOURCE_ROOT; }; C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSWindowManager.h; path = SessionMessagingKit/Utilities/OWSWindowManager.h; sourceTree = SOURCE_ROOT; }; - C38EF304255B6DBE007E1867 /* ImageCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ImageCache.swift; path = SignalUtilitiesKit/Utilities/ImageCache.swift; sourceTree = SOURCE_ROOT; }; C38EF306255B6DBE007E1867 /* OWSWindowManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSWindowManager.m; path = SessionMessagingKit/Utilities/OWSWindowManager.m; sourceTree = SOURCE_ROOT; }; C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = DeviceSleepManager.swift; path = SessionMessagingKit/Utilities/DeviceSleepManager.swift; sourceTree = SOURCE_ROOT; }; C38EF349255B6DC7007E1867 /* ModalActivityIndicatorViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ModalActivityIndicatorViewController.swift; path = "SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift"; sourceTree = SOURCE_ROOT; }; @@ -1844,7 +1855,6 @@ FD09799227FE693200936362 /* Interaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Interaction.swift; sourceTree = ""; }; FD09799827FFC1A300936362 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; FD09799A27FFC82D00936362 /* Quote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quote.swift; sourceTree = ""; }; - FD09B7E228865FDA00ED0B66 /* HighlightMentionBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightMentionBackgroundView.swift; sourceTree = ""; }; FD09B7E4288670BB00ED0B66 /* _017_EmojiReacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _017_EmojiReacts.swift; sourceTree = ""; }; FD09B7E6288670FD00ED0B66 /* Reaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reaction.swift; sourceTree = ""; }; FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryViewModel.swift; sourceTree = ""; }; @@ -1903,7 +1913,7 @@ FD2272642C32911B004D8A6C /* GroupPromoteMemberJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPromoteMemberJob.swift; sourceTree = ""; }; FD2272662C32911B004D8A6C /* GarbageCollectionJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GarbageCollectionJob.swift; sourceTree = ""; }; FD2272672C32911B004D8A6C /* GetExpirationJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GetExpirationJob.swift; sourceTree = ""; }; - FD22726A2C32911C004D8A6C /* UpdateProfilePictureJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UpdateProfilePictureJob.swift; sourceTree = ""; }; + FD22726A2C32911C004D8A6C /* ReuploadUserDisplayPictureJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReuploadUserDisplayPictureJob.swift; sourceTree = ""; }; FD2272822C337830004D8A6C /* GroupPoller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPoller.swift; sourceTree = ""; }; FD2272952C33E335004D8A6C /* ContentProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentProxy.swift; sourceTree = ""; }; FD2272962C33E335004D8A6C /* UpdatableTimestamp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UpdatableTimestamp.swift; sourceTree = ""; }; @@ -1928,7 +1938,6 @@ FD2272D72C34EDE6004D8A6C /* SnodeAPIEndpoint.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnodeAPIEndpoint.swift; sourceTree = ""; }; FD2272DC2C34EFFA004D8A6C /* AppSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSetup.swift; sourceTree = ""; }; FD2272DF2C3502BE004D8A6C /* Setting+Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Setting+Theme.swift"; sourceTree = ""; }; - FD2272E52C351378004D8A6C /* SUIKImageFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SUIKImageFormat.swift; sourceTree = ""; }; FD2272E92C351CA7004D8A6C /* Threading.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Threading.swift; sourceTree = ""; }; FD2272EB2C352155004D8A6C /* Feature.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Feature.swift; sourceTree = ""; }; FD2272ED2C3521D6004D8A6C /* FeatureConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeatureConfig.swift; sourceTree = ""; }; @@ -2036,7 +2045,6 @@ FD49E2452B05C1D500FFBBB5 /* MockKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockKeychain.swift; sourceTree = ""; }; FD4B200D283492210034334B /* InsetLockableTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetLockableTableView.swift; sourceTree = ""; }; FD4BB22A2D63F20600D0DC3D /* MigrationHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationHelper.swift; sourceTree = ""; }; - FD4C4E9B2B02E2A300C72199 /* DisplayPictureError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DisplayPictureError.swift; sourceTree = ""; }; FD4C53AE2CC1D61E003B10F4 /* _035_ReworkRecipientState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _035_ReworkRecipientState.swift; sourceTree = ""; }; FD52090228B4680F006098F6 /* RadioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioButton.swift; sourceTree = ""; }; FD52090428B4915F006098F6 /* PrivacySettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettingsViewModel.swift; sourceTree = ""; }; @@ -2061,6 +2069,8 @@ FD5D201D27B0D87C00FEA984 /* SessionId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionId.swift; sourceTree = ""; }; FD61FCF82D308CC5005752DE /* GroupMemberSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberSpec.swift; sourceTree = ""; }; FD61FCFA2D34A5DE005752DE /* _023_SplitSnodeReceivedMessageInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _023_SplitSnodeReceivedMessageInfo.swift; sourceTree = ""; }; + FD636C662E9DAC4100965D56 /* HTTPFragmentParam+FileServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HTTPFragmentParam+FileServer.swift"; sourceTree = ""; }; + FD636C682E9F0D1100965D56 /* MockMediaDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMediaDecoder.swift; sourceTree = ""; }; FD6531892AA025C500DFEEAA /* TestDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDependencies.swift; sourceTree = ""; }; FD6673FC2D77F54400041530 /* ScreenLockViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenLockViewController.swift; sourceTree = ""; }; FD6673FE2D77F9BE00041530 /* ScreenLock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenLock.swift; sourceTree = ""; }; @@ -2142,7 +2152,6 @@ FD7728972849E8110018502F /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; FD778B6329B189FF001BAC6B /* _028_GenerateInitialUserConfigDumps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _028_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 /* _042_MoveSettingsToLibSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _042_MoveSettingsToLibSession.swift; sourceTree = ""; }; FD78E9FC2DDD97F000D55B50 /* Setting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = ""; }; @@ -2211,6 +2220,7 @@ FD99D0912D10F5EB005D2E15 /* ThreadSafeSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafeSpec.swift; sourceTree = ""; }; FD9AECA42AAA9609009B3406 /* NotificationResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationResolution.swift; sourceTree = ""; }; FD9DD2702A72516D00ECB68E /* TestExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestExtensions.swift; sourceTree = ""; }; + FD9E26AE2EA5DC7100404C7F /* _046_RemoveQuoteUnusedColumnsAndForeignKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _046_RemoveQuoteUnusedColumnsAndForeignKeys.swift; sourceTree = ""; }; FDA335F42D911576007E0EB6 /* SessionImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionImageView.swift; sourceTree = ""; }; FDAA16752AC28A3B00DDBF77 /* UserDefaultsType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsType.swift; sourceTree = ""; }; FDAA167A2AC28E2F00DDBF77 /* SnodeRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeRequestSpec.swift; sourceTree = ""; }; @@ -2315,6 +2325,14 @@ FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FetchRequest+Utilities.swift"; sourceTree = ""; }; FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerSpec.swift; sourceTree = ""; }; FDE125222A837E4E002DA685 /* MainAppContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainAppContext.swift; sourceTree = ""; }; + FDE287522E94C5C900442E03 /* Update.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update.swift; sourceTree = ""; }; + FDE287542E94CFD400442E03 /* URL+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Utilities.swift"; sourceTree = ""; }; + FDE287562E94D7B200442E03 /* DeveloperSettingsFileServerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsFileServerViewModel.swift; sourceTree = ""; }; + FDE287582E95BBA900442E03 /* HTTPFragmentParam.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPFragmentParam.swift; sourceTree = ""; }; + FDE2875C2E95CD3300442E03 /* StringCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringCache.swift; sourceTree = ""; }; + FDE2875E2E96061A00442E03 /* ExtendExpirationResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendExpirationResponse.swift; sourceTree = ""; }; + FDE287602E970D5900442E03 /* Async+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Async+Utilities.swift"; sourceTree = ""; }; + FDE287652E970D9A00442E03 /* AsyncStream+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncStream+Utilities.swift"; sourceTree = ""; }; FDE33BBB2D5C124300E56F42 /* DispatchTimeInterval+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchTimeInterval+Utilities.swift"; sourceTree = ""; }; FDE33BBD2D5C3AE800E56F42 /* _037_GroupsExpiredFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _037_GroupsExpiredFlag.swift; sourceTree = ""; }; FDE519F62AB7CDC700450C53 /* Result+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Result+Utilities.swift"; sourceTree = ""; }; @@ -2322,7 +2340,6 @@ 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 /* SessionAsyncImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAsyncImage.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 = ""; }; FDE6E99729F8E63A00F93C5D /* Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Accessibility.swift; sourceTree = ""; }; @@ -2354,8 +2371,6 @@ FDE754BF2C9BAEF6002A2623 /* Array+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Array+Utilities.swift"; sourceTree = ""; }; FDE754C72C9BAF36002A2623 /* MediaUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaUtils.swift; sourceTree = ""; }; FDE754C82C9BAF36002A2623 /* UTType+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UTType+Utilities.swift"; sourceTree = ""; }; - FDE754C92C9BAF36002A2623 /* ImageFormat.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageFormat.swift; sourceTree = ""; }; - FDE754CA2C9BAF37002A2623 /* DataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataSource.swift; sourceTree = ""; }; FDE754D12C9BAF53002A2623 /* JobDependencies.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JobDependencies.swift; sourceTree = ""; }; FDE754D32C9BAF6B002A2623 /* UICollectionView+ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UICollectionView+ReusableView.swift"; sourceTree = ""; }; FDE754D52C9BAF89002A2623 /* Crypto.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Crypto.swift; sourceTree = ""; }; @@ -2545,6 +2560,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + FDEFDC732E8B9F3300EBCD81 /* SDWebImageWebPCoder in Frameworks */, FD9BDE012A5D24EA005F1EBC /* SessionUIKit.framework in Frameworks */, FD6673FA2D7021F800041530 /* SessionUtil in Frameworks */, FD2286732C38D43900BC06F7 /* DifferenceKit in Frameworks */, @@ -2731,6 +2747,7 @@ 4C090A1A210FD9C7001FD7F9 /* HapticFeedback.swift */, FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */, FDE521932E050B0800061B8E /* DismissCallbackAVPlayerViewController.swift */, + 94D716902E9379A4008294EE /* MentionUtilities+Attributes.swift */, FD981BD42DC978AC00564172 /* MentionUtilities+DisplayName.swift */, 45C0DC1A1E68FE9000E04C47 /* UIApplication+OWS.swift */, 45C0DC1D1E69011F00E04C47 /* UIStoryboard+OWS.swift */, @@ -2742,8 +2759,6 @@ FDB3DA8C2E24881200148F8D /* ImageLoading+Convenience.swift */, FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */, FDB3DA832E1CA21C00148F8D /* UIActivityViewController+Utilities.swift */, - B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */, - B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */, FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */, FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */, C3548F0724456AB6009433A8 /* UIView+Wrapping.swift */, @@ -2865,6 +2880,7 @@ 942256932C23F8DD00C0FDBF /* SwiftUI */ = { isa = PBXGroup; children = ( + 94D716812E8FA19D008294EE /* AttributedLabel.swift */, 942BA9402E4487EE007C4595 /* LightBox.swift */, 94B6BB032E3B208200E718BB /* Seperator+SwiftUI.swift */, 94B6BAFF2E3AE83500E718BB /* QRCodeView.swift */, @@ -2927,6 +2943,8 @@ 94CD963F2E1BABE90097754D /* WebPImages */ = { isa = PBXGroup; children = ( + 9420CAC42E584B5800F738F6 /* GroupAdminCTA.webp */, + 9420CAC52E584B5800F738F6 /* GroupNonAdminCTA.webp */, 94B6BB052E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp */, 94AAB15B2E24C97400A6FA18 /* AnimatedProfileCTA.webp */, 94AAB1562E24BD3700A6FA18 /* PinnedConversationsCTA.webp */, @@ -3054,8 +3072,6 @@ B8A582AF258C665E00AFD84C /* Media */ = { isa = PBXGroup; children = ( - FDE754CA2C9BAF37002A2623 /* DataSource.swift */, - FDE754C92C9BAF36002A2623 /* ImageFormat.swift */, FDE754C72C9BAF36002A2623 /* MediaUtils.swift */, FDE754C82C9BAF36002A2623 /* UTType+Utilities.swift */, ); @@ -3122,7 +3138,6 @@ FD71164028E2C83000B47552 /* Views */, C354E75923FE2A7600CE22E3 /* BaseVC.swift */, FDE754E42C9BB012002A2623 /* BezierPathView.swift */, - 4CA46F4B219CCC630038ABDE /* CaptionView.swift */, B8BB82AA238F669C00BA5194 /* FullConversationCell.swift */, 7B81FB582AB01AA8002FB267 /* LoadingIndicatorView.swift */, 4542DF53208D40AC007B4E76 /* LoadingViewController.swift */, @@ -3211,14 +3226,12 @@ isa = PBXGroup; children = ( FDF0B7562807F35E004C14C5 /* Errors */, - C3D9E3B52567685D0040E4F3 /* Attachments */, C32C5D22256DD496003C73A2 /* Link Previews */, C379DC6825672B5E0002D4EB /* Notifications */, C32C59F8256DB5A6003C73A2 /* Pollers */, C32C5B1B256DC160003C73A2 /* Quotes */, C32C5995256DAF85003C73A2 /* Typing Indicators */, FD7728A1284F0DF50018502F /* Message Handling */, - FD78E9F32DDABA4200D55B50 /* AttachmentUploader.swift */, B8D0A25825E367AC00C1835E /* Notification+MessageReceiver.swift */, C300A5F12554B09800555489 /* MessageSender.swift */, FDF0B75B2807F41D004C14C5 /* MessageSender+Convenience.swift */, @@ -3356,7 +3369,6 @@ isa = PBXGroup; children = ( 94B6BB012E3AE85800E718BB /* QRCode.swift */, - 949D91212E822D520074F595 /* String+SessionProBadge.swift */, 94CD962F2E1B88430097754D /* CGRect+Utilities.swift */, FD848B9728422F1A000E298B /* Date+Utilities.swift */, FD8A5B282DC060DD004C689B /* Double+Utilities.swift */, @@ -3373,6 +3385,7 @@ FD71161F28D97ABC00B47552 /* UIImage+Utilities.swift */, B885D5F52334A32100EE0D8E /* UIView+Constraints.swift */, C33100272559000A00070591 /* UIView+Utilities.swift */, + 942BA9C32E55AB51007C4595 /* UILabel+Utilities.swift */, ); path = Utilities; sourceTree = ""; @@ -3384,7 +3397,9 @@ 94CD96282E1B855E0097754D /* Input View */, 942256932C23F8DD00C0FDBF /* SwiftUI */, B8B5BCEB2394D869003823C9 /* SessionButton.swift */, + C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */, 94CD95BA2E08D9D40097754D /* SessionProBadge.swift */, + 942BA9C02E4EA5BE007C4595 /* SessionLabelWithProBadge.swift */, FD52090228B4680F006098F6 /* RadioButton.swift */, B8BB82B02390C37000BA5194 /* SearchBar.swift */, B8BB82B82394911B00BA5194 /* Separator.swift */, @@ -3392,7 +3407,7 @@ B8BB82B423947F2D00BA5194 /* SNTextField.swift */, C3C3CF8824D8EED300E1CCE7 /* SNTextView.swift */, 7BBBDC43286EAD2D00747E59 /* TappableLabel.swift */, - FD09B7E228865FDA00ED0B66 /* HighlightMentionBackgroundView.swift */, + 94D7167F2E8F6362008294EE /* HighlightMentionView.swift */, C38EF3EE255B6DF6007E1867 /* GradientView.swift */, FD8A5B0F2DBF2F14004C689B /* NavBarSessionIcon.swift */, C38EF2A3255B6D93007E1867 /* PlaceholderIcon.swift */, @@ -3452,8 +3467,8 @@ FD2272632C32911B004D8A6C /* MessageSendJob.swift */, FD2272572C32911A004D8A6C /* ProcessPendingGroupMemberRemovalsJob.swift */, FD22725D2C32911B004D8A6C /* RetrieveDefaultOpenGroupRoomsJob.swift */, + FD22726A2C32911C004D8A6C /* ReuploadUserDisplayPictureJob.swift */, FD2272532C32911A004D8A6C /* SendReadReceiptsJob.swift */, - FD22726A2C32911C004D8A6C /* UpdateProfilePictureJob.swift */, ); path = Jobs; sourceTree = ""; @@ -3561,7 +3576,6 @@ 7BBBDC452875600700747E59 /* DocumentTitleViewController.swift */, 346B66301F4E29B200E5122F /* CropScaleImageViewController.swift */, 34969559219B605E00DCFE74 /* ImagePickerController.swift */, - 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */, FD71160328C95B5600B47552 /* PhotoCollectionPickerViewModel.swift */, 3496955B219B605E00DCFE74 /* PhotoLibrary.swift */, 4CA485BA2232339F004B9E7D /* PhotoCaptureViewController.swift */, @@ -3680,11 +3694,9 @@ 94B6BAF52E30A88800E718BB /* SessionProState.swift */, FD428B1E2B4B758B006D0888 /* AppReadiness.swift */, FDE5218D2E03A06700061B8E /* AttachmentManager.swift */, - FDE5219D2E0D0B9800061B8E /* AsyncAccessible.swift */, FD47E0B02AA6A05800A55E41 /* Authentication+SessionMessagingKit.swift */, FD859EF127BF6BA200510D0C /* Data+Utilities.swift */, C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */, - FD4C4E9B2B02E2A300C72199 /* DisplayPictureError.swift */, FD2273072C353109004D8A6C /* DisplayPictureManager.swift */, FD981BC52DC3310800564172 /* ExtensionHelper.swift */, C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */, @@ -3746,6 +3758,7 @@ FD2272BD2C34B710004D8A6C /* Publisher+Utilities.swift */, FD83DCDC2A739D350065FFAE /* RetryWithDependencies.swift */, C3C2A5D22553860900C340D1 /* String+Trimming.swift */, + FDE287542E94CFD400442E03 /* URL+Utilities.swift */, FD2272A82C33E337004D8A6C /* URLResponse+Utilities.swift */, ); path = Utilities; @@ -3849,9 +3862,6 @@ C38EF240255B6D67007E1867 /* UIView+OWS.swift */, FD71161D28D9772700B47552 /* UIViewController+OWS.swift */, C38EF2B1255B6D9C007E1867 /* UIViewController+Utilities.swift */, - C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */, - C38EF304255B6DBE007E1867 /* ImageCache.swift */, - C33FDA9E255A57FF00E217F9 /* ReverseDispatchQueue.swift */, C38EF241255B6D67007E1867 /* Collection+OWS.swift */, C38EF3AE255B6DE5007E1867 /* OrderedDictionary.swift */, FDB3487D2BE856C800B716C2 /* UIBezierPath+Utilities.swift */, @@ -3859,14 +3869,6 @@ path = Utilities; sourceTree = ""; }; - C3D9E3B52567685D0040E4F3 /* Attachments */ = { - isa = PBXGroup; - children = ( - C38EF224255B6D5D007E1867 /* SignalAttachment.swift */, - ); - path = Attachments; - sourceTree = ""; - }; C3F0A58F255C8E3D007BE2A3 /* Meta */ = { isa = PBXGroup; children = ( @@ -4002,6 +4004,7 @@ 94C58AC82D2E036E00609195 /* Permissions.swift */, FD97B23F2A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift */, FD78EA052DDEC8F100D55B50 /* AsyncSequence+Utilities.swift */, + FDE287652E970D9A00442E03 /* AsyncStream+Utilities.swift */, FD7443452D07CA9F00862443 /* CGFloat+Utilities.swift */, FD7443462D07CA9F00862443 /* CGPoint+Utilities.swift */, FD7443472D07CA9F00862443 /* CGRect+Utilities.swift */, @@ -4107,6 +4110,7 @@ FD05594D2E012D1A00DC48CE /* _043_RenameAttachments.swift */, 94CD95C02E0CBF1C0097754D /* _044_AddProMessageFlag.swift */, 942BA9BE2E4ABB9F007C4595 /* _045_LastProfileUpdateTimestamp.swift */, + FD9E26AE2EA5DC7100404C7F /* _046_RemoveQuoteUnusedColumnsAndForeignKeys.swift */, ); path = Migrations; sourceTree = ""; @@ -4208,7 +4212,9 @@ FD6A38F02C2A66B100762359 /* KeychainStorage.swift */, FD78EA032DDEC3C000D55B50 /* MultiTaskManager.swift */, FD6F5B5F2E657A32009A8D01 /* StreamLifecycleManager.swift */, + FDE2875C2E95CD3300442E03 /* StringCache.swift */, FD2272E92C351CA7004D8A6C /* Threading.swift */, + FDE287522E94C5C900442E03 /* Update.swift */, FDAA16752AC28A3B00DDBF77 /* UserDefaultsType.swift */, ); path = Types; @@ -4440,6 +4446,7 @@ isa = PBXGroup; children = ( FDC4383727B3863200C60D73 /* AppVersionResponse.swift */, + FDE2875E2E96061A00442E03 /* ExtendExpirationResponse.swift */, ); path = Models; sourceTree = ""; @@ -4644,7 +4651,6 @@ FD71163128E2C42A00B47552 /* IconSize.swift */, FDB11A602DD5BDC900BEF49F /* ImageDataManager.swift */, 943C6D832B86B5F1004ACE64 /* Localization.swift */, - FD2272E52C351378004D8A6C /* SUIKImageFormat.swift */, ); path = Types; sourceTree = ""; @@ -4652,13 +4658,13 @@ FD71164028E2C83000B47552 /* Views */ = { isa = PBXGroup; children = ( + 94D716852E93394B008294EE /* SessionProBadge+Utilities.swift */, 942256A02C23F90700C0FDBF /* CustomTopTabBar.swift */, FD7115EF28C5D7DE00B47552 /* SessionHeaderView.swift */, FD37EA0A28AB12E2003AE748 /* SessionCell.swift */, FD71164728E2CE8700B47552 /* SessionCell+AccessoryView.swift */, FD71164528E2CC1300B47552 /* SessionHighlightingBackgroundLabel.swift */, 7B71A98E2925E2A600E54854 /* SessionFooterView.swift */, - C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */, ); path = Views; sourceTree = ""; @@ -4804,6 +4810,7 @@ FD83B9BC27CF2215005E1583 /* _SharedTestUtilities */ = { isa = PBXGroup; children = ( + FDE287602E970D5900442E03 /* Async+Utilities.swift */, FD0150472CA243CB005B08A1 /* Mock.swift */, FD0969F82A69FFE700C5C365 /* Mocked.swift */, FD481A932CAE0ADD00ECC4CF /* MockAppContext.swift */, @@ -4814,6 +4821,7 @@ FD49E2452B05C1D500FFBBB5 /* MockKeychain.swift */, FD0150372CA24328005B08A1 /* MockJobRunner.swift */, FDB11A552DD17C3000BEF49F /* MockLogger.swift */, + FD636C682E9F0D1100965D56 /* MockMediaDecoder.swift */, FD83B9BD27CF2243005E1583 /* TestConstants.swift */, FD6531892AA025C500DFEEAA /* TestDependencies.swift */, FD9DD2702A72516D00ECB68E /* TestExtensions.swift */, @@ -5097,6 +5105,7 @@ FDE71B012E77CCE30023F5F9 /* Types */ = { isa = PBXGroup; children = ( + FD636C662E9DAC4100965D56 /* HTTPFragmentParam+FileServer.swift */, FDE71B022E77CCE90023F5F9 /* HTTPHeader+FileServer.swift */, ); path = Types; @@ -5107,6 +5116,7 @@ children = ( FDC1BD672CFE6EEA002CDC71 /* DeveloperSettingsViewModel.swift */, FD860CBD2D6E7DA000BBE29C /* DeveloperSettingsViewModel+Testing.swift */, + FDE287562E94D7B200442E03 /* DeveloperSettingsFileServerViewModel.swift */, FDE71B0A2E7935250023F5F9 /* DeveloperSettingsGroupsViewModel.swift */, FDE71B0C2E793B1F0023F5F9 /* DeveloperSettingsProViewModel.swift */, ); @@ -5222,6 +5232,7 @@ FD2272A62C33E337004D8A6C /* HTTPHeader.swift */, FD2272A72C33E337004D8A6C /* HTTPMethod.swift */, FD22729A2C33E336004D8A6C /* HTTPQueryParam.swift */, + FDE287582E95BBA900442E03 /* HTTPFragmentParam.swift */, FD2272982C33E336004D8A6C /* IPv4.swift */, FD22729B2C33E336004D8A6C /* JSON.swift */, FD2272992C33E336004D8A6C /* Network.swift */, @@ -5529,6 +5540,7 @@ FD6A39122C2A946A00762359 /* SwiftProtobuf */, FD2286722C38D43900BC06F7 /* DifferenceKit */, FD6673F92D7021F800041530 /* SessionUtil */, + FDEFDC722E8B9F3300EBCD81 /* SDWebImageWebPCoder */, ); productName = SessionMessagingKit; productReference = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; @@ -5793,6 +5805,7 @@ FD756BEE2D06686500BD7199 /* XCRemoteSwiftPackageReference "session-lucide" */, 946F5A712D5DA3AC00A5ADCE /* XCRemoteSwiftPackageReference "PunycodeSwift" */, FD6673F42D7021E700041530 /* XCRemoteSwiftPackageReference "libsession-util-spm" */, + FDEFDC712E8B9F3300EBCD81 /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */, ); productRefGroup = D221A08A169C9E5E00537ABF /* Products */; projectDirPath = ""; @@ -5827,6 +5840,8 @@ 4535186E1FC635DD00210559 /* MainInterface.storyboard in Resources */, B8D07406265C683A00F77E07 /* ElegantIcons.ttf in Resources */, FD86FDA42BC51C5400EC251B /* PrivacyInfo.xcprivacy in Resources */, + 9420CAC62E584B5800F738F6 /* GroupAdminCTA.webp in Resources */, + 9420CAC72E584B5800F738F6 /* GroupNonAdminCTA.webp in Resources */, 3478504C1FD7496D007B8332 /* Images.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -5897,6 +5912,8 @@ 45A2F005204473A3002E978A /* NewMessage.aifc in Resources */, 45B74A882044AAB600CD42F8 /* aurora.aifc in Resources */, 45B74A742044AAB600CD42F8 /* aurora-quiet.aifc in Resources */, + 9420CAC82E584B5800F738F6 /* GroupAdminCTA.webp in Resources */, + 9420CAC92E584B5800F738F6 /* GroupNonAdminCTA.webp in Resources */, 7B0EFDF4275490EA00FFAAE7 /* ringing.mp3 in Resources */, 45B74A852044AAB600CD42F8 /* bamboo.aifc in Resources */, 45B74A782044AAB600CD42F8 /* bamboo-quiet.aifc in Resources */, @@ -6249,6 +6266,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 942BA9C22E53F694007C4595 /* SRCopyableLabel.swift in Sources */, 942256952C23F8DD00C0FDBF /* AttributedText.swift in Sources */, 942256992C23F8DD00C0FDBF /* Toast.swift in Sources */, C331FF972558FA6B00070591 /* Fonts.swift in Sources */, @@ -6266,7 +6284,6 @@ 7BBBDC44286EAD2D00747E59 /* TappableLabel.swift in Sources */, FD8A5B252DC05B16004C689B /* Number+Utilities.swift in Sources */, FDE5219C2E08E76C00061B8E /* SessionAsyncImage.swift in Sources */, - FD09B7E328865FDA00ED0B66 /* HighlightMentionBackgroundView.swift in Sources */, FD3FAB632AEB9A1500DC5421 /* ToastController.swift in Sources */, C331FFE72558FB0000070591 /* SNTextField.swift in Sources */, 942256962C23F8DD00C0FDBF /* CompatibleScrollingVStack.swift in Sources */, @@ -6274,8 +6291,10 @@ C331FFE32558FB0000070591 /* TabBar.swift in Sources */, FD37E9D528A1FCE8003AE748 /* Theme+OceanLight.swift in Sources */, FDF848F129406A30007DCAE5 /* Format.swift in Sources */, + 942BA9C42E55AB54007C4595 /* UILabel+Utilities.swift in Sources */, FD8A5B112DBF34BD004C689B /* Date+Utilities.swift in Sources */, FDB348632BE3774000B716C2 /* BezierPathView.swift in Sources */, + 94D716802E8F6363008294EE /* HighlightMentionView.swift in Sources */, FD8A5B292DC060E2004C689B /* Double+Utilities.swift in Sources */, FD8A5B0E2DBF2DB1004C689B /* SessionHostingViewController.swift in Sources */, 94CD962D2E1B85920097754D /* InputViewButton.swift in Sources */, @@ -6300,7 +6319,7 @@ 94AAB1512E1F753500A6FA18 /* CyclicGradientView.swift in Sources */, FD8A5B1E2DBF4BBC004C689B /* ScreenLock+Errors.swift in Sources */, 942BA9412E4487F7007C4595 /* LightBox.swift in Sources */, - 949D91222E822D5A0074F595 /* String+SessionProBadge.swift in Sources */, + 942BA9C12E4EA5CB007C4595 /* SessionLabelWithProBadge.swift in Sources */, FD2272E62C351378004D8A6C /* SUIKImageFormat.swift in Sources */, 7BF8D1FB2A70AF57005F1D6E /* SwiftUI+Theme.swift in Sources */, FDB11A612DD5BDCC00BEF49F /* ImageDataManager.swift in Sources */, @@ -6328,6 +6347,7 @@ 94AAB14F2E1F6CC100A6FA18 /* SessionProBadge+SwiftUI.swift in Sources */, 94AAB14B2E1E198200A6FA18 /* Modal+SwiftUI.swift in Sources */, 94AAB1532E1F8AE200A6FA18 /* ShineButton.swift in Sources */, + 94D716822E8FA1A0008294EE /* AttributedLabel.swift in Sources */, FD37E9D728A20B5D003AE748 /* UIColor+Utilities.swift in Sources */, 94A6B9DB2DD6BF7C00DB4B44 /* Constants+Apple.swift in Sources */, FD37E9C328A1C6F3003AE748 /* ThemeManager.swift in Sources */, @@ -6359,12 +6379,10 @@ C38EF3C2255B6DE7007E1867 /* ImageEditorPaletteView.swift in Sources */, C38EF363255B6DCC007E1867 /* ModalActivityIndicatorViewController.swift in Sources */, C38EF3C1255B6DE7007E1867 /* ImageEditorBrushViewController.swift in Sources */, - C33FDD8D255A582000E217F9 /* OWSSignalAddress.swift in Sources */, C38EF388255B6DD2007E1867 /* AttachmentApprovalViewController.swift in Sources */, C38EF38C255B6DD2007E1867 /* ApprovalRailCellView.swift in Sources */, C38EF3C7255B6DE7007E1867 /* ImageEditorCanvasView.swift in Sources */, C38EF400255B6DF7007E1867 /* GalleryRailView.swift in Sources */, - C38EF32E255B6DBF007E1867 /* ImageCache.swift in Sources */, C38EF3BA255B6DE7007E1867 /* ImageEditorItem.swift in Sources */, C38EF24E255B6D67007E1867 /* Collection+OWS.swift in Sources */, FD2272DD2C34EFFA004D8A6C /* AppSetup.swift in Sources */, @@ -6381,7 +6399,6 @@ C38EF3BB255B6DE7007E1867 /* ImageEditorStrokeItem.swift in Sources */, C38EF3C0255B6DE7007E1867 /* ImageEditorCropViewController.swift in Sources */, C38EF3BD255B6DE7007E1867 /* ImageEditorTransform.swift in Sources */, - C33FDC58255A582000E217F9 /* ReverseDispatchQueue.swift in Sources */, C38EF3F9255B6DF7007E1867 /* OWSLayerView.swift in Sources */, C38EF3B9255B6DE7007E1867 /* ImageEditorPinchGestureRecognizer.swift in Sources */, FDB3487E2BE856C800B716C2 /* UIBezierPath+Utilities.swift in Sources */, @@ -6420,12 +6437,14 @@ FDD20C162A09E64A003898FB /* GetExpiriesRequest.swift in Sources */, FDF848BC29405C5A007DCAE5 /* SnodeRecursiveResponse.swift in Sources */, FDF848C029405C5A007DCAE5 /* ONSResolveResponse.swift in Sources */, + FD636C672E9DAC4100965D56 /* HTTPFragmentParam+FileServer.swift in Sources */, FD2272AC2C33E337004D8A6C /* IPv4.swift in Sources */, FD2272D82C34EDE7004D8A6C /* SnodeAPIEndpoint.swift in Sources */, FDFC4D9A29F0C51500992FB6 /* String+Trimming.swift in Sources */, FD6B92992E77A06E004463B5 /* Token.swift in Sources */, FDB5DAF32A96DD4F002C8721 /* PreparedRequest+Sending.swift in Sources */, FDF848C629405C5B007DCAE5 /* DeleteAllMessagesRequest.swift in Sources */, + FDE2875F2E96061E00442E03 /* ExtendExpirationResponse.swift in Sources */, FDF848D429405C5B007DCAE5 /* DeleteAllBeforeResponse.swift in Sources */, FD6B92AF2E77AA03004463B5 /* HTTPQueryParam+SOGS.swift in Sources */, FD6B92B02E77AA03004463B5 /* Request+SOGS.swift in Sources */, @@ -6484,8 +6503,10 @@ FD2272AF2C33E337004D8A6C /* JSON.swift in Sources */, FD2272D62C34ED6A004D8A6C /* RetryWithDependencies.swift in Sources */, FDF848D229405C5B007DCAE5 /* LegacyGetMessagesRequest.swift in Sources */, + FDE287592E95BBAF00442E03 /* HTTPFragmentParam.swift in Sources */, FDF848E529405D6E007DCAE5 /* SnodeAPIError.swift in Sources */, FD6B928C2E779DCC004463B5 /* FileServer.swift in Sources */, + FDE287552E94CFDB00442E03 /* URL+Utilities.swift in Sources */, FDF848D529405C5B007DCAE5 /* DeleteAllMessagesResponse.swift in Sources */, FD2272B22C33E337004D8A6C /* PreparedRequest.swift in Sources */, FDF848BF29405C5A007DCAE5 /* SnodeResponse.swift in Sources */, @@ -6533,6 +6554,7 @@ FD2272C82C34EB0A004D8A6C /* Job.swift in Sources */, FD2272C72C34EAF5004D8A6C /* ColumnExpressible.swift in Sources */, FDB3486E2BE8457F00B716C2 /* BackgroundTaskManager.swift in Sources */, + FDE287662E970D9E00442E03 /* AsyncStream+Utilities.swift in Sources */, 7B1D74B027C365960030B423 /* Timer+MainThread.swift in Sources */, FD428B192B4B576F006D0888 /* AppContext.swift in Sources */, FD2272D42C34ECE1004D8A6C /* BencodeEncoder.swift in Sources */, @@ -6540,6 +6562,7 @@ FD559DF52A7368CB00C7C62A /* DispatchQueue+Utilities.swift in Sources */, FDE754DC2C9BAF8A002A2623 /* CryptoError.swift in Sources */, FDFF9FDF2A787F57005E0628 /* JSONEncoder+Utilities.swift in Sources */, + FDE2875D2E95CD3500442E03 /* StringCache.swift in Sources */, FDE754CD2C9BAF37002A2623 /* UTType+Utilities.swift in Sources */, FD42ECD62E3308B5002D03EA /* ObservableKey+SessionUtilitiesKit.swift in Sources */, FDE754D22C9BAF53002A2623 /* JobDependencies.swift in Sources */, @@ -6587,7 +6610,6 @@ FD6673FF2D77F9C100041530 /* ScreenLock.swift in Sources */, FDB3DA8B2E24834000148F8D /* AVURLAsset+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 */, @@ -6605,6 +6627,7 @@ FDE754E02C9BAF8A002A2623 /* Hex.swift in Sources */, FDB11A5B2DD1901000BEF49F /* CurrentValueAsyncStream.swift in Sources */, FDB3DA862E1E1F0E00148F8D /* TaskCancellation.swift in Sources */, + FDE287532E94C5CB00442E03 /* Update.swift in Sources */, FDE754CC2C9BAF37002A2623 /* MediaUtils.swift in Sources */, FDE754DE2C9BAF8A002A2623 /* Crypto+SessionUtilitiesKit.swift in Sources */, FDF2220F281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift in Sources */, @@ -6615,7 +6638,6 @@ FD428B1B2B4B6098006D0888 /* Notifications+Lifecycle.swift in Sources */, 7B0EFDEE274F598600FFAAE7 /* TimestampUtils.swift in Sources */, FD2272D02C34EBD0004D8A6C /* FileManager.swift in Sources */, - FDE754CF2C9BAF37002A2623 /* DataSource.swift in Sources */, FD10AF122AF85D11007709E5 /* Feature+ServiceNetwork.swift in Sources */, B8F5F58325EC94A6003BF8D4 /* Collection+Utilities.swift in Sources */, FD17D7A127F40D2500122BE0 /* Storage.swift in Sources */, @@ -6666,10 +6688,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 94519A932E84C20700F02723 /* _045_LastProfileUpdateTimestamp.swift in Sources */, FD8ECF7B29340FFD00C0D1BB /* LibSession+SessionMessagingKit.swift in Sources */, FDD23AE62E458CAA0057E853 /* _023_SplitSnodeReceivedMessageInfo.swift in Sources */, 7B81682828B310D50069F315 /* _015_HomeQueryOptimisationIndexes.swift in Sources */, - FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */, FD2273002C352D8E004D8A6C /* LibSession+GroupKeys.swift in Sources */, FD70F25C2DC1F184003729B7 /* _040_MessageDeduplicationTable.swift in Sources */, FD2272FB2C352D8E004D8A6C /* LibSession+UserGroups.swift in Sources */, @@ -6682,7 +6704,6 @@ FD245C5F2850662200B966DD /* OWSWindowManager.m in Sources */, FDF40CDE2897A1BC006A0CC4 /* _011_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 */, @@ -6701,12 +6722,12 @@ FD22727E2C32911C004D8A6C /* GarbageCollectionJob.swift in Sources */, FD09B7E7288670FD00ED0B66 /* Reaction.swift in Sources */, FD245C5A2850660100B966DD /* LinkPreviewDraft.swift in Sources */, - FDE5219E2E0D0B9B00061B8E /* AsyncAccessible.swift in Sources */, FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */, FD22726D2C32911C004D8A6C /* CheckForAppUpdatesJob.swift in Sources */, FD2273082C353109004D8A6C /* DisplayPictureManager.swift in Sources */, FDE521A22E0D23AB00061B8E /* ObservableKey+SessionMessagingKit.swift in Sources */, FD2273022C352D8E004D8A6C /* LibSession+GroupInfo.swift in Sources */, + FD9E26AF2EA5DC7D00404C7F /* _046_RemoveQuoteUnusedColumnsAndForeignKeys.swift in Sources */, FDDD554E2C1FCB77006CBF03 /* _033_ScheduleAppUpdateCheckJob.swift in Sources */, FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */, 94CD95C12E0CBF430097754D /* _044_AddProMessageFlag.swift in Sources */, @@ -6752,7 +6773,6 @@ FD245C50285065C700B966DD /* VisibleMessage+Quote.swift in Sources */, FDD23AE82E458DD40057E853 /* _002_SUK_SetupStandardJobs.swift in Sources */, FD72BDA12BE368C800CF6CF6 /* UIWindowLevel+Utilities.swift in Sources */, - FD4C4E9C2B02E2A300C72199 /* DisplayPictureError.swift in Sources */, FDD23AE22E457CE50057E853 /* _008_SNK_YDBToGRDBMigration.swift in Sources */, FD5C7307284F103B0029977D /* MessageReceiver+MessageRequests.swift in Sources */, C3A71D0B2558989C0043A11F /* MessageWrapper.swift in Sources */, @@ -6762,7 +6782,7 @@ FDB11A5D2DD300D300BEF49F /* SNProtoContent+Utilities.swift in Sources */, FDE755002C9BB0FA002A2623 /* SessionEnvironment.swift in Sources */, FDB5DADC2A95D840002C8721 /* GroupUpdateMemberChangeMessage.swift in Sources */, - FD2272812C32911C004D8A6C /* UpdateProfilePictureJob.swift in Sources */, + FD2272812C32911C004D8A6C /* ReuploadUserDisplayPictureJob.swift in Sources */, FDB5DAE02A95D84D002C8721 /* GroupUpdateMemberLeftMessage.swift in Sources */, FD8FD7622C37B7BD001E38C7 /* Position.swift in Sources */, 7B93D07127CF194000811CB6 /* MessageRequestResponse.swift in Sources */, @@ -6859,7 +6879,6 @@ FD09797027FA6FF300936362 /* Profile.swift in Sources */, FD245C56285065EA00B966DD /* SNProto.swift in Sources */, FDE7549D2C9961A4002A2623 /* CommunityPoller.swift in Sources */, - 942BA9BF2E4ABBA1007C4595 /* _045_LastProfileUpdateTimestamp.swift in Sources */, FDE754F12C9BB08B002A2623 /* Crypto+LibSession.swift in Sources */, FD09798B27FD1CFE00936362 /* Capability.swift in Sources */, C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */, @@ -6905,7 +6924,6 @@ FD12A8432AD63BF600EEBA0D /* ObservableTableSource.swift in Sources */, FD52090528B4915F006098F6 /* PrivacySettingsViewModel.swift in Sources */, 7BAF54D027ACCEEC003D12F8 /* EmptySearchResultCell.swift in Sources */, - B8783E9E23EB948D00404FB8 /* UILabel+Interaction.swift in Sources */, FD37E9D928A230F2003AE748 /* TraitObservingWindow.swift in Sources */, B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */, FD848B8F283EF2A8000E298B /* UIScrollView+Utilities.swift in Sources */, @@ -6915,6 +6933,7 @@ FD3FAB5F2AE9BC2200DC5421 /* EditGroupViewModel.swift in Sources */, 7BA37AFB2AEB64CA002438F8 /* DisappearingMessageTimerView.swift in Sources */, FD12A8492AD63C4700EEBA0D /* SessionNavItem.swift in Sources */, + FDE287572E94D7B800442E03 /* DeveloperSettingsFileServerViewModel.swift in Sources */, FD37EA0528AA00C1003AE748 /* NotificationSettingsViewModel.swift in Sources */, C328255225CA64470062D0A7 /* ContextMenuVC+ActionView.swift in Sources */, C3548F0824456AB6009433A8 /* UIView+Wrapping.swift in Sources */, @@ -6931,7 +6950,6 @@ C374EEF425DB31D40073A857 /* VoiceMessageRecordingView.swift in Sources */, 7B1581E6271FD2A100848B49 /* VideoPreviewVC.swift in Sources */, 9422568A2C23F8C800C0FDBF /* LoadingScreen.swift in Sources */, - B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */, 3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */, 4C090A1B210FD9C7001FD7F9 /* HapticFeedback.swift in Sources */, FDE754FA2C9BB0B0002A2623 /* NotificationPresenter.swift in Sources */, @@ -7021,7 +7039,6 @@ 94B3DC172AF8592200C88531 /* QuoteView_SwiftUI.swift in Sources */, 4C4AE6A1224AF35700D4AF6F /* SendMediaNavigationController.swift in Sources */, 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 */, @@ -7055,7 +7072,6 @@ 7B81FB5A2AB01B17002FB267 /* LoadingIndicatorView.swift in Sources */, 7B9F71D42852EEE2006DFE7B /* Emoji+Name.swift in Sources */, FDE71B0D2E793B250023F5F9 /* DeveloperSettingsProViewModel.swift in Sources */, - 4CA46F4C219CCC630038ABDE /* CaptionView.swift in Sources */, C328253025CA55370062D0A7 /* ContextMenuWindow.swift in Sources */, 9479981C2DD44ADC008F5CD5 /* ThreadNotificationSettingsViewModel.swift in Sources */, 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */, @@ -7068,7 +7084,6 @@ B8569AC325CB5D2900DBA3DB /* ConversationVC+Interaction.swift in Sources */, FD981BD52DC978B400564172 /* MentionUtilities+DisplayName.swift in Sources */, 3496955C219B605E00DCFE74 /* ImagePickerController.swift in Sources */, - 34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */, FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */, FD12A8472AD63C3400EEBA0D /* PagedObservationSource.swift in Sources */, FDC1BD682CFE6EEB002CDC71 /* DeveloperSettingsViewModel.swift in Sources */, @@ -7080,6 +7095,7 @@ 4521C3C01F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */, FD860CBA2D66BF2A00BBE29C /* AppIconGridView.swift in Sources */, FD37E9D128A1F2EB003AE748 /* ThemeSelectionView.swift in Sources */, + 94D716862E933958008294EE /* SessionProBadge+Utilities.swift in Sources */, FD39352C28F382920084DADA /* VersionFooterView.swift in Sources */, FD12A83F2AD63BDF00EEBA0D /* Navigatable.swift in Sources */, 7B9F71D22852EEE2006DFE7B /* Emoji+SkinTones.swift in Sources */, @@ -7120,6 +7136,7 @@ FED288F82E4C3BE100C31171 /* AppReviewPromptModel.swift in Sources */, FDE754B12C9B96B4002A2623 /* TurnServerInfo.swift in Sources */, 7BD687D12A5D0D1200D8E455 /* MessageInfoScreen.swift in Sources */, + 94D716912E9379BA008294EE /* MentionUtilities+Attributes.swift in Sources */, B8269D2925C7A4B400488AB4 /* InputView.swift in Sources */, FD71162E28E168C700B47552 /* SettingsViewModel.swift in Sources */, ); @@ -7139,6 +7156,7 @@ FD19363F2ACA66DE004BCF0F /* DatabaseSpec.swift in Sources */, FD23CE332A67C4D90000B97C /* MockNetwork.swift in Sources */, FD71161528D00D6700B47552 /* ThreadDisappearingMessagesViewModelSpec.swift in Sources */, + FD636C692E9F0D1400965D56 /* MockMediaDecoder.swift in Sources */, FD01503A2CA24328005B08A1 /* MockJobRunner.swift in Sources */, FD23EA5E28ED00FD0058676E /* NimbleExtensions.swift in Sources */, FD481A9B2CB4CAF100ECC4CF /* CustomArgSummaryDescribable+SessionMessagingKit.swift in Sources */, @@ -7150,6 +7168,7 @@ 9499E68B2DF92F4E00091434 /* ThreadNotificationSettingsViewModelSpec.swift in Sources */, FD01504B2CA243CB005B08A1 /* Mock.swift in Sources */, FD0969FA2A6A00B000C5C365 /* Mocked.swift in Sources */, + FDE287632E970D5C00442E03 /* Async+Utilities.swift in Sources */, FD481A9A2CB4CAE500ECC4CF /* CommonSMKMockExtensions.swift in Sources */, FD3FAB6C2AF1B28B00DC5421 /* MockFileManager.swift in Sources */, FD23EA6128ED0B260058676E /* CombineExtensions.swift in Sources */, @@ -7184,6 +7203,7 @@ FD0150402CA2433D005B08A1 /* BencodeDecoderSpec.swift in Sources */, FD0150412CA2433D005B08A1 /* BencodeEncoderSpec.swift in Sources */, FD0150422CA2433D005B08A1 /* VersionSpec.swift in Sources */, + FD636C6A2E9F0D1400965D56 /* MockMediaDecoder.swift in Sources */, FD42ECD42E32FF2E002D03EA /* StringUtilitiesSpec.swift in Sources */, FD23EA6328ED0B260058676E /* CombineExtensions.swift in Sources */, FD0150482CA243CB005B08A1 /* Mock.swift in Sources */, @@ -7196,6 +7216,7 @@ FD23CE262A676B5B0000B97C /* DependenciesSpec.swift in Sources */, FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */, FD0969FB2A6A00B100C5C365 /* Mocked.swift in Sources */, + FDE287622E970D5C00442E03 /* Async+Utilities.swift in Sources */, FD0150292CA23DB7005B08A1 /* GRDBExtensions.swift in Sources */, FDD23AEF2E459EC90057E853 /* _012_AddJobPriority.swift in Sources */, FD481A942CAE0AE000ECC4CF /* MockAppContext.swift in Sources */, @@ -7207,6 +7228,7 @@ buildActionMask = 2147483647; files = ( FDB5DB062A981C67002C8721 /* PreparedRequestSendingSpec.swift in Sources */, + FD636C6C2E9F0D1400965D56 /* MockMediaDecoder.swift in Sources */, FD49E2482B05C1D500FFBBB5 /* MockKeychain.swift in Sources */, FDB5DB082A981F8B002C8721 /* Mocked.swift in Sources */, FD6B92CD2E77B22D004463B5 /* SOGSMessageSpec.swift in Sources */, @@ -7223,6 +7245,7 @@ FD6B92D32E77B270004463B5 /* UpdateMessageRequestSpec.swift in Sources */, FDB5DB092A981F8D002C8721 /* MockCrypto.swift in Sources */, FDAA167B2AC28E2F00DDBF77 /* SnodeRequestSpec.swift in Sources */, + FDE287642E970D5C00442E03 /* Async+Utilities.swift in Sources */, FD6B92D02E77B23B004463B5 /* CapabilitiesResponse.swift in Sources */, FD6B92D42E77B2C7004463B5 /* SOGSAPISpec.swift in Sources */, FD65318C2AA025C500DFEEAA /* TestDependencies.swift in Sources */, @@ -7257,6 +7280,7 @@ FD96F3A529DBC3DC00401309 /* MessageSendJobSpec.swift in Sources */, FDE754A82C9B964D002A2623 /* MessageReceiverGroupsSpec.swift in Sources */, FD01504C2CA243CB005B08A1 /* Mock.swift in Sources */, + FD636C6B2E9F0D1400965D56 /* MockMediaDecoder.swift in Sources */, FD981BC92DC4641100564172 /* ExtensionHelperSpec.swift in Sources */, FD981BCB2DC4A21C00564172 /* MessageDeduplicationSpec.swift in Sources */, FD83B9C727CF3F10005E1583 /* CapabilitySpec.swift in Sources */, @@ -7306,6 +7330,7 @@ FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */, FD3C906727E416AF00CD579F /* BlindedIdLookupSpec.swift in Sources */, FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */, + FDE287612E970D5C00442E03 /* Async+Utilities.swift in Sources */, FD3FAB6D2AF1B28B00DC5421 /* MockFileManager.swift in Sources */, FD78E9F22DDA9EA200D55B50 /* MockImageDataManager.swift in Sources */, ); @@ -8362,7 +8387,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = ""; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 640; + CURRENT_PROJECT_VERSION = 658; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8402,7 +8427,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.4; + MARKETING_VERSION = 2.14.6; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "-Werror=protocol"; OTHER_SWIFT_FLAGS = "-D DEBUG -Xfrontend -warn-long-expression-type-checking=100"; @@ -8443,7 +8468,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = ""; - CURRENT_PROJECT_VERSION = 640; + CURRENT_PROJECT_VERSION = 658; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -8478,7 +8503,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.4; + MARKETING_VERSION = 2.14.6; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-DNS_BLOCK_ASSERTIONS=1", @@ -8929,7 +8954,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 640; + CURRENT_PROJECT_VERSION = 658; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8968,7 +8993,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.4; + MARKETING_VERSION = 2.14.6; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "-fobjc-arc-exceptions", @@ -9519,7 +9544,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = YES; - CURRENT_PROJECT_VERSION = 640; + CURRENT_PROJECT_VERSION = 658; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; @@ -9552,7 +9577,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.4; + MARKETING_VERSION = 2.14.6; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-DNS_BLOCK_ASSERTIONS=1", @@ -10472,7 +10497,7 @@ repositoryURL = "https://github.com/session-foundation/libsession-util-spm"; requirement = { kind = exactVersion; - version = 1.5.6; + version = 1.5.7; }; }; FD6A38E72C2A630E00762359 /* XCRemoteSwiftPackageReference "CocoaLumberjack" */ = { @@ -10555,6 +10580,14 @@ minimumVersion = 0.468.0; }; }; + FDEFDC712E8B9F3300EBCD81 /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SDWebImage/SDWebImageWebPCoder.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.14.6; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -10701,6 +10734,11 @@ package = FD6A390E2C2A93CD00762359 /* XCRemoteSwiftPackageReference "WebRTC" */; productName = WebRTC; }; + FDEFDC722E8B9F3300EBCD81 /* SDWebImageWebPCoder */ = { + isa = XCSwiftPackageProductDependency; + package = FDEFDC712E8B9F3300EBCD81 /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */; + productName = SDWebImageWebPCoder; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = D221A080169C9E5E00537ABF /* Project object */; diff --git a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 36a72ad9fd..4c74fe5c21 100644 --- a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "3976430cfdaea7445596ad6123334158bdc83e4997da535d15a15afc3c7aa091", + "originHash" : "659be7201ad78ce5b1fb117c3155ae4e9847a563ac63792741d83100ec19567d", "pins" : [ { "identity" : "cocoalumberjack", "kind" : "remoteSourceControl", "location" : "https://github.com/CocoaLumberjack/CocoaLumberjack.git", "state" : { - "revision" : "4b8714a7fb84d42393314ce897127b3939885ec3", - "version" : "3.8.5" + "revision" : "a9ed4b6f9bdedce7d77046f43adfb8ce1fd54114", + "version" : "3.9.0" } }, { @@ -51,8 +51,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/session-foundation/libsession-util-spm", "state" : { - "revision" : "a092eb8fa4bbc93756530e08b6c281d9eda06c61", - "version" : "1.5.6" + "revision" : "38baf3f75ba50e6ba3950caa5709a40971c13e89", + "version" : "1.5.7" + } + }, + { + "identity" : "libwebp-xcode", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/libwebp-Xcode.git", + "state" : { + "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2", + "version" : "1.5.0" } }, { @@ -91,6 +100,24 @@ "version" : "7.5.0" } }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage.git", + "state" : { + "revision" : "2053b120767c42a70bcba21095f34e4cfb54a75d", + "version" : "5.21.3" + } + }, + { + "identity" : "sdwebimagewebpcoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImageWebPCoder.git", + "state" : { + "revision" : "f534cfe830a7807ecc3d0332127a502426cfa067", + "version" : "0.14.6" + } + }, { "identity" : "session-grdb-swift", "kind" : "remoteSourceControl", @@ -105,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/session-foundation/session-lucide.git", "state" : { - "revision" : "af00ad53d714823e07f984aadd7af38bafaae69e", - "version" : "0.473.0" + "revision" : "43efda6bc6f116ac620810e8955796be6c4c0e1d", + "version" : "0.473.2" } }, { diff --git a/Session/Calls/CallVC.swift b/Session/Calls/CallVC.swift index 85b9f8c668..512e5444a5 100644 --- a/Session/Calls/CallVC.swift +++ b/Session/Calls/CallVC.swift @@ -3,6 +3,7 @@ import UIKit import MediaPlayer import AVKit +import Lucide import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit @@ -210,7 +211,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel let result = UIButton(type: .custom) result.isEnabled = call.isVideoEnabled result.setImage( - UIImage(named: "SwitchCamera")? + Lucide.image(icon: .switchCamera, size: IconSize.medium.size)? .withRenderingMode(.alwaysTemplate), for: .normal ) @@ -227,7 +228,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel private lazy var switchAudioButton: UIButton = { let result = UIButton(type: .custom) result.setImage( - UIImage(named: "AudioOff")? + Lucide.image(icon: .mic, size: IconSize.medium.size)? .withRenderingMode(.alwaysTemplate), for: .normal ) @@ -250,7 +251,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel private lazy var videoButton: UIButton = { let result = UIButton(type: .custom) result.setImage( - UIImage(named: "VideoCall")? + Lucide.image(icon: .videoOff, size: IconSize.medium.size)? .withRenderingMode(.alwaysTemplate), for: .normal ) @@ -278,7 +279,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel private lazy var routePickerButton: UIButton = { let result = UIButton(type: .custom) result.setImage( - UIImage(named: "Speaker")? + Lucide.image(icon: .volume2, size: IconSize.medium.size)? .withRenderingMode(.alwaysTemplate), for: .normal ) @@ -380,6 +381,13 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel UIView.animate(withDuration: 0.25) { let remoteVideoView: RemoteVideoView = self.floatingViewVideoSource == .remote ? self.floatingRemoteVideoView : self.fullScreenRemoteVideoView remoteVideoView.alpha = isEnabled ? 1 : 0 + + // Retain floating view visibility if any of the video feeds are enabled + let isAnyVideoFeedEnabled: Bool = (isEnabled || self.call.isVideoEnabled) + + // Shows floating camera to allow user to switch to fullscreen or floating + // even if the other party has not yet turned on their video feed. + self.floatingViewContainer.isHidden = !isAnyVideoFeedEnabled } if self.callInfoLabelStackView.alpha < 0.5 { @@ -464,9 +472,8 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel setUpViewHierarchy() setUpProfilePictureImage() - if shouldRestartCamera { cameraManager.prepare() } - _ = call.videoCapturer // Force the lazy var to instantiate + titleLabel.text = self.call.contactName if self.call.hasConnected { callDurationLabel.isHidden = false @@ -659,14 +666,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel self.callInfoLabelStackView.alpha = 1 } - Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { [weak self] _ in - DispatchQueue.main.async { - self?.dismiss(animated: true, completion: { - self?.conversationVC?.becomeFirstResponder() - self?.conversationVC?.showInputAccessoryView() - }) - } - } + self.shouldHandleCallDismiss(delay: 2) } @objc private func answerCall() { @@ -680,21 +680,14 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel } } - @objc private func endCall() { + @objc private func endCall(presentCameraRequestDialog: Bool = false) { dependencies[singleton: .callManager].endCall(call) { [weak self, dependencies] error in + self?.shouldHandleCallDismiss(delay: 1, presentCameraRequestDialog: presentCameraRequestDialog) + if let _ = error { self?.call.endSessionCall() dependencies[singleton: .callManager].reportCurrentCallEnded(reason: .declinedElsewhere) } - - Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { [weak self] _ in - DispatchQueue.main.async { - self?.dismiss(animated: true, completion: { - self?.conversationVC?.becomeFirstResponder() - self?.conversationVC?.showInputAccessoryView() - }) - } - } } } @@ -721,45 +714,99 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel // MARK: - Video and Audio @objc private func operateCamera() { + if (call.isVideoEnabled) { - floatingViewContainer.isHidden = true + // Hides local video feed + (floatingViewVideoSource == .local + ? floatingLocalVideoView + : fullScreenLocalVideoView).alpha = 0 + + floatingViewContainer.isHidden = !call.isRemoteVideoEnabled cameraManager.stop() videoButton.themeTintColor = .textPrimary videoButton.themeBackgroundColor = .backgroundSecondary + videoButton.setImage( + Lucide.image(icon: .videoOff, size: IconSize.medium.size)? + .withRenderingMode(.alwaysTemplate), + for: .normal + ) switchCameraButton.isEnabled = false call.isVideoEnabled = false } else { - guard Permissions.requestCameraPermissionIfNeeded(using: dependencies) else { - let confirmationModal: ConfirmationModal = ConfirmationModal( - info: ConfirmationModal.Info( - title: "permissionsRequired".localized(), - body: .text("permissionsCameraAccessRequiredCallsIos".localized()), - showCondition: .disabled, - confirmTitle: "sessionSettings".localized(), - onConfirm: { _ in - UIApplication.shared.openSystemSettings() - } - ) - ) + + // Added delay of preview due to permission dialog alert dismissal on allow. + // It causes issue on `VideoPreviewVC` presentation animation, + // If camera permission is already allowed no animation delay is needed + let previewDelay = Permissions.camera == .undetermined ? 0.5 : 0 + + Permissions.requestCameraPermissionIfNeeded( + useCustomDeniedAlert: true, + using: dependencies + ) { [weak self, dependencies] isAuthorized in - self.navigationController?.present(confirmationModal, animated: true, completion: nil) - return + let status = Permissions.camera + + switch (isAuthorized, status) { + case (false, .denied): + guard let presentingViewController: UIViewController = (self?.navigationController ?? dependencies[singleton: .appContext].frontMostViewController) + else { return } + + DispatchQueue.main.async { + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "cameraAccessRequired".localized(), + body: .attributedText( + "cameraAccessDeniedMessage" + .put(key: "app_name", value: Constants.app_name) + .localizedFormatted(), + scrollMode: .never + ), + confirmTitle: "endCallToEnable".localized(), + confirmStyle: .danger, + cancelTitle: "remindMeLater".localized(), + cancelStyle: .alert_text, + onConfirm: { _ in + self?.endCall(presentCameraRequestDialog: true) + }, + onCancel: { modal in + dependencies[defaults: .standard, key: .shouldRemindGrantingCameraPermissionForCalls] = true + modal.dismiss(animated: true) + } + ) + ) + presentingViewController.present(confirmationModal, animated: true, completion: nil) + } + case (true, _): + DispatchQueue.main.asyncAfter(deadline: .now() + previewDelay) { [weak self] in + let previewVC = VideoPreviewVC() + previewVC.delegate = self + self?.present(previewVC, animated: true, completion: nil) + } + break + default: break + } } - let previewVC = VideoPreviewVC() - previewVC.delegate = self - present(previewVC, animated: true, completion: nil) } } - + func cameraDidConfirmTurningOn() { floatingViewContainer.isHidden = false + let localVideoView: LocalVideoView = self.floatingViewVideoSource == .local ? self.floatingLocalVideoView : self.fullScreenLocalVideoView localVideoView.alpha = 1 + + // Camera preparation cameraManager.prepare() cameraManager.start() + videoButton.themeTintColor = .backgroundSecondary videoButton.themeBackgroundColor = .textPrimary + videoButton.setImage( + Lucide.image(icon: .video, size: IconSize.medium.size)? + .withRenderingMode(.alwaysTemplate), + for: .normal + ) switchCameraButton.isEnabled = true call.isVideoEnabled = true } @@ -807,6 +854,12 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel switchAudioButton.themeBackgroundColor = .danger call.isMuted = true } + + switchAudioButton.setImage( + Lucide.image(icon: call.isMuted ? .micOff: .mic, size: IconSize.medium.size)? + .withRenderingMode(.alwaysTemplate), + for: .normal + ) } @objc private func switchRoute() { @@ -830,7 +883,9 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel switch currentOutput.portType { case .builtInSpeaker: - let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate) + let image = Lucide.image(icon: .volume2, size: IconSize.medium.size)? + .withRenderingMode(.alwaysTemplate) + routePickerButton.setImage(image, for: .normal) routePickerButton.themeTintColor = .backgroundSecondary routePickerButton.themeBackgroundColor = .textPrimary @@ -856,7 +911,9 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel case .builtInReceiver: fallthrough default: - let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate) + let image = Lucide.image(icon: .volume2, size: IconSize.medium.size)? + .withRenderingMode(.alwaysTemplate) + routePickerButton.setImage(image, for: .normal) routePickerButton.themeTintColor = .textPrimary routePickerButton.themeBackgroundColor = .backgroundSecondary @@ -874,13 +931,26 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel } } + private func shouldHandleCallDismiss(delay: TimeInterval, presentCameraRequestDialog: Bool = false) { + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(Int(delay))) { [weak self, dependencies] in + guard self?.presentingViewController != nil else { return } + + self?.dismiss(animated: true, completion: { + self?.conversationVC?.becomeFirstResponder() + self?.conversationVC?.showInputAccessoryView() + + if presentCameraRequestDialog { + Permissions.showEnableCameraAccessInstructions(using: dependencies) + } else { + Permissions.remindCameraAccessRequirement(using: dependencies) + } + }) + } + } + // MARK: - AVRoutePickerViewDelegate - func routePickerViewWillBeginPresentingRoutes(_ routePickerView: AVRoutePickerView) { - - } + func routePickerViewWillBeginPresentingRoutes(_ routePickerView: AVRoutePickerView) {} - func routePickerViewDidEndPresentingRoutes(_ routePickerView: AVRoutePickerView) { - - } + func routePickerViewDidEndPresentingRoutes(_ routePickerView: AVRoutePickerView) {} } diff --git a/Session/Calls/Views & Modals/CallMissedTipsModal.swift b/Session/Calls/Views & Modals/CallMissedTipsModal.swift index eb702f6c4a..d4b17a7eaf 100644 --- a/Session/Calls/Views & Modals/CallMissedTipsModal.swift +++ b/Session/Calls/Views & Modals/CallMissedTipsModal.swift @@ -28,6 +28,7 @@ final class CallMissedTipsModal: Modal { result.text = "callsMissedCallFrom" .put(key: "name", value: caller) .localized() + result.accessibilityIdentifier = "Modal heading" result.themeTextColor = .textPrimary result.textAlignment = .center @@ -44,6 +45,7 @@ final class CallMissedTipsModal: Modal { result.themeAttributedText = "callsYouMissedCallPermissions" .put(key: "name", value: caller) .localizedFormatted(in: result) + result.accessibilityIdentifier = "Modal description" return result }() diff --git a/Session/Closed Groups/EditGroupViewModel.swift b/Session/Closed Groups/EditGroupViewModel.swift index a0dd9dba89..c3378090e6 100644 --- a/Session/Closed Groups/EditGroupViewModel.swift +++ b/Session/Closed Groups/EditGroupViewModel.swift @@ -193,7 +193,10 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Observa ), styling: SessionCell.StyleInfo( alignment: .centerHugging, - customPadding: SessionCell.Padding(bottom: Values.smallSpacing), + customPadding: SessionCell.Padding( + leading: 0, + bottom: Values.smallSpacing + ), backgroundStyle: .noBackground ), accessibility: Accessibility( @@ -297,7 +300,11 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Observa font: .title, accessibility: Accessibility( identifier: "Contact" - ) + ), + trailingImage: { + guard (dependencies.mutate(cache: .libSession) { $0.validateProProof(for: memberInfo.profile) }) else { return nil } + return ("ProBadge", { [dependencies] in SessionProBadge(size: .small).toImage(using: dependencies) }) + }() ), subtitle: (!isUpdatedGroup ? nil : SessionCell.TextInfo( memberInfo.value.statusDescription, diff --git a/Session/Closed Groups/NewClosedGroupVC.swift b/Session/Closed Groups/NewClosedGroupVC.swift index c332d1b6a2..37faac7b3e 100644 --- a/Session/Closed Groups/NewClosedGroupVC.swift +++ b/Session/Closed Groups/NewClosedGroupVC.swift @@ -419,50 +419,52 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate let selectedProfiles: [(String, Profile?)] = self.selectedProfileIds.map { id in (id, self.contacts.first { $0.profileId == id }?.profile) } - - ModalActivityIndicatorViewController.present(fromViewController: navigationController!) { [weak self, dependencies] activityIndicatorViewController in - MessageSender - .createGroup( + + let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController() + navigationController?.present(indicator, animated: false) + + Task(priority: .userInitiated) { [weak self] in + guard let self = self else { return } + + do { + let thread: SessionThread = try await MessageSender.createGroup( name: name, description: nil, - displayPictureData: nil, + displayPicture: nil, + displayPictureCropRect: nil, members: selectedProfiles, using: dependencies ) - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .receive(on: DispatchQueue.main) - .sinkUntilComplete( - receiveCompletion: { result in - switch result { - case .finished: break - case .failure: - self?.dismiss(animated: true, completion: nil) // Dismiss the loader - - let modal: ConfirmationModal = ConfirmationModal( - targetView: self?.view, - info: ConfirmationModal.Info( - title: "groupError".localized(), - body: .text("groupErrorCreate".localized()), - cancelTitle: "okay".localized(), - cancelStyle: .alert_text - ) - ) - self?.present(modal, animated: true) - } - }, - receiveValue: { thread in - /// When this is triggered via the "Recreate Group" action for Legacy Groups the screen will have been - /// pushed instead of presented and, as a result, we need to dismiss the `activityIndicatorViewController` - /// and want the transition to be animated in order to behave nicely - dependencies[singleton: .app].presentConversationCreatingIfNeeded( - for: thread.id, - variant: thread.variant, - action: .none, - dismissing: (self?.presentingViewController ?? activityIndicatorViewController), - animated: (self?.presentingViewController == nil) + + /// When this is triggered via the "Recreate Group" action for Legacy Groups the screen will have been + /// pushed instead of presented and, as a result, we need to dismiss the `activityIndicatorViewController` + /// and want the transition to be animated in order to behave nicely + await MainActor.run { [weak self, dependencies] in + dependencies[singleton: .app].presentConversationCreatingIfNeeded( + for: thread.id, + variant: thread.variant, + action: .none, + dismissing: (self?.presentingViewController ?? indicator), + animated: (self?.presentingViewController == nil) + ) + } + } + catch { + await MainActor.run { [weak self] in + self?.dismiss(animated: true, completion: nil) // Dismiss the loader + + let modal: ConfirmationModal = ConfirmationModal( + targetView: self?.view, + info: ConfirmationModal.Info( + title: "groupError".localized(), + body: .text("groupErrorCreate".localized()), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text ) - } - ) + ) + self?.present(modal, animated: true) + } + } } } } diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index 53b44ea9b5..c122b2b28c 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -21,7 +21,7 @@ extension ContextMenuVC { let actionType: ActionType let shouldDismissInfoScreen: Bool let accessibilityLabel: String? - let work: ((() -> Void)?) -> Void + let work: @MainActor ((@MainActor () -> Void)?) -> Void enum ActionType { case emoji @@ -41,7 +41,7 @@ extension ContextMenuVC { actionType: ActionType = .generic, shouldDismissInfoScreen: Bool = false, accessibilityLabel: String? = nil, - work: @escaping ((() -> Void)?) -> Void + work: @escaping @MainActor ((@MainActor () -> Void)?) -> Void ) { self.icon = icon self.title = title @@ -66,7 +66,7 @@ extension ContextMenuVC { static func retry(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( - icon: UIImage(systemName: "arrow.triangle.2.circlepath"), + icon: Lucide.image(icon: .repeat2, size: 24), title: (cellViewModel.state == .failedToSync ? "resync".localized() : "resend".localized() @@ -77,17 +77,17 @@ extension ContextMenuVC { static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( - icon: UIImage(named: "ic_reply"), + icon: Dependencies.isRTL ? Lucide.image(icon: .reply, size: 24)?.flippedHorizontally() : Lucide.image(icon: .reply, size: 24), title: "reply".localized(), shouldDismissInfoScreen: true, accessibilityLabel: "Reply to message" ) { completion in delegate?.reply(cellViewModel, completion: completion) } } - static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, forMessageInfoScreen: Bool) -> Action { return Action( - icon: UIImage(named: "ic_copy"), - title: "copy".localized(), + icon: Lucide.image(icon: .copy, size: 24), + title: forMessageInfoScreen ? "messageCopy".localized() : "copy".localized(), feedback: "copied".localized(), accessibilityLabel: "Copy text" ) { completion in delegate?.copy(cellViewModel, completion: completion) } @@ -95,7 +95,7 @@ extension ContextMenuVC { static func copySessionID(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( - icon: UIImage(named: "ic_copy"), + icon: Lucide.image(icon: .copy, size: 24), title: "accountIDCopy".localized(), feedback: "copied".localized(), accessibilityLabel: "Copy Session ID" @@ -118,7 +118,7 @@ extension ContextMenuVC { static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( - icon: UIImage(named: "ic_download"), + icon: Lucide.image(icon: .arrowDownToLine, size: 24), title: "save".localized(), feedback: "saved".localized(), accessibilityLabel: "Save attachment" @@ -251,7 +251,8 @@ extension ContextMenuVC { }() let canCopySessionId: Bool = ( cellViewModel.variant == .standardIncoming && - cellViewModel.threadVariant != .community + cellViewModel.threadVariant != .community && + !forMessageInfoScreen ) let canDelete: Bool = (MessageViewModel.DeletionBehaviours.deletionActions( for: [cellViewModel], @@ -291,7 +292,7 @@ extension ContextMenuVC { let generatedActions: [Action] = [ (canRetry ? Action.retry(cellViewModel, delegate) : nil), (viewModelCanReply(cellViewModel, using: dependencies) ? Action.reply(cellViewModel, delegate) : nil), - (canCopy ? Action.copy(cellViewModel, delegate) : nil), + (canCopy ? Action.copy(cellViewModel, delegate, forMessageInfoScreen: forMessageInfoScreen) : nil), (canSave ? Action.save(cellViewModel, delegate) : nil), (canCopySessionId ? Action.copySessionID(cellViewModel, delegate) : nil), (canDelete ? Action.delete(cellViewModel, delegate) : nil), @@ -316,7 +317,7 @@ extension ContextMenuVC { protocol ContextMenuActionDelegate { func info(_ cellViewModel: MessageViewModel) - func retry(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) + @MainActor func retry(_ cellViewModel: MessageViewModel, completion: (@MainActor () -> Void)?) func reply(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) func copy(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) func copySessionID(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) diff --git a/Session/Conversations/Context Menu/ContextMenuVC.swift b/Session/Conversations/Context Menu/ContextMenuVC.swift index 768cbb59c1..1206ba0643 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC.swift @@ -143,7 +143,7 @@ final class ContextMenuVC: UIViewController { emojiBarBackgroundView.pin(to: emojiBar) emojiBar.addSubview(emojiPlusButton) - emojiPlusButton.pin(.right, to: .right, of: emojiBar, withInset: -Values.smallSpacing) + emojiPlusButton.pin(.trailing, to: .trailing, of: emojiBar, withInset: -Values.smallSpacing) emojiPlusButton.center(.vertical, in: emojiBar) let emojiBarStackView = UIStackView( @@ -156,8 +156,8 @@ final class ContextMenuVC: UIViewController { emojiBarStackView.layoutMargins = UIEdgeInsets(top: 0, left: Values.smallSpacing, bottom: 0, right: Values.smallSpacing) emojiBarStackView.isLayoutMarginsRelativeArrangement = true emojiBar.addSubview(emojiBarStackView) - emojiBarStackView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: emojiBar) - emojiBarStackView.pin(.right, to: .left, of: emojiPlusButton) + emojiBarStackView.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: emojiBar) + emojiBarStackView.pin(.trailing, to: .leading, of: emojiPlusButton) // Hide the emoji bar if we have no emoji actions emojiBar.isHidden = emojiBarStackView.arrangedSubviews.isEmpty @@ -188,10 +188,10 @@ final class ContextMenuVC: UIViewController { timestampLabel.center(.vertical, in: snapshot) if cellViewModel.variant == .standardOutgoing { - timestampLabel.pin(.right, to: .left, of: snapshot, withInset: -Values.smallSpacing) + timestampLabel.pin(.trailing, to: .leading, of: snapshot, withInset: -Values.smallSpacing) } else { - timestampLabel.pin(.left, to: .right, of: snapshot, withInset: Values.smallSpacing) + timestampLabel.pin(.leading, to: .trailing, of: snapshot, withInset: Values.smallSpacing) } view.addSubview(fallbackTimestampLabel) @@ -199,14 +199,14 @@ final class ContextMenuVC: UIViewController { fallbackTimestampLabel.set(.height, to: ContextMenuVC.actionViewHeight) if cellViewModel.variant == .standardOutgoing { - fallbackTimestampLabel.textAlignment = .right - fallbackTimestampLabel.pin(.right, to: .left, of: menuView, withInset: -Values.mediumSpacing) - fallbackTimestampLabel.pin(.left, to: .left, of: view, withInset: Values.mediumSpacing) + fallbackTimestampLabel.textAlignment = Dependencies.isRTL ? .left : .right + fallbackTimestampLabel.pin(.trailing, to: .leading, of: menuView, withInset: -Values.mediumSpacing) + fallbackTimestampLabel.pin(.leading, to: .leading, of: view, withInset: Values.mediumSpacing) } else { - fallbackTimestampLabel.textAlignment = .left - fallbackTimestampLabel.pin(.left, to: .right, of: menuView, withInset: Values.mediumSpacing) - fallbackTimestampLabel.pin(.right, to: .right, of: view, withInset: -Values.mediumSpacing) + fallbackTimestampLabel.textAlignment = Dependencies.isRTL ? .right : .left + fallbackTimestampLabel.pin(.leading, to: .trailing, of: menuView, withInset: Values.mediumSpacing) + fallbackTimestampLabel.pin(.trailing, to: .trailing, of: view, withInset: -Values.mediumSpacing) } // Constrains @@ -219,9 +219,15 @@ final class ContextMenuVC: UIViewController { self.timestampLabel.isHidden = { switch cellViewModel.variant { case .standardOutgoing: + if Dependencies.isRTL { + return ((self.targetFrame.maxX + timestampSize.width + Values.mediumSpacing) > UIScreen.main.bounds.width) + } return ((self.targetFrame.minX - timestampSize.width - Values.mediumSpacing) < 0) default: + if Dependencies.isRTL { + return ((self.targetFrame.minX - timestampSize.width - Values.mediumSpacing) < 0) + } return ((self.targetFrame.maxX + timestampSize.width + Values.mediumSpacing) > UIScreen.main.bounds.width) } }() @@ -234,15 +240,18 @@ final class ContextMenuVC: UIViewController { switch cellViewModel.variant { case .standardOutgoing, .standardOutgoingDeleted, .standardOutgoingDeletedLocally: - menuView.pin(.right, to: .right, of: view, withInset: -(UIScreen.main.bounds.width - targetFrame.maxX)) - emojiBar.pin(.right, to: .right, of: view, withInset: -(UIScreen.main.bounds.width - targetFrame.maxX)) + let inset: CGFloat = Dependencies.isRTL ? -targetFrame.minX : -(UIScreen.main.bounds.width - targetFrame.maxX) + menuView.pin(.trailing, to: .trailing, of: view, withInset: inset) + emojiBar.pin(.trailing, to: .trailing, of: view, withInset: inset) case .standardIncoming, .standardIncomingDeleted, .standardIncomingDeletedLocally: - menuView.pin(.left, to: .left, of: view, withInset: targetFrame.minX) - emojiBar.pin(.left, to: .left, of: view, withInset: targetFrame.minX) + let inset: CGFloat = Dependencies.isRTL ? (UIScreen.main.bounds.width - targetFrame.maxX) : targetFrame.minX + menuView.pin(.leading, to: .leading, of: view, withInset: inset) + emojiBar.pin(.leading, to: .leading, of: view, withInset: inset) default: // Should generally only be the 'delete' action - menuView.pin(.left, to: .left, of: view, withInset: targetFrame.minX) + let inset: CGFloat = Dependencies.isRTL ? (UIScreen.main.bounds.width - targetFrame.maxX) : targetFrame.minX + menuView.pin(.leading, to: .leading, of: view, withInset: inset) } // Tap gesture @@ -281,10 +290,7 @@ final class ContextMenuVC: UIViewController { initialSpringVelocity: 0.6, options: .curveEaseInOut, animations: { [weak self] in - self?.snapshot.pin(.left, to: .left, of: view, withInset: targetFrame.origin.x) - self?.snapshot.pin(.top, to: .top, of: view, withInset: targetFrame.origin.y) - self?.snapshot.set(.width, to: targetFrame.width) - self?.snapshot.set(.height, to: targetFrame.height) + self?.snapshot.frame = targetFrame self?.snapshot.superview?.setNeedsLayout() self?.snapshot.superview?.layoutIfNeeded() }, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 3f1c928c60..d446cfdfd7 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -112,6 +112,20 @@ extension ConversationVC: navigationController?.pushViewController(viewController, animated: true) } + // MARK: - External keyboard + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + for press in presses { + guard let key = press.key else { continue } + + if key.keyCode == .keyboardReturnOrEnter && key.modifierFlags.isEmpty { + // Enter only -> send + handleSendButtonTapped() + return + } + } + super.pressesBegan(presses, with: event) + } + // MARK: - Call @objc func startCall(_ sender: Any?) { @@ -206,7 +220,7 @@ extension ConversationVC: // MARK: - Blocking - @discardableResult func showBlockedModalIfNeeded() -> Bool { + @MainActor @discardableResult func showBlockedModalIfNeeded() -> Bool { guard self.viewModel.threadData.threadVariant == .contact && self.viewModel.threadData.threadIsBlocked == true @@ -250,7 +264,7 @@ extension ConversationVC: func sendMediaNav( _ sendMediaNavigationController: SendMediaNavigationController, - didApproveAttachments attachments: [SignalAttachment], + didApproveAttachments attachments: [PendingAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, messageText: String? @@ -280,7 +294,7 @@ extension ConversationVC: func attachmentApproval( _ attachmentApproval: AttachmentApprovalViewController, - didApproveAttachments attachments: [SignalAttachment], + didApproveAttachments attachments: [PendingAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, messageText: String? @@ -306,7 +320,7 @@ extension ConversationVC: snInputView.text = (newMessageText ?? "") } - func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment) { + func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: PendingAttachment) { } func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) { @@ -389,24 +403,81 @@ extension ConversationVC: } let fileName: String = (urlResourceValues.name ?? "attachment".localized()) - guard let dataSource = DataSourcePath(fileUrl: url, sourceFilename: urlResourceValues.name, shouldDeleteOnDeinit: false, using: dependencies) else { - DispatchQueue.main.async { [weak self] in - self?.viewModel.showToast(text: "attachmentsErrorLoad".localized()) + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .file(url), + utType: type, + sourceFilename: fileName, + using: dependencies + ) + + /// Although we want to be able to send higher quality attachments through the document picker + /// it's more imporant that we ensure the sent format is one all clients can accept (e.g. *not* quicktime .mov) + if + UTType.supportedVideoTypes.contains(pendingAttachment.utType) && + !UTType.supportedOutputVideoTypes.contains(pendingAttachment.utType) + { + let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController( + canCancel: true + ) + self?.present(indicator, animated: false) + + Task.detached(priority: .userInitiated) { [weak self, indicator, dependencies] in + do { + let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare( + operations: [.convert(to: .mp4)], + using: dependencies + ) + guard await !indicator.wasCancelled else { return } + + let convertedAttachment: PendingAttachment = PendingAttachment( + source: .media( + .videoUrl( + URL(fileURLWithPath: preparedAttachment.filePath), + .mpeg4Movie, + pendingAttachment.sourceFilename, + dependencies[singleton: .attachmentManager] + ) + ), + utType: .mpeg4Movie, + sourceFilename: pendingAttachment.sourceFilename, + using: dependencies + ) + try convertedAttachment.ensureExpectedEncryptedSize( + domain: .attachment, + maxFileSize: Network.maxFileSize, + using: dependencies + ) + + await indicator.dismiss { + self?.showAttachmentApprovalDialog(for: [ convertedAttachment ]) + } + } + catch { + await indicator.dismiss { + self?.showErrorAlert(for: error) + } + } } return } - dataSource.sourceFilename = fileName - // Although we want to be able to send higher quality attachments through the document picker - // it's more imporant that we ensure the sent format is one all clients can accept (e.g. *not* quicktime .mov) - guard !SignalAttachment.isInvalidVideo(dataSource: dataSource, type: type) else { - self?.showAttachmentApprovalDialogAfterProcessingVideo(at: url, with: fileName) + /// Validate the expected attachment size before proceeding + do { + try pendingAttachment.ensureExpectedEncryptedSize( + domain: .attachment, + maxFileSize: Network.maxFileSize, + using: dependencies + ) + } + catch { + DispatchQueue.main.async { [weak self] in + self?.showErrorAlert(for: error) + } return } // "Document picker" attachments _SHOULD NOT_ be resized - let attachment = SignalAttachment.attachment(dataSource: dataSource, type: type, imageQuality: .original, using: dependencies) - self?.showAttachmentApprovalDialog(for: [ attachment ]) + self?.showAttachmentApprovalDialog(for: [ pendingAttachment ]) }, wasCancelled: { [weak self] _ in self?.showInputAccessoryView() @@ -459,56 +530,24 @@ extension ConversationVC: // MARK: - GifPickerViewControllerDelegate - func gifPickerDidSelect(attachment: SignalAttachment) { + func gifPickerDidSelect(attachment: PendingAttachment) { showAttachmentApprovalDialog(for: [ attachment ]) } - func showAttachmentApprovalDialog(for attachments: [SignalAttachment]) { + func showAttachmentApprovalDialog(for attachments: [PendingAttachment]) { guard let navController = AttachmentApprovalViewController.wrappedInNavController( threadId: self.viewModel.threadData.threadId, threadVariant: self.viewModel.threadData.threadVariant, attachments: attachments, approvalDelegate: self, disableLinkPreviewImageDownload: (self.viewModel.threadData.threadCanUpload != true), + didLoadLinkPreview: nil, using: self.viewModel.dependencies ) else { return } navController.modalPresentationStyle = .fullScreen present(navController, animated: true, completion: nil) } - - func showAttachmentApprovalDialogAfterProcessingVideo(at url: URL, with fileName: String) { - ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: true, message: nil) { [weak self, dependencies = viewModel.dependencies] modalActivityIndicator in - - guard let dataSource = DataSourcePath(fileUrl: url, sourceFilename: fileName, shouldDeleteOnDeinit: false, using: dependencies) else { - self?.showErrorAlert(for: SignalAttachment.empty(using: dependencies)) - return - } - dataSource.sourceFilename = fileName - - SignalAttachment - .compressVideoAsMp4( - dataSource: dataSource, - type: .mpeg4Movie, - using: dependencies - ) - .attachmentPublisher - .sinkUntilComplete( - receiveValue: { [weak self] attachment in - guard !modalActivityIndicator.wasCancelled else { return } - - modalActivityIndicator.dismiss { - guard !attachment.hasError else { - self?.showErrorAlert(for: attachment) - return - } - - self?.showAttachmentApprovalDialog(for: [ attachment ]) - } - } - ) - } - } // MARK: - InputViewDelegate @@ -613,7 +652,7 @@ extension ConversationVC: for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines), isSessionPro: viewModel.isCurrentUserSessionPro ) >= 0 else { - showModalForMessagesExceedingCharacterLimit(isSessionPro: viewModel.isCurrentUserSessionPro) + showModalForMessagesExceedingCharacterLimit(viewModel.isCurrentUserSessionPro) return } @@ -624,7 +663,7 @@ extension ConversationVC: ) } - @MainActor func showModalForMessagesExceedingCharacterLimit(isSessionPro: Bool) { + @MainActor func showModalForMessagesExceedingCharacterLimit(_ isSessionPro: Bool) { guard !viewModel.dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( .longerMessages, beforePresented: { [weak self] in @@ -661,19 +700,26 @@ extension ConversationVC: present(confirmationModal, animated: true, completion: nil) } - func sendMessage( + @MainActor func sendMessage( text: String, - attachments: [SignalAttachment] = [], + attachments: [PendingAttachment] = [], linkPreviewDraft: LinkPreviewDraft? = nil, quoteModel: QuotedReplyModel? = nil, hasPermissionToSendSeed: Bool = false ) { guard !showBlockedModalIfNeeded() else { return } - // Handle attachment errors if applicable - if let failedAttachment: SignalAttachment = attachments.first(where: { $0.hasError }) { - return showErrorAlert(for: failedAttachment) + /// Validate the expected attachment size before proceeding + do { + try attachments.forEach { attachment in + try attachment.ensureExpectedEncryptedSize( + domain: .attachment, + maxFileSize: Network.maxFileSize, + using: viewModel.dependencies + ) + } } + catch { return showErrorAlert(for: error) } let processedText: String = replaceMentions(in: text.trimmingCharacters(in: .whitespacesAndNewlines)) @@ -705,46 +751,45 @@ extension ConversationVC: } // Clearing this out immediately to make this appear more snappy - DispatchQueue.main.async { [weak self] in - self?.snInputView.text = "" - self?.snInputView.quoteDraftInfo = nil - - self?.resetMentions() - self?.scrollToBottom(isAnimated: false) - } + snInputView.text = "" + snInputView.quoteDraftInfo = nil + resetMentions() + scrollToBottom(isAnimated: false) + // Optimistically insert the outgoing message (this will trigger a UI update) self.viewModel.sentMessageBeforeUpdate = true let sentTimestampMs: Int64 = viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - let optimisticData: ConversationViewModel.OptimisticMessageData = self.viewModel.optimisticallyAppendOutgoingMessage( - text: processedText, - sentTimestampMs: sentTimestampMs, - attachments: attachments, - linkPreviewDraft: linkPreviewDraft, - quoteModel: quoteModel - ) - // If this was a message request then approve it - approveMessageRequestIfNeeded( - for: self.viewModel.threadData.threadId, - threadVariant: self.viewModel.threadData.threadVariant, - displayName: self.viewModel.threadData.displayName, - isDraft: (self.viewModel.threadData.threadIsDraft == true), - timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting - ).sinkUntilComplete( - receiveCompletion: { [weak self] _ in - self?.sendMessage(optimisticData: optimisticData) - } - ) + Task.detached(priority: .userInitiated) { [weak self] in + guard let self = self else { return } + + let optimisticData: ConversationViewModel.OptimisticMessageData = await viewModel.optimisticallyAppendOutgoingMessage( + text: processedText, + sentTimestampMs: sentTimestampMs, + attachments: attachments, + linkPreviewDraft: linkPreviewDraft, + quoteModel: quoteModel + ) + await approveMessageRequestIfNeeded( + for: self.viewModel.threadData.threadId, + threadVariant: self.viewModel.threadData.threadVariant, + displayName: self.viewModel.threadData.displayName, + isDraft: (self.viewModel.threadData.threadIsDraft == true), + timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting + ) + + await sendMessage(optimisticData: optimisticData) + } } - private func sendMessage(optimisticData: ConversationViewModel.OptimisticMessageData) { + private func sendMessage(optimisticData: ConversationViewModel.OptimisticMessageData) async { let threadId: String = self.viewModel.threadData.threadId let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant // Actually send the message - viewModel.dependencies[singleton: .storage] - .writePublisher { [weak self, dependencies = viewModel.dependencies] db in + do { + try await viewModel.dependencies[singleton: .storage].writeAsync { [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( @@ -786,7 +831,10 @@ extension ConversationVC: try LinkPreview( url: linkPreviewDraft.urlString, title: linkPreviewDraft.title, - attachmentId: try optimisticData.linkPreviewAttachment?.inserted(db).id, + attachmentId: try optimisticData.linkPreviewPreparedAttachment? + .attachment + .inserted(db) + .id, using: dependencies ).upsert(db) } @@ -797,16 +845,15 @@ extension ConversationVC: try Quote( interactionId: interactionId, authorId: quoteModel.authorId, - timestampMs: quoteModel.timestampMs, - body: nil + timestampMs: quoteModel.timestampMs ).insert(db) } - // Process any attachments - try AttachmentUploader.process( + // Link any attachments to their interaction + try AttachmentUploadJob.link( db, attachments: optimisticData.attachmentData, - for: insertedInteraction.id + toInteractionWithId: insertedInteraction.id ) // If we are sending a blinded message then we need to update the blinded profile @@ -826,7 +873,7 @@ extension ConversationVC: fallback: .none, using: dependencies ), - profileUpdateTimestamp: (currentUserProfile.profileLastUpdated ?? sentTimestamp), + profileUpdateTimestamp: currentUserProfile.profileLastUpdated, using: dependencies ) } @@ -839,36 +886,25 @@ extension ConversationVC: 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() - } - ) + + await handleMessageSent() + } + catch { + viewModel.failedToStoreOptimisticOutgoingMessage(id: optimisticData.id, error: error) + } } - func handleMessageSent() { + func handleMessageSent() async { 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 - - Task { - await viewModel.dependencies[singleton: .typingIndicators].didStopTyping( - threadId: threadId, - direction: .outgoing - ) - } - - viewModel.dependencies[singleton: .storage].writeAsync { db in + await viewModel.dependencies[singleton: .typingIndicators].didStopTyping( + threadId: viewModel.threadData.threadId, + direction: .outgoing + ) + try? await viewModel.dependencies[singleton: .storage].writeAsync { [threadId = viewModel.threadData.threadId] db in _ = try SessionThread .filter(id: threadId) .updateAll(db, SessionThread.Columns.messageDraft.set(to: "")) @@ -931,18 +967,20 @@ extension ConversationVC: // MARK: --Attachments - @MainActor func didPasteImageFromPasteboard(_ image: UIImage) { - guard let imageData = image.jpegData(compressionQuality: 1.0) else { return } + @MainActor func didPasteImageDataFromPasteboard(_ imageData: Data) { + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .media(.data(UUID().uuidString, imageData)), + sourceFilename: nil, + using: viewModel.dependencies + ) - let dataSource = DataSourceValue(data: imageData, dataType: .jpeg, using: viewModel.dependencies) - let attachment = SignalAttachment.attachment(dataSource: dataSource, type: .jpeg, imageQuality: .medium, using: viewModel.dependencies) - guard let approvalVC = AttachmentApprovalViewController.wrappedInNavController( threadId: self.viewModel.threadData.threadId, threadVariant: self.viewModel.threadData.threadVariant, - attachments: [ attachment ], + attachments: [ pendingAttachment ], approvalDelegate: self, disableLinkPreviewImageDownload: (self.viewModel.threadData.threadCanUpload != true), + didLoadLinkPreview: nil, using: self.viewModel.dependencies ) else { return } approvalVC.modalPresentationStyle = .fullScreen @@ -1069,6 +1107,14 @@ extension ConversationVC: } return } + + if !self.isFirstResponder { + // Force this object to become the First Responder. This is necessary + // to trigger the display of its associated inputAccessoryView + // and/or inputView. + self.becomeFirstResponder() + } + UIView.animate(withDuration: 0.25, animations: { self.inputAccessoryView?.isHidden = false self.inputAccessoryView?.alpha = 1 @@ -1341,7 +1387,7 @@ extension ConversationVC: try? AVAudioSession.sharedInstance().setCategory(.playback) 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 { + guard dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(path) else { return } @@ -1401,7 +1447,7 @@ extension ConversationVC: try? AVAudioSession.sharedInstance().setCategory(.playback) 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 { + guard dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(path) else { return } @@ -1472,23 +1518,27 @@ extension ConversationVC: let quoteViewContainsTouch: Bool = (visibleCell.quoteView?.bounds.contains(quotePoint) == true) let linkPreviewViewContainsTouch: Bool = (visibleCell.linkPreviewView?.previewView.bounds.contains(linkPreviewPoint) == true) - switch (containsLinks, quoteViewContainsTouch, linkPreviewViewContainsTouch, cellViewModel.quote, cellViewModel.linkPreview) { + switch (containsLinks, quoteViewContainsTouch, linkPreviewViewContainsTouch, cellViewModel.quotedInfo, cellViewModel.linkPreview) { // If the message contains both links and a quote, and the user tapped on the quote; OR the // message only contained a quote, then scroll to the quote - case (true, true, _, .some(let quote), _), (false, _, _, .some(let quote), _): - let maybeOriginalInteractionInfo: Interaction.TimestampInfo? = viewModel.dependencies[singleton: .storage].read { db in - try quote.originalInteraction - .select(.id, .timestampMs) - .asRequest(of: Interaction.TimestampInfo.self) + case (true, true, _, .some(let quotedInfo), _), (false, _, _, .some(let quotedInfo), _): + let maybeTimestampMs: Int64? = viewModel.dependencies[singleton: .storage].read { db in + try Interaction + .filter(id: quotedInfo.quotedInteractionId) + .select(.timestampMs) + .asRequest(of: Int64.self) .fetchOne(db) } - guard let interactionInfo: Interaction.TimestampInfo = maybeOriginalInteractionInfo else { + guard let timestampMs: Int64 = maybeTimestampMs else { return } self.scrollToInteractionIfNeeded( - with: interactionInfo, + with: Interaction.TimestampInfo( + id: quotedInfo.quotedInteractionId, + timestampMs: timestampMs + ), focusBehaviour: .highlight, originalIndexPath: self.tableView.indexPath(for: cell) ) @@ -1609,13 +1659,21 @@ extension ConversationVC: let (displayName, contactDisplayName): (String?, String?) = { guard let sessionId: String = sessionId else { - return (cellViewModel.authorName, nil) + return (cellViewModel.authorNameSuppressedId, nil) } - let profile: Profile? = dependencies[singleton: .storage].read { db in try? Profile.fetchOne(db, id: sessionId)} + let profile: Profile? = ( + dependencies.mutate(cache: .libSession) { $0.profile(contactId: sessionId) } ?? + dependencies[singleton: .storage].read { db in try? Profile.fetchOne(db, id: sessionId) } + ) + + let isCurrentUser: Bool = (viewModel.threadData.currentUserSessionIds?.contains(sessionId) == true) + guard !isCurrentUser else { + return ("you".localized(), "you".localized()) + } return ( - (profile?.displayName(for: .contact) ?? cellViewModel.authorName), + (profile?.displayName(for: .contact) ?? cellViewModel.authorNameSuppressedId), profile?.displayName(for: .contact, ignoringNickname: true) ) }() @@ -2295,13 +2353,21 @@ extension ConversationVC: cellViewModel.authorId != viewModel.threadData.currentUserSessionId { finalCellViewModel = finalCellViewModel.with( - profile: viewModel.dependencies.mutate(cache: .libSession) { $0.profile } + profile: .set(to: viewModel.dependencies.mutate(cache: .libSession) { $0.profile }) ) } let messageInfoViewController = MessageInfoViewController( actions: actions, messageViewModel: finalCellViewModel, + threadCanWrite: (viewModel.threadData.threadCanWrite == true), + onStartThread: { [weak self] in + self?.startThread( + with: cellViewModel.authorId, + openGroupServer: cellViewModel.threadOpenGroupServer, + openGroupPublicKey: cellViewModel.threadOpenGroupPublicKey + ) + }, using: viewModel.dependencies ) DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in @@ -2309,7 +2375,7 @@ extension ConversationVC: } } - func retry(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { + @MainActor func retry(_ cellViewModel: MessageViewModel, completion: (@MainActor () -> Void)?) { guard cellViewModel.id != MessageViewModel.optimisticUpdateId else { guard let optimisticMessageId: UUID = cellViewModel.optimisticMessageId, @@ -2333,8 +2399,12 @@ extension ConversationVC: } // Try to send the optimistic message again - sendMessage(optimisticData: optimisticMessageData) - completion?() + Task.detached(priority: .userInitiated) { [weak self] in + await self?.sendMessage(optimisticData: optimisticMessageData) + await MainActor.run { + completion?() + } + } return } @@ -2600,7 +2670,7 @@ extension ConversationVC: didPickDocumentsAt: { [weak self, dependencies = viewModel.dependencies] _, _ in validAttachments.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 { + guard dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(path) else { return } @@ -2659,7 +2729,7 @@ extension ConversationVC: completionHandler: { [weak self, dependencies] _, _ in validAttachments.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 { + guard dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(path) else { return } @@ -2849,11 +2919,14 @@ extension ConversationVC: func startVoiceMessageRecording() { // Request permission if needed - Permissions.requestMicrophonePermissionIfNeeded(using: viewModel.dependencies) { [weak self] in - DispatchQueue.main.async { - self?.cancelVoiceMessageRecording() + Permissions.requestMicrophonePermissionIfNeeded( + using: viewModel.dependencies, + onNotGranted: { [weak self] in + DispatchQueue.main.async { + self?.cancelVoiceMessageRecording() + } } - } + ) // Keep screen on UIApplication.shared.isIdleTimerDisabled = false @@ -2964,21 +3037,15 @@ extension ConversationVC: // Get data 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.") - } - - let attachment = SignalAttachment.voiceMessageAttachment(dataSource: dataSource, type: .mpeg4Audio, using: viewModel.dependencies) - - guard !attachment.hasError else { - return showErrorAlert(for: attachment) - } + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .voiceMessage(audioRecorder.url), + utType: .mpeg4Audio, + sourceFilename: fileName, + using: viewModel.dependencies + ) // Send attachment - sendMessage(text: "", attachments: [attachment]) + sendMessage(text: "", attachments: [pendingAttachment]) } func cancelVoiceMessageRecording() { @@ -3023,13 +3090,13 @@ extension ConversationVC: // MARK: - Convenience - func showErrorAlert(for attachment: SignalAttachment) { + @MainActor func showErrorAlert(for error: Error) { DispatchQueue.main.async { [weak self] in let modal: ConfirmationModal = ConfirmationModal( targetView: self?.view, info: ConfirmationModal.Info( title: "attachmentsErrorSending".localized(), - body: .text(attachment.localizedErrorDescription ?? SignalAttachment.missingDataErrorMessage), + body: .text("\(error)"), cancelTitle: "okay".localized(), cancelStyle: .alert_text ) @@ -3051,7 +3118,7 @@ extension ConversationVC: UIDocumentInteractionControllerDelegate { /// 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) { + if viewModel.dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(temporaryFileUrl.path) { try? viewModel.dependencies[singleton: .fileManager].removeItem(atPath: temporaryFileUrl.path) } } @@ -3066,22 +3133,20 @@ extension ConversationVC { displayName: String, isDraft: Bool, timestampMs: Int64 - ) -> AnyPublisher { - let updateNavigationBackStack: () -> Void = { - // Remove the 'SessionTableViewController' from the nav hierarchy if present - DispatchQueue.main.async { [weak self] in - if - let viewControllers: [UIViewController] = self?.navigationController?.viewControllers, - let messageRequestsIndex = viewControllers - .firstIndex(where: { viewCon -> Bool in - (viewCon as? SessionViewModelAccessible)?.viewModelType == MessageRequestsViewModel.self - }), - messageRequestsIndex > 0 - { - var newViewControllers = viewControllers - newViewControllers.remove(at: messageRequestsIndex) - self?.navigationController?.viewControllers = newViewControllers - } + ) async { + let updateNavigationBackStack: @MainActor () -> Void = { [weak self] in + /// Remove the `SessionTableViewController` from the nav hierarchy if present + if + let viewControllers: [UIViewController] = self?.navigationController?.viewControllers, + let messageRequestsIndex = viewControllers + .firstIndex(where: { viewCon -> Bool in + (viewCon as? SessionViewModelAccessible)?.viewModelType == MessageRequestsViewModel.self + }), + messageRequestsIndex > 0 + { + var newViewControllers = viewControllers + newViewControllers.remove(at: messageRequestsIndex) + self?.navigationController?.viewControllers = newViewControllers } } @@ -3089,145 +3154,145 @@ extension ConversationVC { 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) + let maybeContact: Contact? = try? await viewModel.dependencies[singleton: .storage].readAsync { [dependencies = viewModel.dependencies] db in + Contact.fetchOrCreate(db, id: threadId, using: dependencies) + } + guard - let contact: Contact = viewModel.dependencies[singleton: .storage].read({ [dependencies = viewModel.dependencies] db in - Contact.fetchOrCreate(db, id: threadId, using: dependencies) - }), + let contact: Contact = maybeContact, !contact.isApproved - else { return Just(()).eraseToAnyPublisher() } + else { return } - return viewModel.dependencies[singleton: .storage] - .writePublisher { [dependencies = viewModel.dependencies] db in - /// If this isn't a draft thread (ie. sending a message request) then send a `messageRequestResponse` - /// back to the sender (this allows the sender to know that they have been approved and can now use this - /// contact in closed groups) - if !isDraft { - _ = try? Interaction( - threadId: threadId, - threadVariant: threadVariant, - authorId: dependencies[cache: .general].sessionId.hexString, - variant: .infoMessageRequestAccepted, - body: "messageRequestYouHaveAccepted" - .put(key: "name", value: displayName) - .localized(), - timestampMs: timestampMs, - using: dependencies - ).inserted(db) - - try MessageSender.send( - db, - message: MessageRequestResponse( - isApproved: true, - sentTimestampMs: UInt64(timestampMs) - ), - interactionId: nil, - threadId: threadId, - threadVariant: threadVariant, - using: dependencies - ) - } + try? await viewModel.dependencies[singleton: .storage].writeAsync { [dependencies = viewModel.dependencies] db in + /// If this isn't a draft thread (ie. sending a message request) then send a `messageRequestResponse` + /// back to the sender (this allows the sender to know that they have been approved and can now use this + /// contact in closed groups) + if !isDraft { + _ = try? Interaction( + threadId: threadId, + threadVariant: threadVariant, + authorId: dependencies[cache: .general].sessionId.hexString, + variant: .infoMessageRequestAccepted, + body: "messageRequestYouHaveAccepted" + .put(key: "name", value: displayName) + .localized(), + timestampMs: timestampMs, + using: dependencies + ).inserted(db) - // 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: 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) + try MessageSender.send( + db, + message: MessageRequestResponse( + isApproved: true, + sentTimestampMs: UInt64(timestampMs) + ), + interactionId: nil, + threadId: threadId, + threadVariant: threadVariant, + using: dependencies + ) } - .map { _ in () } - .catch { _ in Just(()).eraseToAnyPublisher() } - .handleEvents( - receiveOutput: { _ in - // Update the UI - updateNavigationBackStack() - } - ) - .eraseToAnyPublisher() + + // 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: 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) + } + + // Update the UI + await MainActor.run { + updateNavigationBackStack() + } + return case .group: // If the group is not in the invited state then don't bother doing anything + let maybeGroup: ClosedGroup? = try? await viewModel.dependencies[singleton: .storage].readAsync { db in + try ClosedGroup.fetchOne(db, id: threadId) + } + guard - let group: ClosedGroup = viewModel.dependencies[singleton: .storage].read({ db in - try ClosedGroup.fetchOne(db, id: threadId) - }), + let group: ClosedGroup = maybeGroup, group.invited == true - else { return Just(()).eraseToAnyPublisher() } + else { return } - return viewModel.dependencies[singleton: .storage] - .writePublisher { [dependencies = viewModel.dependencies] db in - /// Remove any existing `infoGroupInfoInvited` interactions from the group (don't want to have a - /// duplicate one from inside the group history) - try Interaction.deleteWhere( - db, - .filter(Interaction.Columns.threadId == group.id), - .filter(Interaction.Columns.variant == Interaction.Variant.infoGroupInfoInvited) - ) - - /// Optimistically insert a `standard` member for the current user in this group (it'll be update to the correct - /// one once we receive the first `GROUP_MEMBERS` config message but adding it here means the `canWrite` - /// state of the group will continue to be `true` while we wait on the initial poll to get back) - try GroupMember( - groupId: group.id, - profileId: dependencies[cache: .general].sessionId.hexString, - role: .standard, - roleStatus: .accepted, - isHidden: false - ).upsert(db) - - /// If this isn't a draft thread (ie. sending a message request) and the user is not an admin then schedule - /// sending a `GroupUpdateInviteResponseMessage` to the group (this allows other members to - /// know that the user has joined the group) - if !isDraft && group.groupIdentityPrivateKey == nil { - try MessageSender.send( - db, - message: GroupUpdateInviteResponseMessage( - isApproved: true, - sentTimestampMs: UInt64(timestampMs) - ), - interactionId: nil, - threadId: threadId, - threadVariant: threadVariant, - using: dependencies - ) - } - - /// Actually trigger the approval - try ClosedGroup.approveGroupIfNeeded( + try? await viewModel.dependencies[singleton: .storage].writeAsync { [dependencies = viewModel.dependencies] db in + /// Remove any existing `infoGroupInfoInvited` interactions from the group (don't want to have a + /// duplicate one from inside the group history) + try Interaction.deleteWhere( + db, + .filter(Interaction.Columns.threadId == group.id), + .filter(Interaction.Columns.variant == Interaction.Variant.infoGroupInfoInvited) + ) + + /// Optimistically insert a `standard` member for the current user in this group (it'll be update to the correct + /// one once we receive the first `GROUP_MEMBERS` config message but adding it here means the `canWrite` + /// state of the group will continue to be `true` while we wait on the initial poll to get back) + try GroupMember( + groupId: group.id, + profileId: dependencies[cache: .general].sessionId.hexString, + role: .standard, + roleStatus: .accepted, + isHidden: false + ).upsert(db) + + /// If this isn't a draft thread (ie. sending a message request) and the user is not an admin then schedule + /// sending a `GroupUpdateInviteResponseMessage` to the group (this allows other members to + /// know that the user has joined the group) + if !isDraft && group.groupIdentityPrivateKey == nil { + try MessageSender.send( db, - group: group, + message: GroupUpdateInviteResponseMessage( + isApproved: true, + sentTimestampMs: UInt64(timestampMs) + ), + interactionId: nil, + threadId: threadId, + threadVariant: threadVariant, using: dependencies ) } - .map { _ in () } - .catch { _ in Just(()).eraseToAnyPublisher() } - .handleEvents( - receiveOutput: { _ in - // Update the UI - updateNavigationBackStack() - } + + /// Actually trigger the approval + try ClosedGroup.approveGroupIfNeeded( + db, + group: group, + using: dependencies ) - .eraseToAnyPublisher() + } + + // Update the UI + await MainActor.run { + updateNavigationBackStack() + } + return - default: return Just(()).eraseToAnyPublisher() + default: break } } func acceptMessageRequest() { - approveMessageRequestIfNeeded( - for: self.viewModel.threadData.threadId, - threadVariant: self.viewModel.threadData.threadVariant, - displayName: self.viewModel.threadData.displayName, - isDraft: (self.viewModel.threadData.threadIsDraft == true), - timestampMs: viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - ).sinkUntilComplete() + Task.detached(priority: .userInitiated) { [weak self] in + guard let self = self else { return } + + await approveMessageRequestIfNeeded( + for: self.viewModel.threadData.threadId, + threadVariant: self.viewModel.threadData.threadVariant, + displayName: self.viewModel.threadData.displayName, + isDraft: (self.viewModel.threadData.threadIsDraft == true), + timestampMs: viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + ) + } } func declineMessageRequest() { diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 671fbaa391..3b269b50b3 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -461,7 +461,8 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa titleView.initialSetup( with: self.viewModel.initialThreadVariant, isNoteToSelf: self.viewModel.threadData.threadIsNoteToSelf, - isMessageRequest: (self.viewModel.threadData.threadIsMessageRequest == true) + isMessageRequest: (self.viewModel.threadData.threadIsMessageRequest == true), + isSessionPro: self.viewModel.threadData.isSessionPro(using: self.viewModel.dependencies) ) // Constraints @@ -796,6 +797,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa with: updatedThreadData.displayName, isNoteToSelf: updatedThreadData.threadIsNoteToSelf, isMessageRequest: (updatedThreadData.threadIsMessageRequest == true), + isSessionPro: updatedThreadData.isSessionPro(using: viewModel.dependencies), threadVariant: updatedThreadData.threadVariant, mutedUntilTimestamp: updatedThreadData.threadMutedUntilTimestamp, onlyNotifyForMentions: (updatedThreadData.threadOnlyNotifyForMentions == true), @@ -838,7 +840,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa { if updatedThreadData.threadCanWrite == true { self.showInputAccessoryView() - } else { + } else if updatedThreadData.threadCanWrite == false && updatedThreadData.threadVariant != .community { self.hideInputAccessoryView() } @@ -1506,7 +1508,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // If we explicitly can't write to the thread then the input will be hidden but they keyboard // still reports that it takes up size, so just report 0 height in that case - if viewModel.threadData.threadCanWrite == false { + if viewModel.threadData.threadCanWrite == false && viewModel.threadData.threadVariant != .community { keyboardEndFrame = CGRect( x: UIScreen.main.bounds.minX, y: UIScreen.main.bounds.maxY, diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 82b8507cf7..8a9b56c87f 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -271,7 +271,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold openGroupPermissions: initialData?.openGroupPermissions, threadWasMarkedUnread: initialData?.threadWasMarkedUnread, using: dependencies - ).populatingPostQueryData( + ) + .populatingPostQueryData( recentReactionEmoji: nil, openGroupCapabilities: nil, currentUserSessionIds: ( @@ -365,18 +366,17 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ) 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)) + let (wasKickedFromGroup, groupIsDestroyed): (Bool, Bool) = { + guard viewModel.threadVariant == .group else { return (false, false) } + + let sessionId: SessionId = SessionId(.group, hex: viewModel.threadId) + return dependencies.mutate(cache: .libSession) { cache in + ( + cache.wasKickedFromGroup(groupSessionId: sessionId), + cache.groupIsDestroyed(groupSessionId: sessionId) + ) } - ) + }() return viewModel.populatingPostQueryData( recentReactionEmoji: recentReactionEmoji, @@ -630,6 +630,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold .sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs } let threadIsTrusted: Bool = data.contains(where: { $0.threadIsTrusted }) + // TODO: [Database Relocation] Source profile data via a separate query for efficiency + var currentUserProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } + // We load messages from newest to oldest so having a pageOffset larger than zero means // there are newer pages to load return [ @@ -660,6 +663,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold .id ), currentUserSessionIds: (threadData.currentUserSessionIds ?? []), + currentUserProfile: currentUserProfile, threadIsTrusted: threadIsTrusted, using: dependencies ) @@ -709,7 +713,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold interaction: Interaction, attachmentData: [Attachment]?, linkPreviewDraft: LinkPreviewDraft?, - linkPreviewAttachment: Attachment?, + linkPreviewPreparedAttachment: PreparedAttachment?, quoteModel: QuotedReplyModel? ) @@ -719,10 +723,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold public func optimisticallyAppendOutgoingMessage( text: String?, sentTimestampMs: Int64, - attachments: [SignalAttachment]?, + attachments: [PendingAttachment]?, linkPreviewDraft: LinkPreviewDraft?, quoteModel: QuotedReplyModel? - ) -> OptimisticMessageData { + ) async -> OptimisticMessageData { // Generate the optimistic data let optimisticMessageId: UUID = UUID() let threadData: SessionThreadViewModel = self.internalThreadData @@ -742,15 +746,23 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ), expiresInSeconds: threadData.disappearingMessagesConfiguration?.expiresInSeconds(), linkPreviewUrl: linkPreviewDraft?.urlString, - isProMessage: dependencies[cache: .libSession].isSessionPro, + isProMessage: (text.defaulting(to: "").utf16.count > LibSession.CharacterLimit), using: dependencies ) - let optimisticAttachments: [Attachment]? = attachments - .map { AttachmentUploader.prepare(attachments: $0, using: dependencies) } - let linkPreviewAttachment: Attachment? = linkPreviewDraft.map { draft in - try? LinkPreview.generateAttachmentIfPossible( - imageData: draft.jpegImageData, - type: .jpeg, + var optimisticAttachments: [Attachment]? + var linkPreviewPreparedAttachment: PreparedAttachment? + + if let pendingAttachments: [PendingAttachment] = attachments { + optimisticAttachments = try? await AttachmentUploadJob.preparePriorToUpload( + attachments: pendingAttachments, + using: dependencies + ) + } + + if let draft: LinkPreviewDraft = linkPreviewDraft { + linkPreviewPreparedAttachment = try? await LinkPreview.prepareAttachmentIfPossible( + urlString: draft.urlString, + imageSource: draft.imageSource, using: dependencies ) } @@ -772,6 +784,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold body: interaction.body, expiresStartedAtMs: interaction.expiresStartedAtMs, expiresInSeconds: interaction.expiresInSeconds, + isProMessage: interaction.isProMessage, isSenderModeratorOrAdmin: { switch threadData.threadVariant { case .group, .legacyGroup: @@ -789,16 +802,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold } }(), currentUserProfile: currentUserProfile, - quote: quoteModel.map { model in - // Don't care about this optimistic quote (the proper one will be generated in the database) - Quote( - interactionId: -1, // Can't save to db optimistically - authorId: model.authorId, - timestampMs: model.timestampMs, - body: model.body - ) - }, - quoteAttachment: quoteModel?.attachment, + quotedInfo: MessageViewModel.QuotedInfo(replyModel: quoteModel), linkPreview: linkPreviewDraft.map { draft in LinkPreview( url: draft.urlString, @@ -807,7 +811,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold using: dependencies ) }, - linkPreviewAttachment: linkPreviewAttachment, + linkPreviewAttachment: linkPreviewPreparedAttachment?.attachment, attachments: optimisticAttachments ) let optimisticData: OptimisticMessageData = ( @@ -816,7 +820,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold interaction, optimisticAttachments, linkPreviewDraft, - linkPreviewAttachment, + linkPreviewPreparedAttachment, quoteModel ) @@ -834,13 +838,13 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ( $0.id, $0.messageViewModel.with( - state: .failed, - mostRecentFailureText: "shareExtensionDatabaseError".localized() + state: .set(to: .failed), + mostRecentFailureText: .set(to: "shareExtensionDatabaseError".localized()) ), $0.interaction, $0.attachmentData, $0.linkPreviewDraft, - $0.linkPreviewAttachment, + $0.linkPreviewPreparedAttachment, $0.quoteModel ) } diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 175222f4a0..0c9ecf6b51 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -21,13 +21,14 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M var quoteDraftInfo: (model: QuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } } var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)? + private var linkPreviewLoadTask: Task? private var voiceMessageRecordingView: VoiceMessageRecordingView? private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0) private lazy var linkPreviewView: LinkPreviewView = { let maxWidth: CGFloat = (self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset) - return LinkPreviewView(maxWidth: maxWidth) { [weak self] in + return LinkPreviewView(maxWidth: maxWidth, using: dependencies) { [weak self] in self?.linkPreviewInfo = nil self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } } @@ -188,7 +189,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M }() private lazy var sessionProBadge: SessionProBadge = { - let result: SessionProBadge = SessionProBadge(size: .small) + let result: SessionProBadge = SessionProBadge(size: .medium) result.isHidden = !dependencies[feature: .sessionProEnabled] || dependencies[cache: .libSession].isSessionPro return result @@ -231,6 +232,10 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M required init?(coder: NSCoder) { preconditionFailure("Use init(delegate:) instead.") } + + deinit { + linkPreviewLoadTask?.cancel() + } private func setUpViewHierarchy() { autoresizingMask = .flexibleHeight @@ -331,8 +336,8 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M characterLimitLabelTapGestureRecognizer.isEnabled = (numberOfCharactersLeft < Self.thresholdForCharacterLimit) } - @MainActor func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage) { - delegate?.didPasteImageFromPasteboard(image) + @MainActor func didPasteImageDataFromPasteboard(_ inputTextView: InputTextView, imageData: Data) { + delegate?.didPasteImageDataFromPasteboard(imageData) } // We want to show either a link preview or a quote draft, but never both at the same time. When trying to @@ -400,7 +405,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M } } - func autoGenerateLinkPreview() { + @MainActor func autoGenerateLinkPreview() { // Check that a valid URL is present guard let linkPreviewURL = LinkPreview.previewUrl(for: text, selectedRange: inputTextView.selectedRange, using: dependencies) else { return @@ -425,37 +430,43 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M linkPreviewView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -4) // Build the link preview - LinkPreview - .tryToBuildPreviewInfo( - previewUrl: linkPreviewURL, - skipImageDownload: (inputState.allowedInputTypes != .all), /// Disable image download if attachments are disabled - using: dependencies - ) - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { [weak self] result in - switch result { - case .finished: break - case .failure: - guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete - - self?.linkPreviewInfo = nil - self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } - } - }, - receiveValue: { [weak self, dependencies] draft in - guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete + linkPreviewLoadTask?.cancel() + linkPreviewLoadTask = Task.detached(priority: .userInitiated) { [weak self, allowedInputTypes = inputState.allowedInputTypes, dependencies] in + do { + /// Load the draft + let draft: LinkPreviewDraft = try await LinkPreview.tryToBuildPreviewInfo( + previewUrl: linkPreviewURL, + skipImageDownload: (allowedInputTypes != .all), /// Disable if attachments are disabled + using: dependencies + ) + try Task.checkCancellation() + + await MainActor.run { [weak self] in + guard let self else { return } + guard linkPreviewInfo?.url == linkPreviewURL else { return } /// Obsolete - self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft) - self?.linkPreviewView.update( + linkPreviewInfo = (url: linkPreviewURL, draft: draft) + linkPreviewView.update( with: LinkPreview.DraftState(linkPreviewDraft: draft), isOutgoing: false, using: dependencies ) + setNeedsLayout() + layoutIfNeeded() } - ) - .store(in: &disposables) + } + catch { + await MainActor.run { [weak self] in + guard let self else { return } + guard linkPreviewInfo?.url == linkPreviewURL else { return } /// Obsolete + + linkPreviewInfo = nil + additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } + setNeedsLayout() + layoutIfNeeded() + } + } + } } func setMessageInputState(_ updatedInputState: SessionThreadViewModel.MessageInputState) { @@ -475,8 +486,13 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M UIView.animate(withDuration: 0.3) { [weak self] in self?.bottomStackView?.arrangedSubviews.forEach { $0.alpha = (updatedInputState.allowedInputTypes != .none ? 1 : 0) } + self?.attachmentsButton.alpha = (updatedInputState.allowedInputTypes == .all ? 1 : 0.4) + self?.attachmentsButton.mainButton.updateAppearance(isEnabled: updatedInputState.allowedInputTypes == .all) + self?.voiceMessageButton.alpha = (updatedInputState.allowedInputTypes == .all ? 1 : 0.4) + self?.voiceMessageButton.updateAppearance(isEnabled: updatedInputState.allowedInputTypes == .all) + self?.disabledInputLabel.alpha = (updatedInputState.allowedInputTypes != .none ? 0 : Values.mediumOpacity) } } @@ -669,5 +685,5 @@ protocol InputViewDelegate: ExpandingAttachmentsButtonDelegate, VoiceMessageReco @MainActor func handleCharacterLimitLabelTapped() @MainActor func inputTextViewDidChangeContent(_ inputTextView: InputTextView) @MainActor func handleMentionSelected(_ mentionInfo: MentionInfo, from view: MentionSelectionView) - @MainActor func didPasteImageFromPasteboard(_ image: UIImage) + @MainActor func didPasteImageDataFromPasteboard(_ imageData: Data) } diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift index 0a57b99575..953dd97f4d 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit @@ -9,7 +10,7 @@ protocol LinkPreviewState { var urlString: String? { get } var title: String? { get } var imageState: LinkPreview.ImageState { get } - var image: UIImage? { get } + var imageSource: ImageDataManager.DataSource? { get } } public extension LinkPreview { @@ -27,7 +28,7 @@ public extension LinkPreview { var urlString: String? { nil } var title: String? { nil } var imageState: LinkPreview.ImageState { .none } - var image: UIImage? { nil } + var imageSource: ImageDataManager.DataSource? { nil } } // MARK: DraftState @@ -43,20 +44,12 @@ public extension LinkPreview { } var imageState: LinkPreview.ImageState { - if linkPreviewDraft.jpegImageData != nil { return .loaded } + if linkPreviewDraft.imageSource != nil { return .loaded } return .none } - var image: UIImage? { - guard let jpegImageData = linkPreviewDraft.jpegImageData else { return nil } - guard let image = UIImage(data: jpegImageData) else { - Log.error("[LinkPreview] Could not load image: \(jpegImageData.count)") - return nil - } - - return image - } + var imageSource: ImageDataManager.DataSource? { linkPreviewDraft.imageSource } // MARK: - Type Specific @@ -101,19 +94,17 @@ public extension LinkPreview { } } - var image: UIImage? { + var imageSource: ImageDataManager.DataSource? { // Note: We don't check if the image is valid here because that can be confirmed // in 'imageState' and it's a little inefficient - guard imageAttachment?.isImage == true else { return nil } - guard let imageData: Data = try? imageAttachment?.readDataFromFile(using: dependencies) else { - return nil - } - guard let image = UIImage(data: imageData) else { - Log.error("[LinkPreview] Could not load image: \(imageAttachment?.downloadUrl ?? "unknown")") - return nil - } + guard + imageAttachment?.isImage == true, + let imageDownloadUrl: String = imageAttachment?.downloadUrl, + let path: String = try? dependencies[singleton: .attachmentManager] + .path(for: imageDownloadUrl) + else { return nil } - return image + return .url(URL(fileURLWithPath: path)) } // MARK: - Type Specific diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift index 7ec1765625..a6dcfacca5 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift @@ -10,6 +10,7 @@ final class LinkPreviewView: UIView { private static let loaderSize: CGFloat = 24 private static let cancelButtonSize: CGFloat = 45 + private let dependencies: Dependencies private let maxWidth: CGFloat private let onCancel: (() -> ())? @@ -23,7 +24,7 @@ final class LinkPreviewView: UIView { public var previewView: UIView { hStackView } private lazy var imageView: SessionImageView = { - let result: SessionImageView = SessionImageView() + let result: SessionImageView = SessionImageView(dataManager: dependencies[singleton: .imageDataManager]) result.contentMode = .scaleAspectFill return result @@ -85,10 +86,16 @@ final class LinkPreviewView: UIView { }() var bodyTappableLabel: TappableLabel? + var bodyTappableLabelHeight: CGFloat = 0 // MARK: - Initialization - init(maxWidth: CGFloat, onCancel: (() -> ())? = nil) { + init( + maxWidth: CGFloat, + using dependencies: Dependencies, + onCancel: (() -> ())? = nil + ) { + self.dependencies = dependencies self.maxWidth = maxWidth self.onCancel = onCancel @@ -142,7 +149,7 @@ final class LinkPreviewView: UIView { // MARK: - Updating - public func update( + @MainActor public func update( with state: LinkPreviewState, isOutgoing: Bool, delegate: TappableLabelDelegate? = nil, @@ -153,10 +160,31 @@ final class LinkPreviewView: UIView { ) { cancelButton.removeFromSuperview() - var image: UIImage? = state.image - let stateHasImage: Bool = (image != nil) - if image == nil && (state is LinkPreview.DraftState || state is LinkPreview.SentState) { - image = UIImage(named: "Link")?.withRenderingMode(.alwaysTemplate) + switch state { + case is LinkPreview.LoadingState: + loader.alpha = 1 + loader.startAnimating() + imageView.image = nil + + case is LinkPreview.DraftState, is LinkPreview.SentState: + let imageContentExists: Bool = (state.imageSource?.contentExists == true) + let imageSource: ImageDataManager.DataSource = { + guard + let source: ImageDataManager.DataSource = state.imageSource, + source.contentExists + else { return .icon(.link, size: 32, renderingMode: .alwaysTemplate) } + + return source + }() + loader.alpha = 0 + loader.stopAnimating() + imageView.loadImage(imageSource) + imageView.contentMode = (imageContentExists ? .scaleAspectFill : .center) + + default: + loader.alpha = 0 + loader.stopAnimating() + imageView.image = nil } // Image view @@ -164,17 +192,10 @@ final class LinkPreviewView: UIView { imageViewContainerWidthConstraint.constant = imageViewContainerSize imageViewContainerHeightConstraint.constant = imageViewContainerSize imageViewContainer.layer.cornerRadius = (state is LinkPreview.SentState ? 0 : 8) - - imageView.image = image imageView.themeTintColor = (isOutgoing ? .messageBubble_outgoingText : .messageBubble_incomingText ) - imageView.contentMode = (stateHasImage ? .scaleAspectFill : .center) - - // Loader - loader.alpha = (image != nil ? 0 : 1) - if image != nil { loader.stopAnimating() } else { loader.startAnimating() } // Title titleLabel.text = state.title @@ -202,18 +223,22 @@ final class LinkPreviewView: UIView { bodyTappableLabelContainer.subviews.forEach { $0.removeFromSuperview() } if let cellViewModel: MessageViewModel = cellViewModel { - let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel( + let (bodyTappableLabel, height) = VisibleMessageCell.getBodyTappableLabel( for: cellViewModel, with: maxWidth, textColor: (bodyLabelTextColor ?? .textPrimary), searchText: lastSearchText, delegate: delegate, using: dependencies - ).label + ) self.bodyTappableLabel = bodyTappableLabel + self.bodyTappableLabelHeight = height bodyTappableLabelContainer.addSubview(bodyTappableLabel) - bodyTappableLabel.pin(to: bodyTappableLabelContainer, withInset: 12) + bodyTappableLabel.pin(.leading, to: .leading, of: bodyTappableLabelContainer, withInset: 12) + bodyTappableLabel.pin(.top, to: .top, of: bodyTappableLabelContainer, withInset: 12) + bodyTappableLabel.pin(.trailing, to: .trailing, of: bodyTappableLabelContainer, withInset: -12) + bodyTappableLabel.pin(.bottom, to: .bottom, of: bodyTappableLabelContainer) } if state is LinkPreview.DraftState { diff --git a/Session/Conversations/Message Cells/Content Views/MediaView.swift b/Session/Conversations/Message Cells/Content Views/MediaView.swift index e1e942ee2d..3f509abbda 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaView.swift @@ -176,7 +176,7 @@ public class MediaView: UIView { addSubview(loadingIndicator) loadingIndicator.pin(.leading, to: .leading, of: self) - loadingIndicator.pin(.trailing, to: .trailing, of: self) + loadingIndicator.pin(.trailing, to: .trailing, of: self).setting(priority: .defaultHigh) loadingIndicator.pin(.bottom, to: .bottom, of: self) /// Load in image data if possible @@ -192,8 +192,8 @@ public class MediaView: UIView { case (_, false, _), (_, _, false): return configure(forError: .invalid) case (_, true, true): - imageView.loadThumbnail(size: .medium, attachment: attachment, using: dependencies) { [weak self] processedData in - guard processedData == nil else { return } + imageView.loadThumbnail(size: .medium, attachment: attachment, using: dependencies) { [weak self] buffer in + guard buffer == nil else { return } Log.error("[MediaView] Could not load thumbnail") 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 ecc93aef65..0ae01b0688 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -4,6 +4,7 @@ import UIKit import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit +import Lucide final class QuoteView: UIView { static let thumbnailSize: CGFloat = 48 @@ -87,8 +88,6 @@ final class QuoteView: UIView { let mainStackView = UIStackView(arrangedSubviews: []) mainStackView.axis = .horizontal mainStackView.spacing = smallSpacing - mainStackView.isLayoutMarginsRelativeArrangement = true - mainStackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: smallSpacing) mainStackView.alignment = .center mainStackView.setCompressionResistance(.vertical, to: .required) @@ -132,8 +131,8 @@ final class QuoteView: UIView { } // Generate the thumbnail if needed - imageView.loadThumbnail(size: .small, attachment: attachment, using: dependencies) { [weak imageView] processedData in - guard processedData != nil else { return } + imageView.loadThumbnail(size: .small, attachment: attachment, using: dependencies) { [weak imageView] buffer in + guard buffer != nil else { return } imageView?.contentMode = .scaleAspectFill } @@ -160,13 +159,13 @@ final class QuoteView: UIView { bodyLabel.lineBreakMode = .byTruncatingTail bodyLabel.numberOfLines = 2 - let targetThemeColor: ThemeValue = { + let (targetThemeColor, proBadgeThemeColor): (ThemeValue, ThemeValue) = { switch mode { case .regular: return (direction == .outgoing ? - .messageBubble_outgoingText : - .messageBubble_incomingText + (.messageBubble_outgoingText, .white) : + (.messageBubble_incomingText, .primary) ) - case .draft: return .textPrimary + case .draft: return (.textPrimary, .primary) } }() bodyLabel.font = .systemFont(ofSize: Values.smallFontSize) @@ -198,7 +197,10 @@ final class QuoteView: UIView { .defaulting(to: ThemedAttributedString(string: "messageErrorOriginal".localized(), attributes: [ .themeForegroundColor: targetThemeColor ])) // Label stack view - let authorLabel = UILabel() + let authorLabel = SessionLabelWithProBadge( + proBadgeSize: .mini, + proBadgeThemeBackgroundColor: proBadgeThemeColor + ) authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize) authorLabel.text = { guard !currentUserSessionIds.contains(authorId) else { return "you".localized() } @@ -207,6 +209,7 @@ final class QuoteView: UIView { return Profile.displayNameNoFallback( id: authorId, threadVariant: threadVariant, + suppressId: true, using: dependencies ) } @@ -214,13 +217,15 @@ final class QuoteView: UIView { return Profile.displayName( id: authorId, threadVariant: threadVariant, + suppressId: true, using: dependencies ) }() authorLabel.themeTextColor = targetThemeColor authorLabel.lineBreakMode = .byTruncatingTail - authorLabel.isHidden = (authorLabel.text == nil) authorLabel.numberOfLines = 1 + authorLabel.isHidden = (authorLabel.text == nil) + authorLabel.isProBadgeHidden = !dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: authorId) } authorLabel.setCompressionResistance(.vertical, to: .required) let labelStackView = UIStackView(arrangedSubviews: [ authorLabel, bodyLabel ]) @@ -239,7 +244,7 @@ final class QuoteView: UIView { if mode == .draft { // Cancel button let cancelButton = UIButton(type: .custom) - cancelButton.setImage(UIImage(named: "X")?.withRenderingMode(.alwaysTemplate), for: .normal) + cancelButton.setImage(Lucide.image(icon: .x, size: 24)?.withRenderingMode(.alwaysTemplate), for: .normal) cancelButton.themeTintColor = .textPrimary cancelButton.set(.width, to: cancelButtonSize) cancelButton.set(.height, to: cancelButtonSize) @@ -247,6 +252,8 @@ final class QuoteView: UIView { mainStackView.addArrangedSubview(cancelButton) cancelButton.center(.vertical, in: self) + mainStackView.isLayoutMarginsRelativeArrangement = true + mainStackView.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 1) } } diff --git a/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift b/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift index 82ba8d086d..270133b68f 100644 --- a/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift +++ b/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift @@ -1,11 +1,13 @@ // Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import SwiftUI +import Lucide import SessionUIKit import SessionMessagingKit public struct LinkPreviewView_SwiftUI: View { private var state: LinkPreviewState + private var dataManager: ImageDataManagerType private var isOutgoing: Bool private let maxWidth: CGFloat private var messageViewModel: MessageViewModel? @@ -18,6 +20,7 @@ public struct LinkPreviewView_SwiftUI: View { init( state: LinkPreviewState, + dataManager: ImageDataManagerType, isOutgoing: Bool, maxWidth: CGFloat = .infinity, messageViewModel: MessageViewModel? = nil, @@ -26,6 +29,7 @@ public struct LinkPreviewView_SwiftUI: View { onCancel: (() -> ())? = nil ) { self.state = state + self.dataManager = dataManager self.isOutgoing = isOutgoing self.maxWidth = maxWidth self.messageViewModel = messageViewModel @@ -48,25 +52,36 @@ public struct LinkPreviewView_SwiftUI: View { ) { // Link preview image let imageSize: CGFloat = state is LinkPreview.SentState ? 100 : 80 - if let linkPreviewImage: UIImage = state.image { - Image(uiImage: linkPreviewImage) - .resizable() - .scaledToFill() - .foregroundColor( - themeColor: isOutgoing ? - .messageBubble_outgoingText : - .messageBubble_incomingText - ) - .frame( - width: imageSize, - height: imageSize - ) - .cornerRadius(state is LinkPreview.SentState ? 0 : 8) - } else if - state is LinkPreview.DraftState || state is LinkPreview.SentState, - let defaultImage: UIImage = UIImage(named: "Link")?.withRenderingMode(.alwaysTemplate) - { - Image(uiImage: defaultImage) + if let linkPreviewImageSource: ImageDataManager.DataSource = state.imageSource { + SessionAsyncImage( + source: linkPreviewImageSource, + dataManager: dataManager, + content: { image in + image + .resizable() + .scaledToFill() + .foregroundColor( + themeColor: isOutgoing ? + .messageBubble_outgoingText : + .messageBubble_incomingText + ) + .frame( + width: imageSize, + height: imageSize + ) + .cornerRadius(state is LinkPreview.SentState ? 0 : 8) + }, + placeholder: { + ThemeColor(.alert_background) + .frame( + width: imageSize, + height: imageSize + ) + .cornerRadius(state is LinkPreview.SentState ? 0 : 8) + } + ) + } else if state is LinkPreview.DraftState || state is LinkPreview.SentState { + LucideIcon(.link, size: IconSize.medium.size) .foregroundColor( themeColor: isOutgoing ? .messageBubble_outgoingText : @@ -131,15 +146,17 @@ struct LinkPreview_SwiftUI_Previews: PreviewProvider { linkPreviewDraft: .init( urlString: "https://github.com/oxen-io", title: "Github - oxen-io/session-ios: A private messenger for iOS.", - jpegImageData: UIImage(named: "AppIcon")?.jpegData(compressionQuality: 1) + imageSource: .image("AppIcon", UIImage(named: "AppIcon")) ) ), + dataManager: ImageDataManager(), isOutgoing: true ) .padding(.horizontal, Values.mediumSpacing) LinkPreviewView_SwiftUI( state: LinkPreview.LoadingState(), + dataManager: ImageDataManager(), isOutgoing: true ) .frame( diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 977da2a226..5f11dded10 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -125,9 +125,10 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { return result }() - private lazy var authorLabel: UILabel = { - let result: UILabel = UILabel() + private lazy var authorLabel: SessionLabelWithProBadge = { + let result = SessionLabelWithProBadge(proBadgeSize: .mini) result.font = .boldSystemFont(ofSize: Values.smallFontSize) + result.isProBadgeHidden = true result.setContentHugging(.vertical, to: .required) result.setCompressionResistance(.vertical, to: .required) @@ -341,11 +342,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { ) contentHStackTopConstraint.constant = (shouldAddTopInset ? Values.mediumSpacing : 0) - // Author label - authorLabel.isHidden = (cellViewModel.senderName == nil) - authorLabel.text = cellViewModel.senderName - authorLabel.themeTextColor = .textPrimary - let isGroupThread: Bool = ( cellViewModel.threadVariant == .community || cellViewModel.threadVariant == .legacyGroup || @@ -391,6 +387,13 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { bubbleView.accessibilityLabel = bodyTappableLabel?.attributedText?.string bubbleView.isAccessibilityElement = true + // Author label + authorLabel.isHidden = (cellViewModel.senderName == nil) + authorLabel.text = cellViewModel.authorNameSuppressedId + authorLabel.extraText = cellViewModel.authorName.replacingOccurrences(of: cellViewModel.authorNameSuppressedId, with: "").trimmingCharacters(in: .whitespacesAndNewlines) + authorLabel.themeTextColor = .textPrimary + authorLabel.isProBadgeHidden = !dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: cellViewModel.authorId) } + // Flip horizontally for RTL languages replyIconImageView.transform = CGAffineTransform.identity .scaledBy( @@ -545,11 +548,20 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { for: cellViewModel, cellWidth: tableSize.width ) - 2 * inset) + let lineHeight: CGFloat = UIFont.systemFont(ofSize: VisibleMessageCell.getFontSize(for: cellViewModel)).lineHeight if let linkPreview: LinkPreview = cellViewModel.linkPreview { switch linkPreview.variant { case .standard: - let linkPreviewView: LinkPreviewView = LinkPreviewView(maxWidth: maxWidth) + // Stack view + let stackView = UIStackView(arrangedSubviews: []) + stackView.axis = .vertical + stackView.spacing = 2 + + let linkPreviewView: LinkPreviewView = LinkPreviewView( + maxWidth: maxWidth, + using: dependencies + ) linkPreviewView.update( with: LinkPreview.SentState( linkPreview: linkPreview, @@ -564,10 +576,26 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { using: dependencies ) self.linkPreviewView = linkPreviewView - bubbleView.addSubview(linkPreviewView) - linkPreviewView.pin(to: bubbleView, withInset: 0) + stackView.addArrangedSubview(linkPreviewView) + readMoreButton.themeTextColor = bodyLabelTextColor + let maxHeight: CGFloat = VisibleMessageCell.getMaxHeightAfterTruncation(for: cellViewModel) + self.bodayTappableLabelHeightConstraint = linkPreviewView.bodyTappableLabel?.set( + .height, + to: (shouldExpanded ? linkPreviewView.bodyTappableLabelHeight : min(linkPreviewView.bodyTappableLabelHeight, maxHeight)) + ) + if ((linkPreviewView.bodyTappableLabelHeight - maxHeight >= lineHeight) && !shouldExpanded) { + stackView.addArrangedSubview(readMoreButton) + readMoreButton.isHidden = false + readMoreButton.transform = CGAffineTransform(translationX: inset, y: 0) + } + + bubbleView.addSubview(stackView) + stackView.pin([UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.top], to: bubbleView) + stackView.pin(.bottom, to: .bottom, of: bubbleView, withInset: -inset) snContentView.addArrangedSubview(bubbleBackgroundView) self.bodyTappableLabel = linkPreviewView.bodyTappableLabel + self.bodyTappableLabelHeight = linkPreviewView.bodyTappableLabelHeight + case .openGroupInvitation: let openGroupInvitationView: OpenGroupInvitationView = OpenGroupInvitationView( @@ -593,16 +621,16 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { stackView.setCompressionResistance(.vertical, to: .required) // Quote view - if let quote: Quote = cellViewModel.quote { + if let quotedInfo: MessageViewModel.QuotedInfo = cellViewModel.quotedInfo { let hInset: CGFloat = 2 let quoteView: QuoteView = QuoteView( for: .regular, - authorId: quote.authorId, - quotedText: quote.body, + authorId: quotedInfo.authorId, + quotedText: quotedInfo.body, threadVariant: cellViewModel.threadVariant, currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), - attachment: cellViewModel.quoteAttachment, + attachment: quotedInfo.attachment, using: dependencies ) self.quoteView = quoteView @@ -626,7 +654,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let maxHeight: CGFloat = VisibleMessageCell.getMaxHeightAfterTruncation(for: cellViewModel) bodyTappableLabel.numberOfLines = shouldExpanded ? 0 : VisibleMessageCell.maxNumberOfLinesAfterTruncation - if (height > maxHeight && !shouldExpanded) { + if ((height - maxHeight >= lineHeight) && !shouldExpanded) { stackView.addArrangedSubview(readMoreButton) readMoreButton.isHidden = false } @@ -675,10 +703,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { cellWidth: tableSize.width ) - 2 * inset ) + let lineHeight: CGFloat = UIFont.systemFont(ofSize: VisibleMessageCell.getFontSize(for: cellViewModel)).lineHeight - switch (cellViewModel.quote, cellViewModel.body) { + switch (cellViewModel.quotedInfo, cellViewModel.body) { /// Both quote and body - case (.some(let quote), .some(let body)) where !body.isEmpty: + case (.some(let quotedInfo), .some(let body)) where !body.isEmpty: // Stack view let stackView = UIStackView(arrangedSubviews: []) stackView.axis = .vertical @@ -688,12 +717,12 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let hInset: CGFloat = 2 let quoteView: QuoteView = QuoteView( for: .regular, - authorId: quote.authorId, - quotedText: quote.body, + authorId: quotedInfo.authorId, + quotedText: quotedInfo.body, threadVariant: cellViewModel.threadVariant, currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), - attachment: cellViewModel.quoteAttachment, + attachment: quotedInfo.attachment, using: dependencies ) self.quoteView = quoteView @@ -716,7 +745,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let maxHeight: CGFloat = VisibleMessageCell.getMaxHeightAfterTruncation(for: cellViewModel) bodyTappableLabel.numberOfLines = shouldExpanded ? 0 : VisibleMessageCell.maxNumberOfLinesAfterTruncation - if (height > maxHeight && !shouldExpanded) { + if ((height - maxHeight >= lineHeight) && !shouldExpanded) { stackView.addArrangedSubview(readMoreButton) readMoreButton.isHidden = false } @@ -751,7 +780,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let maxHeight: CGFloat = VisibleMessageCell.getMaxHeightAfterTruncation(for: cellViewModel) bodyTappableLabel.numberOfLines = shouldExpanded ? 0 : VisibleMessageCell.maxNumberOfLinesAfterTruncation - if (height > maxHeight && !shouldExpanded) { + if ((height - maxHeight > UIFont.systemFont(ofSize: VisibleMessageCell.getFontSize(for: cellViewModel)).lineHeight) && !shouldExpanded) { stackView.addArrangedSubview(readMoreButton) readMoreButton.isHidden = false } @@ -764,15 +793,15 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { snContentView.addArrangedSubview(bubbleBackgroundView) /// Just quote - case (.some(let quote), _): + case (.some(let quotedInfo), _): let quoteView: QuoteView = QuoteView( for: .regular, - authorId: quote.authorId, - quotedText: quote.body, + authorId: quotedInfo.authorId, + quotedText: quotedInfo.body, threadVariant: cellViewModel.threadVariant, currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), - attachment: cellViewModel.quoteAttachment, + attachment: quotedInfo.attachment, using: dependencies ) self.quoteView = quoteView @@ -1034,14 +1063,16 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { guard let cellViewModel: MessageViewModel = self.viewModel else { return } let location = gestureRecognizer.location(in: self) - - if - ( - profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)) || - authorLabel.bounds.contains(authorLabel.convert(location, from: self)) - ), + let tappedAuthorName: Bool = ( + authorLabel.bounds.contains(authorLabel.convert(location, from: self)) && + !(cellViewModel.senderName ?? "").isEmpty + ) + let tappedProfilePicture: Bool = ( + profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)) && cellViewModel.shouldShowProfile - { + ) + + if tappedAuthorName || tappedProfilePicture { delegate?.showUserProfileModal(for: cellViewModel) } else if replyButton.alpha > 0 && replyButton.bounds.contains(replyButton.convert(location, from: self)) { @@ -1186,7 +1217,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { } } - private static func getMaxHeightAfterTruncation(for cellViewModel: MessageViewModel) -> CGFloat { + public static func getMaxHeightAfterTruncation(for cellViewModel: MessageViewModel) -> CGFloat { return CGFloat(maxNumberOfLinesAfterTruncation) * UIFont.systemFont(ofSize: getFontSize(for: cellViewModel)).lineHeight } @@ -1390,7 +1421,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { return attributedText } - static func getBodyTappableLabel( + public static func getBodyTappableLabel( for cellViewModel: MessageViewModel, with availableWidth: CGFloat, textColor: ThemeValue, diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 742ff3c964..43718f2822 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -1,6 +1,8 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +import SwiftUI import Foundation +import PhotosUI import Combine import Lucide import GRDB @@ -11,7 +13,7 @@ import SignalUtilitiesKit import SessionUtilitiesKit import SessionNetworkingKit -class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource { +class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, ObservableTableSource { public let dependencies: Dependencies public let navigatableState: NavigatableState = NavigatableState() public let state: TableDataState = TableDataState() @@ -22,13 +24,17 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob private let didTriggerSearch: () -> () private var updatedName: String? private var updatedDescription: String? - private var onDisplayPictureSelected: ((ConfirmationModal.ValueUpdate) -> Void)? + private var onDisplayPictureSelected: ((ImageDataManager.DataSource, CGRect?) -> Void)? private lazy var imagePickerHandler: ImagePickerHandler = ImagePickerHandler( onTransition: { [weak self] in self?.transitionToScreen($0, transitionType: $1) }, - onImageDataPicked: { [weak self] identifier, resultImageData in - self?.onDisplayPictureSelected?(.image(identifier: identifier, data: resultImageData)) - } + onImagePicked: { [weak self] source, cropRect in + self?.onDisplayPictureSelected?(source, cropRect) + }, + using: dependencies ) + private var profileImageStatus: (previous: ProfileImageStatus?, current: ProfileImageStatus?) + // TODO: Refactor this with SessionThreadViewModel + private var threadViewModelSubject: CurrentValueSubject // MARK: - Initialization @@ -42,36 +48,41 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob self.threadId = threadId self.threadVariant = threadVariant self.didTriggerSearch = didTriggerSearch + self.threadViewModelSubject = CurrentValueSubject(nil) + self.profileImageStatus = (previous: nil, current: .normal) } // MARK: - Config - - enum NavState { - case standard - case editing + enum ProfileImageStatus: Equatable { + case normal + case expanded + case qrCode } enum NavItem: Equatable { case edit - case cancel - case done } public enum Section: SessionTableSection { case conversationInfo + case sessionId + case sessionIdNoteToSelf case content case adminActions case destructiveActions public var title: String? { switch self { - case .adminActions: return "adminSettings".localized() + case .sessionId: return "accountId".localized() + case .sessionIdNoteToSelf: return "accountIdYours".localized() + case .adminActions: return "adminSettings".localized() default: return nil } } public var style: SessionTableSectionStyle { switch self { + case .sessionId, .sessionIdNoteToSelf: return .titleSeparator case .destructiveActions: return .padding case .adminActions: return .titleRoundedContent default: return .none @@ -81,6 +92,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob public enum TableItem: Differentiable { case avatar + case qrCode case displayName case contactName case threadDescription @@ -109,6 +121,47 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob case debugDeleteAttachmentsBeforeNow } + lazy var rightNavItems: AnyPublisher<[SessionNavItem], Never> = threadViewModelSubject + .map { [weak self] threadViewModel -> [SessionNavItem] in + guard let threadViewModel: SessionThreadViewModel = threadViewModel else { return [] } + + let currentUserIsClosedGroupAdmin: Bool = ( + [.legacyGroup, .group].contains(threadViewModel.threadVariant) && + threadViewModel.currentUserIsClosedGroupAdmin == true + ) + + let canEditDisplayName: Bool = ( + threadViewModel.threadIsNoteToSelf != true && + ( + threadViewModel.threadVariant == .contact || + currentUserIsClosedGroupAdmin + ) + ) + + guard canEditDisplayName else { return [] } + + return [ + SessionNavItem( + id: .edit, + image: Lucide.image(icon: .pencil, size: 22)? + .withRenderingMode(.alwaysTemplate), + style: .plain, + accessibilityIdentifier: "Edit Nickname", + action: { [weak self] in + guard + let info: ConfirmationModal.Info = self?.updateDisplayNameModal( + threadViewModel: threadViewModel, + currentUserIsClosedGroupAdmin: currentUserIsClosedGroupAdmin + ) + else { return } + + self?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present) + } + ) + ] + } + .eraseToAnyPublisher() + // MARK: - Content private struct State: Equatable { @@ -124,23 +177,30 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob } lazy var observation: TargetObservation = ObservationBuilderOld - .databaseObservation(self) { [dependencies, threadId = self.threadId] db -> State in + .databaseObservation(self) { [ weak self, dependencies, threadId = self.threadId] db -> State in let userSessionId: SessionId = dependencies[cache: .general].sessionId - let threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel + var threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel .conversationSettingsQuery(threadId: threadId, userSessionId: userSessionId) .fetchOne(db) let disappearingMessagesConfig: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration .fetchOne(db, id: threadId) .defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId)) + self?.threadViewModelSubject.send(threadViewModel) + return State( threadViewModel: threadViewModel, disappearingMessagesConfig: disappearingMessagesConfig ) } - .compactMap { [weak self] current -> [SectionModel]? in self?.content(current) } + .compactMap { [weak self] current -> [SectionModel]? in + self?.content( + current, + profileImageStatus: self?.profileImageStatus + ) + } - private func content(_ current: State) -> [SectionModel] { + private func content(_ current: State, profileImageStatus: (previous: ProfileImageStatus?, current: ProfileImageStatus?)?) -> [SectionModel] { // If we don't get a `SessionThreadViewModel` then it means the thread was probably deleted // so dismiss the screen guard let threadViewModel: SessionThreadViewModel = current.threadViewModel else { @@ -165,89 +225,101 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob isGroup && threadViewModel.currentUserIsClosedGroupAdmin == true ) - let canEditDisplayName: Bool = ( - threadViewModel.threadIsNoteToSelf != true && ( - threadViewModel.threadVariant == .contact || - currentUserIsClosedGroupAdmin - ) - ) let isThreadHidden: Bool = ( threadViewModel.threadShouldBeVisible != true && threadViewModel.threadPinnedPriority == LibSession.hiddenPriority ) + + let showThreadPubkey: Bool = ( + threadViewModel.threadVariant == .contact || ( + threadViewModel.threadVariant == .group && + dependencies[feature: .groupsShowPubkeyInConversationSettings] + ) + ) // MARK: - Conversation Info + let conversationInfoSection: SectionModel = SectionModel( model: .conversationInfo, elements: [ - SessionCell.Info( - id: .avatar, - accessory: .profile( - id: threadViewModel.id, - size: .hero, - threadVariant: threadViewModel.threadVariant, - displayPictureUrl: threadViewModel.threadDisplayPictureUrl, - profile: threadViewModel.profile, - profileIcon: { - guard - threadViewModel.threadVariant == .group && - currentUserIsClosedGroupAdmin && - dependencies[feature: .updatedGroupsAllowDisplayPicture] - else { return .none } - - // If we already have a display picture then the main profile gets the icon - return (threadViewModel.threadDisplayPictureUrl != nil ? .rightPlus : .none) - }(), - additionalProfile: threadViewModel.additionalProfile, - additionalProfileIcon: { - guard - threadViewModel.threadVariant == .group && - currentUserIsClosedGroupAdmin && - dependencies[feature: .updatedGroupsAllowDisplayPicture] - else { return .none } + (profileImageStatus?.current == .qrCode ? + SessionCell.Info( + id: .qrCode, + accessory: .qrCode( + for: threadViewModel.getQRCodeString(), + hasBackground: false, + logo: "SessionWhite40", // stringlint:ignore + themeStyle: ThemeManager.currentTheme.interfaceStyle + ), + styling: SessionCell.StyleInfo( + alignment: .centerHugging, + customPadding: SessionCell.Padding(bottom: Values.smallSpacing), + backgroundStyle: .noBackground + ), + onTapView: { [weak self] targetView in + let didTapProfileIcon: Bool = !(targetView is UIImageView) - // No display picture means the dual-profile so the additionalProfile gets the icon - return .rightPlus - }(), - accessibility: nil - ), - styling: SessionCell.StyleInfo( - alignment: .centerHugging, - customPadding: SessionCell.Padding(bottom: Values.smallSpacing), - backgroundStyle: .noBackground - ), - onTap: { [weak self] in - switch (threadViewModel.threadVariant, threadViewModel.threadDisplayPictureUrl, currentUserIsClosedGroupAdmin) { - case (.contact, _, _): self?.viewDisplayPicture(threadViewModel: threadViewModel) - case (.group, _, true): - self?.updateGroupDisplayPicture(currentUrl: threadViewModel.threadDisplayPictureUrl) + if didTapProfileIcon { + self?.profileImageStatus = (previous: profileImageStatus?.current, current: profileImageStatus?.previous) + self?.forceRefresh(type: .postDatabaseQuery) + } else { + self?.showQRCodeLightBox(for: threadViewModel) + } + } + ) + : + SessionCell.Info( + id: .avatar, + accessory: .profile( + id: threadViewModel.id, + size: (profileImageStatus?.current == .expanded ? .expanded : .hero), + threadVariant: threadViewModel.threadVariant, + displayPictureUrl: threadViewModel.threadDisplayPictureUrl, + profile: threadViewModel.profile, + profileIcon: (threadViewModel.threadIsNoteToSelf || threadVariant == .group ? .none : .qrCode), + additionalProfile: threadViewModel.additionalProfile, + accessibility: nil + ), + styling: SessionCell.StyleInfo( + alignment: .centerHugging, + customPadding: SessionCell.Padding( + leading: 0, + bottom: Values.smallSpacing + ), + backgroundStyle: .noBackground + ), + onTapView: { [weak self] targetView in + let didTapQRCodeIcon: Bool = !(targetView is ProfilePictureView) - case (_, .some, _): self?.viewDisplayPicture(threadViewModel: threadViewModel) - default: break + if didTapQRCodeIcon { + self?.profileImageStatus = (previous: profileImageStatus?.current, current: .qrCode) + } else { + self?.profileImageStatus = ( + previous: profileImageStatus?.current, + current: (profileImageStatus?.current == .expanded ? .normal : .expanded) + ) + } + self?.forceRefresh(type: .postDatabaseQuery) } - - } + ) ), SessionCell.Info( id: .displayName, title: SessionCell.TextInfo( threadViewModel.displayName, font: .titleLarge, - alignment: .center - ), - trailingAccessory: (!canEditDisplayName ? nil : - .icon( - .pencil, - size: .small, - customTint: .textSecondary - ) + alignment: .center, + trailingImage: { + guard !threadViewModel.threadIsNoteToSelf else { return nil } + guard (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }) else { return nil } + return ("ProBadge", { [dependencies] in SessionProBadge(size: .medium).toImage(using: dependencies) }) + }() ), styling: SessionCell.StyleInfo( alignment: .centerHugging, customPadding: SessionCell.Padding( top: Values.smallSpacing, - leading: (!canEditDisplayName ? nil : IconSize.small.size), bottom: { - guard threadViewModel.threadVariant != .contact else { return Values.smallSpacing } + guard threadViewModel.threadVariant != .contact else { return Values.mediumSpacing } guard threadViewModel.threadDescription == nil else { return Values.smallSpacing } return Values.largeSpacing @@ -260,15 +332,37 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob identifier: "Username", label: threadViewModel.displayName ), - onTap: { [weak self] in - guard - let info: ConfirmationModal.Info = self?.updateDisplayNameModal( - threadViewModel: threadViewModel, - currentUserIsClosedGroupAdmin: currentUserIsClosedGroupAdmin - ) - else { return } + onTapView: { [weak self, threadId, dependencies] targetView in + guard targetView is SessionProBadge, !dependencies[cache: .libSession].isSessionPro else { + guard + let info: ConfirmationModal.Info = self?.updateDisplayNameModal( + threadViewModel: threadViewModel, + currentUserIsClosedGroupAdmin: currentUserIsClosedGroupAdmin + ) + else { return } + + self?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present) + return + } - self?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present) + let proCTAModalVariant: ProCTAModal.Variant = { + switch threadViewModel.threadVariant { + case .group: + return .groupLimit( + isAdmin: currentUserIsClosedGroupAdmin, + isSessionProActivated: (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }), + proBadgeImage: SessionProBadge(size: .mini).toImage(using: dependencies) + ) + default: return .generic + } + }() + + dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + proCTAModalVariant, + presenting: { modal in + self?.transitionToScreen(modal, transitionType: .present) + } + ) } ), @@ -284,7 +378,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob tintColor: .textSecondary, customPadding: SessionCell.Padding( top: 0, - bottom: 0 + bottom: Values.largeSpacing ), backgroundStyle: .noBackground ) @@ -313,34 +407,37 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob label: threadDescription ) ) - }, - - (threadViewModel.threadVariant != .contact ? nil : - SessionCell.Info( - id: .sessionId, - subtitle: SessionCell.TextInfo( - threadViewModel.id, - font: .monoSmall, - alignment: .center, - interaction: .copy - ), - styling: SessionCell.StyleInfo( - customPadding: SessionCell.Padding( - top: Values.smallSpacing, - bottom: Values.largeSpacing - ), - backgroundStyle: .noBackground - ), - accessibility: Accessibility( - identifier: "Session ID", - label: threadViewModel.id - ) + } + ].compactMap { $0 } + ) + + // MARK: - Session Id + + let sessionIdSection: SectionModel = SectionModel( + model: (threadViewModel.threadIsNoteToSelf == true ? .sessionIdNoteToSelf : .sessionId), + elements: [ + SessionCell.Info( + id: .sessionId, + subtitle: SessionCell.TextInfo( + threadViewModel.id, + font: .monoLarge, + alignment: .center, + interaction: .copy + ), + styling: SessionCell.StyleInfo( + customPadding: SessionCell.Padding(bottom: Values.smallSpacing), + backgroundStyle: .noBackground + ), + accessibility: Accessibility( + identifier: "Session ID", + label: threadViewModel.id ) ) - ].compactMap { $0 } + ] ) // MARK: - Users kicked from groups + guard !currentUserKickedFromGroup else { return [ conversationInfoSection, @@ -387,6 +484,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob } // MARK: - Standard Actions + let standardActionsSection: SectionModel = SectionModel( model: .content, elements: [ @@ -597,7 +695,9 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob ) ].compactMap { $0 } ) + // MARK: - Admin Actions + let adminActionsSection: SectionModel? = ( !currentUserIsClosedGroupAdmin ? nil : SectionModel( @@ -677,7 +777,9 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob ].compactMap { $0 } ) ) + // MARK: - Destructive Actions + let destructiveActionsSection: SectionModel = SectionModel( model: .destructiveActions, elements: [ @@ -1111,6 +1213,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob return [ conversationInfoSection, + (!showThreadPubkey ? nil : sessionIdSection), standardActionsSection, adminActionsSection, destructiveActionsSection @@ -1119,24 +1222,6 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob // MARK: - Functions - private func viewDisplayPicture(threadViewModel: SessionThreadViewModel) { - guard - let fileUrl: String = threadViewModel.threadDisplayPictureUrl, - let path: String = try? dependencies[singleton: .displayPictureManager].path(for: fileUrl) - else { return } - - let navController: UINavigationController = StyledNavigationController( - rootViewController: ProfilePictureVC( - imageSource: .url(URL(fileURLWithPath: path)), - title: threadViewModel.displayName, - using: dependencies - ) - ) - navController.modalPresentationStyle = .fullScreen - - self.transitionToScreen(navController, transitionType: .present) - } - private func inviteUsersToCommunity(threadViewModel: SessionThreadViewModel) { guard let name: String = threadViewModel.openGroupName, @@ -1521,15 +1606,13 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob /// Update the nickname 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))) + try Profile.updateIfNeeded( + db, + publicKey: threadId, + nicknameUpdate: .set(to: finalNickname), + profileUpdateTimestamp: nil, + using: dependencies + ) }, completion: { _ in DispatchQueue.main.async { @@ -1542,15 +1625,13 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob /// Remove the nickname 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))) + try Profile.updateIfNeeded( + db, + publicKey: threadId, + nicknameUpdate: .set(to: nil), + profileUpdateTimestamp: nil, + using: dependencies + ) }, completion: { _ in DispatchQueue.main.async { @@ -1664,50 +1745,80 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob guard dependencies[feature: .updatedGroupsAllowDisplayPicture] else { return } let iconName: String = "profile_placeholder" // stringlint:ignore + var hasSetNewProfilePicture: Bool = false + let currentSource: ImageDataManager.DataSource? = { + let source: ImageDataManager.DataSource? = currentUrl + .map { try? dependencies[singleton: .displayPictureManager].path(for: $0) } + .map { ImageDataManager.DataSource.url(URL(fileURLWithPath: $0)) } + + return (source?.contentExists == true ? source : nil) + }() + let body: ConfirmationModal.Info.Body = .image( + source: nil, + placeholder: ( + currentSource ?? + Lucide.image(icon: .image, size: 40).map { image in + ImageDataManager.DataSource.image( + iconName, + image + .withTintColor(#colorLiteral(red: 0.631372549, green: 0.6352941176, blue: 0.631372549, alpha: 1), renderingMode: .alwaysTemplate) + .withCircularBackground(backgroundColor: #colorLiteral(red: 0.1764705882, green: 0.1764705882, blue: 0.1764705882, alpha: 1)) + ) + } + ), + icon: (currentUrl != nil ? .pencil : .rightPlus), + style: .circular, + description: nil, // FIXME: Need to add Group Pro display pic description + accessibility: Accessibility( + identifier: "Upload", + label: "Upload" + ), + dataManager: dependencies[singleton: .imageDataManager], + onProBageTapped: nil, // FIXME: Need to add Group Pro display pic CTA + onClick: { [weak self] onDisplayPictureSelected in + self?.onDisplayPictureSelected = { source, cropRect in + onDisplayPictureSelected(.image( + source: source, + cropRect: cropRect, + replacementIcon: .pencil, + replacementCancelTitle: "clear".localized() + )) + hasSetNewProfilePicture = true + } + self?.showPhotoLibraryForAvatar() + } + ) self.transitionToScreen( ConfirmationModal( info: ConfirmationModal.Info( title: "groupSetDisplayPicture".localized(), - body: .image( - 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) - }, - icon: .rightPlus, - style: .circular, - description: nil, - accessibility: Accessibility( - identifier: "Image picker", - label: "Image picker" - ), - dataManager: dependencies[singleton: .imageDataManager], - onProBageTapped: nil, - onClick: { [weak self] onDisplayPictureSelected in - self?.onDisplayPictureSelected = onDisplayPictureSelected - self?.showPhotoLibraryForAvatar() - } - ), + body: body, confirmTitle: "save".localized(), confirmEnabled: .afterChange { info in switch info.body { - case .image(let source, _, _, _, _, _, _, _, _): return (source?.imageData != nil) + case .image(.some(let source), _, _, _, _, _, _, _, _): return source.contentExists default: return false } }, cancelTitle: "remove".localized(), - cancelEnabled: .bool(currentUrl != nil), + cancelEnabled: (currentUrl != nil ? .bool(true) : .afterChange { info in + switch info.body { + case .image(.some(let source), _, _, _, _, _, _, _, _): return source.contentExists + default: return false + } + }), hasCloseButton: true, dismissOnConfirm: false, onConfirm: { [weak self] modal in switch modal.info.body { - case .image(.some(let source), _, _, _, _, _, _, _, _): - guard let imageData: Data = source.imageData else { return } - + case .image(.some(let source), _, _, let style, _, _, _, _, _): + // FIXME: Need to add Group Pro display pic CTA self?.updateGroupDisplayPicture( - displayPictureUpdate: .groupUploadImageData(imageData), + displayPictureUpdate: .groupUploadImage( + source: source, + cropRect: style.cropRect + ), onUploadComplete: { [weak modal] in Task { @MainActor in modal?.close() } } @@ -1717,12 +1828,22 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob } }, onCancel: { [weak self] modal in - self?.updateGroupDisplayPicture( - displayPictureUpdate: .groupRemove, - onUploadComplete: { [weak modal] in - Task { @MainActor in modal?.close() } - } - ) + if hasSetNewProfilePicture { + modal.updateContent( + with: modal.info.with( + body: body, + cancelTitle: "remove".localized() + ) + ) + hasSetNewProfilePicture = false + } else { + self?.updateGroupDisplayPicture( + displayPictureUpdate: .groupRemove, + onUploadComplete: { [weak modal] in + Task { @MainActor in modal?.close() } + } + ) + } } ) ), @@ -1733,9 +1854,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob @MainActor private func showPhotoLibraryForAvatar() { Permissions.requestLibraryPermissionIfNeeded(isSavingMedia: false, using: dependencies) { [weak self] in DispatchQueue.main.async { - let picker: UIImagePickerController = UIImagePickerController() - picker.sourceType = .photoLibrary - picker.mediaTypes = [ "public.image" ] // stringlint:disable + var configuration: PHPickerConfiguration = PHPickerConfiguration() + configuration.selectionLimit = 1 + configuration.filter = .any(of: [.images, .livePhotos]) + + let picker: PHPickerViewController = PHPickerViewController(configuration: configuration) picker.delegate = self?.imagePickerHandler self?.transitionToScreen(picker, transitionType: .present) @@ -1752,102 +1875,106 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob default: break } - Just(displayPictureUpdate) - .setFailureType(to: Error.self) - .flatMap { [weak self, dependencies] update -> AnyPublisher in + Task.detached(priority: .userInitiated) { [weak self, threadId, dependencies] in + var targetUpdate: DisplayPictureManager.Update = displayPictureUpdate + var indicator: ModalActivityIndicatorViewController? + + do { switch displayPictureUpdate { - case .none, .currentUserRemove, .currentUserUploadImageData, .currentUserUpdateTo, - .contactRemove, .contactUpdateTo: - return Fail(error: AttachmentError.invalidStartState).eraseToAnyPublisher() - - case .groupRemove, .groupUpdateTo: - return Just(displayPictureUpdate) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + case .none, .currentUserRemove, .currentUserUpdateTo, .contactRemove, + .contactUpdateTo: + throw AttachmentError.invalidStartState - case .groupUploadImageData(let data): + case .groupRemove, .groupUpdateTo: break + case .groupUploadImage(let source, let cropRect): /// Show a blocking loading indicator while uploading but not while updating or syncing the group configs - return dependencies[singleton: .displayPictureManager] - .prepareAndUploadDisplayPicture(imageData: data, compression: true) - .showingBlockingLoading(in: self?.navigatableState) - .map { url, filePath, key, _ -> DisplayPictureManager.Update in - .groupUpdateTo(url: url, key: key, filePath: filePath) - } - .mapError { $0 as Error } - .handleEvents( - receiveCompletion: { result in - switch result { - case .failure(let error): - let message: String = { - switch (displayPictureUpdate, error) { - case (.groupRemove, _): return "profileDisplayPictureRemoveError".localized() - case (_, DisplayPictureError.uploadMaxFileSizeExceeded): - return "profileDisplayPictureSizeError".localized() - - default: return "errorConnection".localized() - } - }() - - self?.transitionToScreen( - ConfirmationModal( - info: ConfirmationModal.Info( - title: "deleteAfterLegacyGroupsGroupUpdateErrorTitle".localized(), - body: .text(message), - cancelTitle: "okay".localized(), - cancelStyle: .alert_text, - dismissType: .single - ) - ), - transitionType: .present - ) - - case .finished: onUploadComplete() - } - } + indicator = await MainActor.run { [weak self] in + let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController(onAppear: { _ in }) + self?.transitionToScreen(indicator, transitionType: .present) + return indicator + } + + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .media(source), + using: dependencies + ) + let preparedAttachment: PreparedAttachment = try await dependencies[singleton: .displayPictureManager] + .prepareDisplayPicture( + attachment: pendingAttachment, + fallbackIfConversionTakesTooLong: true, + cropRect: cropRect ) - .eraseToAnyPublisher() + let result = try await dependencies[singleton: .displayPictureManager] + .uploadDisplayPicture(preparedAttachment: preparedAttachment) + await MainActor.run { onUploadComplete() } + + targetUpdate = .groupUpdateTo( + url: result.downloadUrl, + key: result.encryptionKey + ) } } - .flatMapStorageReadPublisher(using: dependencies) { [threadId] db, displayPictureUpdate -> (DisplayPictureManager.Update, String?) in - ( - displayPictureUpdate, - try? ClosedGroup - .filter(id: threadId) - .select(.displayPictureUrl) - .asRequest(of: String.self) - .fetchOne(db) - ) - } - .flatMap { [threadId, dependencies] displayPictureUpdate, existingDownloadUrl -> AnyPublisher in - MessageSender - .updateGroup( - groupSessionId: threadId, - displayPictureUpdate: displayPictureUpdate, - using: dependencies + catch { + let message: String = { + switch (displayPictureUpdate, error) { + case (.groupRemove, _): return "profileDisplayPictureRemoveError".localized() + case (_, AttachmentError.fileSizeTooLarge): + return "profileDisplayPictureSizeError".localized() + + default: return "errorConnection".localized() + } + }() + + await indicator?.dismiss { [weak self] in + self?.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "deleteAfterLegacyGroupsGroupUpdateErrorTitle".localized(), + body: .text(message), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text, + dismissType: .single + ) + ), + transitionType: .present ) - .map { _ in existingDownloadUrl } - .eraseToAnyPublisher() + } + return } - .handleEvents( - 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: existingFilePath - ) - try? dependencies[singleton: .fileManager].removeItem(atPath: existingFilePath) - } + + let existingDownloadUrl: String? = try? await dependencies[singleton: .storage].readAsync { db in + try? ClosedGroup + .filter(id: threadId) + .select(.displayPictureUrl) + .asRequest(of: String.self) + .fetchOne(db) + } + + do { + try await MessageSender.updateGroup( + groupSessionId: threadId, + displayPictureUpdate: targetUpdate, + using: dependencies + ) + + /// Remove any cached avatar image value (only want to do so if the above update succeeded) + if + let existingDownloadUrl: String = existingDownloadUrl, + let existingFilePath: String = try? dependencies[singleton: .displayPictureManager] + .path(for: existingDownloadUrl) + { + Task { [dependencies] in + await dependencies[singleton: .imageDataManager].removeImage( + identifier: existingFilePath + ) + try? dependencies[singleton: .fileManager].removeItem(atPath: existingFilePath) } } - ) - .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) - .receive(on: DispatchQueue.main, using: dependencies) - .sinkUntilComplete() + } + catch {} + + await indicator?.dismiss() + } } private func updateBlockedState( @@ -1985,4 +2112,45 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob case (.community, _), (.legacyGroup, false), (.group, false): return nil } } + + private func showQRCodeLightBox(for threadViewModel: SessionThreadViewModel) { + let qrCodeImage: UIImage = QRCode.generate( + for: threadViewModel.getQRCodeString(), + hasBackground: false, + iconName: "SessionWhite40" // stringlint:ignore + ) + .withRenderingMode(.alwaysTemplate) + + let viewController = SessionHostingViewController( + rootView: LightBox( + itemsToShare: [ + QRCode.qrCodeImageWithBackground( + image: qrCodeImage, + size: CGSize(width: 400, height: 400), + insets: UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + ) + ] + ) { + VStack { + Spacer() + + QRCodeView( + qrCodeImage: qrCodeImage, + themeStyle: ThemeManager.currentTheme.interfaceStyle + ) + .aspectRatio(1, contentMode: .fit) + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) + + Spacer() + } + .backgroundColor(themeColor: .newConversation_background) + }, + customizedNavigationBackground: .backgroundSecondary + ) + viewController.modalPresentationStyle = .fullScreen + self.transitionToScreen(viewController, transitionType: .present) + } } diff --git a/Session/Conversations/Views & Modals/ConversationTitleView.swift b/Session/Conversations/Views & Modals/ConversationTitleView.swift index 81c07987e0..090b46e07a 100644 --- a/Session/Conversations/Views & Modals/ConversationTitleView.swift +++ b/Session/Conversations/Views & Modals/ConversationTitleView.swift @@ -27,14 +27,18 @@ final class ConversationTitleView: UIView { private lazy var stackViewLeadingConstraint: NSLayoutConstraint = stackView.pin(.leading, to: .leading, of: self) private lazy var stackViewTrailingConstraint: NSLayoutConstraint = stackView.pin(.trailing, to: .trailing, of: self) - private lazy var titleLabel: UILabel = { - let result: UILabel = UILabel() + private lazy var titleLabel: SessionLabelWithProBadge = { + let result: SessionLabelWithProBadge = SessionLabelWithProBadge( + proBadgeSize: .medium, + withStretchingSpacer: false + ) result.accessibilityIdentifier = "Conversation header name" result.accessibilityLabel = "Conversation header name" result.isAccessibilityElement = true - result.font = .boldSystemFont(ofSize: Values.mediumFontSize) + result.font = Fonts.Headings.H5 result.themeTextColor = .textPrimary result.lineBreakMode = .byTruncatingTail + result.isProBadgeHidden = true return result }() @@ -48,6 +52,7 @@ final class ConversationTitleView: UIView { let result = UIStackView(arrangedSubviews: [ titleLabel, labelCarouselView ]) result.axis = .vertical result.alignment = .center + result.spacing = 2 return result }() @@ -80,12 +85,14 @@ final class ConversationTitleView: UIView { public func initialSetup( with threadVariant: SessionThread.Variant, isNoteToSelf: Bool, - isMessageRequest: Bool + isMessageRequest: Bool, + isSessionPro: Bool ) { self.update( with: " ", isNoteToSelf: isNoteToSelf, isMessageRequest: isMessageRequest, + isSessionPro: isSessionPro, threadVariant: threadVariant, mutedUntilTimestamp: nil, onlyNotifyForMentions: false, @@ -113,6 +120,7 @@ final class ConversationTitleView: UIView { with name: String, isNoteToSelf: Bool, isMessageRequest: Bool, + isSessionPro: Bool, threadVariant: SessionThread.Variant, mutedUntilTimestamp: TimeInterval?, onlyNotifyForMentions: Bool, @@ -130,12 +138,8 @@ final class ConversationTitleView: UIView { self.titleLabel.text = name self.titleLabel.accessibilityLabel = name - self.titleLabel.font = .boldSystemFont( - ofSize: (shouldHaveSubtitle ? - Values.largeFontSize : - Values.veryLargeFontSize - ) - ) + self.titleLabel.font = (shouldHaveSubtitle ? Fonts.Headings.H6 : Fonts.Headings.H5) + self.titleLabel.isProBadgeHidden = !isSessionPro self.labelCarouselView.isHidden = !shouldHaveSubtitle // Contact threads also have the call button to compensate for diff --git a/Session/Conversations/Views & Modals/InfoBanner.swift b/Session/Conversations/Views & Modals/InfoBanner.swift index a76ec30f60..e7ecc44bd4 100644 --- a/Session/Conversations/Views & Modals/InfoBanner.swift +++ b/Session/Conversations/Views & Modals/InfoBanner.swift @@ -3,6 +3,7 @@ // stringlint:disable import UIKit +import Lucide import SessionUIKit final class InfoBanner: UIView { @@ -14,12 +15,12 @@ final class InfoBanner: UIView { var image: UIImage? { switch self { case .none: return nil - case .link: return UIImage(systemName: "arrow.up.right.square")?.withRenderingMode(.alwaysTemplate) + case .link: + return Lucide.image(icon: .squareArrowUpRight, size: 12)? + .withRenderingMode(.alwaysTemplate) case .close: - return UIImage( - systemName: "xmark", - withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .bold) - )?.withRenderingMode(.alwaysTemplate) + return Lucide.image(icon: .x, size: 12)? + .withRenderingMode(.alwaysTemplate) } } } diff --git a/Session/Conversations/Views & Modals/ReactionListSheet.swift b/Session/Conversations/Views & Modals/ReactionListSheet.swift index e723a66c1b..9e568bb8a2 100644 --- a/Session/Conversations/Views & Modals/ReactionListSheet.swift +++ b/Session/Conversations/Views & Modals/ReactionListSheet.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import Lucide import DifferenceKit import SessionUIKit import SessionMessagingKit @@ -452,11 +453,12 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { authorId.truncated(threadVariant: self.messageViewModel.threadVariant) ), trailingAccessory: (!canRemoveEmoji ? nil : - .icon( - UIImage(named: "X")? - .withRenderingMode(.alwaysTemplate), - size: .medium - ) + .icon( + Lucide.image(icon: .x, size: IconSize.medium.size)? + .withRenderingMode(.alwaysTemplate), + size: .medium, + pinEdges: [.right] + ) ), styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge), isEnabled: (self.messageViewModel.currentUserSessionIds ?? []).contains(authorId) diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index 5ae0d22528..4de1f19992 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -223,6 +223,7 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI state: .defaultContacts, data: contacts .sorted { lhs, rhs in lhs.displayName.lowercased() < rhs.displayName.lowercased() } + .filter { $0.isContactApproved == true } // Only show default contacts that have been approved via message request .reduce(into: [String: SectionModel]()) { result, next in guard !next.threadIsNoteToSelf else { result[""] = SectionModel( diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index cf9e8ed989..d56d28ff63 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -4,6 +4,7 @@ import UIKit import Combine import GRDB import DifferenceKit +import Lucide import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit @@ -39,11 +40,19 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi } // MARK: - UI - - private var tableViewTopConstraint: NSLayoutConstraint? - private var loadingConversationsLabelTopConstraint: NSLayoutConstraint? private var navBarProfileView: ProfilePictureView? + private lazy var bannersStackView: UIStackView = { + let result: UIStackView = UIStackView(arrangedSubviews: [ + versionSupportBanner, + seedReminderView + ]) + result.axis = .vertical + result.alignment = .fill + + return result + }() + private lazy var seedReminderView: SeedReminderView = { let result = SeedReminderView() result.accessibilityLabel = "Recovery phrase reminder" @@ -56,6 +65,23 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi return result }() + lazy var versionSupportBanner: InfoBanner = { + let result: InfoBanner = InfoBanner( + info: InfoBanner.Info( + font: .systemFont(ofSize: Values.verySmallFontSize), + message: "warningIosVersionEndingSupport" + .localizedFormatted(baseFont: .systemFont(ofSize: Values.verySmallFontSize)), + icon: .none, + tintColor: .messageBubble_outgoingText, + backgroundColor: .primary, + labelAccessibility: Accessibility(identifier: "Warning supported version banner") + ) + ) + + result.isHidden = false + return result + }() + private lazy var loadingConversationsLabel: UILabel = { let result: UILabel = UILabel() result.translatesAutoresizingMaskIntoConstraints = false @@ -251,7 +277,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi welcomeLabel.font = .systemFont(ofSize: Values.smallFontSize) welcomeLabel.text = "onboardingBubbleWelcomeToSession" .put(key: "app_name", value: Constants.app_name) - .put(key: "emoji", value: "") + .put(key: "emoji", value: "👋") .localized() welcomeLabel.themeTextColor = .sessionButton_text welcomeLabel.textAlignment = .center @@ -298,34 +324,27 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi serviceNetwork: self.viewModel.state.serviceNetwork, forceOffline: self.viewModel.state.forceOffline ) - setUpNavBarSessionHeading() + setUpNavBarSessionHeading(currentUserSessionProState: viewModel.dependencies[singleton: .sessionProState]) - // Recovery phrase reminder - view.addSubview(seedReminderView) - seedReminderView.pin(.leading, to: .leading, of: view) - seedReminderView.pin(.top, to: .top, of: view) - seedReminderView.pin(.trailing, to: .trailing, of: view) + // Banner stack view + view.addSubview(bannersStackView) + bannersStackView.pin(.leading, to: .leading, of: view) + bannersStackView.pin(.top, to: .top, of: view) + bannersStackView.pin(.trailing, to: .trailing, of: view) // Loading conversations label view.addSubview(loadingConversationsLabel) loadingConversationsLabel.pin(.leading, to: .leading, of: view, withInset: 50) loadingConversationsLabel.pin(.trailing, to: .trailing, of: view, withInset: -50) + loadingConversationsLabel.pin(.top, to: .bottom, of: bannersStackView, withInset: Values.mediumSpacing) // Table view view.addSubview(tableView) tableView.pin(.leading, to: .leading, of: view) tableView.pin(.trailing, to: .trailing, of: view) tableView.pin(.bottom, to: .bottom, of: view) - - if self.viewModel.state.showViewedSeedBanner { - loadingConversationsLabelTopConstraint = loadingConversationsLabel.pin(.top, to: .bottom, of: seedReminderView, withInset: Values.mediumSpacing) - tableViewTopConstraint = tableView.pin(.top, to: .bottom, of: seedReminderView) - } - else { - loadingConversationsLabelTopConstraint = loadingConversationsLabel.pin(.top, to: .top, of: view, withInset: Values.veryLargeSpacing) - tableViewTopConstraint = tableView.pin(.top, to: .top, of: view) - } + tableView.pin(.top, to: .bottom, of: bannersStackView) // Empty state view view.addSubview(emptyStateStackView) @@ -355,7 +374,9 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi } // Onion request path countries cache - viewModel.dependencies.warmCache(cache: .ip2Country) + Task.detached(priority: .background) { [dependencies = viewModel.dependencies] in + dependencies.warmCache(cache: .ip2Country) + } // Bind the UI to the view model bindViewModel() @@ -410,26 +431,10 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi serviceNetwork: state.serviceNetwork, forceOffline: state.forceOffline ) - + // Update the 'view seed' UI - let shouldHideSeedReminderView: Bool = !state.showViewedSeedBanner - - if seedReminderView.isHidden != shouldHideSeedReminderView { - tableViewTopConstraint?.isActive = false - loadingConversationsLabelTopConstraint?.isActive = false - seedReminderView.isHidden = !state.showViewedSeedBanner - - if state.showViewedSeedBanner { - loadingConversationsLabelTopConstraint = loadingConversationsLabel.pin(.top, to: .bottom, of: seedReminderView, withInset: Values.mediumSpacing) - tableViewTopConstraint = tableView.pin(.top, to: .bottom, of: seedReminderView) - } - else { - loadingConversationsLabelTopConstraint = loadingConversationsLabel.pin(.top, to: .top, of: view, withInset: Values.veryLargeSpacing) - tableViewTopConstraint = tableView.pin(.top, to: .top, of: view, withInset: Values.smallSpacing) - } - - view.layoutIfNeeded() - } + seedReminderView.isHidden = !state.showViewedSeedBanner + versionSupportBanner.isHidden = !state.showVersionSupportBanner // Update the overall view state (loading, empty, or loaded) switch state.viewState { @@ -547,7 +552,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi navigationItem.leftBarButtonItem = leftBarButtonItem // Right bar button item - search button - let rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .search, target: self, action: #selector(showSearchUI)) + let rightBarButtonItem = UIBarButtonItem(image: Lucide.image(icon: .search, size: 24), style: .plain, target: self, action: #selector(showSearchUI)) rightBarButtonItem.accessibilityLabel = "Search button" rightBarButtonItem.isAccessibilityElement = true navigationItem.rightBarButtonItem = rightBarButtonItem diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 98d29d990e..2359464703 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -35,6 +35,13 @@ public class HomeViewModel: NavigatableStateHolder { private static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 15) + // Reusable OS version check for initial and updated state check + // Check if the current device is running a version LESS THAN iOS 16.0 + private static func isOSVersionDeprecated(using dependencies: Dependencies) -> Bool { + let systemVersion = ProcessInfo.processInfo.operatingSystemVersion + return systemVersion.majorVersion < dependencies[feature: .versionDeprecationMinimum] + } + public let dependencies: Dependencies private let userSessionId: SessionId private var didPresentAppReviewPrompt: Bool = false @@ -53,7 +60,8 @@ public class HomeViewModel: NavigatableStateHolder { appReviewPromptState: AppReviewPromptModel .loadInitialAppReviewPromptState(using: dependencies), appWasInstalledPriorToAppReviewRelease: AppReviewPromptModel - .checkIfAppWasInstalledPriorToAppReviewRelease(using: dependencies) + .checkIfAppWasInstalledPriorToAppReviewRelease(using: dependencies), + showVersionSupportBanner: Self.isOSVersionDeprecated(using: dependencies) && dependencies[feature: .versionDeprecationWarning] ) /// Bind the state @@ -94,9 +102,11 @@ public class HomeViewModel: NavigatableStateHolder { let unreadMessageRequestThreadCount: Int let loadedPageInfo: PagedData.LoadedInfo let itemCache: [String: SessionThreadViewModel] + let profileCache: [String: Profile] let appReviewPromptState: AppReviewPromptState? let pendingAppReviewPromptState: AppReviewPromptState? let appWasInstalledPriorToAppReviewRelease: Bool + let showVersionSupportBanner: Bool @MainActor public func sections(viewModel: HomeViewModel) -> [SectionModel] { HomeViewModel.sections(state: self, viewModel: viewModel) @@ -124,7 +134,9 @@ public class HomeViewModel: NavigatableStateHolder { .userDefault(.hasVisitedPathScreen), .userDefault(.hasPressedDonateButton), .userDefault(.hasChangedTheme), - .updateScreen(HomeViewModel.self) + .updateScreen(HomeViewModel.self), + .feature(.versionDeprecationWarning), + .feature(.versionDeprecationMinimum) ] itemCache.values.forEach { threadViewModel in @@ -151,7 +163,12 @@ public class HomeViewModel: NavigatableStateHolder { return result } - static func initialState(using dependencies: Dependencies, appReviewPromptState: AppReviewPromptState?, appWasInstalledPriorToAppReviewRelease: Bool) -> State { + static func initialState( + using dependencies: Dependencies, + appReviewPromptState: AppReviewPromptState?, + appWasInstalledPriorToAppReviewRelease: Bool, + showVersionSupportBanner: Bool + ) -> State { return State( viewState: .loading, userProfile: Profile(id: dependencies[cache: .general].sessionId.hexString, name: ""), @@ -176,9 +193,11 @@ public class HomeViewModel: NavigatableStateHolder { orderSQL: SessionThreadViewModel.homeOrderSQL ), itemCache: [:], + profileCache: [:], appReviewPromptState: nil, pendingAppReviewPromptState: appReviewPromptState, - appWasInstalledPriorToAppReviewRelease: appWasInstalledPriorToAppReviewRelease + appWasInstalledPriorToAppReviewRelease: appWasInstalledPriorToAppReviewRelease, + showVersionSupportBanner: showVersionSupportBanner ) } } @@ -200,9 +219,11 @@ public class HomeViewModel: NavigatableStateHolder { var unreadMessageRequestThreadCount: Int = previousState.unreadMessageRequestThreadCount var loadResult: PagedData.LoadResult = previousState.loadedPageInfo.asResult var itemCache: [String: SessionThreadViewModel] = previousState.itemCache + var profileCache: [String: Profile] = previousState.profileCache var appReviewPromptState: AppReviewPromptState? = previousState.appReviewPromptState var pendingAppReviewPromptState: AppReviewPromptState? = previousState.pendingAppReviewPromptState let appWasInstalledPriorToAppReviewRelease: Bool = previousState.appWasInstalledPriorToAppReviewRelease + var showVersionSupportBanner: Bool = previousState.showVersionSupportBanner /// Store a local copy of the events so we can manipulate it based on the state changes var eventsToProcess: [ObservedEvent] = events @@ -222,6 +243,20 @@ public class HomeViewModel: NavigatableStateHolder { hasHiddenMessageRequests = libSession.get(.hasHiddenMessageRequests) } + /// If the users profile picture doesn't exist on disk then clear out the value (that way if we get events after downloading + /// it then then there will be a diff in the `State` and the UI will update + if + let displayPictureUrl: String = userProfile.displayPictureUrl, + let filePath: String = try? dependencies[singleton: .displayPictureManager] + .path(for: displayPictureUrl), + !dependencies[singleton: .fileManager].fileExists(atPath: filePath) + { + userProfile = userProfile.with(displayPictureUrl: .set(to: nil)) + } + + // TODO: [Database Relocation] All profiles should be stored in the `profileCache` + profileCache[userProfile.id] = userProfile + /// If we haven't hidden the message requests banner then we should include that in the initial fetch if !hasHiddenMessageRequests { eventsToProcess.append(ObservedEvent( @@ -245,8 +280,30 @@ public class HomeViewModel: NavigatableStateHolder { result[.other, default: []].insert(next) } } + let groupedOtherEvents: [GenericObservableKey: Set]? = splitEvents[.other]? + .reduce(into: [:]) { result, event in + result[event.key.generic, default: []].insert(event) + } - /// Handle database events first + /// Handle profile events first + groupedOtherEvents?[.profile]?.forEach { event in + guard + let eventValue: ProfileEvent = event.value as? ProfileEvent, + eventValue.id == userProfile.id + else { return } + + switch eventValue.change { + case .name(let name): userProfile = userProfile.with(name: name) + case .nickname(let nickname): userProfile = userProfile.with(nickname: .set(to: nickname)) + case .displayPictureUrl(let url): userProfile = userProfile.with(displayPictureUrl: .set(to: url)) + } + + // TODO: [Database Relocation] All profiles should be stored in the `profileCache` + profileCache[eventValue.id] = userProfile + } + + + /// Then handle database events if !dependencies[singleton: .storage].isSuspended, let databaseEvents: Set = splitEvents[.databaseQuery], !databaseEvents.isEmpty { do { var fetchedConversations: [SessionThreadViewModel] = [] @@ -360,23 +417,7 @@ public class HomeViewModel: NavigatableStateHolder { Log.warn(.homeViewModel, "Ignored \(databaseEvents.count) database event(s) sent while storage was suspended.") } - /// Then handle non-database events - let groupedOtherEvents: [GenericObservableKey: Set]? = splitEvents[.other]? - .reduce(into: [:]) { result, event in - result[event.key.generic, default: []].insert(event) - } - groupedOtherEvents?[.profile]?.forEach { event in - guard - let eventValue: ProfileEvent = event.value as? ProfileEvent, - eventValue.id == userProfile.id - else { return } - - switch eventValue.change { - case .name(let name): userProfile = userProfile.with(name: name) - case .nickname(let nickname): userProfile = userProfile.with(nickname: nickname) - case .displayPictureUrl(let url): userProfile = userProfile.with(displayPictureUrl: url) - } - } + /// Then handle remaining non-database events groupedOtherEvents?[.setting]?.forEach { event in guard let updatedValue: Bool = event.value as? Bool else { return } @@ -395,6 +436,12 @@ public class HomeViewModel: NavigatableStateHolder { else if event.key == .feature(.forceOffline), let updatedValue = event.value as? Bool { forceOffline = updatedValue } + else if event.key == .feature(.versionDeprecationWarning), let updatedValue = event.value as? Bool { + showVersionSupportBanner = isOSVersionDeprecated(using: dependencies) && updatedValue + } + else if event.key == .feature(.versionDeprecationMinimum) { + showVersionSupportBanner = isOSVersionDeprecated(using: dependencies) && dependencies[feature: .versionDeprecationWarning] + } } /// Next trigger should be ignored if `didShowAppReviewPrompt` is true @@ -440,9 +487,11 @@ public class HomeViewModel: NavigatableStateHolder { unreadMessageRequestThreadCount: unreadMessageRequestThreadCount, loadedPageInfo: loadResult.info, itemCache: itemCache, + profileCache: profileCache, appReviewPromptState: appReviewPromptState, pendingAppReviewPromptState: pendingAppReviewPromptState, - appWasInstalledPriorToAppReviewRelease: appWasInstalledPriorToAppReviewRelease + appWasInstalledPriorToAppReviewRelease: appWasInstalledPriorToAppReviewRelease, + showVersionSupportBanner: showVersionSupportBanner ) } @@ -495,6 +544,8 @@ public class HomeViewModel: NavigatableStateHolder { } private static func sections(state: State, viewModel: HomeViewModel) -> [SectionModel] { + let userSessionId: SessionId = viewModel.dependencies[cache: .general].sessionId + return [ /// If the message request section is hidden or there are no unread message requests then hide the message request banner (state.hasHiddenMessageRequests || state.unreadMessageRequestThreadCount == 0 ? @@ -520,7 +571,7 @@ public class HomeViewModel: NavigatableStateHolder { recentReactionEmoji: nil, openGroupCapabilities: nil, // TODO: [Database Relocation] Do we need all of these???? - currentUserSessionIds: [viewModel.dependencies[cache: .general].sessionId.hexString], + currentUserSessionIds: [userSessionId.hexString], wasKickedFromGroup: ( conversation.threadVariant == .group && viewModel.dependencies.mutate(cache: .libSession) { cache in @@ -553,15 +604,18 @@ public class HomeViewModel: NavigatableStateHolder { // MARK: - Handle App review @MainActor func viewDidAppear() { - guard state.pendingAppReviewPromptState != nil else { return } - - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self, dependencies] in - guard let updatedState: AppReviewPromptState = self?.state.pendingAppReviewPromptState else { return } - - dependencies[defaults: .standard, key: .didActionAppReviewPrompt] = false - - self?.handlePromptChangeState(updatedState) + if state.pendingAppReviewPromptState != nil { + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self, dependencies] in + guard let updatedState: AppReviewPromptState = self?.state.pendingAppReviewPromptState else { return } + + dependencies[defaults: .standard, key: .didActionAppReviewPrompt] = false + + self?.handlePromptChangeState(updatedState) + } } + + // Camera reminder + willShowCameraPermissionReminder() } func scheduleAppReviewRetry() { @@ -706,6 +760,20 @@ public class HomeViewModel: NavigatableStateHolder { } } + // Camera permission + func willShowCameraPermissionReminder() { + guard + dependencies[singleton: .screenLock].checkIfScreenIsUnlocked(), // Show camera access reminder when app has been unlocked + !dependencies[defaults: .appGroup, key: .isCallOngoing] // Checks if there is still an ongoing call + else { + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [dependencies] in + Permissions.remindCameraAccessRequirement(using: dependencies) + } + } + @MainActor @objc func didReturnFromBackground() { // Observe changes to app state retry and flags when app goes to bg to fg @@ -718,6 +786,9 @@ public class HomeViewModel: NavigatableStateHolder { self?.handlePromptChangeState(updatedState) } } + + // Camera reminder + willShowCameraPermissionReminder() } // MARK: - Functions diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index de9e64fe72..ddece1ddc5 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -297,7 +297,7 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O elements: state.loadedPageInfo.currentIds .compactMap { state.itemCache[$0] } .map { conversation -> SessionCell.Info in - SessionCell.Info( + return SessionCell.Info( id: conversation.populatingPostQueryData( recentReactionEmoji: nil, openGroupCapabilities: nil, diff --git a/Session/Media Viewing & Editing/AllMediaViewController.swift b/Session/Media Viewing & Editing/AllMediaViewController.swift index 008c21c169..1b6f32d42c 100644 --- a/Session/Media Viewing & Editing/AllMediaViewController.swift +++ b/Session/Media Viewing & Editing/AllMediaViewController.swift @@ -164,7 +164,7 @@ extension AllMediaViewController: UIDocumentInteractionControllerDelegate { /// 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) { + if dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(temporaryFileUrl.path) { try? dependencies[singleton: .fileManager].removeItem(atPath: temporaryFileUrl.path) } } @@ -189,7 +189,7 @@ extension AllMediaViewController: DocumentTileViewControllerDelegate { 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) { + if dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(temporaryFileUrl.path) { try? dependencies[singleton: .fileManager].removeItem(atPath: temporaryFileUrl.path) } } diff --git a/Session/Media Viewing & Editing/CropScaleImageViewController.swift b/Session/Media Viewing & Editing/CropScaleImageViewController.swift index 47cea9863f..8e858b1681 100644 --- a/Session/Media Viewing & Editing/CropScaleImageViewController.swift +++ b/Session/Media Viewing & Editing/CropScaleImageViewController.swift @@ -1,6 +1,6 @@ // Copyright (c) 2019 Open Whisper Systems. All rights reserved. -import Foundation +import UIKit import MediaPlayer import SessionUIKit import SignalUtilitiesKit @@ -25,51 +25,119 @@ import SessionUtilitiesKit // region b) the rectangle at which the src image should be rendered // given a dst view or output context that will yield the // appropriate cropping. -@objc class CropScaleImageViewController: OWSViewController { +class CropScaleImageViewController: OWSViewController, UIScrollViewDelegate { // MARK: Properties - let srcImage: UIImage + private let dataManager: ImageDataManagerType + let source: ImageDataManager.DataSource - let successCompletion: ((CGRect, Data) -> Void) - - var imageView: UIView! - - // We use a CALayer to render the image for performance reasons. - var imageLayer: CALayer! + let successCompletion: ((ImageDataManager.DataSource, CGRect) -> Void) // In width/height. - // - // TODO: We could make this a parameter. - var dstSizePixels: CGSize { - return CGSize(width: 640, height: 640) - } + let dstSizePixels: CGSize var dstAspectRatio: CGFloat { return dstSizePixels.width / dstSizePixels.height } // The size of the src image in points. var srcImageSizePoints: CGSize = CGSize.zero - // The size of the default crop region, which is the - // largest crop region with the correct dst aspect ratio - // that fits in the src image's aspect ratio, - // in src image point coordinates. - var srcDefaultCropSizePoints: CGSize = CGSize.zero - - // N = Scaled, zoomed in. - let kMaxImageScale: CGFloat = 4.0 - // 1.0 = Unscaled, cropped to fill crop rect. - let kMinImageScale: CGFloat = 1.0 - // This represents the current scaling of the src image. - var imageScale: CGFloat = 1.0 - - // This represents the current translation from the - // upper-left corner of the src image to the upper-left - // corner of the crop region in src image point coordinates. - var srcTranslation: CGPoint = CGPoint.zero // space between the cropping circle and the outside edge of the view let maskMargin = CGFloat(20) + + // MARK: - UI + + private lazy var scrollView: UIScrollView = { + let result: UIScrollView = UIScrollView() + result.delegate = self + result.minimumZoomScale = 1 + result.maximumZoomScale = 5 + result.showsHorizontalScrollIndicator = false + result.showsVerticalScrollIndicator = false + + return result + }() + + private lazy var imageContainerView: UIView = { + let result: UIView = UIView() + result.themeBackgroundColor = .clear + + return result + }() + + private lazy var imageView: SessionImageView = { + let result: SessionImageView = SessionImageView(dataManager: dataManager) + result.loadImage(source) + + return result + }() + + private lazy var buttonStackView: UIStackView = { + let result: UIStackView = UIStackView(arrangedSubviews: [cancelButton, doneButton]) + result.axis = .horizontal + result.distribution = .fillEqually + result.alignment = .fill + + return result + }() + + private lazy var cancelButton: UIButton = { + let result: UIButton = UIButton() + result.titleLabel?.font = .systemFont(ofSize: 18) + result.setTitle("cancel".localized(), for: .normal) + result.setThemeTitleColor(.textPrimary, for: .normal) + result.setThemeBackgroundColor(.backgroundSecondary, for: .highlighted) + result.contentEdgeInsets = UIEdgeInsets( + top: Values.mediumSpacing, + leading: 0, + bottom: (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? Values.mediumSpacing), + trailing: 0 + ) + result.addTarget(self, action: #selector(cancelPressed), for: .touchUpInside) + + return result + }() + + private lazy var doneButton: UIButton = { + let result: UIButton = UIButton() + result.titleLabel?.font = .systemFont(ofSize: 18) + result.setTitle("done".localized(), for: .normal) + result.setThemeTitleColor(.textPrimary, for: .normal) + result.setThemeBackgroundColor(.backgroundPrimary, for: .highlighted) + result.setThemeBackgroundColor(.backgroundSecondary, for: .highlighted) + result.contentEdgeInsets = UIEdgeInsets( + top: Values.mediumSpacing, + leading: 0, + bottom: (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? Values.mediumSpacing), + trailing: 0 + ) + result.addTarget(self, action: #selector(donePressed), for: .touchUpInside) + + return result + }() + + private lazy var maskingView: BezierPathView = { + let result: BezierPathView = BezierPathView() + result.configureShapeLayer = { [weak self] layer, bounds in + guard let self = self else { return } + + let path = UIBezierPath(rect: bounds) + let circleRect = cropFrame(forBounds: bounds) + let radius = circleRect.size.width * 0.5 + let circlePath = UIBezierPath(roundedRect: circleRect, cornerRadius: radius) + + path.append(circlePath) + path.usesEvenOddFillRule = true + + layer.path = path.cgPath + layer.fillRule = .evenOdd + layer.themeFillColor = .black + layer.opacity = 0.75 + } + + return result + }() // MARK: Initializers @@ -78,60 +146,20 @@ import SessionUtilitiesKit fatalError("init(coder:) has not been implemented") } - @objc required init(srcImage: UIImage, successCompletion : @escaping (CGRect, Data) -> Void) { - // normalized() can be slightly expensive but in practice this is fine. - self.srcImage = srcImage.normalizedImage() + init( + source: ImageDataManager.DataSource, + dstSizePixels: CGSize, + dataManager: ImageDataManagerType, + successCompletion: @escaping (ImageDataManager.DataSource, CGRect) -> Void + ) { + self.dataManager = dataManager + self.source = source + self.dstSizePixels = dstSizePixels self.successCompletion = successCompletion + super.init(nibName: nil, bundle: nil) - configureCropAndScale() - } - - // MARK: Cropping and Scaling - - private func configureCropAndScale() { - // We use a "unit" view size (long dimension of length 1, short dimension reflects - // the dst aspect ratio) since we want to be able to perform this logic before we - // know the actual size of the cropped image view. - let unitSquareHeight: CGFloat = (dstAspectRatio >= 1.0 ? 1.0 : 1.0 / dstAspectRatio) - let unitSquareWidth: CGFloat = (dstAspectRatio >= 1.0 ? dstAspectRatio * unitSquareHeight : 1.0) - let unitSquareSize = CGSize(width: unitSquareWidth, height: unitSquareHeight) - - srcImageSizePoints = srcImage.size - guard - (srcImageSizePoints.width > 0 && srcImageSizePoints.height > 0) else { - return - } - - // Default - - // The "default" (no scaling, no translation) crop frame, expressed in - // srcImage's coordinate system. - srcDefaultCropSizePoints = defaultCropSizePoints(dstSizePoints: unitSquareSize) - assert(srcImageSizePoints.width >= srcDefaultCropSizePoints.width) - assert(srcImageSizePoints.height >= srcDefaultCropSizePoints.height) - - // By default, center the crop region in the src image. - srcTranslation = CGPoint(x: (srcImageSizePoints.width - srcDefaultCropSizePoints.width) * 0.5, - y: (srcImageSizePoints.height - srcDefaultCropSizePoints.height) * 0.5) - } - - // Given a dst size, find the size of the largest crop region - // that fits in the src image. - private func defaultCropSizePoints(dstSizePoints: CGSize) -> (CGSize) { - assert(srcImageSizePoints.width > 0) - assert(srcImageSizePoints.height > 0) - - let imageAspectRatio = srcImageSizePoints.width / srcImageSizePoints.height - let dstAspectRatio = dstSizePoints.width / dstSizePoints.height - - var dstCropSizePoints = CGSize.zero - if imageAspectRatio > dstAspectRatio { - dstCropSizePoints = CGSize(width: dstSizePoints.width / dstSizePoints.height * srcImageSizePoints.height, height: srcImageSizePoints.height) - } else { - dstCropSizePoints = CGSize(width: srcImageSizePoints.width, height: dstSizePoints.height / dstSizePoints.width * srcImageSizePoints.width) - } - return dstCropSizePoints + srcImageSizePoints = (source.displaySizeFromMetadata ?? .zero) } // MARK: View Lifecycle @@ -141,385 +169,141 @@ import SessionUtilitiesKit createViews() } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + if scrollView.minimumZoomScale == 1.0 && scrollView.bounds.width > 0 { + configureScrollView() + } + } // MARK: - Create Views private func createViews() { + title = "attachmentsMoveAndScale".localized() view.themeBackgroundColor = .backgroundPrimary - let contentView = UIView() - contentView.themeBackgroundColor = .backgroundPrimary - self.view.addSubview(contentView) - contentView.pin(to: self.view) + view.addSubview(scrollView) + view.addSubview(maskingView) + view.addSubview(buttonStackView) + scrollView.addSubview(imageContainerView) + imageContainerView.addSubview(imageView) - let titleLabel: UILabel = UILabel() - titleLabel.font = .boldSystemFont(ofSize: Values.veryLargeFontSize) - titleLabel.text = "attachmentsMoveAndScale".localized() - titleLabel.themeTextColor = .textPrimary - titleLabel.textAlignment = .center - contentView.addSubview(titleLabel) - titleLabel.set(.width, to: .width, of: contentView) - - let titleLabelMargin = Values.scaleFromIPhone5(16) - titleLabel.pin(.top, to: .top, of: titleLabel.safeAreaLayoutGuide, withInset: titleLabelMargin) + scrollView.pin(.top, to: .top, of: view) + scrollView.pin(.leading, to: .leading, of: view) + scrollView.pin(.trailing, to: .trailing, of: view) + scrollView.pin(.bottom, to: .top, of: buttonStackView) + + maskingView.pin(to: scrollView) + + imageContainerView.pin(to: scrollView) + imageView.pin(to: imageContainerView) + imageView.set(.width, to: srcImageSizePoints.width) + imageView.set(.height, to: srcImageSizePoints.height) - let buttonRow: UIView = createButtonRow() - contentView.addSubview(buttonRow) - buttonRow.pin(.leading, to: .leading, of: contentView) - buttonRow.pin(.trailing, to: .trailing, of: contentView) - buttonRow.pin(.bottom, to: .bottom, of: contentView) - buttonRow.set( - .height, - to: ( - Values.scaleFromIPhone5To7Plus(35, 45) + - Values.mediumSpacing + - (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? Values.mediumSpacing) - ) + buttonStackView.pin(.leading, to: .leading, of: view) + buttonStackView.pin(.trailing, to: .trailing, of: view) + buttonStackView.pin(.bottom, to: .bottom, of: view) + } + + private func configureScrollView() { + guard srcImageSizePoints.width > 0 && srcImageSizePoints.height > 0 else { return } + guard scrollView.bounds.width > 0 && scrollView.bounds.height > 0 else { return } + + // Get the crop circle size + let cropCircleSize: CGFloat = min(scrollView.bounds.width, scrollView.bounds.height) - (maskMargin * 2) + + // Calculate the scale to fit the image to fill the crop circle then start at min scale + let widthScale: CGFloat = (cropCircleSize / srcImageSizePoints.width) + let heightScale: CGFloat = (cropCircleSize / srcImageSizePoints.height) + let minScale = max(widthScale, heightScale) // Fill, not fit + scrollView.minimumZoomScale = minScale + scrollView.maximumZoomScale = (minScale * 5.0) + scrollView.zoomScale = minScale + + // Center the content + let cropRect: CGRect = cropFrame(forBounds: scrollView.bounds) + let scaledImageWidth: CGFloat = (srcImageSizePoints.width * minScale) + let scaledImageHeight: CGFloat = (srcImageSizePoints.height * minScale) + let offsetX: CGFloat = ((cropCircleSize - scaledImageWidth) / 2) + let offsetY: CGFloat = ((cropCircleSize - scaledImageHeight) / 2) + + scrollView.contentInset = UIEdgeInsets( + top: cropRect.minY, + left: cropRect.minX, + bottom: (scrollView.bounds.height - cropRect.maxY), + right: (scrollView.bounds.width - cropRect.maxX) + ) + scrollView.contentOffset = CGPoint( + x: -cropRect.minX - offsetX, + y: -cropRect.minY - offsetY ) - - let imageView = OWSLayerView(frame: CGRect.zero, layoutCallback: { [weak self] _ in - guard let strongSelf = self else { return } - strongSelf.updateImageLayout() - }) - imageView.clipsToBounds = true - self.imageView = imageView - contentView.addSubview(imageView) - imageView.pin(.top, to: .top, of: contentView, withInset: (Values.massiveSpacing + Values.smallSpacing)) - imageView.pin(.leading, to: .leading, of: contentView) - imageView.pin(.trailing, to: .trailing, of: contentView) - imageView.pin(.bottom, to: .top, of: buttonRow) - - let imageLayer = CALayer() - self.imageLayer = imageLayer - imageLayer.contents = srcImage.cgImage - imageView.layer.addSublayer(imageLayer) - - let maskingView = BezierPathView() - contentView.addSubview(maskingView) - - maskingView.configureShapeLayer = { [weak self] layer, bounds in - guard let strongSelf = self else { - return - } - let path = UIBezierPath(rect: bounds) - - let circleRect = strongSelf.cropFrame(forBounds: bounds) - let radius = circleRect.size.width * 0.5 - let circlePath = UIBezierPath(roundedRect: circleRect, cornerRadius: radius) - - path.append(circlePath) - path.usesEvenOddFillRule = true - - layer.path = path.cgPath - layer.fillRule = .evenOdd - layer.themeFillColor = .black - layer.opacity = 0.75 - } - maskingView.pin(.top, to: .top, of: contentView, withInset: (Values.massiveSpacing + Values.smallSpacing)) - maskingView.pin(.leading, to: .leading, of: contentView) - maskingView.pin(.trailing, to: .trailing, of: contentView) - maskingView.pin(.bottom, to: .top, of: buttonRow) - - contentView.isUserInteractionEnabled = true - contentView.addGestureRecognizer(UIPinchGestureRecognizer(target: self, action: #selector(handlePinch(sender:)))) - contentView.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(handlePan(sender:)))) } // Given the current bounds for the image view, return the frame of the // crop region within that view. private func cropFrame(forBounds bounds: CGRect) -> CGRect { - let radius = min(bounds.size.width, bounds.size.height) * 0.5 - self.maskMargin - // Center the circle's bounding rectangle - let circleRect = CGRect(x: bounds.size.width * 0.5 - radius, y: bounds.size.height * 0.5 - radius, width: radius * 2, height: radius * 2) - return circleRect - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - updateImageLayout() - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - self.view.layoutSubviews() - updateImageLayout() - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - updateImageLayout() - } - - // Given a src image size and a dst view size, this finds the bounds - // of the largest rectangular crop region with the correct dst aspect - // ratio that fits in the src image's aspect ratio, in src image point - // coordinates. - private func defaultCropFramePoints(imageSizePoints: CGSize, viewSizePoints: CGSize) -> (CGRect) { - let imageAspectRatio = imageSizePoints.width / imageSizePoints.height - let viewAspectRatio = viewSizePoints.width / viewSizePoints.height - - var defaultCropSizePoints = CGSize.zero - if imageAspectRatio > viewAspectRatio { - defaultCropSizePoints = CGSize(width: viewSizePoints.width / viewSizePoints.height * imageSizePoints.height, height: imageSizePoints.height) - } else { - defaultCropSizePoints = CGSize(width: imageSizePoints.width, height: viewSizePoints.height / viewSizePoints.width * imageSizePoints.width) - } - - let defaultCropOriginPoints = CGPoint(x: (imageSizePoints.width - defaultCropSizePoints.width) * 0.5, - y: (imageSizePoints.height - defaultCropSizePoints.height) * 0.5) - assert(defaultCropOriginPoints.x >= 0) - assert(defaultCropOriginPoints.y >= 0) - assert(defaultCropOriginPoints.x <= imageSizePoints.width - defaultCropSizePoints.width) - assert(defaultCropOriginPoints.y <= imageSizePoints.height - defaultCropSizePoints.height) - return CGRect(origin: defaultCropOriginPoints, size: defaultCropSizePoints) - } - - // Updates the image view _AND_ normalizes the current scale/translate state. - private func updateImageLayout() { - guard let imageView = self.imageView else { - return - } - guard srcImageSizePoints.width > 0 && srcImageSizePoints.height > 0 else { - return - } - guard srcDefaultCropSizePoints.width > 0 && srcDefaultCropSizePoints.height > 0 else { - return - } - - // The size of the image view (should be full screen). - let imageViewSizePoints = imageView.frame.size - guard - (imageViewSizePoints.width > 0 && imageViewSizePoints.height > 0) else { - return - } - // The frame of the crop circle within the image view. - let cropFrame = self.cropFrame(forBounds: CGRect(origin: CGPoint.zero, size: imageViewSizePoints)) - - // Normalize the scaling property. - imageScale = max(kMinImageScale, min(kMaxImageScale, imageScale)) - - let srcCropSizePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, - height: srcDefaultCropSizePoints.height / imageScale) - - let minSrcTranslationPoints = CGPoint.zero - - // Prevent panning outside of image area. - let maxSrcTranslationPoints = CGPoint(x: srcImageSizePoints.width - srcCropSizePoints.width, - y: srcImageSizePoints.height - srcCropSizePoints.height - ) - - // Normalize the translation property - srcTranslation = CGPoint(x: max(minSrcTranslationPoints.x, min(maxSrcTranslationPoints.x, srcTranslation.x)), - y: max(minSrcTranslationPoints.y, min(maxSrcTranslationPoints.y, srcTranslation.y))) - - // The frame of the image layer in crop frame coordinates. - let rawImageLayerFrame = imageRenderRect(forDstSize: cropFrame.size) - // The frame of the image layer in image view coordinates. - let imageLayerFrame = CGRect(x: rawImageLayerFrame.origin.x + cropFrame.origin.x, - y: rawImageLayerFrame.origin.y + cropFrame.origin.y, - width: rawImageLayerFrame.size.width, - height: rawImageLayerFrame.size.height) - - // Disable implicit animations for snappier panning/zooming. - CATransaction.begin() - CATransaction.setDisableActions(true) - - imageLayer.frame = imageLayerFrame - - CATransaction.commit() - } - - // Give the size of a given view or image context into which we - // will render the source image, return the frame (in that - // view/context's coordinate system) to render the source image. - // - // Gathering this logic in a single function ensures that the - // output will be WYSIWYG with the view state. - private func imageRenderRect(forDstSize dstSize: CGSize) -> CGRect { - - let srcCropSizePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, - height: srcDefaultCropSizePoints.height / imageScale) - - let srcToViewRatio = dstSize.width / srcCropSizePoints.width - - return CGRect(origin: CGPoint(x: srcTranslation.x * -srcToViewRatio, - y: srcTranslation.y * -srcToViewRatio), - size: CGSize(width: srcImageSizePoints.width * +srcToViewRatio, - height: srcImageSizePoints.height * +srcToViewRatio - )) - } - - var srcTranslationAtPinchStart: CGPoint = CGPoint.zero - var imageScaleAtPinchStart: CGFloat = 0 - var lastPinchLocation: CGPoint = CGPoint.zero - var lastPinchScale: CGFloat = 1.0 - - @objc func handlePinch(sender: UIPinchGestureRecognizer) { - switch sender.state { - case .possible: break - case .began: - srcTranslationAtPinchStart = srcTranslation - imageScaleAtPinchStart = imageScale - - lastPinchLocation = - sender.location(in: sender.view) - lastPinchScale = sender.scale - - case .changed, .ended: - if sender.numberOfTouches > 1 { - let location = - sender.location(in: sender.view) - let scaleDiff = sender.scale / lastPinchScale - - // Update scaling. - let srcCropSizeBeforeScalePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, - height: srcDefaultCropSizePoints.height / imageScale) - imageScale = max(kMinImageScale, min(kMaxImageScale, imageScale * scaleDiff)) - let srcCropSizeAfterScalePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, - height: srcDefaultCropSizePoints.height / imageScale) - // Since the translation state reflects the "upper left" corner of the crop region, we need to - // adjust the translation when scaling to preserve the "center" of the crop region. - srcTranslation.x += (srcCropSizeBeforeScalePoints.width - srcCropSizeAfterScalePoints.width) * 0.5 - srcTranslation.y += (srcCropSizeBeforeScalePoints.height - srcCropSizeAfterScalePoints.height) * 0.5 - - // Update translation. - let viewSizePoints = imageView.frame.size - let srcCropSizePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, - height: srcDefaultCropSizePoints.height / imageScale) - - let viewToSrcRatio = srcCropSizePoints.width / viewSizePoints.width - - let gestureTranslation = CGPoint(x: location.x - lastPinchLocation.x, - y: location.y - lastPinchLocation.y) - - srcTranslation = CGPoint(x: srcTranslation.x + gestureTranslation.x * -viewToSrcRatio, - y: srcTranslation.y + gestureTranslation.y * -viewToSrcRatio) - - lastPinchLocation = location - lastPinchScale = sender.scale - } - - case .cancelled, .failed: - srcTranslation = srcTranslationAtPinchStart - imageScale = imageScaleAtPinchStart - - @unknown default: break - } - - updateImageLayout() - } - - var srcTranslationAtPanStart: CGPoint = CGPoint.zero - - @objc func handlePan(sender: UIPanGestureRecognizer) { - switch sender.state { - case .possible: break - case .began: - srcTranslationAtPanStart = srcTranslation - - case .changed, .ended: - let viewSizePoints = imageView.frame.size - let srcCropSizePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, - height: srcDefaultCropSizePoints.height / imageScale) - - let viewToSrcRatio = srcCropSizePoints.width / viewSizePoints.width - - let gestureTranslation = - sender.translation(in: sender.view) - - // Update translation. - srcTranslation = CGPoint(x: srcTranslationAtPanStart.x + gestureTranslation.x * -viewToSrcRatio, - y: srcTranslationAtPanStart.y + gestureTranslation.y * -viewToSrcRatio) - - case .cancelled, .failed: - srcTranslation = srcTranslationAtPanStart - - @unknown default: break - } - - updateImageLayout() - } - - private func createButtonRow() -> UIView { - let result: UIStackView = UIStackView() - result.axis = .horizontal - result.distribution = .fillEqually - result.alignment = .fill - - let cancelButton = createButton(title: "cancel".localized(), action: #selector(cancelPressed)) - result.addArrangedSubview(cancelButton) - - let doneButton = createButton(title: "done".localized(), action: #selector(donePressed)) - doneButton.accessibilityLabel = "Done" - result.addArrangedSubview(doneButton) + let radius: CGFloat = ((min(bounds.size.width, bounds.size.height) * 0.5) - self.maskMargin) - return result - } - - private func createButton(title: String, action: Selector) -> UIButton { - let button: UIButton = UIButton() - button.titleLabel?.font = .systemFont(ofSize: 18) - button.setTitle(title, for: .normal) - button.setThemeTitleColor(.textPrimary, for: .normal) - button.setThemeBackgroundColor(.backgroundSecondary, for: .highlighted) - button.contentEdgeInsets = UIEdgeInsets( - top: Values.mediumSpacing, - leading: 0, - bottom: (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? Values.mediumSpacing), - trailing: 0 + return CGRect( + x: ((bounds.size.width * 0.5) - radius), + y: ((bounds.size.height * 0.5) - radius), + width: (radius * 2), + height: (radius * 2) ) - button.addTarget(self, action: action, for: .touchUpInside) - - return button + } + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return imageContainerView } // MARK: - Event Handlers - @objc func cancelPressed(sender: UIButton) { + @objc func cancelPressed() { dismiss(animated: true, completion: nil) } - @objc func donePressed(sender: UIButton) { - let successCompletion = self.successCompletion - let dstSizePixels = self.dstSizePixels + @objc func donePressed() { dismiss(animated: true, completion: { [weak self] in - guard - let dstImageData: Data = self?.generateDstImageData(), - let imageViewFrame: CGRect = self?.imageRenderRect(forDstSize: dstSizePixels) - else { return } + guard let self = self else { return } - successCompletion(imageViewFrame, dstImageData) + self.successCompletion(self.source, self.calculateCropRect()) }) } - - // MARK: - Output - - func generateDstImage() -> UIImage? { - let hasAlpha = false - let dstScale: CGFloat = 1.0 // The size is specified in pixels, not in points. - UIGraphicsBeginImageContextWithOptions(dstSizePixels, !hasAlpha, dstScale) - - guard let context = UIGraphicsGetCurrentContext() else { - Log.error("[CropScaleImageViewController] Could not generate dst image.") - return nil - } - context.interpolationQuality = .high - - let imageViewFrame = imageRenderRect(forDstSize: dstSizePixels) - srcImage.draw(in: imageViewFrame) - - guard let scaledImage = UIGraphicsGetImageFromCurrentImageContext() else { - Log.error("[CropScaleImageViewController] Could not generate dst image.") - return nil - } - UIGraphicsEndImageContext() - return scaledImage - } - func generateDstImageData() -> Data? { - return generateDstImage().map { $0.pngData() } + // MARK: - Internal Functions + + private func calculateCropRect() -> CGRect { + let cropCircleFrame = cropFrame(forBounds: scrollView.bounds) + let zoomScale = scrollView.zoomScale + let contentOffset = scrollView.contentOffset + let contentInset = scrollView.contentInset + + // Convert to content coordinates + let contentX = (contentOffset.x + contentInset.left) / zoomScale + let contentY = (contentOffset.y + contentInset.top) / zoomScale + + // Crop size in image coordinates + let cropSize = cropCircleFrame.width / zoomScale + + // Convert to normalized coordinates (0-1) + let normalizedX = contentX / srcImageSizePoints.width + let normalizedY = contentY / srcImageSizePoints.height + let normalizedWidth = cropSize / srcImageSizePoints.width + let normalizedHeight = cropSize / srcImageSizePoints.height + + // Clamp to valid range [0, 1] and ensure width/height don't exceed bounds + let clampedX = max(0, min(1 - normalizedWidth, normalizedX)) + let clampedY = max(0, min(1 - normalizedHeight, normalizedY)) + let clampedWidth = min(1.0, normalizedWidth, 1.0 - clampedX) + let clampedHeight = min(1.0, normalizedHeight, 1.0 - clampedY) + + return CGRect( + x: clampedX, + y: clampedY, + width: clampedWidth, + height: clampedHeight + ) } } diff --git a/Session/Media Viewing & Editing/GIFs/GifPickerCell.swift b/Session/Media Viewing & Editing/GIFs/GifPickerCell.swift index 81bf19c1c7..1b03460d65 100644 --- a/Session/Media Viewing & Editing/GIFs/GifPickerCell.swift +++ b/Session/Media Viewing & Editing/GIFs/GifPickerCell.swift @@ -201,7 +201,16 @@ class GifPickerCell: UICollectionViewCell { clearViewState() return } - guard let dependencies: Dependencies = dependencies, MediaUtils.isValidImage(at: asset.filePath, type: .gif, using: dependencies) else { + guard + let dependencies: Dependencies = dependencies, + let metadata: MediaUtils.MediaMetadata = MediaUtils.MediaMetadata( + from: asset.filePath, + utType: .gif, + sourceFilename: nil, + using: dependencies + ), + metadata.isValidImage + else { Log.error(.giphy, "Cell received invalid asset.") clearViewState() return diff --git a/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift index b741b3d384..9cf30fb0c1 100644 --- a/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift +++ b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift @@ -357,7 +357,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: DispatchQueue.main) .sink( - receiveCompletion: { [weak self, dependencies] result in + receiveCompletion: { [weak self] result in switch result { case .finished: break case .failure(let error): @@ -382,13 +382,17 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect Log.error(.giphy, "ViewController invalid asset description.") return } - - let dataSource = DataSourcePath(filePath: asset.filePath, sourceFilename: URL(fileURLWithPath: asset.filePath).pathExtension, shouldDeleteOnDeinit: false, using: dependencies) - let attachment = SignalAttachment.attachment(dataSource: dataSource, type: rendition.type, imageQuality: .medium, using: dependencies) + + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .media(URL(fileURLWithPath: asset.filePath)), + utType: rendition.type, + sourceFilename: URL(fileURLWithPath: asset.filePath).lastPathComponent, + using: dependencies + ) self?.dismiss(animated: true) { // Delegate presents view controllers, so it's important that *this* controller be dismissed before that occurs. - self?.delegate?.gifPickerDidSelect(attachment: attachment) + self?.delegate?.gifPickerDidSelect(attachment: pendingAttachment) } } ) @@ -494,8 +498,19 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect receiveValue: { [weak self] imageInfos in Log.debug(.giphy, "ViewController showing trending") - if imageInfos.count > 0 { - self?.imageInfos = imageInfos + // Filter out invalid images before displaying + let validImageInfos = imageInfos.filter { imageInfo in + let isValid: Bool = imageInfo.isValid() + + if !isValid { + Log.debug(.giphy, "Filtering out invalid GIF: \(imageInfo.giphyId)") + } + + return isValid + } + + if validImageInfos.count > 0 { + self?.imageInfos = validImageInfos self?.viewMode = .results } else { @@ -532,7 +547,19 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect }, receiveValue: { [weak self] imageInfos in Log.verbose(.giphy, "ViewController search complete") - self?.imageInfos = imageInfos + + // Filter out invalid images before displaying + let validImageInfos = imageInfos.filter { imageInfo in + let isValid: Bool = imageInfo.isValid() + + if !isValid { + Log.debug(.giphy, "Filtering out invalid GIF: \(imageInfo.giphyId)") + } + + return isValid + } + + self?.imageInfos = validImageInfos if imageInfos.count > 0 { self?.viewMode = .results @@ -573,5 +600,5 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect // MARK: - GifPickerViewControllerDelegate protocol GifPickerViewControllerDelegate: AnyObject { - func gifPickerDidSelect(attachment: SignalAttachment) + func gifPickerDidSelect(attachment: PendingAttachment) } diff --git a/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift b/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift index eb2cd33430..e991b16622 100644 --- a/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift +++ b/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift @@ -95,9 +95,15 @@ class GiphyRendition: ProxiedContentAssetDescription { return name.hasSuffix("_still") } + /// A frame-reduced version of the animation public var isDownsampled: Bool { return name.hasSuffix("_downsampled") } + + /// A scaled down version of the animation + public var isDownsized: Bool { + return name.hasPrefix("downsized_") || name == "downsized" + } public func log() { Log.verbose(.giphy, "\t \(format), \(name), \(width), \(height), \(fileSize)") @@ -124,15 +130,16 @@ class GiphyImageInfo: NSObject { // source of truth for the aspect ratio of the image. let originalRendition: GiphyRendition - init(giphyId: String, - renditions: [GiphyRendition], - originalRendition: GiphyRendition) { + init( + giphyId: String, + renditions: [GiphyRendition], + originalRendition: GiphyRendition + ) { self.giphyId = giphyId self.renditions = renditions self.originalRendition = originalRendition } - // TODO: We may need to tweak these constants. let kMaxDimension = UInt(618) let kMinPreviewDimension = UInt(60) let kMinSendingDimension = UInt(101) @@ -149,36 +156,88 @@ class GiphyImageInfo: NSObject { rendition.log() } } - + + public func isValid() -> Bool { + return ( + pickStillRendition() != nil && + pickPreviewRendition() != nil && + pickSendingRendition() != nil + ) + } + public func pickStillRendition() -> GiphyRendition? { // Stills are just temporary placeholders, so use the smallest still possible. - return pickRendition(renditionType: .stillPreview, pickingStrategy: .smallerIsBetter, maxFileSize: kPreferedPreviewFileSize) + let originalRendition: GiphyRendition? = pickRendition( + renditionType: .stillPreview, + pickingStrategy: .smallerIsBetter, + maxFileSize: kPreferedPreviewFileSize, + allowDownsized: false + ) + + if let rendition: GiphyRendition = originalRendition { + return rendition + } + + return pickRendition( + renditionType: .stillPreview, + pickingStrategy: .smallerIsBetter, + maxFileSize: kPreferedPreviewFileSize, + allowDownsized: false + ) } public func pickPreviewRendition() -> GiphyRendition? { - // Try to pick a small file... - if let rendition = pickRendition(renditionType: .animatedLowQuality, pickingStrategy: .largerIsBetter, maxFileSize: kPreferedPreviewFileSize) { - return rendition - } - // ...but gradually relax the file restriction... - if let rendition = pickRendition(renditionType: .animatedLowQuality, pickingStrategy: .smallerIsBetter, maxFileSize: kPreferedPreviewFileSize * 2) { - return rendition + // Try to pick a small file, then gradually relax the limit until we find an animation + let options: [(size: UInt, strategy: PickingStrategy, allowDownsized: Bool)] = [ + (kPreferedPreviewFileSize, .largerIsBetter, false), + (kPreferedPreviewFileSize * 2, .smallerIsBetter, false), + (kPreferedPreviewFileSize * 3, .smallerIsBetter, false), + (kPreferedPreviewFileSize, .largerIsBetter, true), + (kPreferedPreviewFileSize * 2, .smallerIsBetter, true), + (kPreferedPreviewFileSize * 3, .smallerIsBetter, true), + ] + + for (size, strategy, allowDownsized) in options { + let maybeRendition: GiphyRendition? = pickRendition( + renditionType: .animatedLowQuality, + pickingStrategy: strategy, + maxFileSize: size, + allowDownsized: allowDownsized + ) + + if let rendition: GiphyRendition = maybeRendition { + return rendition + } } - // ...and relax even more until we find an animated rendition. - return pickRendition(renditionType: .animatedLowQuality, pickingStrategy: .smallerIsBetter, maxFileSize: kPreferedPreviewFileSize * 3) + + return nil } public func pickSendingRendition() -> GiphyRendition? { - // Try to pick a small file... - if let rendition = pickRendition(renditionType: .animatedHighQuality, pickingStrategy: .largerIsBetter, maxFileSize: kPreferedSendingFileSize) { - return rendition - } - // ...but gradually relax the file restriction... - if let rendition = pickRendition(renditionType: .animatedHighQuality, pickingStrategy: .smallerIsBetter, maxFileSize: kPreferedSendingFileSize * 2) { - return rendition + // Try to pick a small file, then gradually relax the limit until we find an animation + let options: [(size: UInt, strategy: PickingStrategy, allowDownsized: Bool)] = [ + (kPreferedPreviewFileSize, .largerIsBetter, false), + (kPreferedPreviewFileSize * 2, .smallerIsBetter, false), + (kPreferedPreviewFileSize * 3, .smallerIsBetter, false), + (kPreferedPreviewFileSize, .largerIsBetter, true), + (kPreferedPreviewFileSize * 2, .smallerIsBetter, true), + (kPreferedPreviewFileSize * 3, .smallerIsBetter, true), + ] + + for (size, strategy, allowDownsized) in options { + let maybeRendition: GiphyRendition? = pickRendition( + renditionType: .animatedLowQuality, + pickingStrategy: strategy, + maxFileSize: size, + allowDownsized: allowDownsized + ) + + if let rendition: GiphyRendition = maybeRendition { + return rendition + } } - // ...and relax even more until we find an animated rendition. - return pickRendition(renditionType: .animatedHighQuality, pickingStrategy: .smallerIsBetter, maxFileSize: kPreferedSendingFileSize * 3) + + return nil } enum RenditionType { @@ -189,100 +248,106 @@ class GiphyImageInfo: NSObject { // // * We want to avoid incomplete renditions. // * We want to pick a rendition of "just good enough" quality. - private func pickRendition(renditionType: RenditionType, pickingStrategy: PickingStrategy, maxFileSize: UInt) -> GiphyRendition? { + private func pickRendition( + renditionType: RenditionType, + pickingStrategy: PickingStrategy, + maxFileSize: UInt, + allowDownsized: Bool + ) -> GiphyRendition? { var bestRendition: GiphyRendition? for rendition in renditions { + // Check if we should skip downsized renditions + if !allowDownsized && rendition.isDownsized { + continue + } + switch renditionType { - case .stillPreview: - // Accept GIF or JPEG stills. In practice we'll - // usually select a JPEG since they'll be smaller. - guard [.gif, .jpg].contains(rendition.format) else { - continue - } - // Only consider still renditions. - guard rendition.isStill else { - continue - } - // Accept still renditions without a valid file size. Note that fileSize - // will be zero for renditions without a valid file size, so they will pass - // the maxFileSize test. - // - // Don't worry about max content size; still images are tiny in comparison - // with animated renditions. - guard rendition.width >= kMinPreviewDimension && - rendition.height >= kMinPreviewDimension && - rendition.fileSize <= maxFileSize - else { - continue - } + case .stillPreview: + // Accept GIF or JPEG stills. In practice we'll + // usually select a JPEG since they'll be smaller. + guard [.gif, .jpg].contains(rendition.format) else { continue } + + // Only consider still renditions. + guard rendition.isStill else { continue } + + // Accept still renditions without a valid file size. Note that fileSize + // will be zero for renditions without a valid file size, so they will pass + // the maxFileSize test. + // + // Don't worry about max content size; still images are tiny in comparison + // with animated renditions. + guard + rendition.width >= kMinPreviewDimension && + rendition.height >= kMinPreviewDimension && + rendition.fileSize <= maxFileSize + else { continue } + case .animatedLowQuality: // Only use GIFs for animated renditions. - guard rendition.format == .gif else { - continue - } + guard rendition.format == .gif else { continue } + // Ignore stills. - guard !rendition.isStill else { - continue - } + guard !rendition.isStill else { continue } + // Ignore "downsampled" renditions which skip frames, etc. - guard !rendition.isDownsampled else { - continue - } - guard rendition.width >= kMinPreviewDimension && + guard !rendition.isDownsampled else { continue } + + guard + rendition.width >= kMinPreviewDimension && rendition.width <= kMaxDimension && rendition.height >= kMinPreviewDimension && rendition.height <= kMaxDimension && rendition.fileSize > 0 && rendition.fileSize <= maxFileSize - else { - continue - } + else { continue } + case .animatedHighQuality: // Only use GIFs for animated renditions. - guard rendition.format == .gif else { - continue - } + guard rendition.format == .gif else { continue } + // Ignore stills. - guard !rendition.isStill else { - continue - } + guard !rendition.isStill else { continue } + // Ignore "downsampled" renditions which skip frames, etc. - guard !rendition.isDownsampled else { - continue - } - guard rendition.width >= kMinSendingDimension && + guard !rendition.isDownsampled else { continue } + + guard + rendition.width >= kMinSendingDimension && rendition.width <= kMaxDimension && rendition.height >= kMinSendingDimension && rendition.height <= kMaxDimension && rendition.fileSize > 0 && rendition.fileSize <= maxFileSize - else { - continue - } + else { continue } } if let currentBestRendition = bestRendition { - if rendition.width == currentBestRendition.width && + if + rendition.width == currentBestRendition.width && rendition.fileSize > 0 && currentBestRendition.fileSize > 0 && - rendition.fileSize < currentBestRendition.fileSize { + rendition.fileSize < currentBestRendition.fileSize + { // If two renditions have the same content size, prefer // the rendition with the smaller file size, e.g. // prefer JPEG over GIF for stills. bestRendition = rendition - } else if pickingStrategy == .smallerIsBetter { + } + else if pickingStrategy == .smallerIsBetter { // "Smaller is better" if rendition.width < currentBestRendition.width { bestRendition = rendition } - } else { + } + else { // "Larger is better" if rendition.width > currentBestRendition.width { bestRendition = rendition } } - } else { + } + else { bestRendition = rendition } } diff --git a/Session/Media Viewing & Editing/ImagePickerController.swift b/Session/Media Viewing & Editing/ImagePickerController.swift index ae89e63281..74f0df3599 100644 --- a/Session/Media Viewing & Editing/ImagePickerController.swift +++ b/Session/Media Viewing & Editing/ImagePickerController.swift @@ -14,7 +14,7 @@ protocol ImagePickerGridControllerDelegate: AnyObject { func imagePickerDidCancel(_ imagePicker: ImagePickerGridController) func imagePicker(_ imagePicker: ImagePickerGridController, isAssetSelected asset: PHAsset) -> Bool - func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPublisher: AnyPublisher) + func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, retrievalTask: Task) func imagePicker(_ imagePicker: ImagePickerGridController, didDeselectAsset asset: PHAsset) var isInBatchSelectMode: Bool { get } @@ -40,7 +40,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat self.dependencies = dependencies collectionViewFlowLayout = type(of: self).buildLayout() photoCollection = library.defaultPhotoCollection() - photoCollectionContents = photoCollection.contents() + photoCollectionContents = photoCollection.contents(using: dependencies) super.init(collectionViewLayout: collectionViewFlowLayout) } @@ -189,7 +189,19 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat delegate.imagePicker( self, didSelectAsset: asset, - attachmentPublisher: photoCollectionContents.outgoingAttachment(for: asset, using: dependencies) + retrievalTask: Task.detached { [weak photoCollectionContents, dependencies] in + guard let contents: PhotoCollectionContents = photoCollectionContents else { + throw CancellationError() + } + + return MediaLibraryAttachment( + asset: asset, + attachment: try await contents.pendingAttachment( + for: asset, + using: dependencies + ) + ) + } ) collectionView.selectItem(at: indexPath, animated: true, scrollPosition: []) case .deselect: @@ -389,7 +401,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat // MARK: - PhotoLibraryDelegate func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) { - photoCollectionContents = photoCollection.contents() + photoCollectionContents = photoCollection.contents(using: dependencies) collectionView?.reloadData() } @@ -398,7 +410,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat var isShowingCollectionPickerController: Bool = false lazy var collectionPickerController: SessionTableViewController = SessionTableViewController( - viewModel: PhotoCollectionPickerViewModel(library: library, using: dependencies) { [weak self] collection in + viewModel: PhotoCollectionPickerViewModel(library: library, using: dependencies) { [weak self, dependencies] collection in guard self?.photoCollection != collection else { self?.hideCollectionPicker() return @@ -408,7 +420,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat self?.clearCollectionViewSelection() self?.photoCollection = collection - self?.photoCollectionContents = collection.contents() + self?.photoCollectionContents = collection.contents(using: dependencies) self?.titleView.text = collection.localizedTitle() @@ -468,7 +480,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat return true } - if (indexPathsForSelectedItems.count < SignalAttachment.maxAttachmentsAllowed) { + if (indexPathsForSelectedItems.count < AttachmentManager.maxAttachmentsAllowed) { return true } else { showTooManySelectedToast() @@ -491,7 +503,19 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat delegate.imagePicker( self, didSelectAsset: asset, - attachmentPublisher: photoCollectionContents.outgoingAttachment(for: asset, using: dependencies) + retrievalTask: Task.detached { [weak photoCollectionContents, dependencies] in + guard let contents: PhotoCollectionContents = photoCollectionContents else { + throw CancellationError() + } + + return MediaLibraryAttachment( + asset: asset, + attachment: try await contents.pendingAttachment( + for: asset, + using: dependencies + ) + ) + } ) firstSelectedIndexPath = nil diff --git a/Session/Media Viewing & Editing/MediaDetailViewController.swift b/Session/Media Viewing & Editing/MediaDetailViewController.swift index 692baabe0c..b507ab3e1a 100644 --- a/Session/Media Viewing & Editing/MediaDetailViewController.swift +++ b/Session/Media Viewing & Editing/MediaDetailViewController.swift @@ -245,7 +245,7 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate { let player: AVPlayer = AVPlayer(url: videoUrl) 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 { + guard dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(path) else { return } diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index 28ab4d2426..1c32207dc9 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -48,7 +48,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou startObservingChanges() updateTitle(item: item) - updateCaption(item: item) setViewControllers([galleryPage], direction: direction, animated: isAnimated) { [weak galleryPage] _ in galleryPage?.parentDidAppear() // Trigger any custom appearance animations } @@ -122,7 +121,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou return result }() - let captionContainerView: CaptionContainerView = CaptionContainerView() var galleryRailView: GalleryRailView = GalleryRailView() var pagerScrollView: UIScrollView! @@ -167,25 +165,18 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou // e.g. when getting to media details via message details screen, there's only // one "Page" so the bounce doesn't make sense. pagerScrollView.isScrollEnabled = sliderEnabled - pagerScrollViewContentOffsetObservation = pagerScrollView.observe(\.contentOffset, options: [.new]) { [weak self] _, change in - guard let strongSelf = self else { return } - strongSelf.pagerScrollView(strongSelf.pagerScrollView, contentOffsetDidChange: change) - } - + // Views pagerScrollView.themeBackgroundColor = .newConversation_background view.themeBackgroundColor = .newConversation_background - captionContainerView.delegate = self - updateCaptionContainerVisibility() - galleryRailView.isHidden = true galleryRailView.delegate = self galleryRailView.set(.height, to: 72) footerBar.set(.height, to: 44) - let bottomStack = UIStackView(arrangedSubviews: [captionContainerView, galleryRailView, footerBar]) + let bottomStack = UIStackView(arrangedSubviews: [galleryRailView, footerBar]) bottomStack.axis = .vertical bottomStack.isLayoutMarginsRelativeArrangement = true bottomContainer.addSubview(bottomStack) @@ -200,7 +191,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou galleryRailBlockingView.pin(.bottom, to: .bottom, of: bottomStack) updateTitle(item: currentItem) - updateCaption(item: currentItem) updateMediaRail(item: currentItem) updateFooterBarButtonItems() @@ -253,23 +243,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou self.cachedPages = [:] } - // MARK: KVO - - var pagerScrollViewContentOffsetObservation: NSKeyValueObservation? - func pagerScrollView(_ pagerScrollView: UIScrollView, contentOffsetDidChange change: NSKeyValueObservedChange) { - guard let newValue = change.newValue else { - Log.error("[MediaPageViewController] newValue was unexpectedly nil") - return - } - - let width = pagerScrollView.frame.size.width - guard width > 0 else { - return - } - let ratioComplete = abs((newValue.x - width) / width) - captionContainerView.updatePagerTransition(ratioComplete: ratioComplete) - } - // MARK: View Helpers public func willBePresentedAgain() { @@ -516,7 +489,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou shareVC.popoverPresentationController?.sourceRect = self.view.bounds } - shareVC.completionWithItemsHandler = { [dependencies = viewModel.dependencies] activityType, completed, returnedItems, activityError in + shareVC.completionWithItemsHandler = { [weak self, dependencies = viewModel.dependencies] activityType, completed, returnedItems, activityError in if let activityError = activityError { Log.error("[MediaPageViewController] Failed to share with activityError: \(activityError)") } @@ -525,23 +498,22 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou } /// Sanity check to make sure we don't unintentionally remove a proper attachment file - if path.hasPrefix(dependencies[singleton: .fileManager].temporaryDirectory) { + if dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(path) { 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, currentViewController.galleryItem.interactionVariant == .standardIncoming, - self.viewModel.threadVariant == .contact + self?.viewModel.threadVariant == .contact, + let threadId: String = self?.viewModel.threadId, + let threadVariant: SessionThread.Variant = self?.viewModel.threadVariant else { return } - let threadId: String = self.viewModel.threadId - let threadVariant: SessionThread.Variant = self.viewModel.threadVariant - dependencies[singleton: .storage].writeAsync { db in try MessageSender.send( db, @@ -619,25 +591,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou // MARK: UIPageViewControllerDelegate - var pendingViewController: MediaDetailViewController? - public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { - - Log.assert(pendingViewControllers.count == 1) - pendingViewControllers.forEach { viewController in - guard let pendingViewController = viewController as? MediaDetailViewController else { - Log.error("[MediaPageViewController] Unexpected mediaDetailViewController: \(viewController)") - return - } - self.pendingViewController = pendingViewController - - if let pendingCaptionText = pendingViewController.galleryItem.captionForDisplay, pendingCaptionText.count > 0 { - self.captionContainerView.pendingText = pendingCaptionText - } else { - self.captionContainerView.pendingText = nil - } - } - } - public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted: Bool) { Log.assert(previousViewControllers.count == 1) @@ -649,21 +602,11 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou // Do any cleanup for the no-longer visible view controller if transitionCompleted { - pendingViewController = nil - - // This can happen when trying to page past the last (or first) view controller - // In that case, we don't want to change the captionView. - if (previousPage != currentViewController) { - captionContainerView.completePagerTransition() - } - currentViewController?.parentDidAppear() // Trigger any custom appearance animations updateTitle(item: currentItem) updateMediaRail(item: currentItem) previousPage.zoomOut(animated: false) updateFooterBarButtonItems() - } else { - captionContainerView.pendingText = nil } } } @@ -859,10 +802,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou return containerView }() - private func updateCaption(item: MediaGalleryViewModel.Item?) { - captionContainerView.currentText = item?.captionForDisplay - } - private func updateTitle(item: MediaGalleryViewModel.Item?) { guard let targetItem: MediaGalleryViewModel.Item = item else { return } let threadVariant: SessionThread.Variant = self.viewModel.threadVariant @@ -952,29 +891,6 @@ extension MediaPageViewController: GalleryRailViewDelegate { } } -extension MediaPageViewController: CaptionContainerViewDelegate { - - func captionContainerViewDidUpdateText(_ captionContainerView: CaptionContainerView) { - updateCaptionContainerVisibility() - } - - // MARK: Helpers - - func updateCaptionContainerVisibility() { - if let currentText = captionContainerView.currentText, currentText.count > 0 { - captionContainerView.isHidden = false - return - } - - if let pendingText = captionContainerView.pendingText, pendingText.count > 0 { - captionContainerView.isHidden = false - return - } - - captionContainerView.isHidden = true - } -} - // MARK: - UIViewControllerTransitioningDelegate extension MediaPageViewController: UIViewControllerTransitioningDelegate { @@ -1015,12 +931,12 @@ extension MediaPageViewController: MediaPresentationContextProvider { guard let mediaView: SessionImageView = currentViewController?.mediaView, let mediaSuperview: UIView = mediaView.superview, - let mediaSize: CGSize = { + let mediaDisplaySize: CGSize = { /// Because we load images in the background now it can take a small amount of time for the image to actually be /// loaded in that case we want to use the size of the image found in the image metadata (which we read in /// synchronously when scheduling an image to be loaded) guard let image: UIImage = mediaView.image else { - return mediaView.imageSizeMetadata + return mediaView.imageDisplaySizeMetadata } return image.size @@ -1028,7 +944,7 @@ extension MediaPageViewController: MediaPresentationContextProvider { else { return nil } let scaledWidth: CGFloat = mediaSuperview.frame.width - let scaledHeight: CGFloat = (mediaSize.height * (mediaSuperview.frame.width / mediaSize.width)) + let scaledHeight: CGFloat = (mediaDisplaySize.height * (mediaSuperview.frame.width / mediaDisplaySize.width)) let topInset: CGFloat = ((mediaSuperview.frame.height - scaledHeight) / 2.0) let leftInset: CGFloat = ((mediaSuperview.frame.width - scaledWidth) / 2.0) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 6c42a3751c..b49b9926e4 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -5,22 +5,60 @@ import SessionUIKit import SessionNetworkingKit import SessionUtilitiesKit import SessionMessagingKit +import Lucide struct MessageInfoScreen: View { @EnvironmentObject var host: HostWrapper @State var index = 1 @State var feedbackMessage: String? = nil + @State var isExpanded: Bool = false static private let cornerRadius: CGFloat = 17 var actions: [ContextMenuVC.Action] var messageViewModel: MessageViewModel + let threadCanWrite: Bool + let onStartThread: (@MainActor () -> Void)? let dependencies: Dependencies - var isMessageFailed: Bool { - return [.failed, .failedToSync].contains(messageViewModel.state) + let isMessageFailed: Bool + let isCurrentUser: Bool + let profileInfo: ProfilePictureView.Info? + var proFeatures: [String] = [] + var proCTAVariant: ProCTAModal.Variant = .generic + + public init( + actions: [ContextMenuVC.Action], + messageViewModel: MessageViewModel, + threadCanWrite: Bool, + onStartThread: (@MainActor () -> Void)?, + using dependencies: Dependencies + ) { + self.actions = actions + self.messageViewModel = messageViewModel + self.threadCanWrite = threadCanWrite + self.onStartThread = onStartThread + self.dependencies = dependencies + + self.isMessageFailed = [.failed, .failedToSync].contains(messageViewModel.state) + self.isCurrentUser = (messageViewModel.currentUserSessionIds ?? []).contains(messageViewModel.authorId) + self.profileInfo = ProfilePictureView.getProfilePictureInfo( + size: .message, + publicKey: ( + // Prioritise the profile.id because we override it for + // messages sent by the current user in communities + messageViewModel.profile?.id ?? + messageViewModel.authorId + ), + threadVariant: .contact, // Always show the display picture in 'contact' mode + displayPictureUrl: nil, + profile: messageViewModel.profile, + profileIcon: (messageViewModel.isSenderModeratorOrAdmin ? .crown : .none), + using: dependencies + ).info + + (self.proFeatures, self.proCTAVariant) = getProFeaturesInfo() } - private var isCurrentUser: Bool { (messageViewModel.currentUserSessionIds ?? []).contains(messageViewModel.authorId) } var body: some View { ZStack (alignment: .topLeading) { @@ -83,7 +121,6 @@ struct MessageInfoScreen: View { .foregroundColor(themeColor: tintColor) } } - .padding(.top, -Values.smallSpacing) .padding(.bottom, Values.verySmallSpacing) .padding(.horizontal, Values.largeSpacing) } @@ -183,8 +220,8 @@ struct MessageInfoScreen: View { spacing: Values.mediumSpacing ) { InfoBlock(title: "attachmentsFileId".localized()) { - Text(attachment.downloadUrl.map { Network.FileServer.fileId(for: $0) } ?? "") - .font(.system(size: Values.mediumFontSize)) + Text(attachment.downloadUrl.map { Network.FileServer.fileId(for: URL(string: $0)?.strippingQueryAndFragment?.absoluteString) } ?? "") + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } @@ -193,7 +230,7 @@ struct MessageInfoScreen: View { ) { InfoBlock(title: "attachmentsFileType".localized()) { Text(attachment.contentType) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } @@ -201,7 +238,7 @@ struct MessageInfoScreen: View { InfoBlock(title: "attachmentsFileSize".localized()) { Text(Format.fileSize(attachment.byteCount)) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } @@ -216,7 +253,7 @@ struct MessageInfoScreen: View { }() InfoBlock(title: "attachmentsResolution".localized()) { Text(resolution) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } @@ -228,7 +265,7 @@ struct MessageInfoScreen: View { }() InfoBlock(title: "attachmentsDuration".localized()) { Text(duration) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } @@ -256,53 +293,80 @@ struct MessageInfoScreen: View { alignment: .leading, spacing: Values.mediumSpacing ) { - InfoBlock(title: "sent".localized()) { - Text(messageViewModel.dateForUI.fromattedForMessageInfo) - .font(.system(size: Values.mediumFontSize)) - .foregroundColor(themeColor: .textPrimary) - } - - InfoBlock(title: "received".localized()) { - Text(messageViewModel.receivedDateForUI.fromattedForMessageInfo) - .font(.system(size: Values.mediumFontSize)) + // Pro feature message + if proFeatures.count > 0 { + VStack( + alignment: .leading, + spacing: Values.mediumSpacing + ) { + HStack(spacing: Values.verySmallSpacing) { + SessionProBadge_SwiftUI(size: .small) + Text("message".localized()) + .font(.Body.extraLargeBold) + .foregroundColor(themeColor: .textPrimary) + } + .onTapGesture { + showSessionProCTAIfNeeded() + } + + Text( + "proMessageInfoFeatures" + .put(key: "app_pro", value: Constants.app_pro) + .localized() + ) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) + + VStack( + alignment: .leading, + spacing: Values.smallSpacing + ) { + ForEach(self.proFeatures, id: \.self) { feature in + HStack(spacing: Values.smallSpacing) { + AttributedText(Lucide.Icon.circleCheck.attributedString(size: 17)) + .font(.system(size: 17)) + .foregroundColor(themeColor: .primary) + + Text(feature) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .textPrimary) + } + } + } + } } if isMessageFailed { let failureText: String = messageViewModel.mostRecentFailureText ?? "messageStatusFailedToSend".localized() InfoBlock(title: "theError".localized() + ":") { Text(failureText) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .danger) } + } else { + InfoBlock(title: "sent".localized()) { + Text(messageViewModel.dateForUI.fromattedForMessageInfo) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .textPrimary) + } + + InfoBlock(title: "received".localized()) { + Text(messageViewModel.receivedDateForUI.fromattedForMessageInfo) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .textPrimary) + } } InfoBlock(title: "from".localized()) { HStack( spacing: 10 ) { - let (info, additionalInfo) = ProfilePictureView.getProfilePictureInfo( - size: .message, - publicKey: ( - // Prioritise the profile.id because we override it for - // messages sent by the current user in communities - messageViewModel.profile?.id ?? - messageViewModel.authorId - ), - threadVariant: .contact, // Always show the display picture in 'contact' mode - displayPictureUrl: nil, - profile: messageViewModel.profile, - profileIcon: (messageViewModel.isSenderModeratorOrAdmin ? .crown : .none), - using: dependencies - ) - let size: ProfilePictureView.Size = .list - - if let info: ProfilePictureView.Info = info { + if let info: ProfilePictureView.Info = self.profileInfo { ProfilePictureSwiftUI( size: size, info: info, - additionalInfo: additionalInfo, + additionalInfo: nil, dataManager: dependencies[singleton: .imageDataManager] ) .frame( @@ -316,24 +380,47 @@ struct MessageInfoScreen: View { alignment: .leading, spacing: Values.verySmallSpacing ) { - if isCurrentUser { - Text("you".localized()) - .bold() - .font(.system(size: Values.mediumLargeFontSize)) - .foregroundColor(themeColor: .textPrimary) - } - else if !messageViewModel.authorName.isEmpty { - Text(messageViewModel.authorName) - .bold() - .font(.system(size: Values.mediumLargeFontSize)) - .foregroundColor(themeColor: .textPrimary) + HStack(spacing: Values.verySmallSpacing) { + if isCurrentUser { + Text("you".localized()) + .font(.Body.extraLargeBold) + .foregroundColor(themeColor: .textPrimary) + } + else if !messageViewModel.authorNameSuppressedId.isEmpty { + Text(messageViewModel.authorNameSuppressedId) + .font(.Body.extraLargeBold) + .foregroundColor(themeColor: .textPrimary) + } + + if (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: messageViewModel.authorId)}) { + SessionProBadge_SwiftUI(size: .small) + .onTapGesture { + showSessionProCTAIfNeeded() + } + } } + Text(messageViewModel.authorId) - .font(.spaceMono(size: Values.smallFontSize)) - .foregroundColor(themeColor: .textPrimary) + .font(.Display.base) + .foregroundColor( + themeColor: { + if + messageViewModel.authorId.hasPrefix(SessionId.Prefix.blinded15.rawValue) || + messageViewModel.authorId.hasPrefix(SessionId.Prefix.blinded25.rawValue) + { + return .textSecondary + } + else { + return .textPrimary + } + }() + ) } } } + .onTapGesture { + showUserProfileModal() + } } .frame( maxWidth: .infinity, @@ -383,8 +470,7 @@ struct MessageInfoScreen: View { .foregroundColor(themeColor: tintColor) .frame(width: 26, height: 26) Text(actions[index].title) - .bold() - .font(.system(size: Values.mediumLargeFontSize)) + .font(.Headings.H8) .foregroundColor(themeColor: tintColor) } .frame(maxWidth: .infinity, alignment: .topLeading) @@ -419,6 +505,144 @@ struct MessageInfoScreen: View { .toastView(message: $feedbackMessage) } + private func getProFeaturesInfo() -> (proFeatures: [String], proCTAVariant: ProCTAModal.Variant) { + var proFeatures: [String] = [] + var proCTAVariant: ProCTAModal.Variant = .generic + + guard dependencies[feature: .sessionProEnabled] else { return (proFeatures, proCTAVariant) } + + if (dependencies.mutate(cache: .libSession) { $0.shouldShowProBadge(for: messageViewModel.profile) }) { + proFeatures.append("appProBadge".put(key: "app_pro", value: Constants.app_pro).localized()) + } + + if ( + messageViewModel.isProMessage && + messageViewModel.body.defaulting(to: "").utf16.count > LibSession.CharacterLimit || + dependencies[feature: .messageFeatureLongMessage] + ) { + proFeatures.append("proIncreasedMessageLengthFeature".localized()) + proCTAVariant = (proFeatures.count > 1 ? .generic : .longerMessages) + } + + if ( + ImageDataManager.isAnimatedImage(profileInfo?.source) || + dependencies[feature: .messageFeatureAnimatedAvatar] + ) { + proFeatures.append("proAnimatedDisplayPictureFeature".localized()) + proCTAVariant = (proFeatures.count > 1 ? .generic : .animatedProfileImage(isSessionProActivated: false)) + } + + return (proFeatures, proCTAVariant) + } + + private func showSessionProCTAIfNeeded() { + guard dependencies[feature: .sessionProEnabled] && (!dependencies[cache: .libSession].isSessionPro) else { + return + } + let sessionProModal: ModalHostingViewController = ModalHostingViewController( + modal: ProCTAModal( + delegate: dependencies[singleton: .sessionProState], + variant: proCTAVariant, + dataManager: dependencies[singleton: .imageDataManager] + ) + ) + self.host.controller?.present(sessionProModal, animated: true) + } + + func showUserProfileModal() { + guard threadCanWrite else { return } + // FIXME: Add in support for starting a thread with a 'blinded25' id (disabled until we support this decoding) + guard (try? SessionId.Prefix(from: messageViewModel.authorId)) != .blinded25 else { return } + + guard let profileInfo: ProfilePictureView.Info = ProfilePictureView.getProfilePictureInfo( + size: .message, + publicKey: ( + // Prioritise the profile.id because we override it for + // messages sent by the current user in communities + messageViewModel.profile?.id ?? + messageViewModel.authorId + ), + threadVariant: .contact, // Always show the display picture in 'contact' mode + displayPictureUrl: nil, + profile: messageViewModel.profile, + profileIcon: .none, + using: dependencies + ).info else { + return + } + + let (sessionId, blindedId): (String?, String?) = { + guard + (try? SessionId.Prefix(from: messageViewModel.authorId)) == .blinded15, + let openGroupServer: String = messageViewModel.threadOpenGroupServer, + let openGroupPublicKey: String = messageViewModel.threadOpenGroupPublicKey + else { + return (messageViewModel.authorId, nil) + } + let lookup: BlindedIdLookup? = dependencies[singleton: .storage].write { db in + try BlindedIdLookup.fetchOrCreate( + db, + blindedId: messageViewModel.authorId, + openGroupServer: openGroupServer, + openGroupPublicKey: openGroupPublicKey, + isCheckingForOutbox: false, + using: dependencies + ) + } + return (lookup?.sessionId, messageViewModel.authorId.truncated(prefix: 10, suffix: 10)) + }() + + let qrCodeImage: UIImage? = { + guard let sessionId: String = sessionId else { return nil } + return QRCode.generate(for: sessionId, hasBackground: false, iconName: "SessionWhite40") // stringlint:ignore + }() + + let isMessasgeRequestsEnabled: Bool = { + guard messageViewModel.threadVariant == .community else { return true } + return messageViewModel.profile?.blocksCommunityMessageRequests != true + }() + + let (displayName, contactDisplayName): (String?, String?) = { + guard let sessionId: String = sessionId else { + return (messageViewModel.authorNameSuppressedId, nil) + } + + let profile: Profile? = ( + dependencies.mutate(cache: .libSession) { $0.profile(contactId: sessionId) } ?? + dependencies[singleton: .storage].read { db in try? Profile.fetchOne(db, id: sessionId) } + ) + + let isCurrentUser: Bool = (messageViewModel.currentUserSessionIds?.contains(sessionId) == true) + guard !isCurrentUser else { + return ("you".localized(), "you".localized()) + } + + return ( + (profile?.displayName(for: .contact) ?? messageViewModel.authorNameSuppressedId), + profile?.displayName(for: .contact, ignoringNickname: true) + ) + }() + + let userProfileModal: ModalHostingViewController = ModalHostingViewController( + modal: UserProfileModal( + info: .init( + sessionId: sessionId, + blindedId: blindedId, + qrCodeImage: qrCodeImage, + profileInfo: profileInfo, + displayName: displayName, + contactDisplayName: contactDisplayName, + isProUser: dependencies.mutate(cache: .libSession, { $0.validateProProof(for: messageViewModel.profile) }), + isMessageRequestsEnabled: isMessasgeRequestsEnabled, + onStartThread: self.onStartThread, + onProBadgeTapped: self.showSessionProCTAIfNeeded + ), + dataManager: dependencies[singleton: .imageDataManager] + ) + ) + self.host.controller?.present(userProfileModal, animated: true, completion: nil) + } + private func showMediaFullScreen(attachment: Attachment) { if let mediaGalleryView = MediaGalleryViewModel.createDetailViewController( for: messageViewModel.threadId, @@ -438,8 +662,11 @@ struct MessageInfoScreen: View { } } +// MARK: - MessageBubble + struct MessageBubble: View { @State private var maxWidth: CGFloat? + @State private var isExpanded: Bool = false static private let cornerRadius: CGFloat = 18 static private let inset: CGFloat = 12 @@ -462,6 +689,15 @@ struct MessageBubble: View { cellWidth: UIScreen.main.bounds.width ) - 2 * Self.inset ) + let maxHeight: CGFloat = VisibleMessageCell.getMaxHeightAfterTruncation(for: messageViewModel) + let height: CGFloat = VisibleMessageCell.getBodyTappableLabel( + for: messageViewModel, + with: maxWidth, + textColor: bodyLabelTextColor, + searchText: nil, + delegate: nil, + using: dependencies + ).height VStack( alignment: .leading, @@ -478,6 +714,7 @@ struct MessageBubble: View { imageAttachment: messageViewModel.linkPreviewAttachment, using: dependencies ), + dataManager: dependencies[singleton: .imageDataManager], isOutgoing: (messageViewModel.variant == .standardOutgoing), maxWidth: maxWidth, messageViewModel: messageViewModel, @@ -494,16 +731,16 @@ struct MessageBubble: View { } } else { - if let quote = messageViewModel.quote { + if let quotedInfo: MessageViewModel.QuotedInfo = messageViewModel.quotedInfo { QuoteView_SwiftUI( info: .init( mode: .regular, - authorId: quote.authorId, - quotedText: quote.body, + authorId: quotedInfo.authorId, + quotedText: quotedInfo.body, threadVariant: messageViewModel.threadVariant, currentUserSessionIds: (messageViewModel.currentUserSessionIds ?? []), direction: (messageViewModel.variant == .standardOutgoing ? .outgoing : .incoming), - attachment: messageViewModel.quoteAttachment + attachment: quotedInfo.attachment ), using: dependencies ) @@ -523,9 +760,20 @@ struct MessageBubble: View { searchText: nil, using: dependencies ) { - AttributedText(bodyText) + AttributedLabel(bodyText, maxWidth: maxWidth) + .padding(.horizontal, Self.inset) + .padding(.top, Self.inset) + .frame( + maxHeight: (isExpanded ? .infinity : maxHeight) + ) + } + + if (maxHeight < height && !isExpanded) { + Text("messageBubbleReadMore".localized()) + .bold() + .font(.system(size: Values.smallFontSize)) .foregroundColor(themeColor: bodyLabelTextColor) - .padding(.all, Self.inset) + .padding(.horizontal, Self.inset) } } else { @@ -534,6 +782,7 @@ struct MessageBubble: View { if let attachment: Attachment = messageViewModel.attachments?.first(where: { $0.isAudio }){ // TODO: Playback Info and check if playing function is needed VoiceMessageView_SwiftUI(attachment: attachment) + .padding(.top, Self.inset) } case .audio, .genericAttachment: if let attachment: Attachment = messageViewModel.attachments?.first { @@ -543,6 +792,7 @@ struct MessageBubble: View { textColor: bodyLabelTextColor ) .modifier(MaxWidthEqualizer.notify) + .padding(.top, Self.inset) .frame( width: maxWidth, alignment: .leading @@ -552,10 +802,16 @@ struct MessageBubble: View { } } } + .padding(.bottom, Self.inset) + .onTapGesture { + self.isExpanded = true + } } } } +// MARK: - InfoBlock + struct InfoBlock: View where Content: View { let title: String let content: () -> Content @@ -568,8 +824,7 @@ struct InfoBlock: View where Content: View { spacing: Values.verySmallSpacing ) { Text(self.title) - .bold() - .font(.system(size: Values.mediumLargeFontSize)) + .font(.Body.extraLargeBold) .foregroundColor(themeColor: .textPrimary) self.content() } @@ -580,16 +835,22 @@ struct InfoBlock: View where Content: View { } } +// MARK: - MessageInfoViewController + final class MessageInfoViewController: SessionHostingViewController { init( actions: [ContextMenuVC.Action], messageViewModel: MessageViewModel, + threadCanWrite: Bool, + onStartThread: (() -> Void)?, using dependencies: Dependencies ) { let messageInfoView = MessageInfoScreen( actions: actions, messageViewModel: messageViewModel, - dependencies: dependencies + threadCanWrite: threadCanWrite, + onStartThread: onStartThread, + using: dependencies ) super.init(rootView: messageInfoView) @@ -607,6 +868,8 @@ final class MessageInfoViewController: SessionHostingViewController [SectionModel] in + .map { [thumbnailSize, thumbnailPixelDimension, dependencies] collections -> [SectionModel] in [ SectionModel( model: .content, elements: collections.map { collection in - let contents: PhotoCollectionContents = collection.contents() + let contents: PhotoCollectionContents = collection.contents(using: dependencies) let lastAssetItem: PhotoPickerAssetItem? = contents.lastAssetItem(size: thumbnailSize, pixelDimension: thumbnailPixelDimension) return SessionCell.Info( @@ -94,6 +94,6 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel, ObservableTableSour // MARK: PhotoLibraryDelegate func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) { - self.photoCollections.send(library.allPhotoCollections()) + self.photoCollections.send(library.allPhotoCollections(using: dependencies)) } } diff --git a/Session/Media Viewing & Editing/PhotoGridViewCell.swift b/Session/Media Viewing & Editing/PhotoGridViewCell.swift index b687238049..cc003abddd 100644 --- a/Session/Media Viewing & Editing/PhotoGridViewCell.swift +++ b/Session/Media Viewing & Editing/PhotoGridViewCell.swift @@ -112,8 +112,8 @@ public class PhotoGridViewCell: UICollectionViewCell { self.item = item imageView.setDataManager(dependencies[singleton: .imageDataManager]) imageView.themeBackgroundColor = .textSecondary - imageView.loadImage(item.source) { [weak imageView] processedData in - imageView?.themeBackgroundColor = (processedData != nil ? .clear : .textSecondary) + imageView.loadImage(item.source) { [weak imageView] buffer in + imageView?.themeBackgroundColor = (buffer != nil ? .clear : .textSecondary) } contentTypeBadgeView.isHidden = !item.isVideo diff --git a/Session/Media Viewing & Editing/PhotoLibrary.swift b/Session/Media Viewing & Editing/PhotoLibrary.swift index 16f3c4e715..73be46da44 100644 --- a/Session/Media Viewing & Editing/PhotoLibrary.swift +++ b/Session/Media Viewing & Editing/PhotoLibrary.swift @@ -27,7 +27,7 @@ class PhotoMediaSize { } class PhotoPickerAssetItem: PhotoGridItem { - + let dependencies: Dependencies let asset: PHAsset let photoCollectionContents: PhotoCollectionContents let size: ImageDataManager.ThumbnailSize @@ -37,8 +37,10 @@ class PhotoPickerAssetItem: PhotoGridItem { asset: PHAsset, photoCollectionContents: PhotoCollectionContents, size: ImageDataManager.ThumbnailSize, - pixelDimension: CGFloat + pixelDimension: CGFloat, + using dependencies: Dependencies ) { + self.dependencies = dependencies self.asset = asset self.photoCollectionContents = photoCollectionContents self.size = size @@ -49,18 +51,19 @@ class PhotoPickerAssetItem: PhotoGridItem { var isVideo: Bool { asset.mediaType == .video } var source: ImageDataManager.DataSource { - return .asyncSource(self.asset.localIdentifier) { [photoCollectionContents, asset, size, pixelDimension] in + return .asyncSource(self.asset.localIdentifier) { [photoCollectionContents, asset, size, pixelDimension, dependencies] in await photoCollectionContents.requestThumbnail( for: asset, size: size, - thumbnailSize: CGSize(width: pixelDimension, height: pixelDimension) + pixelDimension: pixelDimension, + using: dependencies ) } } } class PhotoCollectionContents { - + private let dependencies: Dependencies let fetchResult: PHFetchResult let localizedTitle: String? @@ -69,7 +72,8 @@ class PhotoCollectionContents { case unsupportedMediaType } - init(fetchResult: PHFetchResult, localizedTitle: String?) { + init(fetchResult: PHFetchResult, localizedTitle: String?, using dependencies: Dependencies) { + self.dependencies = dependencies self.fetchResult = fetchResult self.localizedTitle = localizedTitle } @@ -111,7 +115,8 @@ class PhotoCollectionContents { asset: mediaAsset, photoCollectionContents: self, size: size, - pixelDimension: pixelDimension + pixelDimension: pixelDimension, + using: dependencies ) } @@ -122,7 +127,8 @@ class PhotoCollectionContents { asset: mediaAsset, photoCollectionContents: self, size: size, - pixelDimension: pixelDimension + pixelDimension: pixelDimension, + using: dependencies ) } @@ -133,20 +139,26 @@ class PhotoCollectionContents { asset: mediaAsset, photoCollectionContents: self, size: size, - pixelDimension: pixelDimension + pixelDimension: pixelDimension, + using: dependencies ) } // MARK: ImageManager - func requestThumbnail(for asset: PHAsset, size: ImageDataManager.ThumbnailSize, thumbnailSize: CGSize) async -> ImageDataManager.DataSource? { + func requestThumbnail( + for asset: PHAsset, + size: ImageDataManager.ThumbnailSize, + pixelDimension: CGFloat, + using dependencies: Dependencies + ) async -> ImageDataManager.DataSource? { var hasResumed: Bool = false /// The `requestImage` function will always return a static thumbnail so if it's an animated image then we need custom - /// handling (the default PhotoKit resizing can't resize animated images so we need to return the original file) + /// handling (the default PhotoKit resizing can't resize animated images so we need to do it ourselves) switch asset.utType?.isAnimated { case .some(true): - return await withCheckedContinuation { [imageManager] continuation in + let maybeData: Data? = await withCheckedContinuation { [imageManager] continuation in let options = PHImageRequestOptions() options.deliveryMode = .highQualityFormat options.isNetworkAccessAllowed = true @@ -160,12 +172,27 @@ class PhotoCollectionContents { return } - // Successfully fetched the data, resume with the animated result + // Successfully fetched the data hasResumed = true - continuation.resume(returning: .data(asset.localIdentifier, data)) + continuation.resume(returning: data) } } + guard + let data: Data = maybeData, + let path: String = try? dependencies[singleton: .attachmentManager] + .path(for: asset.localIdentifier) + else { return nil } + do { + let generatedFileName: String = URL(fileURLWithPath: path).lastPathComponent + let fileUrl: URL = URL(fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectory) + .appendingPathComponent(generatedFileName) + try dependencies[singleton: .fileManager].write(data: data, toPath: fileUrl.path) + + return .urlThumbnail(fileUrl, size, dependencies[singleton: .attachmentManager]) + } + catch { return nil } + default: return await withCheckedContinuation { [imageManager] continuation in let options = PHImageRequestOptions() @@ -177,7 +204,7 @@ class PhotoCollectionContents { imageManager.requestImage( for: asset, - targetSize: thumbnailSize, + targetSize: CGSize(width: pixelDimension, height: pixelDimension), contentMode: .aspectFill, options: options ) { image, info in @@ -206,133 +233,152 @@ class PhotoCollectionContents { } } - private func requestImageDataSource(for asset: PHAsset, using dependencies: Dependencies) -> AnyPublisher<(dataSource: (any DataSource), type: UTType), Error> { - return Deferred { - Future { [weak self] resolver in - let options: PHImageRequestOptions = PHImageRequestOptions() - options.isNetworkAccessAllowed = true - options.deliveryMode = .highQualityFormat + private func requestImageDataSource( + for asset: PHAsset, + using dependencies: Dependencies + ) async throws -> PendingAttachment { + let options: PHImageRequestOptions = PHImageRequestOptions() + options.isSynchronous = false + options.isNetworkAccessAllowed = true + options.deliveryMode = .highQualityFormat + + let pendingAttachment: PendingAttachment = try await withCheckedThrowingContinuation { [imageManager] continuation in + imageManager.requestImageDataAndOrientation(for: asset, options: options) { imageData, dataUTI, orientation, info in + if let error: Error = info?[PHImageErrorKey] as? Error { + return continuation.resume(throwing: error) + } - _ = 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"))) - return - } - - guard let type: UTType = dataUTI.map({ UTType($0) }) else { - resolver(Result.failure(PhotoLibraryError.assertionError(description: "dataUTI was unexpectedly nil"))) - return - } - - guard let dataSource = DataSourceValue(data: imageData, dataType: type, using: dependencies) else { - resolver(Result.failure(PhotoLibraryError.assertionError(description: "dataSource was unexpectedly nil"))) - return - } - - resolver(Result.success((dataSource: dataSource, type: type))) + if (info?[PHImageCancelledKey] as? Bool) == true { + return continuation.resume(throwing: 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: Data = imageData else { + return continuation.resume(throwing: PhotoLibraryError.assertionError(description: "imageData was unexpectedly nil")) + } + + guard let type: UTType = dataUTI.map({ UTType($0) }) else { + return continuation.resume(throwing: PhotoLibraryError.assertionError(description: "dataUTI was unexpectedly nil")) } + + guard let filePath: String = try? dependencies[singleton: .fileManager].write(dataToTemporaryFile: imageData) else { + return continuation.resume(throwing: PhotoLibraryError.assertionError(description: "failed to write temporary file")) + } + + continuation.resume( + returning: PendingAttachment( + source: .media(URL(fileURLWithPath: filePath)), + utType: type, + using: dependencies + ) + ) } } - .eraseToAnyPublisher() + + /// Apple likes to use special formats for media so in order to maintain compatibility with other clients we want to + /// convert the selected image into a `WebP` if it's not one of the supported output types + guard UTType.supportedOutputImageTypes.contains(pendingAttachment.utType) else { + /// Since we need to convert the file we should clean up the temporary one we created earlier (the conversion will create + /// a new one) + defer { + switch pendingAttachment.source { + case .file(let url), .media(.url(let url)): + if dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(url.path) { + try? dependencies[singleton: .fileManager].removeItem(atPath: url.path) + } + default: break + } + } + + let targetFormat: PendingAttachment.ConversionFormat = (dependencies[feature: .usePngInsteadOfWebPForFallbackImageType] ? + .png : .webPLossy + ) + let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare( + operations: [.convert(to: targetFormat)], + using: dependencies + ) + + return PendingAttachment( + source: .media(.url(URL(fileURLWithPath: preparedAttachment.filePath))), + utType: .webP, + sourceFilename: pendingAttachment.sourceFilename, + using: dependencies + ) + } + + return pendingAttachment } - private func requestVideoDataSource(for asset: PHAsset, using dependencies: Dependencies) -> AnyPublisher<(dataSource: (any DataSource), type: UTType), Error> { - return Deferred { - Future { [weak self] resolver in - let options: PHVideoRequestOptions = PHVideoRequestOptions() - options.isNetworkAccessAllowed = true - options.deliveryMode = .highQualityFormat + private func requestVideoDataSource( + for asset: PHAsset, + using dependencies: Dependencies + ) async throws -> PendingAttachment { + let options: PHVideoRequestOptions = PHVideoRequestOptions() + options.isNetworkAccessAllowed = true + options.deliveryMode = .highQualityFormat + + return try await withCheckedThrowingContinuation { [imageManager] continuation in + imageManager.requestAVAsset(forVideo: asset, options: options) { avAsset, _, info in + if let error: Error = info?[PHImageErrorKey] as? Error { + return continuation.resume(throwing: error) + } - self?.imageManager.requestAVAsset(forVideo: asset, options: options) { avAsset, _, info in - - if let error: Error = info?[PHImageErrorKey] as? Error { - return resolver(.failure(error)) - } - - guard let avAsset: AVAsset = avAsset else { - return resolver(Result.failure(PhotoLibraryError.assertionError(description: "avAsset was unexpectedly nil"))) - } - - let compatiblePresets = AVAssetExportSession.exportPresets(compatibleWith: avAsset) - var bestExportPreset: String - - if compatiblePresets.contains(AVAssetExportPresetPassthrough) { - bestExportPreset = AVAssetExportPresetPassthrough - Log.debug("[PhotoLibrary] Using Passthrough export preset.") - } else { - bestExportPreset = AVAssetExportPresetHighestQuality - Log.debug("[PhotoLibrary] Passthrough not available. Falling back to HighestQuality export preset.") - } - - if (info?[PHImageCancelledKey] as? Bool) == true { - return resolver(.failure(PhotoLibraryError.assertionError(description: "Video request cancelled"))) - } - - guard let exportSession: AVAssetExportSession = AVAssetExportSession(asset: avAsset, presetName: bestExportPreset) else { - resolver(Result.failure(PhotoLibraryError.assertionError(description: "exportSession was unexpectedly nil"))) - return - } - - exportSession.outputFileType = AVFileType.mp4 - exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing() - - let exportPath = dependencies[singleton: .fileManager].temporaryFilePath(fileExtension: "mp4") // stringlint:ignore - let exportURL = URL(fileURLWithPath: exportPath) - exportSession.outputURL = exportURL - - Log.debug("[PhotoLibrary] Starting video export") - exportSession.exportAsynchronously { [weak exportSession] in - Log.debug("[PhotoLibrary] Completed video export") - - guard - exportSession?.status == .completed, - let dataSource = DataSourcePath(fileUrl: exportURL, sourceFilename: nil, shouldDeleteOnDeinit: true, using: dependencies) - else { - resolver(Result.failure(PhotoLibraryError.assertionError(description: "Failed to build data source for exported video URL"))) - return - } + if (info?[PHImageCancelledKey] as? Bool) == true { + return continuation.resume(throwing: PhotoLibraryError.assertionError(description: "Video request cancelled")) + } + + guard let avAsset: AVAsset = avAsset else { + return continuation.resume(throwing: PhotoLibraryError.assertionError(description: "avAsset was unexpectedly nil")) + } + + let compatiblePresets = AVAssetExportSession.exportPresets(compatibleWith: avAsset) + let bestExportPreset: String = (compatiblePresets.contains(AVAssetExportPresetPassthrough) ? + AVAssetExportPresetPassthrough : + AVAssetExportPresetHighestQuality + ) + let exportPath: String = dependencies[singleton: .fileManager].temporaryFilePath() + + Task { + do { + /// Apple likes to use special formats for media so in order to maintain compatibility with other clients we want to + /// convert the selected video into an `mp4` + try await PendingAttachment.convertToMpeg4( + asset: avAsset, + presetName: bestExportPreset, + filePath: exportPath + ) - resolver(Result.success((dataSource: dataSource, type: .mpeg4Movie))) + continuation.resume( + returning: PendingAttachment( + source: .media( + .videoUrl( + URL(fileURLWithPath: exportPath), + .mpeg4Movie, + nil, + dependencies[singleton: .attachmentManager] + ) + ), + utType: .mpeg4Movie, + using: dependencies + ) + ) } + catch { continuation.resume(throwing: error) } } } } - .eraseToAnyPublisher() } - func outgoingAttachment(for asset: PHAsset, using dependencies: Dependencies) -> AnyPublisher { + func pendingAttachment(for asset: PHAsset, using dependencies: Dependencies) async throws -> PendingAttachment { switch asset.mediaType { - case .image: - return requestImageDataSource(for: asset, using: dependencies) - .map { (dataSource: DataSource, type: UTType) in - SignalAttachment.attachment(dataSource: dataSource, type: type, imageQuality: .medium, using: dependencies) - } - .eraseToAnyPublisher() - - case .video: - return requestVideoDataSource(for: asset, using: dependencies) - .map { (dataSource: DataSource, type: UTType) in - SignalAttachment.attachment(dataSource: dataSource, type: type, using: dependencies) - } - .eraseToAnyPublisher() - - default: - return Fail(error: PhotoLibraryError.unsupportedMediaType) - .eraseToAnyPublisher() + case .image: return try await requestImageDataSource(for: asset, using: dependencies) + case .video: return try await requestVideoDataSource(for: asset, using: dependencies) + default: throw PhotoLibraryError.unsupportedMediaType } } } @@ -359,12 +405,16 @@ class PhotoCollection { } // stringlint:ignore_contents - func contents() -> PhotoCollectionContents { + func contents(using dependencies: Dependencies) -> PhotoCollectionContents { let options = PHFetchOptions() options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)] let fetchResult = PHAsset.fetchAssets(in: collection, options: options) - return PhotoCollectionContents(fetchResult: fetchResult, localizedTitle: localizedTitle()) + return PhotoCollectionContents( + fetchResult: fetchResult, + localizedTitle: localizedTitle(), + using: dependencies + ) } } @@ -428,7 +478,7 @@ class PhotoLibrary: NSObject, PHPhotoLibraryChangeObserver { return photoCollection } - func allPhotoCollections() -> [PhotoCollection] { + func allPhotoCollections(using dependencies: Dependencies) -> [PhotoCollection] { var collections = [PhotoCollection]() var collectionIds = Set() @@ -446,7 +496,7 @@ class PhotoLibrary: NSObject, PHPhotoLibraryChangeObserver { return } let photoCollection = PhotoCollection(id: collectionId, collection: assetCollection) - guard !hideIfEmpty || photoCollection.contents().assetCount > 0 else { + guard !hideIfEmpty || photoCollection.contents(using: dependencies).assetCount > 0 else { return } diff --git a/Session/Media Viewing & Editing/SendMediaNavigationController.swift b/Session/Media Viewing & Editing/SendMediaNavigationController.swift index 5be73955ea..89663e8702 100644 --- a/Session/Media Viewing & Editing/SendMediaNavigationController.swift +++ b/Session/Media Viewing & Editing/SendMediaNavigationController.swift @@ -21,6 +21,7 @@ class SendMediaNavigationController: UINavigationController { private let threadId: String private let threadVariant: SessionThread.Variant private var disposables: Set = Set() + private var loadMediaTask: Task? // MARK: - Initialization @@ -36,6 +37,10 @@ class SendMediaNavigationController: UINavigationController { fatalError("init(coder:) has not been implemented") } + deinit { + loadMediaTask?.cancel() + } + // MARK: - Overrides override func viewDidLoad() { @@ -204,11 +209,11 @@ class SendMediaNavigationController: UINavigationController { private lazy var attachmentDraftCollection = AttachmentDraftCollection.empty // Lazy to avoid https://bugs.swift.org/browse/SR-6657 - private var attachments: [SignalAttachment] { + private var attachments: [PendingAttachment] { return attachmentDraftCollection.attachmentDrafts.map { $0.attachment } } - private lazy var mediaLibrarySelections = OrderedDictionary() // Lazy to avoid https://bugs.swift.org/browse/SR-6657 + private lazy var mediaLibrarySelections = OrderedDictionary() // Lazy to avoid https://bugs.swift.org/browse/SR-6657 // MARK: Child VC's @@ -240,6 +245,7 @@ class SendMediaNavigationController: UINavigationController { threadVariant: self.threadVariant, attachments: self.attachments, disableLinkPreviewImageDownload: false, + didLoadLinkPreview: nil, using: dependencies ) else { return false } @@ -252,6 +258,36 @@ class SendMediaNavigationController: UINavigationController { } private func didRequestExit() { + /// Kick off a task to clean up any temporary files we had created + let mediaLibrarySelections: [MediaLibrarySelection] = self.mediaLibrarySelections.orderedValues + + if !mediaLibrarySelections.isEmpty { + Task.detached(priority: .utility) { [fileManager = dependencies[singleton: .fileManager]] in + let attachmentResults = await withTaskGroup(of: Result.self) { group in + mediaLibrarySelections.forEach { selection in + group.addTask { await selection.retrievalTask.result } + } + + return await group.reduce(into: []) { result, next in result.append(next) } + } + + for result in attachmentResults { + switch result { + case .failure: break + case .success(let info): + switch info.attachment.visualMediaSource { + case .url(let url), .videoUrl(let url, _, _, _): + if fileManager.isLocatedInTemporaryDirectory(url.path) { + try? fileManager.removeItem(atPath: url.path) + } + + default: break + } + } + } + } + } + self.sendMediaNavDelegate?.sendMediaNavDidCancel(self) } } @@ -286,7 +322,7 @@ extension SendMediaNavigationController: UINavigationControllerDelegate { } extension SendMediaNavigationController: PhotoCaptureViewControllerDelegate { - func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didFinishProcessingAttachment attachment: SignalAttachment) { + func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didFinishProcessingAttachment attachment: PendingAttachment) { attachmentDraftCollection.append(.camera(attachment: attachment)) if isInBatchSelectMode { updateButtons(topViewController: photoCaptureViewController) @@ -329,87 +365,78 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate { didRequestExit() } - func showApprovalAfterProcessingAnyMediaLibrarySelections() { + @MainActor func showApprovalAfterProcessingAnyMediaLibrarySelections() { let mediaLibrarySelections: [MediaLibrarySelection] = self.mediaLibrarySelections.orderedValues - - let backgroundBlock: (ModalActivityIndicatorViewController) -> Void = { [weak self, dependencies] modal in - guard let strongSelf = self else { return } - - Publishers - .MergeMany(mediaLibrarySelections.map { $0.publisher }) - .collect() - .sink( - receiveCompletion: { result in - switch result { - case .finished: break - case .failure(let error): - Log.error("[SendMediaNavigationController] Failed to prepare attachments. error: \(error)") - modal.dismiss { [weak self] in - let modal: ConfirmationModal = ConfirmationModal( - targetView: self?.view, - info: ConfirmationModal.Info( - title: "attachmentsErrorMediaSelection".localized(), - cancelTitle: "okay".localized(), - cancelStyle: .alert_text - ) - ) - self?.present(modal, animated: true) - } - } - }, - receiveValue: { attachments in - Log.debug("[SendMediaNavigationController] Built all attachments") - modal.dismiss { - self?.attachmentDraftCollection.selectedFromPicker(attachments: attachments) - - guard self?.pushApprovalViewController() == true else { - let modal: ConfirmationModal = ConfirmationModal( - info: ConfirmationModal.Info( - title: "attachmentsErrorMediaSelection".localized(), - cancelTitle: "okay".localized(), - cancelStyle: .alert_text - ) - ) - self?.present(modal, animated: true) - return - } - } + let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController() + self.present(indicator, animated: false) + + loadMediaTask?.cancel() + loadMediaTask = Task.detached(priority: .userInitiated) { [weak self, indicator] in + do { + let attachments: [MediaLibraryAttachment] = try await withThrowingTaskGroup(of: MediaLibraryAttachment.self) { group in + mediaLibrarySelections.forEach { selection in + group.addTask { try await selection.retrievalTask.value } } - ) - .store(in: &strongSelf.disposables) + + return try await group.reduce(into: []) { result, next in result.append(next) } + } + guard !Task.isCancelled else { return } + + Log.debug("[SendMediaNavigationController] Built all attachments") + await indicator.dismiss { + self?.attachmentDraftCollection.selectedFromPicker(attachments: attachments) + + guard self?.pushApprovalViewController() == true else { + let modal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "attachmentsErrorMediaSelection".localized(), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text + ) + ) + self?.present(modal, animated: true) + return + } + } + } + catch { + Log.error("[SendMediaNavigationController] Failed to prepare attachments. error: \(error)") + await indicator.dismiss { [weak self] in + let modal: ConfirmationModal = ConfirmationModal( + targetView: self?.view, + info: ConfirmationModal.Info( + title: "attachmentsErrorMediaSelection".localized(), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text + ) + ) + self?.present(modal, animated: true) + } + } } - - ModalActivityIndicatorViewController.present( - fromViewController: self, - canCancel: false, - onAppear: backgroundBlock - ) } func imagePicker(_ imagePicker: ImagePickerGridController, isAssetSelected asset: PHAsset) -> Bool { - return mediaLibrarySelections.hasValue(forKey: asset) + return mediaLibrarySelections.hasValue(forKey: asset.localIdentifier) } - func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPublisher: AnyPublisher) { - guard !mediaLibrarySelections.hasValue(forKey: asset) else { return } + func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, retrievalTask: Task) { + guard !mediaLibrarySelections.hasValue(forKey: asset.localIdentifier) else { return } - let libraryMedia = MediaLibrarySelection( - asset: asset, - signalAttachmentPublisher: attachmentPublisher - ) - mediaLibrarySelections.append(key: asset, value: libraryMedia) + let libraryMedia = MediaLibrarySelection(asset: asset, retrievalTask: retrievalTask) + mediaLibrarySelections.append(key: asset.localIdentifier, value: libraryMedia) updateButtons(topViewController: imagePicker) } func imagePicker(_ imagePicker: ImagePickerGridController, didDeselectAsset asset: PHAsset) { - guard mediaLibrarySelections.hasValue(forKey: asset) else { return } + guard mediaLibrarySelections.hasValue(forKey: asset.localIdentifier) else { return } - mediaLibrarySelections.remove(key: asset) + mediaLibrarySelections.remove(key: asset.localIdentifier) updateButtons(topViewController: imagePicker) } func imagePickerCanSelectAdditionalItems(_ imagePicker: ImagePickerGridController) -> Bool { - return attachmentDraftCollection.count <= SignalAttachment.maxAttachmentsAllowed + return attachmentDraftCollection.count <= AttachmentManager.maxAttachmentsAllowed } func imagePicker(_ imagePicker: ImagePickerGridController, failedToRetrieveAssetAt index: Int, forCount count: Int) { @@ -430,17 +457,16 @@ extension SendMediaNavigationController: AttachmentApprovalViewControllerDelegat sendMediaNavDelegate?.sendMediaNav(self, didChangeMessageText: newMessageText) } - func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment) { + func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: PendingAttachment) { guard let removedDraft = attachmentDraftCollection.attachmentDrafts.first(where: { $0.attachment == attachment}) else { Log.error("[SendMediaNavigationController] removedDraft was unexpectedly nil") return } switch removedDraft.source { - case .picker(attachment: let pickerAttachment): - mediaLibrarySelections.remove(key: pickerAttachment.asset) - case .camera(attachment: _): - break + case .camera(attachment: _): break + case .picker(attachment: let pickerAttachment): + mediaLibrarySelections.remove(key: pickerAttachment.asset.localIdentifier) } attachmentDraftCollection.remove(attachment: attachment) @@ -448,7 +474,7 @@ extension SendMediaNavigationController: AttachmentApprovalViewControllerDelegat func attachmentApproval( _ attachmentApproval: AttachmentApprovalViewController, - didApproveAttachments attachments: [SignalAttachment], + didApproveAttachments attachments: [PendingAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, messageText: String? @@ -479,17 +505,15 @@ extension SendMediaNavigationController: AttachmentApprovalViewControllerDelegat } private enum AttachmentDraft { - case camera(attachment: SignalAttachment) + case camera(attachment: PendingAttachment) case picker(attachment: MediaLibraryAttachment) } private extension AttachmentDraft { - var attachment: SignalAttachment { + var attachment: PendingAttachment { switch self { - case .camera(let cameraAttachment): - return cameraAttachment - case .picker(let pickerAttachment): - return pickerAttachment.signalAttachment + case .camera(let cameraAttachment): return cameraAttachment + case .picker(let pickerAttachment): return pickerAttachment.attachment } } @@ -499,7 +523,7 @@ private extension AttachmentDraft { } private final class AttachmentDraftCollection { - lazy var attachmentDrafts = [AttachmentDraft]() // Lazy to avoid https://bugs.swift.org/browse/SR-6657 + lazy var attachmentDrafts: [AttachmentDraft] = [] static var empty: AttachmentDraftCollection { return AttachmentDraftCollection(attachmentDrafts: []) @@ -518,21 +542,17 @@ private final class AttachmentDraftCollection { var pickerAttachments: [MediaLibraryAttachment] { return attachmentDrafts.compactMap { attachmentDraft in switch attachmentDraft.source { - case .picker(let pickerAttachment): - return pickerAttachment - case .camera: - return nil + case .picker(let pickerAttachment): return pickerAttachment + case .camera: return nil } } } - var cameraAttachments: [SignalAttachment] { + var cameraAttachments: [PendingAttachment] { return attachmentDrafts.compactMap { attachmentDraft in switch attachmentDraft.source { - case .picker: - return nil - case .camera(let cameraAttachment): - return cameraAttachment + case .picker: return nil + case .camera(let cameraAttachment): return cameraAttachment } } } @@ -541,7 +561,7 @@ private final class AttachmentDraftCollection { attachmentDrafts.append(element) } - func remove(attachment: SignalAttachment) { + func remove(attachment: PendingAttachment) { attachmentDrafts.removeAll { $0.attachment == attachment } } @@ -550,7 +570,7 @@ private final class AttachmentDraftCollection { let oldPickerAttachments: Set = Set(self.pickerAttachments) for removedAttachment in oldPickerAttachments.subtracting(pickedAttachments) { - remove(attachment: removedAttachment.signalAttachment) + remove(attachment: removedAttachment.attachment) } // enumerate over new attachments to maintain order from picker @@ -565,29 +585,22 @@ private final class AttachmentDraftCollection { private struct MediaLibrarySelection: Hashable, Equatable { let asset: PHAsset - let signalAttachmentPublisher: AnyPublisher + let retrievalTask: Task func hash(into hasher: inout Hasher) { asset.hash(into: &hasher) } - var publisher: AnyPublisher { - let asset = self.asset - return signalAttachmentPublisher - .map { MediaLibraryAttachment(asset: asset, signalAttachment: $0) } - .eraseToAnyPublisher() - } - static func ==(lhs: MediaLibrarySelection, rhs: MediaLibrarySelection) -> Bool { return lhs.asset == rhs.asset } } -private struct MediaLibraryAttachment: Hashable, Equatable { +public struct MediaLibraryAttachment: Hashable, Equatable { let asset: PHAsset - let signalAttachment: SignalAttachment + let attachment: PendingAttachment - func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { asset.hash(into: &hasher) } @@ -794,7 +807,7 @@ private class DoneButton: UIView { protocol SendMediaNavDelegate: AnyObject { func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController?) - func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, messageText: String?) + func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [PendingAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, messageText: String?) func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String? func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?) diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index e70361c687..72d0ee2c58 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -130,13 +130,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD Task { @MainActor in SNUIKit.configure( with: SessionSNUIKitConfig(using: dependencies), - themeSettings: dependencies.mutate(cache: .libSession) { cache -> ThemeSettings in - ( - cache.get(.theme), - cache.get(.themePrimaryColor), - cache.get(.themeMatchSystemDayNightCycle) - ) - } + themeSettings: { + /// Only try to extract the theme settings if we actually have an account (if not the `libSession` + /// cache won't exist anyway) + guard dependencies[cache: .general].userExists else { return nil } + + return dependencies.mutate(cache: .libSession) { cache -> ThemeSettings in + ( + cache.get(.theme), + cache.get(.themePrimaryColor), + cache.get(.themeMatchSystemDayNightCycle) + ) + } + }() ) } @@ -147,8 +153,10 @@ 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.mutate(cache: .libSession) { cache in - cache.get(.areCallsEnabled) + if dependencies[cache: .general].userExists { + 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 @@ -918,7 +926,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // need to handle this behavior for legacy UINotification users anyway, we "allow" all // notification options here, and rely on the shared logic in NotificationPresenter to // honor notification sound preferences for both modern and legacy users. - completionHandler([.badge, .banner, .sound]) + completionHandler([.badge, .banner, .sound, .list]) } } diff --git a/Session/Meta/Images.xcassets/ic_crown.imageset/Contents.json b/Session/Meta/Images.xcassets/ic_crown.imageset/Contents.json new file mode 100644 index 0000000000..4171612590 --- /dev/null +++ b/Session/Meta/Images.xcassets/ic_crown.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Union.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/ic_crown.imageset/Union.pdf b/Session/Meta/Images.xcassets/ic_crown.imageset/Union.pdf new file mode 100644 index 0000000000..e57702e74d Binary files /dev/null and b/Session/Meta/Images.xcassets/ic_crown.imageset/Union.pdf differ diff --git a/Session/Meta/Session+SNUIKit.swift b/Session/Meta/Session+SNUIKit.swift index 2c7a2aedfe..1f146f1257 100644 --- a/Session/Meta/Session+SNUIKit.swift +++ b/Session/Meta/Session+SNUIKit.swift @@ -2,6 +2,7 @@ import UIKit import AVFoundation +import UniformTypeIdentifiers import SessionUIKit import SessionNetworkingKit import SessionUtilitiesKit @@ -88,12 +89,32 @@ internal struct SessionSNUIKitConfig: SNUIKit.ConfigType { 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 - ) + func assetInfo(for path: String, utType: UTType, sourceFilename: String?) -> (asset: AVURLAsset, isValidVideo: Bool, cleanup: () -> Void)? { + guard + let result: (asset: AVURLAsset, utType: UTType, cleanup: () -> Void) = AVURLAsset.asset( + for: path, + utType: utType, + sourceFilename: sourceFilename, + using: dependencies + ) + else { return nil } + + return (result.asset, MediaUtils.isValidVideo(asset: result.asset, utType: result.utType), result.cleanup) + } + + func mediaDecoderDefaultImageOptions() -> CFDictionary { + return dependencies[singleton: .mediaDecoder].defaultImageOptions + } + + func mediaDecoderDefaultThumbnailOptions(maxDimension: CGFloat) -> CFDictionary { + return dependencies[singleton: .mediaDecoder].defaultThumbnailOptions(maxDimension: maxDimension) + } + + func mediaDecoderSource(for url: URL) -> CGImageSource? { + return dependencies[singleton: .mediaDecoder].source(for: url) + } + + func mediaDecoderSource(for data: Data) -> CGImageSource? { + return dependencies[singleton: .mediaDecoder].source(for: data) } } diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index e311b2ed71..d468e3d906 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -149,9 +149,9 @@ public class SessionApp: SessionAppType { Log.info("Data Reset Complete.") Log.flush() - /// Wait until the next run loop to kill the app (hoping to avoid a crash due to the connection closes - /// triggering logs) - DispatchQueue.main.async { + /// Wait for a small duration before killing the app (hoping to avoid a crash due to `libSession` shutting down connections + /// which result in spdlog trying to log and crashing) + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { exit(0) } } diff --git a/Session/Meta/Settings.bundle/ThirdPartyLicenses.plist b/Session/Meta/Settings.bundle/ThirdPartyLicenses.plist index 3848270117..16af45f5d0 100644 --- a/Session/Meta/Settings.bundle/ThirdPartyLicenses.plist +++ b/Session/Meta/Settings.bundle/ThirdPartyLicenses.plist @@ -6,7 +6,7 @@ License BSD 3-Clause License -Copyright (c) 2010-2024, Deusty, LLC +Copyright (c) 2010-2025, Deusty, LLC All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: @@ -955,6 +955,42 @@ Public License instead of this License. But first, please read Title libsession-util-spm + + License + Copyright (c) 2010, Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + + Title + libwebp-Xcode - libwebp + License Apache License @@ -1630,6 +1666,57 @@ SOFTWARE. Title Quick - nimble + + License + Copyright (c) 2009-2020 Olivier Poitrey rs@dailymotion.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + + Title + SDWebImage + + + License + Copyright (c) 2018 Bogdan Poplauschi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + Title + SDWebImageWebPCoder + License Copyright (C) 2015-2025 Gwendal Roué diff --git a/Session/Meta/Translations/Localizable.xcstrings b/Session/Meta/Translations/Localizable.xcstrings index 929defed6a..28a6b11b68 100644 --- a/Session/Meta/Translations/Localizable.xcstrings +++ b/Session/Meta/Translations/Localizable.xcstrings @@ -6916,6 +6916,39 @@ } } }, + "addAdmin" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Admin" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Admins" + } + } + } + } + } + } + } + } + }, "addAdmins" : { "extractionState" : "manual", "localizations" : { @@ -7220,6 +7253,17 @@ } } }, + "adminCannotBeDemoted" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Admins cannot be demoted or removed from the group." + } + } + } + }, "adminCannotBeRemoved" : { "extractionState" : "manual", "localizations" : { @@ -18128,6 +18172,40 @@ } } }, + "admins" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Admins" + } + } + } + }, + "adminSelected" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Admin Selected" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Admins Selected" + } + } + } + } + } + } + }, "adminSendingPromotion" : { "extractionState" : "manual", "localizations" : { @@ -19584,6 +19662,17 @@ } } }, + "adminStatusYou" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You cannot change your admin status. To leave the group, open the conversation settings and select Leave Group." + } + } + } + }, "adminTwoPromotedToAdmin" : { "extractionState" : "manual", "localizations" : { @@ -80465,6 +80554,50 @@ } } }, + "cameraAccessDeniedMessage" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{app_name} needs access to your camera to enable video calls, but this permission has been denied. You can’t update your camera permissions during a call.

Would you like to end the call now and enable camera access, or would you like to be reminded after the call?" + } + } + } + }, + "cameraAccessInstructions" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "To allow camera access, open settings and turn on the Camera permission." + } + } + } + }, + "cameraAccessReminderMessage" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "During your last call, you tried to use video but couldn’t because camera access was previously denied. To allow camera access, open settings and turn on the Camera permission." + } + } + } + }, + "cameraAccessRequired" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera Access Required" + } + } + } + }, "cameraErrorNotFound" : { "extractionState" : "manual", "localizations" : { @@ -83848,49 +83981,24 @@ } } }, - "cancelPlan" : { + "cancelAccess" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Planı ləğv et" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zrušit tarif" - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Cancel Plan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Annuler l’abonnement" + "value" : "Cancel {pro}" } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abonnement annuleren" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anuluj plan" - } - }, - "uk" : { + } + } + }, + "cancelProPlan" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Скасувати тарифний план" + "value" : "Cancel {pro} Plan" } } } @@ -83901,7 +84009,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Cancel your plan on the {platform} website, using the {platform_account} you signed up for Pro with." + "value" : "Cancel on the {platform} website, using the {platform_account} you signed up for {pro} with." } } } @@ -83912,7 +84020,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Cancel your plan on the {platform_store} website, using the {platform_account} you signed up for Pro with." + "value" : "Cancel on the {platform_store} website, using the {platform_account} you signed up for {pro} with." } } } @@ -84555,25 +84663,46 @@ } } }, + "checkingProStatusContinue" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checking your {pro} status. You'll be able to continue once this check is complete." + } + } + } + }, "checkingProStatusDescription" : { "extractionState" : "manual", "localizations" : { - "az" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "{pro} məlumatlarınız yoxlanılır. Bu səhifədəki bəzi məlumatlar, yoxlama tamamlanana qədər qeyri-dəqiq ola bilər." + "value" : "Checking your {pro} details. Some actions on this page may be unavailable until this check is complete." } - }, - "cs" : { + } + } + }, + "checkingProStatusEllipsis" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Kontrolují se vaše údaje {pro}. Některé informace na této stránce mohou být nepřesné, dokud kontrola nebude dokončena." + "value" : "Checking {pro} Status..." } - }, + } + } + }, + "checkingProStatusRenew" : { + "extractionState" : "manual", + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Checking your {pro} details. Some information on this page may be inaccurate until this check is complete." + "value" : "Checking your {pro} details. You cannot renew until this check is complete." } } } @@ -108168,6 +108297,28 @@ } } }, + "confirmPromotion" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirm Promotion" + } + } + } + }, + "confirmPromotionDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Are you sure? Admins cannot be demoted or removed from the group." + } + } + } + }, "contactContacts" : { "extractionState" : "manual", "localizations" : { @@ -110581,6 +110732,29 @@ } } }, + "contactSelected" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Contact Selected" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Contacts Selected" + } + } + } + } + } + } + }, "contactUserDetails" : { "extractionState" : "manual", "localizations" : { @@ -126657,6 +126831,17 @@ } } }, + "currentBilling" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Current Billing" + } + } + } + }, "currentPassword" : { "extractionState" : "manual", "localizations" : { @@ -126716,53 +126901,6 @@ } } }, - "currentPlan" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hazırkı plan" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Současný tarif" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Current Plan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Forfait actuel" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Huidig abonnement" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Obecny plan" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Поточна передплата" - } - } - } - }, "cut" : { "extractionState" : "manual", "localizations" : { @@ -137849,6 +137987,72 @@ } } }, + "deleteAttachments" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Selected Attachment" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Selected Attachments" + } + } + } + } + } + } + } + } + }, + "deleteAttachmentsDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Are you sure you want to delete the selected attachment? The message associated with the attachment will also be deleted." + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Are you sure you want to delete the selected attachments? The message associated with the attachments will also be deleted." + } + } + } + } + } + } + } + } + }, "deleteContactDescription" : { "extractionState" : "manual", "localizations" : { @@ -188998,6 +189202,17 @@ } } }, + "enableCameraAccess" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable Camera Access?" + } + } + } + }, "enableNotifications" : { "extractionState" : "manual", "localizations" : { @@ -189063,6 +189278,17 @@ } } }, + "endCallToEnable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "End Call to Enable" + } + } + } + }, "enjoyingSession" : { "extractionState" : "manual", "localizations" : { @@ -189651,7 +189877,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Enter the password you use to unlock Session \r\non startup, not your Recovery Password" + "value" : "Enter the password you use to unlock {app_name} on startup, not your Recovery Password" } } } @@ -189659,22 +189885,10 @@ "errorCheckingProStatus" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} statusunu yoxlama xətası." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chyba kontroly stavu {pro}." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Error checking {pro} status." + "value" : "Error checking {pro} status" } } } @@ -191277,13 +191491,24 @@ } } }, - "errorLoadingProPlan" : { + "errorLoadingProAccess" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Error loading {pro} plan" + "value" : "Error loading {pro} access" + } + } + } + }, + "errorNoLookupOns" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{app_name} was unable to search for this ONS. Please check your network connection and try again." } } } @@ -191773,6 +191998,50 @@ } } }, + "errorUnregisteredOns" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This ONS is not registered. Please check it is correct and try again." + } + } + } + }, + "failedResendInvite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to resend invite to {name} in {group_name}" + } + } + } + }, + "failedResendInviteMultiple" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to resend invite to {name} and {count} others in {group_name}" + } + } + } + }, + "failedResendInviteTwo" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to resend invite to {name} and {other_name} in {group_name}" + } + } + } + }, "failedToDownload" : { "extractionState" : "manual", "localizations" : { @@ -211497,6 +211766,39 @@ } } }, + "groupMemberInvitedHistory" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{name} was invited to join the group. Chat history from the last 14 days was shared." + } + } + } + }, + "groupMemberInvitedHistoryMultiple" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{name} and {count} others were invited to join the group. Chat history from the last 14 days was shared." + } + } + } + }, + "groupMemberInvitedHistoryTwo" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{name} and {other_name} were invited to join the group. Chat history from the last 14 days was shared." + } + } + } + }, "groupMemberLeft" : { "extractionState" : "manual", "localizations" : { @@ -216430,6 +216732,39 @@ } } }, + "groupMemberRemoveFailed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to remove {name} from {group_name}" + } + } + } + }, + "groupMemberRemoveFailedMultiple" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to remove {name} and {count} others from {group_name}" + } + } + } + }, + "groupMemberRemoveFailedOther" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to remove {name} and {other_name} from {group_name}" + } + } + } + }, "groupMembers" : { "extractionState" : "manual", "localizations" : { @@ -222087,6 +222422,17 @@ } } }, + "groupOnlyAdminLeave" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You are the only admin in {group_name}.

Group members and settings cannot be changed without an admin. To leave the group without deleting it, please add a new admin first" + } + } + } + }, "groupPendingRemoval" : { "extractionState" : "manual", "localizations" : { @@ -228168,46 +228514,6 @@ } } }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "%#@arg1@" - }, - "substitutions" : { - "arg1" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "few" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odstranit uživatele a jejich zprávy" - } - }, - "many" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odstranit uživatele a jejich zprávy" - } - }, - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odstranit uživatele a jeho zprávy" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odstranit uživatele a jejich zprávy" - } - } - } - } - } - } - }, "cy" : { "stringUnit" : { "state" : "translated", @@ -230461,46 +230767,6 @@ } } }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "%#@arg1@" - }, - "substitutions" : { - "arg1" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "few" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odstranit uživatele" - } - }, - "many" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odstranit uživatele" - } - }, - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odstranit uživatele" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odstranit uživatele" - } - } - } - } - } - } - }, "cy" : { "stringUnit" : { "state" : "translated", @@ -242026,6 +242292,39 @@ } } }, + "inviteContactsPlural" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invite Contact" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invite Contacts" + } + } + } + } + } + } + } + } + }, "inviteFailed" : { "extractionState" : "manual", "localizations" : { @@ -243920,6 +244219,50 @@ } } }, + "inviteMembers" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invite Member" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invite Members" + } + } + } + } + } + } + } + } + }, + "inviteNewMemberGroupLink" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invite a new member to the group by entering your friend's Account ID, ONS or scanning their QR code {icon}" + } + } + } + }, "join" : { "extractionState" : "manual", "localizations" : { @@ -260815,6 +261158,17 @@ } } }, + "manageAdmins" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manage Admins" + } + } + } + }, "manageMembers" : { "extractionState" : "manual", "localizations" : { @@ -265596,6 +265950,40 @@ } } }, + "memberSelected" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Member Selected" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Members Selected" + } + } + } + } + } + } + }, + "membersGroupPromotionAcceptInvite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Members can only be promoted once they've\r\naccepted an invite to join the group." + } + } + } + }, "membersInvite" : { "extractionState" : "manual", "localizations" : { @@ -266075,6 +266463,17 @@ } } }, + "membersInviteNoContacts" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You don’t have any contacts to invite to this group.
Go back and invite members using their Account ID or ONS." + } + } + } + }, "membersInviteSend" : { "extractionState" : "manual", "localizations" : { @@ -270284,6 +270683,17 @@ } } }, + "membersInviteShareMessageHistoryDays" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share message history from last\r\n14 days" + } + } + } + }, "membersInviteShareNewMessagesOnly" : { "extractionState" : "manual", "localizations" : { @@ -271242,6 +271652,17 @@ } } }, + "membersNonAdmins" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Members (Non-Admins)" + } + } + } + }, "menuBar" : { "extractionState" : "manual", "localizations" : { @@ -279077,6 +279498,17 @@ } } }, + "messageNewDescriptionMobileLink" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start a new conversation by entering your friend's Account ID, ONS or scanning their QR code {icon}" + } + } + } + }, "messageNewYouveGot" : { "extractionState" : "manual", "localizations" : { @@ -299950,6 +300382,39 @@ } } }, + "NoNonAdminsInGroup" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There are no non-admin members in this group." + } + } + } + }, + "nonProLongerMessagesDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send messages up to 10,000 characters in all conversations." + } + } + } + }, + "nonProUnlimitedPinnedDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Organize chats with unlimited pinned conversations." + } + } + } + }, "noSuggestions" : { "extractionState" : "manual", "localizations" : { @@ -327617,37 +328082,24 @@ } } }, - "onDeviceDescription" : { + "onDeviceCancelDescription" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Başda qeydiyyatdan keçdiyiniz {platform_account} hesabına giriş etdiyiniz {device_type} cihazından bu {app_name} hesabını açın. Sonra planınızı {app_pro} ayarları vasitəsilə dəyişdirin." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Otevřete tento účet {app_name} na zařízení {device_type}, které je přihlášeno do účtu {platform_account}, pomocí kterého jste se původně zaregistrovali. Poté změňte svůj tarif v nastavení {app_pro}." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, change your plan via the {app_pro} settings." + "value" : "Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, cancel {pro} via the {app_pro} settings." } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ouvrez ce compte {app_name} sur un appareil {device_type} connecté au compte {platform_account} avec lequel vous vous êtes inscrit à l'origine. Ensuite, modifiez votre abonnement via les paramètres {app_pro}." - } - }, - "nl" : { + } + } + }, + "onDeviceDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Open dit {app_name} account op een {device_type} apparaat waarop je bent aangemeld met het {platform_account} waarmee je je oorspronkelijk hebt geregistreerd. Wijzig vervolgens je abonnement via de instellingen van {app_pro}." + "value" : "Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, update your {pro} access via the {app_pro} settings." } } } @@ -330544,6 +330996,17 @@ } } }, + "onLinkedDevice" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On a linked device" + } + } + } + }, "onPlatformStoreWebsite" : { "extractionState" : "manual", "localizations" : { @@ -332025,6 +332488,17 @@ } } }, + "openSettings" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Settings" + } + } + } + }, "openSurvey" : { "extractionState" : "manual", "localizations" : { @@ -354503,6 +354977,237 @@ } } }, + "proAccessActivatedAutoShort" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {pro} access is active!

Your {pro} access will automatically renew for another {current_plan_length} on {date}." + } + } + } + }, + "proAccessActivatedNotAuto" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {pro} access will expire on {date}.

Update your {pro} access now to ensure you automatically renew before your {pro} access expires." + } + } + } + }, + "proAccessActivatesAuto" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {pro} access is active!

Your {pro} access will automatically renew for another
{current_plan_length} on {date}. Any updates you make here will take effect at your next renewal." + } + } + } + }, + "proAccessError" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} Access Error" + } + } + } + }, + "proAccessExpireDate" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {pro} access will expire on {date}." + } + } + } + }, + "proAccessLoading" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} Access Loading" + } + } + } + }, + "proAccessLoadingDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {pro} access information is still being loaded. You cannot update until this process is complete." + } + } + } + }, + "proAccessLoadingEllipsis" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} access loading..." + } + } + } + }, + "proAccessNetworkLoadError" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to connect to the network to load your {pro} access information. Updating {pro} via {app_name} will be disabled until connectivity is restored.

Please check your network connection and retry." + } + } + } + }, + "proAccessNotFound" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} Access Not Found" + } + } + } + }, + "proAccessNotFoundDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{app_name} detected that your account does not have {pro} access. If you believe this is a mistake, please reach out to {app_name} support for assistance." + } + } + } + }, + "proAccessRecover" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recover {pro} Access" + } + } + } + }, + "proAccessRenew" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renew {pro} Access" + } + } + } + }, + "proAccessRenewDesktop" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Currently, {pro} access can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you are using {app_name} Desktop, you're not able to renew here.

{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon}" + } + } + } + }, + "proAccessRenewPlatformStoreWebsite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renew your {pro} access on the {platform_store} website using the {platform_account} you signed up for {pro} with." + } + } + } + }, + "proAccessRenewPlatformWebsite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renew on the {platform} website using the {platform_account} you signed up for {pro} with." + } + } + } + }, + "proAccessRenewStart" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renew your {pro} access to start using powerful {app_pro} Beta features again." + } + } + } + }, + "proAccessRestored" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} Access Recovered" + } + } + } + }, + "proAccessRestoredDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{app_name} detected and recovered {pro} access for your account. Your {pro} status has been restored!" + } + } + } + }, + "proAccessSignUp" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Because you originally signed up for {app_pro} via the {platform_store}, you'll need to use your {platform_account} to update your {pro} access." + } + } + } + }, + "proAccessUpgradeDesktop" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Currently, {pro} access can only be purchased via the {platform_store} or {platform_store_other}. Because you are using {app_name} Desktop, you're not able to upgrade to {pro} here.

{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon}" + } + } + } + }, "proActivated" : { "extractionState" : "manual", "localizations" : { @@ -354678,46 +355383,10 @@ "proAllSetDescription" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_pro} planınız güncəlləndi! Hazırkı {pro} planınız avtomatik olaraq {date} tarixində yeniləndiyi zaman ödəniş haqqı alınacaq." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Váš tarif {app_pro} byl aktualizován! Účtování proběhne při automatickém obnovení vašeho aktuálního tarifu {pro} dne {date}." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your {app_pro} plan was updated! You will be billed when your current {pro} plan is automatically renewed on {date}." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre forfait {app_pro} a été mis à jour ! Vous serez facturé lorsque votre forfait {pro} actuel sera automatiquement renouvelé le {date}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je {app_pro} abonnement is bijgewerkt! Je wordt gefactureerd wanneer je huidige {pro} abonnement automatisch wordt verlengd op {date}." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Twój plan {app_pro} został zaktualizowany! Opłata zostanie pobrana, kiedy Twój obecny plan {pro} odnowi się automatycznie {date}." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Твою підписку {app_pro} оновлено. {date} коли підписку {pro} буде подовжено, тоді й стягнуть гроші." + "value" : "Your {app_pro} access was updated! You will be billed when {pro} is automatically renewed on {date}." } } } @@ -355611,12 +356280,6 @@ "value" : "{pro}, {time} tarixində avto-yenilənir" } }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} se automaticky obnoví za {time}" - } - }, "de" : { "stringUnit" : { "state" : "translated", @@ -356555,130 +357218,10 @@ "proCallToActionPinnedConversationsMoreThan" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "5-dən çoxunu sancmaq istəyirsiniz? {app_pro} ilə söhbətlərinizi təşkil edin və premium özəlliklərin kilidini açın" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vols més de 5 pins? Organitzes els teus xats i desbloqueges les funcions premium amb {app_pro}" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chcete více než 5 připnutí? Organizujte své chaty a odemkněte prémiové funkce pomocí Session Pro" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mehr als 5 Anheftungen gewünscht? Organisiere deine Chats und schalte Premium-Funktionen mit {app_pro} frei" - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Want more than 5 pins? Organize your chats and unlock premium features with {app_pro}" - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "¿Quieres más de 5 conversaciones fijadas? Organiza tus chats y desbloquea funciones premium con {app_pro}" - } - }, - "es-ES" : { - "stringUnit" : { - "state" : "translated", - "value" : "¿Quieres más de 5 conversaciones fijadas? Organiza tus chats y desbloquea funciones premium con {app_pro}" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vous voulez plus que 5 messages épinglés ? Organisez vos chats et débloquez les fonctionnalités premium avec {app_pro}" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "5 से अधिक पिन करना चाहते हैं? अपनी चैट व्यवस्थित करें और {app_pro} के साथ प्रीमियम सुविधाओं का अनलॉक करें" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vuoi più di 5 chat bloccate? Organizza le tue chat e sblocca le funzionalità premium con {app_pro}" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "5件以上ピン留めしたいですか?{app_pro}でチャットを整理して、プレミアム機能を解除しましょう" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wil je meer dan 5 vastgezette gesprekken? Organiseer je chats en ontgrendel premiumfuncties met {app_pro}" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chcesz przypiąć więcej niż 5 czatów? Zorganizuj konwersacje i odblokuj funkcje premium dzięki {app_pro}" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "Quer fixar mais de 5 conversas? Organize os seus chats e desbloqueie funcionalidades premium com {app_pro}" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vrei mai mult de 5 fixări? Organizează-ți conversațiile și deblochează funcționalități premium cu {app_pro}" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Нужно более 5 закреплений? С {app_pro} организуйте свои чаты и получите доступ к премиум функциям" - } - }, - "sv-SE" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vill du ha mer än 5 fästisar? Organisera dina chattar och lås upp premiumfunktioner med {app_pro}" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "5'ten fazla sabitleme mi istiyorsunuz? Sohbetlerinizi düzenleyin ve {app_pro} ile premium özelliklerin kilidini açın" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Потрібно понад 5 закріплених бесід? Впорядкуйте свої бесіди та розблокуйте преміальні функції з {app_pro}" - } - }, - "zh-CN" : { - "stringUnit" : { - "state" : "translated", - "value" : "想要固定超过 5 个对话?使用 {app_pro} 整理你的聊天并解锁高级功能" - } - }, - "zh-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "想要釘選超過 5 則對話嗎?使用 {app_pro} 整理您的聊天並解鎖進階功能" + "value" : "Want more than {limit} pins? Organize your chats and unlock premium features with {app_pro}" } } } @@ -356706,7 +357249,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Canceling your {app_pro} plan will prevent your plan from automatically renewing before your {pro} plan expires. Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your plan expires.

Because you originally signed up for {app_pro} using your {platform_account}, you'll need to use the same {platform_account} to cancel your plan." + "value" : "Canceling {pro} access will prevent automatic renewal from occurring before {pro} access expires. Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your {pro} access expires.

Because you originally signed up for {app_pro} using your {platform_account}, you'll need to use the same {platform_account} to cancel {pro}." } } } @@ -356714,61 +357257,76 @@ "proCancellationOptions" : { "extractionState" : "manual", "localizations" : { - "cs" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Dva způsoby, jak zrušit váš tarif:" + "value" : "Two ways to cancel your {pro} access:" } - }, + } + } + }, + "proCancellationShortDescription" : { + "extractionState" : "manual", + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Two ways to cancel your plan:" + "value" : "Canceling {pro} access will prevent automatic renewal from occurring before {pro} expires.

Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your {pro} access expires." } } } }, - "processingRefundRequest" : { + "proCancelSorry" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "{platform} is processing your refund request" + "value" : "Sorry to see you cancel {pro}. Here's what you need to know before canceling your {pro} access." } } } }, - "proClearAllDataDevice" : { + "processingRefundRequest" : { "extractionState" : "manual", "localizations" : { - "cs" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Opravdu chcete smazat svá data z tohoto zařízení?

{app_pro} nelze převést na jiný účet. Uložte si své heslo pro obnovení, abyste mohli svůj tarif {pro} později obnovit." + "value" : "{platform} is processing your refund request" } - }, + } + } + }, + "proChooseAccess" : { + "extractionState" : "manual", + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Are you sure you want to delete your data from this device?

{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} plan later." + "value" : "Choose the {pro} access option that's right for you.
Longer access means bigger discounts." } } } }, - "proClearAllDataNetwork" : { + "proClearAllDataDevice" : { "extractionState" : "manual", "localizations" : { - "cs" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Opravdu chcete smazat svá data ze sítě? Pokud budete pokračovat, nebudete moci obnovit vaše zprávy ani kontakty.

{app_pro} nelze převést na jiný účet. Uložte si své heslo pro obnovení, abyste mohli svůj tarif {pro} později obnovit." + "value" : "Are you sure you want to delete your data from this device?

{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} access later." } - }, + } + } + }, + "proClearAllDataNetwork" : { + "extractionState" : "manual", + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Are you sure you want to delete your data from the network? If you continue, you will not be able to restore your messages or contacts.

{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} plan later." + "value" : "Are you sure you want to delete your data from the network? If you continue, you will not be able to restore your messages or contacts.

{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} access later." } } } @@ -356776,46 +357334,10 @@ "proDiscountTooltip" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hazırkı planınızda artıq tam {app_pro} qiymətinin {percent}% endirimi mövcuddur." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Váš aktuální tarif je již zlevněn o {percent} % z plné ceny {app_pro}." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your current plan is already discounted by {percent}% of the full {app_pro} price." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre abonnement actuel bénéficie déjà d'une remise de {percent}% sur le prix de {app_pro}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je huidige abonnement is al met {percent}% korting ten opzichte van de volledige {app_pro} prijs." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cena Twojego obecnego planu jest obniżona o {percent}% pełnej ceny {app_pro}." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "На поточну підписку ти вже маєш знижку {percent}% від загальної ціни {app_pro}." + "value" : "Your {pro} access is already discounted by {percent}% of the full {app_pro} price." } } } @@ -356893,46 +357415,10 @@ "proExpiredDescription" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Təəssüf ki, {pro} planınızın müddəti bitib. {app_pro} tətbiqinin eksklüziv imtiyazlarına və özəlliklərinə erişimi davam etdirmək üçün yeniləyin." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bohužel, váš tarif {pro} vypršel. Obnovte jej, abyste nadále měli přístup k exkluzivním výhodám a funkcím {app_pro}." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Unfortunately, your {pro} plan has expired. Renew to keep accessing the exclusive perks and features of {app_pro}." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Malheureusement, votre formule {pro} a expiré. Renouvelez-la pour continuer à profiter des avantages et fonctionnalités exclusifs de {app_pro}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Helaas is je {pro} abonnement verlopen. Verleng om toegang te blijven houden tot de exclusieve voordelen en functies van {app_pro}." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Niestety, Twój plan {pro} wygasł. Odnów go, by odzyskać dostęp do ekskluzywnych korzyści i funkcji {app_pro}." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "На жаль, підписка {pro} сплила. Онови її задля збереження переваг і можливостей {app_pro}." + "value" : "Unfortunately, your {pro} access has expired. Renew to reactivate the exclusive perks and features of {app_pro}." } } } @@ -356987,46 +357473,10 @@ "proExpiringSoonDescription" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} planınızın müddəti {time} vaxtında bitir. {app_pro} tətbiqinin eksklüziv imtiyazlarına və özəlliklərinə erişimi davam etdirmək üçün planınızı güncəlləyin." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Váš tarif {pro} vyprší za {time}. Aktualizujte si tarif, abyste i nadále měli přístup k exkluzivním výhodám a funkcím {app_pro}." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your {pro} plan is expiring in {time}. Update your plan to keep accessing the exclusive perks and features of {app_pro}." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre formule {pro} expire dans {time}. Mettez à jour votre formule pour continuer à profiter des avantages et fonctionnalités exclusifs de {app_pro}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je {pro} abonnement verloopt over {time}. Werk je abonnement bij om toegang te blijven houden tot de exclusieve voordelen en functies van {app_pro}." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Twój plan {pro} wygasa za {time}. Zaktualizuj swój plan, aby zachować dostęp do ekskluzywnych korzyści i funkcji {app_pro}." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Підписка {pro} спливе {time}. Онови підписку задля збереження переваг і можливостей {app_pro}." + "value" : "Your {pro} access is expiring in {time}. Update now to keep accessing the exclusive perks and features of {app_pro}." } } } @@ -360713,6 +361163,17 @@ } } }, + "proFullestPotential" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Want to use {app_name} to its fullest potential?
Upgrade to {app_pro} Beta to get access to loads of exclusive perks and features." + } + } + } + }, "proGroupActivated" : { "extractionState" : "manual", "localizations" : { @@ -361191,46 +361652,10 @@ "proImportantDescription" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Geri ödəniş tələbi qətidir. Əgər təsdiqlənsə, {pro} planınız dərhal ləğv ediləcək və bütün {pro} özəlliklərinə erişimi itirəcəksiniz." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Žádost o vrácení peněz je konečné. Pokud bude schváleno, váš tarif {pro} bude ihned zrušen a ztratíte přístup ke všem funkcím {pro}." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Requesting a refund is final. If approved, your {pro} plan will be canceled immediately and you will lose access to all {pro} features." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "La demande de remboursement est définitive. Si elle est approuvée, votre formule {pro} sera immédiatement annulée et vous perdrez l'accès à toutes les fonctionnalités {pro}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Het aanvragen van een terugbetaling is definitief. Indien goedgekeurd, wordt je {pro} abonnement onmiddellijk geannuleerd en verlies je de toegang tot alle {pro} functies." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wniosek o zwrot jest ostateczny. Jeżeli zostanie zatwierdzony, Twój plan {pro} zostanie natychmiast anulowany i utracisz dostęp do wszystkich funkcji {pro}." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Вимагання повернення грошей закінчено. В разі схвалення твою підписку {pro} негайно скасують і ти втратиш всі можливості {pro}." + "value" : "Requesting a refund is final. If approved, your {pro} access will be canceled immediately and you will lose access to all {pro} features." } } } @@ -362513,6 +362938,50 @@ } } }, + "promoteAdminsWarning" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Admins will be able to see the last 14 days of message history and cannot be demoted or removed from the group." + } + } + } + }, + "promoteMember" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Promote Member" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Promote Members" + } + } + } + } + } + } + } + } + }, "promotionFailed" : { "extractionState" : "manual", "localizations" : { @@ -364312,7 +364781,18 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Reinstall {app_name} on this device via the {platform_store}, restore your account with your Recovery Password, and renew your plan in the {app_pro} settings." + "value" : "Reinstall {app_name} on this device via the {platform_store}, restore your account with your Recovery Password, and renew {pro} from the {app_pro} settings." + } + } + } + }, + "proNewInstallationUpgrade" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reinstall {app_name} on this device via the {platform_store}, restore your account with your Recovery Password, and upgrade to {pro} from the {app_pro} settings." } } } @@ -364320,16 +364800,21 @@ "proOptionsRenewalSubtitle" : { "extractionState" : "manual", "localizations" : { - "cs" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Nyní jsou k dispozici tři způsoby obnovy:" + "value" : "For now, there are three ways to renew:" } - }, + } + } + }, + "proOptionsTwoRenewalSubtitle" : { + "extractionState" : "manual", + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Currently, there are three ways to renew:" + "value" : "For now, there are two ways to renew:" } } } @@ -364600,404 +365085,13 @@ } } }, - "proPlanActivatedAuto" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_pro} planınız aktivdir!

Planınız avtomatik olaraq {date} tarixində başqa bir {current_plan} üçün yenilənəcək. Planınıza edilən güncəlləmələr növbəti {pro} yenilənməsi zamanı qüvvəyə minəcək." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Váš tarif {app_pro} je aktivní!

Váš tarif se automaticky obnoví na další {current_plan} dne {date}. Změny tarifu se projeví při příštím obnovení {pro}." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Your {app_pro} plan is active!

Your plan will automatically renew for another {current_plan} on {date}. Updates to your plan take effect when {pro} is next renewed." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre formule {app_pro} est active !

Votre formule sera automatiquement renouvelée pour une autre {current_plan} le {date}. Les modifications apportées à votre formule prendront effet lors du prochain renouvellement de {pro}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je {app_pro} abonnement is actief!

Je abonnement wordt automatisch verlengd voor een nieuw {current_plan} op {date}. Wijzigingen aan je abonnement gaan in wanneer {pro} de volgende keer wordt verlengd." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Twój plan {app_pro} jest aktywny!

Zostanie on automatycznie odnowiony na kolejny {current_plan}, dnia {date}. Zmiany w Twoim planie wejdą w życie przy następnym odnowieniu subskrypcji {pro}." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Для тебе діє підписка {app_pro}.

{date} твою підписку буде самодійно поновлено як {current_plan}. Оновлення підписки настане під час наступного оновлення {pro}." - } - } - } - }, - "proPlanActivatedAutoShort" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_pro} planınız aktivdir!

Planınız avtomatik olaraq {date} tarixində başqa bir {current_plan} üçün yenilənəcək." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Váš tarif {app_pro} je aktivní!

Tarif se automaticky obnoví na další {current_plan} dne {date}." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Your {app_pro} plan is active!

Your plan will automatically renew for another {current_plan} on {date}." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre forfait {app_pro} est actif

Votre abonnement se renouvellera automatiquement pour un autre {current_plan} le {date}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je {app_pro} abonnement is actief!

Je abonnement wordt automatisch verlengd met een {current_plan} op {date}." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Twój plan {app_pro} jest aktywny!

Zostanie on automatycznie odnowiony na kolejny {current_plan}, dnia {date}." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Для тебе діє підписка {app_pro}.

{date} твою підписку буде самодійно поновлено як {current_plan}." - } - } - } - }, - "proPlanActivatedNotAuto" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_pro} planınızın müddəti {date} tarixində bitir.

Eksklüziv Pro özəlliklərinə kəsintisiz erişimi təmin etmək üçün planınızı indi güncəlləyin." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Váš tarif {app_pro} vyprší dne {date}.

Aktualizujte si tarif nyní, abyste měli i nadále nepřerušený přístup k exkluzivním funkcím Pro." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Your {app_pro} plan will expire on {date}.

Update your plan now to ensure uninterrupted access to exclusive Pro features." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre offre {app_pro} expirera le {date}.

Mettez votre offre à jour maintenant pour garantir un accès ininterrompu aux fonctionnalités exclusives Pro." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je {app_pro} abonnement verloopt op {date}.

Werk je abonnement nu bij om ononderbroken toegang te behouden tot exclusieve Pro functies." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Twój plan {app_pro} wygasa {date}.

Zaktualizuj swój plan już teraz, by zapewnić sobie nieprzerwany dostęp do ekskluzywnych funkcji Pro." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Твоя підписка {app_pro} спливе {date}.

Для збереження особливих можливостей подовж свою підписку." - } - } - } - }, - "proPlanError" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} planı xətası" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chyba tarifu {pro}" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} Plan Error" - } - } - } - }, - "proPlanExpireDate" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_pro} planınızın müddəti {date} tarixində bitir." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Váš tarif {app_pro} vyprší dne {date}." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Your {app_pro} plan will expire on {date}." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre forfait {app_pro} expirera le {date}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je {app_pro} abonnement verloopt op {date}." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Twój plan {app_pro} wygasa {date}." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Підписка {app_pro} спливе {date}." - } - } - } - }, - "proPlanLoading" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} planı yüklənir" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Načítání tarifu {pro}" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} Plan Loading" - } - } - } - }, - "proPlanLoadingDescription" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} planınız barədə məlumatlar hələ də yüklənir. Bu proses tamamlanana qədər planınızı güncəlləyə bilməzsiniz." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Váš tarif {pro} se stále načítá. Dokud nebude načítání dokončeno, nemůžete tarif změnit." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Information about your {pro} plan is still being loaded. You cannot update your plan until this process is complete." - } - } - } - }, - "proPlanLoadingEllipsis" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} planı yüklənir..." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Načítání tarifu {pro}..." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} plan loading..." - } - } - } - }, - "proPlanNetworkLoadError" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hazırkı planınızı yükləmək üçün şəbəkəyə bağlana bilmir. Bağlantı bərpa olunana qədər {app_name} ilə planınızı güncəlləmək sıradan çıxarılacaq.

Lütfən şəbəkə bağlantınızı yoxlayıb yenidən sınayın." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nelze se připojit k síti a načíst váš aktuální tarif. Aktualizace tarifu prostřednictvím {app_name} bude deaktivována, dokud nebude obnoveno připojení.

Zkontrolujte připojení k síti a zkuste to znovu." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unable to connect to the network to load your current plan. Updating your plan via {app_name} will be disabled until connectivity is restored.

Please check your network connection and retry." - } - } - } - }, - "proPlanNotFound" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} planı tapılmadı" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} nebyl nalezen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} Plan Not Found" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Forfait {pro} introuvable" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} abonnement niet gevonden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nie znaleziono planu {pro}" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Передплата {pro} не знайдена" - } - } - } - }, - "proPlanNotFoundDescription" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hesabınız üçün heç bir aktiv plan tapılmadı. Bunun bir səhv olduğunu düşünürsünüzsə, lütfən kömək üçün {app_name} dəstəyi ilə əlaqə saxlayın." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pro váš účet nebyl nalezen žádný aktivní tarif. Pokud si myslíte, že se jedná o chybu, kontaktujte podporu {app_name} a požádejte o pomoc." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No active plan was found for your account. If you believe this is a mistake, please reach out to {app_name} support for assistance." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aucun forfait actif n’a été trouvé pour votre compte. Si vous pensez qu’il s’agit d’une erreur, veuillez contacter l’assistance de {app_name} pour obtenir de l’aide." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Er is geen actief abonnement gevonden voor je account. Als je denkt dat dit een vergissing is, neem dan contact op met de ondersteuning van {app_name} voor hulp." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nie znaleziono aktywnych planów dla Twojego konta. Jeżeli uważasz, że to błąd, prosimy o kontakt z Supportem {app_name}." - } - } - } - }, "proPlanPlatformRefund" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Because you originally signed up for {app_pro} via the {platform_store}, you'll need to use the same {platform_account} to request a refund." + "value" : "Because you originally signed up for {app_pro} via the {platform_store}, you'll need to use your {platform_account} to request a refund." } } } @@ -365013,327 +365107,13 @@ } } }, - "proPlanRecover" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} planını geri qaytar" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Znovu nabýt tarif {pro}" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Recover {pro} Plan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Récupérer le forfait {pro}" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} abonnement herstellen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odzyskaj plan {pro}" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Відновити передплату {pro}" - } - } - } - }, - "proPlanRenew" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} planını yenilə" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Obnovit tarif {pro}" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Renew {pro} Plan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Renouveler l’abonnement {pro}" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} abonnement verlengen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odnów plan {pro}" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Оновити підписку {pro}" - } - } - } - }, - "proPlanRenewDesktop" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store}. Because you are using {app_name} Desktop, you're not able to renew your plan here.

{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store}. {pro} Roadmap {icon}" - } - } - } - }, - "proPlanRenewDesktopLinked" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Renew your plan in the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store}." - } - } - } - }, - "proPlanRenewPlatformStoreWebsite" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Renew your plan on the {platform_store} website using the {platform_account} you signed up for {pro} with." - } - } - } - }, - "proPlanRenewPlatformWebsite" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Renew your plan on the {platform} website using the {platform_account} you signed up for {pro} with." - } - } - } - }, - "proPlanRenewStart" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Güclü {app_pro} özəlliklərini yenidən istifadə etməyə başlamaq üçün {app_pro} planınızı yeniləyin." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Obnovte váš tarif {app_pro}, abyste mohli znovu využívat užitečné funkce {app_pro}." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Renew your {app_pro} plan to start using powerful {app_pro} Beta features again." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Renouvelez votre abonnement {app_pro} pour recommencer à utiliser les puissantes fonctionnalités bêta de {app_pro}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verleng je {app_pro} abonnement om opnieuw gebruik te maken van krachtige {app_pro} functies." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odnów swój plan {app_pro}, aby znów używać potężnych funkcji {app_pro} Beta." - } - } - } - }, "proPlanRenewSupport" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_pro} planınız yeniləndi! {network_name} dəstək verdiyiniz üçün təşəkkürlər." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Váš tarif {app_pro} byl obnoven! Děkujeme, že podporujete síť {network_name}." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Your {app_pro} plan has been renewed! Thank you for supporting the {network_name}." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre forfait {app_pro} a été renouvelé ! Merci de soutenir le {network_name}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je {app_pro} abonnement is verlengd! Bedankt voor je steun aan de {network_name}." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Twój plan {app_pro} został odnowiony! Dziękujemy za wspieranie {network_name}." - } - } - } - }, - "proPlanRestored" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} planı bərpa edildi" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} obnoven" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} Plan Restored" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} Forfait rétabli" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} abonnement hersteld" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Plan {pro} został odzyskany" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "План {pro} відновлено" - } - } - } - }, - "proPlanRestoredDescription" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_pro} üçün yararlı bir plan aşkarlandı və {pro} statusunuz bərpa edildi!" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Byl rozpoznán platný tarif {app_pro} a váš stav {pro} byl obnoven!" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "A valid plan for {app_pro} was detected and your {pro} status has been restored!" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Un abonnement valide pour {app_pro} a été détecté et votre statut {pro} a été restauré !" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Een geldig abonnement voor {app_pro} is gedetecteerd en je {pro} status is hersteld!" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wykryto ważny plan {app_pro} oraz przywrócono Twój status {pro}!" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Виявлено дійсний план {app_pro}, та ваш статус {pro} було відновлено!" - } - } - } - }, - "proPlanSignUp" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Because you originally signed up for {app_pro} via the {platform_store}, you'll need to use your {platform_account} to update your plan." + "value" : "Your {app_pro} access has been renewed! Thank you for supporting the {network_name}." } } } @@ -365590,7 +365370,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Refunds for {app_pro} plans are handled exclusively by {platform} through the {platform_store}.

Due to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued." + "value" : "Refunds for {app_pro} are handled exclusively by {platform} through the {platform_store}.

Due to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued." } } } @@ -365663,15 +365443,31 @@ } } }, - "proRenewBeta" : { + "proRenewalUnsuccessful" : { "extractionState" : "manual", "localizations" : { - "cs" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Obnovit {pro} Beta" + "value" : "Pro renewal unsuccessful, retrying soon" } - }, + } + } + }, + "proRenewAnimatedDisplayPicture" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Want to use animated display pictures again?
Renew your {pro} access to unlock the features you’ve been missing out on." + } + } + } + }, + "proRenewBeta" : { + "extractionState" : "manual", + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", @@ -365680,13 +365476,79 @@ } } }, + "proRenewDesktopLinked" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renew your {pro} access from the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store_other}." + } + } + } + }, "proRenewingNoAccessBilling" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store}. Because you installed {app_name} using the {buildVariant}, you're not able to renew your plan here.

{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store}. {pro} Roadmap {icon}" + "value" : "Currently, {pro} access can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you're not able to renew here.

{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon}" + } + } + } + }, + "proRenewLongerMessages" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Want to send longer messages again?
Renew your {pro} access to unlock the features you’ve been missing out on." + } + } + } + }, + "proRenewMaxPotential" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Want to use {app_name} to its max potential again?
Renew your {pro} access to unlock the features you’ve been missing out on." + } + } + } + }, + "proRenewPinFiveConversations" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Want to pin more than 5 conversations again?
Renew your {pro} access to unlock the features you’ve been missing out on." + } + } + } + }, + "proRenewPinMoreConversations" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Want to pin more conversations again?
Renew your {pro} access to unlock the features you’ve been missing out on." + } + } + } + }, + "proRenewTosPrivacy" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "By renewing, you agree to the {app_pro} Terms of Service {icon} and Privacy Policy {icon}" } } } @@ -365922,6 +365784,17 @@ } } }, + "proStartUsing" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start Using {pro}" + } + } + } + }, "proStats" : { "extractionState" : "manual", "localizations" : { @@ -366183,6 +366056,17 @@ } } }, + "proStatusNetworkErrorContinue" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to connect to the network to check your {pro} status. You cannot continue until connectivity is restored.

Please check your network connection and retry." + } + } + } + }, "proStatusNetworkErrorDescription" : { "extractionState" : "manual", "localizations" : { @@ -366229,49 +366113,24 @@ } } }, - "proSupportDescription" : { + "proStatusRenewError" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} planınızla bağlı kömək lazımdır? Dəstək komandamıza müraciət edin." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Potřebujete pomoc se svým tarifem {pro}? Pošlete žádost týmu podpory." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Need help with your {pro} plan? Submit a request to the support team." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Besoin d'aide avec votre forfait {pro} ? Envoyez une demande à l'équipe d'assistance." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hulp nodig met je {pro} abonnement? Dien een verzoek in bij het ondersteuningsteam." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Potrzebujesz pomocy z planem {pro}? Wyślij zgłoszenie zespołowi wsparcia." + "value" : "Unable to connect to the network to load your current {pro} access. Renewing {pro} via {app_name} will be disabled until connectivity is restored.

Please check your network connection and retry." } - }, - "uk" : { + } + } + }, + "proSupportDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Якщо потребуєш допомоги щодо підписки {pro}, надійшли звернення до відділу підтримки." + "value" : "Need help with {pro}? Submit a request to the support team." } } } @@ -366423,78 +366282,112 @@ } } }, - "proUpdatePlanDescription" : { + "proUpdateAccessDescription" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hazırda {current_plan} Planı üzərindəsiniz. {selected_plan} Planınana keçmək istədiyinizə əminsiniz?

Güncəlləsəniz, planınız {date} tarixində əlavə {selected_plan} {pro} erişimi üçün avtomatik yenilənəcək." - } - }, - "cs" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "V současné době jste na tarifu {current_plan}. Jste si jisti, že chcete přepnout na tarif {selected_plan}?

Po aktualizaci bude váš tarif automaticky obnoven {date} na další {selected_plan} přístupu {pro}." + "value" : "Your current billing option grants {current_plan_length} of {pro} access. Are you sure you want to switch to the {selected_plan_length_singular} billing option?

By updating, your {pro} access will automatically renew on {date} for an additional {selected_plan_length} of {pro} access." } - }, + } + } + }, + "proUpdateAccessExpireDescription" : { + "extractionState" : "manual", + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "You are currently on the {current_plan} Plan. Are you sure you want to switch to the {selected_plan} Plan?

By updating, your plan will automatically renew on {date} for an additional {selected_plan} of {pro} access." + "value" : "Your {pro} access will expire on {date}.

By updating, your {pro} access will automatically renew on {date} for an additional {selected_plan_length} of {pro} access." } - }, - "fr" : { + } + } + }, + "proUpgradeAccess" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Vous êtes actuellement sur le forfait {current_plan}. Voulez-vous vraiment passer au forfait {selected_plan}?

En mettant à jour, votre forfait sera automatiquement renouvelé le {date} pour {selected_plan} supplémentaire de l'accès {pro}." + "value" : "Upgrade to {app_pro} Beta to get access to loads of exclusive perks and features." } - }, - "nl" : { + } + } + }, + "proUpgraded" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Je zit momenteel op het {current_plan} abonnement. Weet je zeker dat je wilt overschakelen naar het {selected_plan} abonnement?

Als je dit bijwerkt, wordt je abonnement op {date} automatisch verlengd met {selected_plan} {pro} toegang." + "value" : "You have upgraded to {app_pro}!
Thank you for supporting the {network_name}." } } } }, - "proUpdatePlanExpireDescription" : { + "proUpgradeDesktopLinked" : { "extractionState" : "manual", "localizations" : { - "az" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Planınız {date} tarixində bitəcək.

Güncəlləsəniz, planınız {date} tarixində əlavə {selected_plan} Pro erişimi üçün avtomatik olaraq yenilənəcək." + "value" : "Upgrade to {pro} from the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store_other}." } - }, - "cs" : { + } + } + }, + "proUpgradeNoAccessBilling" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Váš tarif vyprší {date}.

Po aktualizaci se váš tarif automaticky obnoví {date} na další {selected_plan} přístupu Pro." + "value" : "Currently, {pro} access can only be purchased via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you're not able to upgrade to {pro} here.

{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon}" } - }, + } + } + }, + "proUpgradeOption" : { + "extractionState" : "manual", + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your plan will expire on {date}.

By updating, your plan will automatically renew on {date} for an additional {selected_plan} of Pro access." + "value" : "For now, there is only one way to upgrade:" } - }, - "fr" : { + } + } + }, + "proUpgradeOptionsTwo" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Votre forfait expirera le {date}.

En le mettant à jour, votre forfait sera automatiquement renouvelé le {date} pour {selected_plan} supplémentaires d’accès Pro." + "value" : "For now, there are two ways to upgrade:" } - }, - "nl" : { + } + } + }, + "proUpgradingTo" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Je abonnement verloopt op {date}.

Door bij te werken wordt je abonnement automatisch verlengd op {date} voor een extra {selected_plan} aan Pro-toegang." + "value" : "Upgrading to {pro}" } - }, - "uk" : { + } + } + }, + "proUpgradingTosPrivacy" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ваш план завершиться {date}.

Після оновлення налаштувань автоматичної оплати, ваш план автоматично подовжиться {date} на додатковий {selected_plan} доступу до Pro." + "value" : "By upgrading, you agree to the {app_pro} Terms of Service {icon} and Privacy Policy {icon}" } } } @@ -381602,13 +381495,13 @@ } } }, - "refundPlanNonOriginatorApple" : { + "refundNonOriginatorApple" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Because you originally signed up for {app_pro} via a different {platform_account}, you'll need to use that {platform_account} to update your plan." + "value" : "Because you originally signed up for {app_pro} via a different {platform_account}, you'll need to use that {platform_account} to update your {pro} access." } } } @@ -382258,6 +382151,17 @@ } } }, + "remindMeLater" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remind Me Later" + } + } + } + }, "remove" : { "extractionState" : "manual", "localizations" : { @@ -382737,6 +382641,72 @@ } } }, + "removeMember" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove Member" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove Members" + } + } + } + } + } + } + } + } + }, + "removeMemberMessages" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove member and their messages" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove member and their messages" + } + } + } + } + } + } + } + } + }, "removePasswordFail" : { "extractionState" : "manual", "localizations" : { @@ -383281,19 +383251,46 @@ } } }, - "renew" : { + "removingMember" : { "extractionState" : "manual", "localizations" : { - "az" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Yenilə" + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Removing member" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Removing members" + } + } + } + } + } } - }, - "cs" : { + } + } + }, + "renew" : { + "extractionState" : "manual", + "localizations" : { + "az" : { "stringUnit" : { "state" : "translated", - "value" : "Obnovit" + "value" : "Yenilə" } }, "en" : { @@ -383325,16 +383322,10 @@ "renewingPro" : { "extractionState" : "manual", "localizations" : { - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Obnovení Pro" - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Renewing Pro" + "value" : "Renewing {pro}" } } } @@ -384361,6 +384352,138 @@ } } }, + "resendingInvite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resending invite" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resending invites" + } + } + } + } + } + } + } + } + }, + "resendingPromotion" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resending promotion" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resending promotions" + } + } + } + } + } + } + } + } + }, + "resendInvite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resend Invite" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resend Invites" + } + } + } + } + } + } + } + } + }, + "resendPromotion" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resend Promotion" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resend Promotions" + } + } + } + } + } + } + } + } + }, "resolving" : { "extractionState" : "manual", "localizations" : { @@ -418356,6 +418479,39 @@ } } }, + "update" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update" + } + } + } + }, + "updateAccess" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update {pro} Access" + } + } + } + }, + "updateAccessTwo" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Two ways to update your {pro} access:" + } + } + } + }, "updateApp" : { "extractionState" : "manual", "localizations" : { @@ -422583,100 +422739,6 @@ } } }, - "updatePlan" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Planı güncəllə" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aktualizovat tarif" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Update Plan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mettre à jour le forfait" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abonnement bijwerken" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zaktualizuj plan" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Оновити тарифний план" - } - } - } - }, - "updatePlanTwo" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Planınızı güncəlləməyin iki yolu var:" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dva způsoby, jak aktualizovat váš tarif:" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Two ways to update your plan:" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Deux façons de mettre à jour votre abonnement :" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Twee manieren om je abonnement bij te werken:" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dwa sposoby na aktualizację planu:" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Два шляхи поновлення твоєї підписки:" - } - } - } - }, "updateProfileInformation" : { "extractionState" : "manual", "localizations" : { @@ -424404,6 +424466,17 @@ } } }, + "upgrade" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upgrade" + } + } + } + }, "upgradeSession" : { "extractionState" : "manual", "localizations" : { @@ -427479,48 +427552,35 @@ } } }, - "viaStoreWebsite" : { + "viaPlatformWebsiteDescription" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Via the {platform} website" + "value" : "Change your plan using the {platform_account} you used to sign up with, via the {platform} website ." } } } }, - "viaStoreWebsiteDescription" : { + "viaStoreWebsite" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Qeydiyyatdan keçərkən istifadə etdiyiniz {platform_account} hesabı ilə {platform_store} veb saytı üzərindən planınızı dəyişdirin." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Změňte svůj tarif pomocí {platform_account}, se kterým jste se zaregistrovali, prostřednictvím webu {platform_store}." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Change your plan using the {platform_account} you used to sign up with, via the {platform_store} website." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Modifiez votre abonnement en utilisant le compte {platform_account} avec lequel vous vous êtes inscrit, via le site web de {platform_store}." + "value" : "Via the {platform} website" } - }, - "nl" : { + } + } + }, + "viaStoreWebsiteDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Wijzig je abonnement met het {platform_account} waarmee je je hebt aangemeld, via de {platform_store} website." + "value" : "Update your {pro} access using the {platform_account} you used to sign up with, via the {platform_store} website." } } } diff --git a/Session/Meta/WebPImages/GroupAdminCTA.webp b/Session/Meta/WebPImages/GroupAdminCTA.webp new file mode 100644 index 0000000000..7b78b71ad8 Binary files /dev/null and b/Session/Meta/WebPImages/GroupAdminCTA.webp differ diff --git a/Session/Meta/WebPImages/GroupNonAdminCTA.webp b/Session/Meta/WebPImages/GroupNonAdminCTA.webp new file mode 100644 index 0000000000..aabe43ff1d Binary files /dev/null and b/Session/Meta/WebPImages/GroupNonAdminCTA.webp differ diff --git a/Session/Notifications/NotificationPresenter.swift b/Session/Notifications/NotificationPresenter.swift index c335c898bd..b60ed79b92 100644 --- a/Session/Notifications/NotificationPresenter.swift +++ b/Session/Notifications/NotificationPresenter.swift @@ -230,6 +230,7 @@ public class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate, threadVariant: threadVariant, identifier: threadId, category: .errorMessage, + groupingIdentifier: .threadId(threadId), body: "messageErrorDelivery".localized(), sound: notificationSettings.sound, userInfo: notificationUserInfo(threadId: threadId, threadVariant: threadVariant), @@ -339,12 +340,12 @@ public class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate, switch shouldPresentNotification { case true: - let shouldGroupNotification: Bool = ( + let shouldDelayNotificationForBatching: Bool = ( content.threadVariant == .community && content.identifier == content.threadId ) - - if shouldGroupNotification { + + if shouldDelayNotificationForBatching { /// Only set a trigger for grouped notifications if we don't already have one if trigger == nil { trigger = UNTimeIntervalNotificationTrigger( diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 5dda7496ea..1ad6a5d528 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -135,8 +135,13 @@ extension Onboarding { 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 + /// Retrieve the users `displayName` from `libSession` (the source of truth - if the `ed25519SecretKey` is + /// empty then we don't have an account yet so don't want to try to access the invalid `libSession` cache) + let displayName: String = (ed25519SecretKey.isEmpty ? + "" : + dependencies.mutate(cache: .libSession) { $0.profile }.name + ) + let hasInitialDisplayName: Bool = !displayName.isEmpty self.ed25519KeyPair = ed25519KeyPair @@ -370,17 +375,8 @@ extension Onboarding { 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 + let cachedProfile: Profile = 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 @@ -395,29 +391,31 @@ extension Onboarding { ) } - /// Update the `displayName` and trigger a dump/push of the config - try? cache.performAndPushChange(db, for: .userProfile) { - try? cache.updateProfile(displayName: displayName) - } + return cache.profile } - /// 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.profileLastUpdated.set(to: nil)) - try Profile.updateIfNeeded( - db, - publicKey: userSessionId.hexString, - displayNameUpdate: .currentUserUpdate(displayName), - displayPictureUpdate: .none, - profileUpdateTimestamp: dependencies.dateNow.timeIntervalSince1970, - using: dependencies - ) + /// If we don't have the `Note to Self` thread then create it (not visible by default) + if (try? SessionThread.exists(db, id: userSessionId.hexString)) != nil { + try SessionThread.upsert( + db, + id: userSessionId.hexString, + variant: .contact, + values: SessionThread.TargetValues(shouldBeVisible: .setTo(false)), + using: dependencies + ) + } + + /// Update the `displayName` if changed + if cachedProfile.name != displayName { + try Profile.updateIfNeeded( + db, + publicKey: userSessionId.hexString, + displayNameUpdate: .currentUserUpdate(displayName), + displayPictureUpdate: .none, + profileUpdateTimestamp: 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) diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index 0c5fa3c2cc..866b868495 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -82,10 +82,8 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC // presentation type is `fullScreen` var navBarHeight: CGFloat { switch modalPresentationStyle { - case .fullScreen: - return navigationController?.navigationBar.frame.size.height ?? 0 - default: - return 0 + case .fullScreen: return (navigationController?.navigationBar.frame.size.height ?? 0) + default: return 0 } } @@ -115,7 +113,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC pageVCView.pin(.bottom, to: .bottom, of: view) let statusBarHeight: CGFloat = UIApplication.shared.statusBarFrame.size.height - let height: CGFloat = ((navigationController?.view.bounds.height ?? 0) - navBarHeight - TabBar.snHeight - statusBarHeight) + let height: CGFloat = ((navigationController?.view.bounds.height ?? 0) - (navigationController?.navigationBar.frame.size.height ?? 0) - TabBar.snHeight - statusBarHeight) let size: CGSize = CGSize(width: UIScreen.main.bounds.width, height: height) enterURLVC.constrainSize(to: size) scanQRCodePlaceholderVC.constrainSize(to: size) @@ -339,7 +337,9 @@ private final class EnterURLVC: UIViewController, UIGestureRecognizerDelegate, O private var keyboardTransitionSnapshot2: UIView? private lazy var urlTextView: SNTextView = { - let result: SNTextView = SNTextView(placeholder: "communityEnterUrl".localized()) + let result: SNTextView = SNTextView(placeholder: "communityEnterUrl".localized()) { [weak self] text in + self?.joinButton.isEnabled = !text.isEmpty + } result.keyboardType = .URL result.autocapitalizationType = .none result.autocorrectionType = .no @@ -347,6 +347,15 @@ private final class EnterURLVC: UIViewController, UIGestureRecognizerDelegate, O return result }() + private lazy var joinButton: UIButton = { + let result: SessionButton = SessionButton(style: .bordered, size: .large) + result.setTitle("join".localized(), for: UIControl.State.normal) + result.addTarget(self, action: #selector(joinOpenGroup), for: .touchUpInside) + result.isEnabled = false + + return result + }() + private lazy var suggestionGridTitleLabel: UILabel = { let result: UILabel = UILabel() result.setContentHuggingPriority(.required, for: .vertical) @@ -393,10 +402,6 @@ private final class EnterURLVC: UIViewController, UIGestureRecognizerDelegate, O view.themeBackgroundColor = .clear // Next button - let joinButton = SessionButton(style: .bordered, size: .large) - joinButton.setTitle("join".localized(), for: UIControl.State.normal) - joinButton.addTarget(self, action: #selector(joinOpenGroup), for: UIControl.Event.touchUpInside) - let joinButtonContainer = UIView( wrapping: joinButton, withInsets: UIEdgeInsets(top: 0, leading: 80, bottom: 0, trailing: 80), diff --git a/Session/Settings/BlockedContactsViewModel.swift b/Session/Settings/BlockedContactsViewModel.swift index 48ef8d91d4..95d766688f 100644 --- a/Session/Settings/BlockedContactsViewModel.swift +++ b/Session/Settings/BlockedContactsViewModel.swift @@ -297,9 +297,13 @@ public class BlockedContactsViewModel: SessionTableViewModel, NavigatableStateHo SessionCell.Info( id: model, leadingAccessory: .profile(id: model.id, profile: model.profile), - title: ( - model.profile?.displayName() ?? - model.id.truncated() + title: SessionCell.TextInfo( + (model.profile?.displayName() ?? model.id.truncated()), + font: .title, + trailingImage: { + guard (viewModel.dependencies.mutate(cache: .libSession) { $0.validateProProof(for: model.profile) }) else { return nil } + return ("ProBadge", { [dependencies = viewModel.dependencies] in SessionProBadge(size: .small).toImage(using: dependencies) }) + }() ), trailingAccessory: .radio( isSelected: state.selectedIds.contains(model.id) diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsFileServerViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsFileServerViewModel.swift new file mode 100644 index 0000000000..bacb638a73 --- /dev/null +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsFileServerViewModel.swift @@ -0,0 +1,623 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import Combine +import GRDB +import DifferenceKit +import SessionUIKit +import SessionNetworkingKit +import SessionMessagingKit +import SessionUtilitiesKit + +class DeveloperSettingsFileServerViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource { + public let dependencies: Dependencies + public let navigatableState: NavigatableState = NavigatableState() + public let state: TableDataState = TableDataState() + public let observableState: ObservableTableSourceState = ObservableTableSourceState() + + private var shareDownloadedFileUrl: String? + private var shareDownloadedFileMimetype: String? + private var updatedCustomServerUrl: String? + private var updatedCustomServerPubkey: String? + + /// This value is the current state of the view + @MainActor @Published private(set) var internalState: State + private var observationTask: Task? + + // MARK: - Initialization + + @MainActor init(using dependencies: Dependencies) { + self.dependencies = dependencies + self.internalState = State.initialState(using: dependencies) + + /// Bind the state + self.observationTask = ObservationBuilder + .initialValue(self.internalState) + .debounce(for: .never) + .using(dependencies: dependencies) + .query(DeveloperSettingsFileServerViewModel.queryState) + .assign { [weak self] updatedState in + guard let self = self else { return } + + // FIXME: To slightly reduce the size of the changes this new observation mechanism is currently wired into the old SessionTableViewController observation mechanism, we should refactor it so everything uses the new mechanism + let oldState: State = self.internalState + self.internalState = updatedState + self.pendingTableDataSubject.send(updatedState.sections(viewModel: self, previousState: oldState)) + } + } + + // MARK: - Config + + public enum Section: SessionTableSection { + case general + + var title: String? { + switch self { + case .general: return nil + } + } + + var style: SessionTableSectionStyle { + switch self { + case .general: return .padding + } + } + } + + public enum TableItem: Hashable, Differentiable, CaseIterable { + case shortenFileTTL + case deterministicAttachmentEncryption + case shareDownloadedFile + case customFileServerUrl + case customFileServerPubkey + + // MARK: - Conformance + + public typealias DifferenceIdentifier = String + + public var differenceIdentifier: String { + switch self { + case .shortenFileTTL: return "shortenFileTTL" + case .deterministicAttachmentEncryption: return "deterministicAttachmentEncryption" + case .shareDownloadedFile: return "shareDownloadedFile" + case .customFileServerUrl: return "customFileServerUrl" + case .customFileServerPubkey: return "customFileServerPubkey" + } + } + + public func isContentEqual(to source: TableItem) -> Bool { + self.differenceIdentifier == source.differenceIdentifier + } + + public static var allCases: [TableItem] { + var result: [TableItem] = [] + switch TableItem.shortenFileTTL { + case .shortenFileTTL: result.append(.shortenFileTTL); fallthrough + case .deterministicAttachmentEncryption: result.append(.deterministicAttachmentEncryption); fallthrough + case .shareDownloadedFile: result.append(.shareDownloadedFile); fallthrough + case .customFileServerUrl: result.append(.customFileServerUrl); fallthrough + case .customFileServerPubkey: result.append(.customFileServerPubkey) + } + + return result + } + } + + // MARK: - Content + + public struct State: Equatable, ObservableKeyProvider { + struct Info: Equatable, Hashable { + let shortenFileTTL: Bool + let deterministicAttachmentEncryption: Bool + let customFileServer: Network.FileServer.Custom + + public func with( + shortenFileTTL: Bool? = nil, + deterministicAttachmentEncryption: Bool? = nil, + customFileServer: Network.FileServer.Custom? = nil + ) -> Info { + return Info( + shortenFileTTL: (shortenFileTTL ?? self.shortenFileTTL), + deterministicAttachmentEncryption: (deterministicAttachmentEncryption ?? self.deterministicAttachmentEncryption), + customFileServer: (customFileServer ?? self.customFileServer) + ) + } + } + + let initialState: Info + let pendingState: Info + + @MainActor public func sections(viewModel: DeveloperSettingsFileServerViewModel, previousState: State) -> [SectionModel] { + DeveloperSettingsFileServerViewModel.sections( + state: self, + previousState: previousState, + viewModel: viewModel + ) + } + + public let observedKeys: Set = [ + .updateScreen(DeveloperSettingsFileServerViewModel.self), + .feature(.shortenFileTTL), + .feature(.deterministicAttachmentEncryption), + .feature(.customFileServer) + ] + + static func initialState(using dependencies: Dependencies) -> State { + let initialInfo: Info = Info( + shortenFileTTL: dependencies[feature: .shortenFileTTL], + deterministicAttachmentEncryption: dependencies[feature: .deterministicAttachmentEncryption], + customFileServer: dependencies[feature: .customFileServer] + ) + + return State( + initialState: initialInfo, + pendingState: initialInfo + ) + } + } + + let title: String = "Developer File Server Settings" + + lazy var footerButtonInfo: AnyPublisher = $internalState + .map { [weak self] state -> SessionButton.Info? in + return SessionButton.Info( + style: .bordered, + title: "set".localized(), + isEnabled: { + guard state.initialState != state.pendingState else { return false } + + return ( + state.pendingState.customFileServer.isEmpty || + state.pendingState.customFileServer.isValid + ) + }(), + accessibility: Accessibility( + identifier: "Set button", + label: "Set button" + ), + minWidth: 110, + onTap: { [weak self] in + Task { [weak self] in + await self?.saveChanges() + } + } + ) + } + .eraseToAnyPublisher() + + @Sendable private static func queryState( + previousState: State, + events: [ObservedEvent], + isInitialQuery: Bool, + using dependencies: Dependencies + ) async -> State { + return State( + initialState: previousState.initialState, + pendingState: (events.first?.value as? State.Info ?? previousState.pendingState) + ) + } + + private static func sections( + state: State, + previousState: State, + viewModel: DeveloperSettingsFileServerViewModel + ) -> [SectionModel] { + let general: SectionModel = SectionModel( + model: .general, + elements: [ + SessionCell.Info( + id: .shortenFileTTL, + title: "Shorten File TTL", + subtitle: "Set the TTL for files in the cache to 1 minute", + trailingAccessory: .toggle( + state.pendingState.shortenFileTTL, + oldValue: previousState.pendingState.shortenFileTTL + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(DeveloperSettingsFileServerViewModel.self), + value: state.pendingState.with( + shortenFileTTL: !state.pendingState.shortenFileTTL + ) + ) + } + ), + SessionCell.Info( + id: .deterministicAttachmentEncryption, + title: "Deterministic Attachment Encryption", + subtitle: """ + Controls whether the new deterministic encryption should be used for attachment and display pictures + + Warning: Old clients won't be able to decrypt attachments sent while this is enabled + """, + trailingAccessory: .toggle( + state.pendingState.deterministicAttachmentEncryption, + oldValue: previousState.pendingState.deterministicAttachmentEncryption + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(DeveloperSettingsFileServerViewModel.self), + value: state.pendingState.with( + deterministicAttachmentEncryption: !state.pendingState.deterministicAttachmentEncryption + ) + ) + } + ), + SessionCell.Info( + id: .shareDownloadedFile, + title: "Share Downloaded File", + subtitle: """ + Share the downloaded file of a given URL if it exists via the native share sheet + """, + trailingAccessory: .icon(.share), + onTap: { [weak viewModel] in + viewModel?.showShareFileModal() + } + ), + SessionCell.Info( + id: .customFileServerUrl, + title: "Custom File Server URL", + subtitle: """ + The URL to use instead of the default File Server for uploading files + + Current: \(state.pendingState.customFileServer.url.isEmpty ? "Default" : state.pendingState.customFileServer.url) + """, + trailingAccessory: .icon(.squarePen), + onTap: { [weak viewModel] in + viewModel?.showServerUrlModal(pendingState: state.pendingState) + } + ), + SessionCell.Info( + id: .customFileServerPubkey, + title: "Custom File Server Public Key", + subtitle: """ + The public key to use for the above custom File Server (if empty then the pubkey for the default file server will be used) + + Current: \(state.pendingState.customFileServer.pubkey.isEmpty ? "Default" : state.pendingState.customFileServer.pubkey) + """, + trailingAccessory: .icon(.squarePen), + onTap: { [weak viewModel] in + viewModel?.showServerPubkeyModal(pendingState: state.pendingState) + } + ) + ] + ) + + return [general] + } + + // MARK: - Internal Functions + + private func showShareFileModal() { + func originalPath(for value: String) -> String? { + let maybeAttachmentPath: String? = try? dependencies[singleton: .attachmentManager].path(for: value) + let maybeDisplayPicPath: String? = try? dependencies[singleton: .displayPictureManager].path(for: value) + + if + let path: String = maybeAttachmentPath, + dependencies[singleton: .fileManager].fileExists(atPath: path) + { + return path + } + + if + let path: String = maybeDisplayPicPath, + dependencies[singleton: .fileManager].fileExists(atPath: path) + { + return path + } + + return nil + } + func showFileMissingError() { + let modal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "theError".localized(), + body: .text("There doesn't appear to be a downloaded file for that url."), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text + + ) + ) + self.transitionToScreen(modal, transitionType: .present) + } + + self.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "Share Downloaded File", + body: .dualInput( + explanation: ThemedAttributedString( + string: "The download url and mime type for the file to share." + ), + firstInfo: ConfirmationModal.Info.Body.InputInfo(placeholder: "MIME Type (Optional)"), + secondInfo: ConfirmationModal.Info.Body.InputInfo(placeholder: "Enter URL"), + onChange: { [weak self] mimeTypeValue, urlValue in + self?.shareDownloadedFileUrl = urlValue.lowercased() + self?.shareDownloadedFileMimetype = mimeTypeValue.lowercased() + } + ), + confirmTitle: "Copy Path", + confirmEnabled: .afterChange { [weak self] _ in + guard let value: String = self?.shareDownloadedFileUrl else { return false } + + return !value.isEmpty + }, + cancelTitle: "share".localized(), + cancelStyle: .alert_text, + cancelEnabled: .afterChange { [weak self] _ in + guard let value: String = self?.shareDownloadedFileUrl else { return false } + + return !value.isEmpty + }, + hasCloseButton: true, + dismissOnConfirm: false, + onConfirm: { [weak self] modal in + guard let value: String = self?.shareDownloadedFileUrl else { return } + guard let originalPath: String = originalPath(for: value) else { + return showFileMissingError() + } + + UIPasteboard.general.string = originalPath + self?.showToast(text: "copied".localized()) + }, + onCancel: { [weak self, dependencies] modal in + guard let value: String = self?.shareDownloadedFileUrl else { return } + + modal.dismiss(animated: true) { + guard + let originalPath: String = originalPath(for: value), + let temporaryPath: String = try? dependencies[singleton: .attachmentManager].temporaryPathForOpening( + originalPath: originalPath, + mimeType: self?.shareDownloadedFileMimetype?.lowercased(), + sourceFilename: nil, + allowInvalidType: true + ) + else { return showFileMissingError() } + + /// Create the temporary file + try? dependencies[singleton: .fileManager].copyItem( + atPath: originalPath, + toPath: temporaryPath + ) + + /// Ensure the temporary file was created successfully + guard dependencies[singleton: .fileManager].fileExists(atPath: temporaryPath) else { + return showFileMissingError() + } + + let shareVC = UIActivityViewController(activityItems: [ URL(fileURLWithPath: temporaryPath) ], applicationActivities: nil) + shareVC.completionWithItemsHandler = { [dependencies] activityType, completed, returnedItems, activityError in + /// Sanity check to make sure we don't unintentionally remove a proper attachment file + if dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(temporaryPath) { + try? dependencies[singleton: .fileManager].removeItem(atPath: temporaryPath) + } + } + self?.transitionToScreen(shareVC, transitionType: .present) + } + } + ) + ), + transitionType: .present + ) + } + + private func showServerUrlModal(pendingState: State.Info) { + self.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "Custom File Server URL", + body: .input( + explanation: ThemedAttributedString( + string: "The url for the custom file server." + ), + info: ConfirmationModal.Info.Body.InputInfo( + placeholder: "Enter URL", + initialValue: pendingState.customFileServer.url, + inputChecker: { text in + guard URL(string: text) != nil else { + return "Value must be a valid url (with HTTP or HTTPS)." + } + + return nil + } + ), + onChange: { [weak self] value in + self?.updatedCustomServerUrl = value.lowercased() + } + ), + confirmTitle: "save".localized(), + confirmEnabled: .afterChange { [weak self] _ in + guard + let value: String = self?.updatedCustomServerUrl, + let url: URL = URL(string: value) + else { return false } + + return (url.scheme != nil && url.host != nil) + }, + cancelTitle: (pendingState.customFileServer.url.isEmpty ? + "cancel".localized() : + "remove".localized() + ), + cancelStyle: (pendingState.customFileServer.url.isEmpty ? .alert_text : .danger), + hasCloseButton: !pendingState.customFileServer.url.isEmpty, + dismissOnConfirm: false, + onConfirm: { [weak self, dependencies] modal in + guard + let value: String = self?.updatedCustomServerUrl, + URL(string: value) != nil + else { + modal.updateContent( + withError: "Value must be a valid url (with HTTP or HTTPS)." + ) + return + } + + modal.dismiss(animated: true) + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(DeveloperSettingsFileServerViewModel.self), + value: pendingState.with( + customFileServer: pendingState.customFileServer.with( + url: value + ) + ) + ) + }, + onCancel: { [dependencies] modal in + modal.dismiss(animated: true) + + guard !pendingState.customFileServer.url.isEmpty else { return } + + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(DeveloperSettingsFileServerViewModel.self), + value: pendingState.with( + customFileServer: pendingState.customFileServer.with(url: "") + ) + ) + } + ) + ), + transitionType: .present + ) + } + + private func showServerPubkeyModal(pendingState: State.Info) { + self.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "Custom File Server Pubkey", + body: .input( + explanation: ThemedAttributedString( + string: """ + The public key for the custom file server. + + This is 64 character hexadecimal value. + """ + ), + info: ConfirmationModal.Info.Body.InputInfo( + placeholder: "Enter Pubkey", + initialValue: pendingState.customFileServer.pubkey, + inputChecker: { text in + guard text.count <= 64 else { + return "Value must be a 64 character hexadecimal string." + } + + return nil + } + ), + onChange: { [weak self] value in + self?.updatedCustomServerPubkey = value + } + ), + confirmTitle: "save".localized(), + confirmEnabled: .afterChange { [weak self] _ in + guard let value: String = self?.updatedCustomServerPubkey else { + return false + } + + return ( + Hex.isValid(value) && + value.trimmingCharacters(in: .whitespacesAndNewlines).count == 64 + ) + }, + cancelTitle: (pendingState.customFileServer.pubkey.isEmpty ? + "cancel".localized() : + "remove".localized() + ), + cancelStyle: (pendingState.customFileServer.pubkey.isEmpty ? .alert_text : .danger), + hasCloseButton: !pendingState.customFileServer.pubkey.isEmpty, + dismissOnConfirm: false, + onConfirm: { [weak self, dependencies] modal in + guard + let value: String = self?.updatedCustomServerPubkey, + Hex.isValid(value), + value.trimmingCharacters(in: .whitespacesAndNewlines).count == 64 + else { + modal.updateContent( + withError: "Value must be a 64 character hexadecimal string." + ) + return + } + + modal.dismiss(animated: true) + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(DeveloperSettingsFileServerViewModel.self), + value: pendingState.with( + customFileServer: pendingState.customFileServer.with( + pubkey: value + ) + ) + ) + }, + onCancel: { [dependencies] modal in + modal.dismiss(animated: true) + + guard !pendingState.customFileServer.pubkey.isEmpty else { return } + + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(DeveloperSettingsFileServerViewModel.self), + value: pendingState.with( + customFileServer: pendingState.customFileServer.with(pubkey: "") + ) + ) + } + ) + ), + transitionType: .present + ) + } + + // MARK: - Reverting + + public static func disableDeveloperMode(using dependencies: Dependencies) { + let features: [FeatureConfig] = [ + .shortenFileTTL, + .deterministicAttachmentEncryption + ] + + features.forEach { feature in + guard dependencies.hasSet(feature: feature) else { return } + + dependencies.set(feature: feature, to: nil) + } + + if dependencies.hasSet(feature: .customFileServer) { + dependencies.set(feature: .customFileServer, to: nil) + } + } + + // MARK: - Saving + + @MainActor private func saveChanges(hasConfirmed: Bool = false) async { + guard internalState.initialState != internalState.pendingState else { return } + + if internalState.initialState.shortenFileTTL != internalState.pendingState.shortenFileTTL { + dependencies.set(feature: .shortenFileTTL, to: internalState.pendingState.shortenFileTTL) + } + + if internalState.initialState.deterministicAttachmentEncryption != internalState.pendingState.deterministicAttachmentEncryption { + dependencies.set( + feature: .deterministicAttachmentEncryption, + to: internalState.pendingState.deterministicAttachmentEncryption + ) + } + + if internalState.initialState.customFileServer != internalState.pendingState.customFileServer { + dependencies.set( + feature: .customFileServer, + to: internalState.pendingState.customFileServer + ) + } + + /// Changes have been saved so we can dismiss the screen + self.dismissScreen() + } +} diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsGroupsViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsGroupsViewModel.swift index 61d62d1034..7c7c169262 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsGroupsViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsGroupsViewModel.swift @@ -62,6 +62,7 @@ class DeveloperSettingsGroupsViewModel: SessionTableViewModel, NavigatableStateH } public enum TableItem: Hashable, Differentiable, CaseIterable { + case groupsShowPubkeyInConversationSettings case updatedGroupsDisableAutoApprove case updatedGroupsRemoveMessagesOnKick case updatedGroupsAllowHistoricAccessOnInvite @@ -78,6 +79,7 @@ class DeveloperSettingsGroupsViewModel: SessionTableViewModel, NavigatableStateH public var differenceIdentifier: String { switch self { + case .groupsShowPubkeyInConversationSettings: return "groupsShowPubkeyInConversationSettings" case .updatedGroupsDisableAutoApprove: return "updatedGroupsDisableAutoApprove" case .updatedGroupsRemoveMessagesOnKick: return "updatedGroupsRemoveMessagesOnKick" case .updatedGroupsAllowHistoricAccessOnInvite: return "updatedGroupsAllowHistoricAccessOnInvite" @@ -96,7 +98,8 @@ class DeveloperSettingsGroupsViewModel: SessionTableViewModel, NavigatableStateH public static var allCases: [TableItem] { var result: [TableItem] = [] - switch TableItem.updatedGroupsDisableAutoApprove { + switch TableItem.groupsShowPubkeyInConversationSettings { + case .groupsShowPubkeyInConversationSettings: result.append(groupsShowPubkeyInConversationSettings); fallthrough case .updatedGroupsDisableAutoApprove: result.append(.updatedGroupsDisableAutoApprove); fallthrough case .updatedGroupsRemoveMessagesOnKick: result.append(.updatedGroupsRemoveMessagesOnKick); fallthrough case .updatedGroupsAllowHistoricAccessOnInvite: @@ -116,6 +119,7 @@ class DeveloperSettingsGroupsViewModel: SessionTableViewModel, NavigatableStateH // MARK: - Content public struct State: Equatable, ObservableKeyProvider { + let groupsShowPubkeyInConversationSettings: Bool let updatedGroupsDisableAutoApprove: Bool let updatedGroupsRemoveMessagesOnKick: Bool let updatedGroupsAllowHistoricAccessOnInvite: Bool @@ -135,6 +139,7 @@ class DeveloperSettingsGroupsViewModel: SessionTableViewModel, NavigatableStateH } public let observedKeys: Set = [ + .feature(.groupsShowPubkeyInConversationSettings), .feature(.updatedGroupsDisableAutoApprove), .feature(.updatedGroupsRemoveMessagesOnKick), .feature(.updatedGroupsAllowHistoricAccessOnInvite), @@ -148,6 +153,7 @@ class DeveloperSettingsGroupsViewModel: SessionTableViewModel, NavigatableStateH static func initialState(using dependencies: Dependencies) -> State { return State( + groupsShowPubkeyInConversationSettings: dependencies[feature: .groupsShowPubkeyInConversationSettings], updatedGroupsDisableAutoApprove: dependencies[feature: .updatedGroupsDisableAutoApprove], updatedGroupsRemoveMessagesOnKick: dependencies[feature: .updatedGroupsRemoveMessagesOnKick], updatedGroupsAllowHistoricAccessOnInvite: dependencies[feature: .updatedGroupsAllowHistoricAccessOnInvite], @@ -170,6 +176,7 @@ class DeveloperSettingsGroupsViewModel: SessionTableViewModel, NavigatableStateH using dependencies: Dependencies ) async -> State { return State( + groupsShowPubkeyInConversationSettings: dependencies[feature: .groupsShowPubkeyInConversationSettings], updatedGroupsDisableAutoApprove: dependencies[feature: .updatedGroupsDisableAutoApprove], updatedGroupsRemoveMessagesOnKick: dependencies[feature: .updatedGroupsRemoveMessagesOnKick], updatedGroupsAllowHistoricAccessOnInvite: dependencies[feature: .updatedGroupsAllowHistoricAccessOnInvite], @@ -190,6 +197,23 @@ class DeveloperSettingsGroupsViewModel: SessionTableViewModel, NavigatableStateH let general: SectionModel = SectionModel( model: .general, elements: [ + SessionCell.Info( + id: .groupsShowPubkeyInConversationSettings, + title: "Show Group Pubkey in Conversation Settings", + subtitle: """ + Makes the group identity public key appear in the conversation settings screen. + """, + trailingAccessory: .toggle( + state.groupsShowPubkeyInConversationSettings, + oldValue: previousState.groupsShowPubkeyInConversationSettings + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.set( + feature: .groupsShowPubkeyInConversationSettings, + to: !state.groupsShowPubkeyInConversationSettings + ) + } + ), SessionCell.Info( id: .updatedGroupsDisableAutoApprove, title: "Disable Auto Approve", diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index 0eb37764ab..9d1ff903fd 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -73,9 +73,14 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case purchaseProSubscription case manageProSubscriptions case restoreProSubscription + case requestRefund case proStatus - case proIncomingMessages + case allUsersSessionPro + + case messageFeatureProBadge + case messageFeatureLongMessage + case messageFeatureAnimatedAvatar // MARK: - Conformance @@ -88,9 +93,14 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .purchaseProSubscription: return "purchaseProSubscription" case .manageProSubscriptions: return "manageProSubscriptions" case .restoreProSubscription: return "restoreProSubscription" + case .requestRefund: return "requestRefund" case .proStatus: return "proStatus" - case .proIncomingMessages: return "proIncomingMessages" + case .allUsersSessionPro: return "allUsersSessionPro" + + case .messageFeatureProBadge: return "messageFeatureProBadge" + case .messageFeatureLongMessage: return "messageFeatureLongMessage" + case .messageFeatureAnimatedAvatar: return "messageFeatureAnimatedAvatar" } } @@ -106,9 +116,14 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .purchaseProSubscription: result.append(.purchaseProSubscription); fallthrough case .manageProSubscriptions: result.append(.manageProSubscriptions); fallthrough case .restoreProSubscription: result.append(.restoreProSubscription); fallthrough + case .requestRefund: result.append(.requestRefund); fallthrough case .proStatus: result.append(.proStatus); fallthrough - case .proIncomingMessages: result.append(.proIncomingMessages) + case .allUsersSessionPro: result.append(.allUsersSessionPro); fallthrough + + case .messageFeatureProBadge: result.append(.messageFeatureProBadge); fallthrough + case .messageFeatureLongMessage: result.append(.messageFeatureLongMessage); fallthrough + case .messageFeatureAnimatedAvatar: result.append(.messageFeatureAnimatedAvatar) } return result @@ -116,7 +131,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold } public enum DeveloperSettingsProEvent: Hashable { - case purchasedProduct([Product], Product?, String?, String?, UInt64?) + case purchasedProduct([Product], Product?, String?, String?, Transaction?) + case refundTransaction(Transaction.RefundRequestStatus) } // MARK: - Content @@ -128,10 +144,15 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let purchasedProduct: Product? let purchaseError: String? let purchaseStatus: String? - let purchaseTransactionId: String? + let purchaseTransaction: Transaction? + let refundRequestStatus: Transaction.RefundRequestStatus? let mockCurrentUserSessionPro: Bool - let treatAllIncomingMessagesAsProMessages: Bool + let allUsersSessionPro: Bool + + let messageFeatureProBadge: Bool + let messageFeatureLongMessage: Bool + let messageFeatureAnimatedAvatar: Bool @MainActor public func sections(viewModel: DeveloperSettingsProViewModel, previousState: State) -> [SectionModel] { DeveloperSettingsProViewModel.sections( @@ -145,7 +166,10 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold .feature(.sessionProEnabled), .updateScreen(DeveloperSettingsProViewModel.self), .feature(.mockCurrentUserSessionPro), - .feature(.treatAllIncomingMessagesAsProMessages) + .feature(.allUsersSessionPro), + .feature(.messageFeatureProBadge), + .feature(.messageFeatureLongMessage), + .feature(.messageFeatureAnimatedAvatar) ] static func initialState(using dependencies: Dependencies) -> State { @@ -156,10 +180,15 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold purchasedProduct: nil, purchaseError: nil, purchaseStatus: nil, - purchaseTransactionId: nil, + purchaseTransaction: nil, + refundRequestStatus: nil, mockCurrentUserSessionPro: dependencies[feature: .mockCurrentUserSessionPro], - treatAllIncomingMessagesAsProMessages: dependencies[feature: .treatAllIncomingMessagesAsProMessages] + allUsersSessionPro: dependencies[feature: .allUsersSessionPro], + + messageFeatureProBadge: dependencies[feature: .messageFeatureProBadge], + messageFeatureLongMessage: dependencies[feature: .messageFeatureLongMessage], + messageFeatureAnimatedAvatar: dependencies[feature: .messageFeatureAnimatedAvatar] ) } } @@ -176,18 +205,22 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold var purchasedProduct: Product? = previousState.purchasedProduct var purchaseError: String? = previousState.purchaseError var purchaseStatus: String? = previousState.purchaseStatus - var purchaseTransactionId: String? = previousState.purchaseTransactionId + var purchaseTransaction: Transaction? = previousState.purchaseTransaction + var refundRequestStatus: Transaction.RefundRequestStatus? = previousState.refundRequestStatus events.forEach { event in guard let eventValue: DeveloperSettingsProEvent = event.value as? DeveloperSettingsProEvent else { return } switch eventValue { - case .purchasedProduct(let receivedProducts, let purchased, let error, let status, let id): + case .purchasedProduct(let receivedProducts, let purchased, let error, let status, let transaction): products = receivedProducts purchasedProduct = purchased purchaseError = error purchaseStatus = status - purchaseTransactionId = id.map { "\($0)" } + purchaseTransaction = transaction + + case .refundTransaction(let status): + refundRequestStatus = status } } @@ -197,9 +230,13 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold purchasedProduct: purchasedProduct, purchaseError: purchaseError, purchaseStatus: purchaseStatus, - purchaseTransactionId: purchaseTransactionId, + purchaseTransaction: purchaseTransaction, + refundRequestStatus: refundRequestStatus, mockCurrentUserSessionPro: dependencies[feature: .mockCurrentUserSessionPro], - treatAllIncomingMessagesAsProMessages: dependencies[feature: .treatAllIncomingMessagesAsProMessages] + allUsersSessionPro: dependencies[feature: .allUsersSessionPro], + messageFeatureProBadge: dependencies[feature: .messageFeatureProBadge], + messageFeatureLongMessage: dependencies[feature: .messageFeatureLongMessage], + messageFeatureAnimatedAvatar: dependencies[feature: .messageFeatureAnimatedAvatar] ) } @@ -243,9 +280,17 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold "N/A" ) let transactionId: String = ( - state.purchaseTransactionId.map { "\($0)" } ?? + state.purchaseTransaction.map { "\($0.id)" } ?? "N/A" ) + let refundStatus: String = { + switch state.refundRequestStatus { + case .success: return "Success (Does not mean approved)" + case .userCancelled: return "User Cancelled" + case .none: return "N/A" + @unknown default: return "N/A" + } + }() let subscriptions: SectionModel = SectionModel( model: .subscriptions, elements: [ @@ -287,6 +332,20 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold onTap: { [weak viewModel] in Task { await viewModel?.restoreSubscriptions() } } + ), + SessionCell.Info( + id: .requestRefund, + title: "Request Refund", + subtitle: """ + Request a refund for a Session Pro subscription via the App Store. + + Status:\(refundStatus) + """, + trailingAccessory: .highlightingBackgroundLabel(title: "Request"), + isEnabled: (state.purchaseTransaction != nil), + onTap: { [weak viewModel] in + Task { await viewModel?.requestRefund() } + } ) ] ) @@ -309,26 +368,73 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold feature: .mockCurrentUserSessionPro, to: !state.mockCurrentUserSessionPro ) + dependencies[singleton: .sessionProState].isSessionProSubject.send(!state.mockCurrentUserSessionPro) } ), SessionCell.Info( - id: .proIncomingMessages, - title: "All Pro Incoming Messages", + id: .allUsersSessionPro, + title: "Everyone is a Pro", subtitle: """ Treat all incoming messages as Pro messages. + Treat all contacts, groups as Session Pro. """, trailingAccessory: .toggle( - state.treatAllIncomingMessagesAsProMessages, - oldValue: previousState.treatAllIncomingMessagesAsProMessages + state.allUsersSessionPro, + oldValue: previousState.allUsersSessionPro ), onTap: { [dependencies = viewModel.dependencies] in dependencies.set( - feature: .treatAllIncomingMessagesAsProMessages, - to: !state.treatAllIncomingMessagesAsProMessages + feature: .allUsersSessionPro, + to: !state.allUsersSessionPro ) } ) - ] + ].appending( + contentsOf: !state.allUsersSessionPro ? [] : [ + SessionCell.Info( + id: .messageFeatureProBadge, + title: .init("Message Feature: Pro Badge", font: .subtitle), + trailingAccessory: .toggle( + state.messageFeatureProBadge, + oldValue: previousState.messageFeatureProBadge + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.set( + feature: .messageFeatureProBadge, + to: !state.messageFeatureProBadge + ) + } + ), + SessionCell.Info( + id: .messageFeatureLongMessage, + title: .init("Message Feature: Long Message", font: .subtitle), + trailingAccessory: .toggle( + state.messageFeatureLongMessage, + oldValue: previousState.messageFeatureLongMessage + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.set( + feature: .messageFeatureLongMessage, + to: !state.messageFeatureLongMessage + ) + } + ), + SessionCell.Info( + id: .messageFeatureAnimatedAvatar, + title: .init("Message Feature: Animated Avatar", font: .subtitle), + trailingAccessory: .toggle( + state.messageFeatureAnimatedAvatar, + oldValue: previousState.messageFeatureAnimatedAvatar + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.set( + feature: .messageFeatureAnimatedAvatar, + to: !state.messageFeatureAnimatedAvatar + ) + } + ) + ] + ) ) return [general, subscriptions, features] @@ -340,7 +446,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let features: [FeatureConfig] = [ .sessionProEnabled, .mockCurrentUserSessionPro, - .treatAllIncomingMessagesAsProMessages + .allUsersSessionPro ] features.forEach { feature in @@ -357,8 +463,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold dependencies.set(feature: .mockCurrentUserSessionPro, to: nil) } - if dependencies.hasSet(feature: .treatAllIncomingMessagesAsProMessages) { - dependencies.set(feature: .treatAllIncomingMessagesAsProMessages, to: nil) + if dependencies.hasSet(feature: .allUsersSessionPro) { + dependencies.set(feature: .allUsersSessionPro, to: nil) } } @@ -381,7 +487,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let transaction = try verificationResult.payloadValue dependencies.notifyAsync( key: .updateScreen(DeveloperSettingsProViewModel.self), - value: DeveloperSettingsProEvent.purchasedProduct(products, product, nil, "Successful", transaction.id) + value: DeveloperSettingsProEvent.purchasedProduct(products, product, nil, "Successful", transaction) ) await transaction.finish() @@ -421,7 +527,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold do { try await AppStore.showManageSubscriptions(in: scene) - print("AS") } catch { Log.error("[DevSettings] Unable to show manage subscriptions: \(error)") @@ -436,4 +541,22 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold Log.error("[DevSettings] Unable to show manage subscriptions: \(error)") } } + + private func requestRefund() async { + guard let transaction: Transaction = await internalState.purchaseTransaction else { return } + guard let scene: UIWindowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { + return Log.error("[DevSettings] Unable to show manage subscriptions: Unable to get UIWindowScene") + } + + do { + let result = try await transaction.beginRefundRequest(in: scene) + dependencies.notifyAsync( + key: .updateScreen(DeveloperSettingsProViewModel.self), + value: DeveloperSettingsProEvent.refundTransaction(result) + ) + } + catch { + Log.error("[DevSettings] Unable to request refund: \(error)") + } + } } diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel+Testing.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel+Testing.swift index 09381d297d..7f2bc2f823 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel+Testing.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel+Testing.swift @@ -3,6 +3,7 @@ // stringlint:disable import UIKit +import SessionNetworkingKit import SessionUtilitiesKit // MARK: - Automated Test Convenience @@ -29,7 +30,7 @@ extension DeveloperSettingsViewModel { /// **Note:** All values need to be provided as strings (eg. booleans) static func processUnitTestEnvVariablesIfNeeded(using dependencies: Dependencies) { #if targetEnvironment(simulator) - enum EnvironmentVariable: String { + enum EnvironmentVariable: String, CaseIterable { /// Disables animations for the app (where possible) /// /// **Value:** `true`/`false` (default: `true`) @@ -65,12 +66,43 @@ extension DeveloperSettingsViewModel { /// /// **Value:** `1-256` (default: `100`, a value of `0` will use the default) case communityPollLimit + + /// Controls whether we should shorten the TTL of files to `60s` instead of the default on the File Server + /// + /// **Value:** `true`/`false` (default: `false`) + case shortenFileTTL + + /// Controls the url which is used for the file server + /// + /// **Value:** Valid url string + /// + /// **Note:** If `customFileServerPubkey` isn't also provided then the default file server pubkey will be used + case customFileServerUrl + + /// Controls the pubkey which is used for the file server + /// + /// **Value:** 64 character hex encoded public key + /// + /// **Note:** Only used if `customFileServerUrl` is valid + case customFileServerPubkey } - ProcessInfo.processInfo.environment.forEach { key, value in - guard let variable: EnvironmentVariable = EnvironmentVariable(rawValue: key) else { return } + let envVars: [EnvironmentVariable: String] = ProcessInfo.processInfo.environment + .reduce(into: [:]) { result, next in + guard let variable: EnvironmentVariable = EnvironmentVariable(rawValue: next.key) else { + return + } + + result[variable] = next.value + } + let allKeys: Set = Set(envVars.keys) + + /// The order the the environment variables are applied in is important (configuring the network needs to happen in a certain + /// order to simplify the below logic) + for key in EnvironmentVariable.allCases { + guard let value: String = envVars[key] else { continue } - switch variable { + switch key { case .animationsEnabled: dependencies.set(feature: .animationsEnabled, to: (value == "true")) @@ -107,6 +139,27 @@ extension DeveloperSettingsViewModel { else { return } dependencies.set(feature: .communityPollLimit, to: intValue) + + case .shortenFileTTL: + dependencies.set(feature: .shortenFileTTL, to: (value == "true")) + + case .customFileServerUrl: + /// Ensure values were provided first + guard let url: String = envVars[.customFileServerUrl], !url.isEmpty else { + Log.warn("An empty 'customFileServerUrl' was provided") + break + } + let pubkey: String = (envVars[.customFileServerPubkey] ?? "") + let server: Network.FileServer.Custom = Network.FileServer.Custom(url: url, pubkey: pubkey) + + guard server.isValid else { + Log.warn("The custom file server info provided was not valid: (url: '\(url)', pubkey: '\(pubkey)'") + break + } + dependencies.set(feature: .customFileServer, to: server) + + /// This is handled in the `customFileServerUrl` case + case .customFileServerPubkey: break } } #endif diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift index dd611e73eb..b6b05ee1bb 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift @@ -74,7 +74,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case proConfig case groupConfig - case shortenFileTTL + case fileServerConfig case animationsEnabled case showStringKeys case truncatePubkeysInLogs @@ -82,6 +82,9 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case copyAppGroupPath case resetAppReviewPrompt case simulateAppReviewLimit + case usePngInsteadOfWebPForFallbackImageType + case versionDeprecationWarning + case versionDeprecationMinimum case defaultLogLevel case advancedLogging @@ -115,7 +118,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .proConfig: return "proConfig" case .groupConfig: return "groupConfig" - case .shortenFileTTL: return "shortenFileTTL" + case .fileServerConfig: return "fileServerConfig" case .animationsEnabled: return "animationsEnabled" case .showStringKeys: return "showStringKeys" case .truncatePubkeysInLogs: return "truncatePubkeysInLogs" @@ -123,6 +126,9 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .copyAppGroupPath: return "copyAppGroupPath" case .resetAppReviewPrompt: return "resetAppReviewPrompt" case .simulateAppReviewLimit: return "simulateAppReviewLimit" + case .usePngInsteadOfWebPForFallbackImageType: return "usePngInsteadOfWebPForFallbackImageType" + case .versionDeprecationWarning: return "versionDeprecationWarning" + case .versionDeprecationMinimum: return "versionDeprecationMinimum" case .defaultLogLevel: return "defaultLogLevel" case .advancedLogging: return "advancedLogging" @@ -159,7 +165,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .proConfig: result.append(.proConfig); fallthrough case .groupConfig: result.append(.groupConfig); fallthrough - case .shortenFileTTL: result.append(.shortenFileTTL); fallthrough + case .fileServerConfig: result.append(.fileServerConfig); fallthrough case .animationsEnabled: result.append(.animationsEnabled); fallthrough case .showStringKeys: result.append(.showStringKeys); fallthrough case .truncatePubkeysInLogs: result.append(.truncatePubkeysInLogs); fallthrough @@ -167,6 +173,10 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .copyAppGroupPath: result.append(.copyAppGroupPath); fallthrough case .resetAppReviewPrompt: result.append(.resetAppReviewPrompt); fallthrough case .simulateAppReviewLimit: result.append(.simulateAppReviewLimit); fallthrough + case .usePngInsteadOfWebPForFallbackImageType: + result.append(usePngInsteadOfWebPForFallbackImageType); fallthrough + case .versionDeprecationWarning: result.append(.versionDeprecationWarning); fallthrough + case .versionDeprecationMinimum: result.append(.versionDeprecationMinimum); fallthrough case .defaultLogLevel: result.append(.defaultLogLevel); fallthrough case .advancedLogging: result.append(.advancedLogging); fallthrough @@ -220,6 +230,10 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, let forceSlowDatabaseQueries: Bool let updateSimulateAppReviewLimit: Bool + let usePngInsteadOfWebPForFallbackImageType: Bool + + let versionDeprecationWarning: Bool + let versionDeprecationMinimum: Int } let title: String = "Developer Settings" @@ -262,7 +276,11 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, communityPollLimit: dependencies[feature: .communityPollLimit], forceSlowDatabaseQueries: dependencies[feature: .forceSlowDatabaseQueries], - updateSimulateAppReviewLimit: dependencies[feature: .simulateAppReviewLimit] + updateSimulateAppReviewLimit: dependencies[feature: .simulateAppReviewLimit], + usePngInsteadOfWebPForFallbackImageType: dependencies[feature: .usePngInsteadOfWebPForFallbackImageType], + + versionDeprecationWarning: dependencies[feature: .versionDeprecationWarning], + versionDeprecationMinimum: dependencies[feature: .versionDeprecationMinimum] ) } .compactMapWithPrevious { [weak self] prev, current -> [SectionModel]? in self?.content(prev, current) } @@ -339,17 +357,21 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, model: .general, elements: [ SessionCell.Info( - id: .shortenFileTTL, - title: "Shorten File TTL", - subtitle: "Set the TTL for files in the cache to 1 minute", - trailingAccessory: .toggle( - current.shortenFileTTL, - oldValue: previous?.shortenFileTTL - ), - onTap: { [weak self] in - self?.updateFlag( - for: .shortenFileTTL, - to: !current.shortenFileTTL + id: .fileServerConfig, + title: "File Server Configuration", + subtitle: """ + Configure settings related to the File Server. + + File TTL: \(dependencies[feature: .shortenFileTTL] ? "60 Seconds" : "14 Days") + Deterministic Encryption: \(dependencies[feature: .deterministicAttachmentEncryption] ? "Enabled" : "Disabled") + File Server: \(Network.FileServer.server(using: dependencies)) + """, + trailingAccessory: .icon(.chevronRight), + onTap: { [weak self, dependencies] in + self?.transitionToScreen( + SessionTableViewController( + viewModel: DeveloperSettingsFileServerViewModel(using: dependencies) + ) ) } ), @@ -460,6 +482,71 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, ) } ), + SessionCell.Info( + id: .usePngInsteadOfWebPForFallbackImageType, + title: "Use PNG instead of WebP for fallback image type", + subtitle: """ + Controls whether we should encode to PNG and GIF when sending less common image types (eg. HEIC/HEIF). + + This is beneficial to enable when testing Debug builds as the WebP encoding is an order of magnitude slower than in Release builds. + """, + trailingAccessory: .toggle( + current.usePngInsteadOfWebPForFallbackImageType, + oldValue: previous?.usePngInsteadOfWebPForFallbackImageType + ), + onTap: { [weak self] in + self?.updateFlag( + for: .usePngInsteadOfWebPForFallbackImageType, + to: !current.usePngInsteadOfWebPForFallbackImageType + ) + } + ), + SessionCell.Info( + id: .versionDeprecationWarning, + title: "Version Deprecation Banner", + subtitle: """ + Enable the banner that warns users when their operating system (iOS 15.x or earlier) is nearing the end of support or cannot access the latest features. + """, + trailingAccessory: .toggle( + current.versionDeprecationWarning, + oldValue: previous?.versionDeprecationWarning + ), + onTap: { [weak self] in + self?.updateFlag( + for: .versionDeprecationWarning, + to: !current.versionDeprecationWarning + ) + } + ), + SessionCell.Info( + id: .versionDeprecationMinimum, + title: "Version Deprecation Minimum Version", + subtitle: """ + The minimum version allowed before showing version deprecation warning. + """, + trailingAccessory: .dropDown { "iOS \(current.versionDeprecationMinimum)" }, + onTap: { [weak self, dependencies] in + self?.transitionToScreen( + SessionTableViewController( + viewModel: SessionListViewModel( + title: "Minimum iOS Version", + options: [ + WarningVersion(version: 16), + WarningVersion(version: 17), + WarningVersion(version: 18) + ], + behaviour: .autoDismiss( + initialSelection: WarningVersion(version: current.versionDeprecationMinimum), + onOptionSelected: { [weak self] selected in + dependencies.set(feature: .versionDeprecationMinimum, to: selected.version) + } + ), + using: dependencies + ) + ) + ) + } + ) ] ) let logging: SectionModel = SectionModel( @@ -635,6 +722,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, ) ] ) + let communities: SectionModel = SectionModel( model: .communities, elements: [ @@ -785,15 +873,12 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, .copyAppGroupPath, .resetSnodeCache, .createMockContacts, .exportDatabase, .importDatabase, .advancedLogging, .resetAppReviewPrompt: break /// These are actions rather than values stored as "features" so no need to do anything - + case .groupConfig: DeveloperSettingsGroupsViewModel.disableDeveloperMode(using: dependencies) case .proConfig: DeveloperSettingsProViewModel.disableDeveloperMode(using: dependencies) - - case .shortenFileTTL: - guard dependencies.hasSet(feature: .shortenFileTTL) else { return } + case .fileServerConfig: + DeveloperSettingsFileServerViewModel.disableDeveloperMode(using: dependencies) - updateFlag(for: .shortenFileTTL, to: nil) - case .animationsEnabled: guard dependencies.hasSet(feature: .animationsEnabled) else { return } @@ -814,6 +899,11 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, updateFlag(for: .simulateAppReviewLimit, to: nil) + case .usePngInsteadOfWebPForFallbackImageType: + guard dependencies.hasSet(feature: .usePngInsteadOfWebPForFallbackImageType) else { return } + + updateFlag(for: .usePngInsteadOfWebPForFallbackImageType, to: nil) + case .defaultLogLevel: updateDefaulLogLevel(to: nil) // Always reset case .loggingCategory: resetLoggingCategories() // Always reset @@ -836,7 +926,16 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, guard dependencies.hasSet(feature: .debugDisappearingMessageDurations) else { return } updateFlag(for: .debugDisappearingMessageDurations, to: nil) - + + case .versionDeprecationWarning: + guard dependencies.hasSet(feature: .versionDeprecationWarning) else { return } + + updateFlag(for: .versionDeprecationWarning, to: nil) + case .versionDeprecationMinimum: + guard dependencies.hasSet(feature: .versionDeprecationMinimum) else { return } + + dependencies.set(feature: .versionDeprecationMinimum, to: nil) + case .communityPollLimit: guard dependencies.hasSet(feature: .communityPollLimit) else { return } @@ -1027,7 +1126,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, x25519KeyPair: identityData.x25519KeyPair, displayName: existingProfile.name .nullIfEmpty - .defaulting(to: "Anonymous"), + .defaulting(to: "anonymous".localized()), using: dependencies ).completeRegistration { [dependencies] in /// Re-enable developer mode @@ -1792,6 +1891,13 @@ final class PollLimitInputView: UIView, UITextFieldDelegate, SessionCell.Accesso } } +// MARK: - WarningVersion +struct WarningVersion: Listable { + var version: Int + + var id: String { "\(version)" } + var title: String { "iOS \(version)" } +} // MARK: - Listable Conformance diff --git a/Session/Settings/HelpViewModel.swift b/Session/Settings/HelpViewModel.swift index 4ab69ce0d1..4b3ddc2932 100644 --- a/Session/Settings/HelpViewModel.swift +++ b/Session/Settings/HelpViewModel.swift @@ -224,15 +224,25 @@ class HelpViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTa guard let latestLogFilePath: String = await Log.logFilePath(using: dependencies), - let viewController: UIViewController = dependencies[singleton: .appContext].frontMostViewController + let viewController: UIViewController = dependencies[singleton: .appContext].frontMostViewController, + let sanitizedLogFilePath = try? dependencies[singleton: .attachmentManager] + .createTemporaryFileForOpening(filePath: latestLogFilePath) // Creates a copy of the log file with whitespaces on the filename removed else { return } let showShareSheet: () -> () = { let shareVC = UIActivityViewController( - activityItems: [ URL(fileURLWithPath: latestLogFilePath) ], + activityItems: [ + URL(fileURLWithPath: sanitizedLogFilePath) + ], applicationActivities: nil ) shareVC.completionWithItemsHandler = { _, success, _, _ in + /// Sanity check to make sure we don't unintentionally remove a proper attachment file + if sanitizedLogFilePath.hasPrefix(dependencies[singleton: .fileManager].temporaryDirectory) { + /// Deletes file copy of the log file + try? dependencies[singleton: .fileManager].removeItem(atPath: sanitizedLogFilePath) + } + UIActivityViewController.notifyIfNeeded(success, using: dependencies) onShareComplete?() } diff --git a/Session/Settings/ImagePickerHandler.swift b/Session/Settings/ImagePickerHandler.swift index 24811c715f..dfa08073b9 100644 --- a/Session/Settings/ImagePickerHandler.swift +++ b/Session/Settings/ImagePickerHandler.swift @@ -1,71 +1,77 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import PhotosUI import UniformTypeIdentifiers +import SessionUIKit +import SessionMessagingKit import SessionUtilitiesKit -class ImagePickerHandler: NSObject, UIImagePickerControllerDelegate & UINavigationControllerDelegate { +class ImagePickerHandler: PHPickerViewControllerDelegate { + private let dependencies: Dependencies private let onTransition: (UIViewController, TransitionType) -> Void - private let onImageDataPicked: (String, Data) -> Void + private let onImagePicked: (ImageDataManager.DataSource, CGRect?) -> Void // MARK: - Initialization init( onTransition: @escaping (UIViewController, TransitionType) -> Void, - onImageDataPicked: @escaping (String, Data) -> Void + onImagePicked: @escaping (ImageDataManager.DataSource, CGRect?) -> Void, + using dependencies: Dependencies ) { + self.dependencies = dependencies self.onTransition = onTransition - self.onImageDataPicked = onImageDataPicked + self.onImagePicked = onImagePicked } // MARK: - UIImagePickerControllerDelegate - func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { - picker.dismiss(animated: true) - } - - func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { guard - let imageUrl: URL = info[.imageURL] as? URL, - let rawAvatar: UIImage = info[.originalImage] as? UIImage + let result: PHPickerResult = results.first, + let typeIdentifier: String = result.itemProvider.registeredTypeIdentifiers.first else { - picker.presentingViewController?.dismiss(animated: true) + picker.dismiss(animated: true) return } - picker.presentingViewController?.dismiss(animated: true) { [weak self] in - // Check if the user selected an animated image (if so then don't crop, just - // set the avatar directly - guard - let resourceValues: URLResourceValues = (try? imageUrl.resourceValues(forKeys: [.typeIdentifierKey])), - let type: Any = resourceValues.allValues.first?.value, - let typeString: String = type as? String, - UTType.isAnimated(typeString) - else { - let viewController: CropScaleImageViewController = CropScaleImageViewController( - srcImage: rawAvatar, - successCompletion: { cropFrame, resultImageData in - let croppedImagePath: String = imageUrl - .deletingLastPathComponent() - .appendingPathComponent([ - "\(Int(round(cropFrame.minX)))", - "\(Int(round(cropFrame.minY)))", - "\(Int(round(cropFrame.width)))", - "\(Int(round(cropFrame.height)))", - imageUrl.lastPathComponent - ].joined(separator: "-")) // stringlint:ignore - .path + picker.dismiss(animated: true) { [weak self] in + result.itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in + guard let self = self else { return } + guard let url: URL = url else { + Log.debug("[ImagePickHandler] Error loading file: \(error?.localizedDescription ?? "unknown")") + return + } + + do { + let onImagePicked: (ImageDataManager.DataSource, CGRect?) -> Void = self.onImagePicked + let filePath: String = self.dependencies[singleton: .fileManager].temporaryFilePath() + try self.dependencies[singleton: .fileManager].copyItem( + atPath: url.path, + toPath: filePath + ) + // TODO: Need to remove file when we are done + DispatchQueue.main.async { [weak self, dataManager = self.dependencies[singleton: .imageDataManager]] in + let viewController: CropScaleImageViewController = CropScaleImageViewController( + source: .url(URL(fileURLWithPath: filePath)), + dstSizePixels: CGSize( + width: DisplayPictureManager.maxDimension, + height: DisplayPictureManager.maxDimension + ), + dataManager: dataManager, + successCompletion: onImagePicked + ) - self?.onImageDataPicked(croppedImagePath, resultImageData) + self?.onTransition( + StyledNavigationController(rootViewController: viewController), + .present + ) } - ) - self?.onTransition(viewController, .present) - return + } + catch { + Log.debug("[ImagePickHandler] Error copying file: \(error)") + } } - - guard let imageData: Data = try? Data(contentsOf: URL(fileURLWithPath: imageUrl.path)) else { return } - - self?.onImageDataPicked(imageUrl.path, imageData) } } } diff --git a/Session/Settings/PrivacySettingsViewModel.swift b/Session/Settings/PrivacySettingsViewModel.swift index 66eaf0e948..9d85494286 100644 --- a/Session/Settings/PrivacySettingsViewModel.swift +++ b/Session/Settings/PrivacySettingsViewModel.swift @@ -505,9 +505,11 @@ class PrivacySettingsViewModel: SessionTableViewModel, NavigationItemSource, Nav let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "callsVoiceAndVideoBeta".localized(), - body: .text("callsVoiceAndVideoModalDescription" - .put(key: "session_foundation", value: Constants.session_foundation) - .localized()), + body: .text( + "callsVoiceAndVideoModalDescription" + .put(key: "session_foundation", value: Constants.session_foundation) + .localized() + ), showCondition: .disabled, confirmTitle: "theContinue".localized(), confirmStyle: .danger, diff --git a/Session/Settings/RecoveryPasswordScreen.swift b/Session/Settings/RecoveryPasswordScreen.swift index 1fd3034852..989a75a93b 100644 --- a/Session/Settings/RecoveryPasswordScreen.swift +++ b/Session/Settings/RecoveryPasswordScreen.swift @@ -91,19 +91,25 @@ struct RecoveryPasswordScreen: View { self.showQRCode.toggle() } } label: { - Text("recoveryPasswordView".localized()) - .bold() - .font(.system(size: Values.verySmallFontSize)) - .foregroundColor(themeColor: .textPrimary) - .frame( - maxWidth: Self.buttonWidth, - maxHeight: Values.mediumSmallButtonHeight, - alignment: .center - ) - .overlay( - Capsule() - .stroke(themeColor: .textPrimary) - ) + HStack { + Spacer() + + Text("recoveryPasswordView".localized()) + .bold() + .font(.system(size: Values.verySmallFontSize)) + .foregroundColor(themeColor: .textPrimary) + .frame( + maxHeight: Values.mediumSmallButtonHeight, + alignment: .center + ) + .padding(.horizontal, Values.mediumSmallSpacing) + .overlay( + Capsule() + .stroke(themeColor: .textPrimary) + ) + + Spacer() + } } } .frame(maxWidth: .infinity) diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 52ae1b611a..278f21f9c0 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import PhotosUI import Combine import Lucide import GRDB @@ -17,12 +18,13 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl public let observableState: ObservableTableSourceState = ObservableTableSourceState() private var updatedName: String? - private var onDisplayPictureSelected: ((ConfirmationModal.ValueUpdate) -> Void)? + private var onDisplayPictureSelected: ((ImageDataManager.DataSource, CGRect?) -> Void)? private lazy var imagePickerHandler: ImagePickerHandler = ImagePickerHandler( onTransition: { [weak self] in self?.transitionToScreen($0, transitionType: $1) }, - onImageDataPicked: { [weak self] identifier, resultImageData in - self?.onDisplayPictureSelected?(.image(identifier: identifier, data: resultImageData)) - } + onImagePicked: { [weak self] source, cropRect in + self?.onDisplayPictureSelected?(source, cropRect) + }, + using: dependencies ) /// This value is the current state of the view @@ -33,7 +35,10 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl @MainActor init(using dependencies: Dependencies) { self.dependencies = dependencies - self.internalState = State.initialState(userSessionId: dependencies[cache: .general].sessionId) + self.internalState = State.initialState( + userSessionId: dependencies[cache: .general].sessionId, + isSessionPro: dependencies[cache: .libSession].isSessionPro + ) bindState() } @@ -42,6 +47,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl enum NavItem: Equatable { case close + case edit case qrCode } @@ -49,8 +55,8 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl case profileInfo case sessionId - case donationAndCommunity - case network + case sessionProAndCommunity + case donationAndnetwork case settings case helpAndData @@ -66,7 +72,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl var style: SessionTableSectionStyle { switch self { case .sessionId: return .titleSeparator - case .donationAndCommunity, .network, .settings, .helpAndData: return .padding + case .sessionProAndCommunity, .donationAndnetwork, .settings, .helpAndData: return .padding default: return .none } } @@ -79,9 +85,10 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl case sessionId case idActions - case donate + case sessionPro case inviteAFriend + case donate case path case sessionNetwork @@ -112,7 +119,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl lazy var rightNavItems: AnyPublisher<[SessionNavItem], Never> = [ SessionNavItem( id: .qrCode, - image: UIImage(named: "QRCode")? + image: Lucide.image(icon: .qrCode, size: 24)? .withRenderingMode(.alwaysTemplate), style: .plain, accessibilityIdentifier: "View QR code", @@ -123,6 +130,24 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl viewController.setNavBarTitle("qrCode".localized()) self?.transitionToScreen(viewController) } + ), + SessionNavItem( + id: .edit, + image: Lucide.image(icon: .pencil, size: 22)? + .withRenderingMode(.alwaysTemplate), + style: .plain, + accessibilityIdentifier: "Edit Profile Name", + action: { [weak self] in + Task { @MainActor [weak self] in + guard let self = self else { return } + self.transitionToScreen( + ConfirmationModal( + info: self.updateDisplayName(current: self.internalState.profile.displayName()) + ), + transitionType: .present + ) + } + } ) ] @@ -131,6 +156,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl public struct State: ObservableKeyProvider { let userSessionId: SessionId let profile: Profile + let isSessionPro: Bool let serviceNetwork: ServiceNetwork let forceOffline: Bool let developerModeEnabled: Bool @@ -145,15 +171,18 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl .profile(userSessionId.hexString), .feature(.serviceNetwork), .feature(.forceOffline), + .feature(.mockCurrentUserSessionPro), .setting(.developerModeEnabled), .setting(.hideRecoveryPasswordPermanently) + // TODO: [PRO] Need to observe changes to the users pro status ] } - static func initialState(userSessionId: SessionId) -> State { + static func initialState(userSessionId: SessionId, isSessionPro: Bool) -> State { return State( userSessionId: userSessionId, profile: Profile.defaultFor(userSessionId.hexString), + isSessionPro: isSessionPro, serviceNetwork: .mainnet, forceOffline: false, developerModeEnabled: false, @@ -187,6 +216,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ) async -> State { /// Store mutable copies of the data to update var profile: Profile = previousState.profile + var isSessionPro: Bool = previousState.isSessionPro var serviceNetwork: ServiceNetwork = previousState.serviceNetwork var forceOffline: Bool = previousState.forceOffline var developerModeEnabled: Bool = previousState.developerModeEnabled @@ -203,6 +233,17 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } } + /// If the users profile picture doesn't exist on disk then clear out the value (that way if we get events after downloading + /// it then then there will be a diff in the `State` and the UI will update + if + let displayPictureUrl: String = profile.displayPictureUrl, + let filePath: String = try? dependencies[singleton: .displayPictureManager] + .path(for: displayPictureUrl), + !dependencies[singleton: .fileManager].fileExists(atPath: filePath) + { + profile = profile.with(displayPictureUrl: .set(to: nil)) + } + /// Process any event changes let groupedEvents: [GenericObservableKey: Set]? = events .reduce(into: [:]) { result, event in @@ -211,8 +252,8 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl groupedEvents?[.profile]?.forEach { event in switch (event.value as? ProfileEvent)?.change { case .name(let name): profile = profile.with(name: name) - case .nickname(let nickname): profile = profile.with(nickname: nickname) - case .displayPictureUrl(let url): profile = profile.with(displayPictureUrl: url) + case .nickname(let nickname): profile = profile.with(nickname: .set(to: nickname)) + case .displayPictureUrl(let url): profile = profile.with(displayPictureUrl: .set(to: url)) default: break } } @@ -236,12 +277,18 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl forceOffline = updatedValue } + else if event.key == .feature(.mockCurrentUserSessionPro) { + guard let updatedValue: Bool = event.value as? Bool else { return } + + isSessionPro = updatedValue + } } /// Generate the new state return State( userSessionId: previousState.userSessionId, profile: profile, + isSessionPro: isSessionPro, serviceNetwork: serviceNetwork, forceOffline: forceOffline, developerModeEnabled: developerModeEnabled, @@ -263,13 +310,17 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl switch (state.serviceNetwork, state.forceOffline) { case (.testnet, false): return .letter("T", false) // stringlint:ignore case (.testnet, true): return .letter("T", true) // stringlint:ignore - default: return .none + default: return (state.profile.displayPictureUrl?.isEmpty == false) ? .pencil : .rightPlus } }() ), styling: SessionCell.StyleInfo( alignment: .centerHugging, - customPadding: SessionCell.Padding(bottom: Values.smallSpacing), + customPadding: SessionCell.Padding( + top: 0, + leading: 0, + bottom: Values.smallSpacing + ), backgroundStyle: .noBackground ), accessibility: Accessibility( @@ -285,18 +336,16 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl title: SessionCell.TextInfo( state.profile.displayName(), font: .titleLarge, - alignment: .center - ), - trailingAccessory: .icon( - .pencil, - size: .small, - customTint: .textSecondary + alignment: .center, + trailingImage: (state.isSessionPro ? + ("ProBadge", { SessionProBadge(size: .medium).toImage(using: viewModel.dependencies) }) : + nil + ) ), styling: SessionCell.StyleInfo( alignment: .centerHugging, customPadding: SessionCell.Padding( top: Values.smallSpacing, - leading: IconSize.small.size, bottom: Values.mediumSpacing, interItem: 0 ), @@ -367,20 +416,16 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ) ] ) - let donationAndCommunity: SectionModel = SectionModel( - model: .donationAndCommunity, + let sessionProAndCommunity: SectionModel = SectionModel( + model: .sessionProAndCommunity, elements: [ SessionCell.Info( - id: .donate, - leadingAccessory: .icon( - .heart, - customTint: .sessionButton_border - ), - title: "donate".localized(), - styling: SessionCell.StyleInfo( - tintColor: .sessionButton_border - ), - onTap: { [weak viewModel] in viewModel?.openDonationsUrl() } + id: .sessionPro, + leadingAccessory: .proBadge(size: .small), + title: Constants.app_pro, + onTap: { [weak viewModel] in + // TODO: Implement + } ), SessionCell.Info( id: .inviteAFriend, @@ -404,9 +449,18 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ) ] ) - let network: SectionModel = SectionModel( - model: .network, + let donationAndNetwork: SectionModel = SectionModel( + model: .donationAndnetwork, elements: [ + SessionCell.Info( + id: .donate, + leadingAccessory: .icon( + .heart, + customTint: .sessionButton_border + ), + title: "donate".localized(), + onTap: { [weak viewModel] in viewModel?.openDonationsUrl() } + ), SessionCell.Info( id: .path, leadingAccessory: .custom( @@ -424,9 +478,6 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl .withRenderingMode(.alwaysTemplate) ), title: Constants.network_name, - trailingAccessory: .custom( - info: NewTagView.Info() - ), onTap: { [weak viewModel, dependencies = viewModel.dependencies] in let viewController: SessionHostingViewController = SessionHostingViewController( rootView: SessionNetworkScreen( @@ -579,7 +630,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl elements: helpAndDataElements ) - return [profileInfo, sessionId, donationAndCommunity, network, settings, helpAndData] + return [profileInfo, sessionId, sessionProAndCommunity, donationAndNetwork, settings, helpAndData] } public lazy var footerView: AnyPublisher = Just(VersionFooterView( @@ -627,6 +678,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl self?.updatedName != current }, cancelStyle: .alert_text, + hasCloseButton: true, dismissOnConfirm: false, onConfirm: { [weak self] modal in guard @@ -652,19 +704,26 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl private func updateProfilePicture(currentUrl: String?) { let iconName: String = "profile_placeholder" // stringlint:ignore var hasSetNewProfilePicture: Bool = false - let body: ConfirmationModal.Info.Body = .image( - source: nil, - placeholder: currentUrl + let currentSource: ImageDataManager.DataSource? = { + let source: ImageDataManager.DataSource? = currentUrl .map { try? dependencies[singleton: .displayPictureManager].path(for: $0) } .map { ImageDataManager.DataSource.url(URL(fileURLWithPath: $0)) } - .defaulting(to: Lucide.image(icon: .image, size: 40).map { image in + + return (source?.contentExists == true ? source : nil) + }() + let body: ConfirmationModal.Info.Body = .image( + source: nil, + placeholder: ( + currentSource ?? + Lucide.image(icon: .image, size: 40).map { image in ImageDataManager.DataSource.image( iconName, image .withTintColor(#colorLiteral(red: 0.631372549, green: 0.6352941176, blue: 0.631372549, alpha: 1), renderingMode: .alwaysTemplate) .withCircularBackground(backgroundColor: #colorLiteral(red: 0.1764705882, green: 0.1764705882, blue: 0.1764705882, alpha: 1)) ) - }), + } + ), icon: (currentUrl != nil ? .pencil : .rightPlus), style: .circular, description: { @@ -676,7 +735,8 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl at: .leading, font: .systemFont(ofSize: Values.smallFontSize), textColor: .textSecondary, - proBadgeSize: .small + proBadgeSize: .small, + using: dependencies ): "proAnimatedDisplayPicturesNonProModalDescription" .localized() @@ -684,7 +744,8 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl at: .trailing, font: .systemFont(ofSize: Values.smallFontSize), textColor: .textSecondary, - proBadgeSize: .small + proBadgeSize: .small, + using: dependencies ) }(), accessibility: Accessibility( @@ -705,8 +766,13 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } }, onClick: { [weak self] onDisplayPictureSelected in - self?.onDisplayPictureSelected = { valueUpdate in - onDisplayPictureSelected(valueUpdate) + self?.onDisplayPictureSelected = { source, cropRect in + onDisplayPictureSelected(.image( + source: source, + cropRect: cropRect, + replacementIcon: .pencil, + replacementCancelTitle: "clear".localized() + )) hasSetNewProfilePicture = true } self?.showPhotoLibraryForAvatar() @@ -721,25 +787,23 @@ 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(.some(let source), _, _, _, _, _, _, _, _): return source.contentExists default: return false } }, cancelTitle: "remove".localized(), - cancelEnabled: (currentUrl != nil) ? .bool(true) : .afterChange { info in + cancelEnabled: (currentUrl != nil ? .bool(true) : .afterChange { info in switch info.body { - case .image(let source, _, _, _, _, _, _, _, _): return (source?.imageData != nil) + case .image(.some(let source), _, _, _, _, _, _, _, _): return source.contentExists default: return false } - }, + }), hasCloseButton: true, dismissOnConfirm: false, onConfirm: { [weak self, dependencies] modal in switch modal.info.body { - case .image(.some(let source), _, _, _, _, _, _, _, _): - guard let imageData: Data = source.imageData else { return } - - let isAnimatedImage: Bool = ImageDataManager.isAnimatedImage(imageData) + case .image(.some(let source), _, _, let style, _, _, _, _, _): + let isAnimatedImage: Bool = ImageDataManager.isAnimatedImage(source) guard ( !isAnimatedImage || dependencies[cache: .libSession].isSessionPro || @@ -757,9 +821,16 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } return } - + self?.updateProfile( - displayPictureUpdate: .currentUserUploadImageData(data: imageData, isReupload: false), + displayPictureUpdateGenerator: { [weak self] in + guard let self = self else { throw AttachmentError.uploadFailed } + + return try await uploadDisplayPicture( + source: source, + cropRect: style.cropRect + ) + }, onComplete: { [weak modal] in modal?.close() } ) @@ -777,7 +848,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl hasSetNewProfilePicture = false } else { self?.updateProfile( - displayPictureUpdate: .currentUserRemove, + displayPictureUpdateGenerator: { .currentUserRemove }, onComplete: { [weak modal] in modal?.close() } ) } @@ -791,9 +862,11 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl @MainActor private func showPhotoLibraryForAvatar() { Permissions.requestLibraryPermissionIfNeeded(isSavingMedia: false, using: dependencies) { [weak self] in DispatchQueue.main.async { - let picker: UIImagePickerController = UIImagePickerController() - picker.sourceType = .photoLibrary - picker.mediaTypes = [ "public.image" ] // stringlint:ignore + var configuration: PHPickerConfiguration = PHPickerConfiguration() + configuration.selectionLimit = 1 + configuration.filter = .any(of: [.images, .livePhotos]) + + let picker: PHPickerViewController = PHPickerViewController(configuration: configuration) picker.delegate = self?.imagePickerHandler self?.transitionToScreen(picker, transitionType: .present) @@ -801,55 +874,80 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } } + fileprivate func uploadDisplayPicture( + source: ImageDataManager.DataSource, + cropRect: CGRect? + ) async throws -> DisplayPictureManager.Update { + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .media(source), + using: dependencies + ) + let preparedAttachment: PreparedAttachment = try await dependencies[singleton: .displayPictureManager].prepareDisplayPicture( + attachment: pendingAttachment, + fallbackIfConversionTakesTooLong: true, + cropRect: cropRect + ) + let result = try await dependencies[singleton: .displayPictureManager] + .uploadDisplayPicture(preparedAttachment: preparedAttachment) + + return .currentUserUpdateTo( + url: result.downloadUrl, + key: result.encryptionKey, + sessionProProof: dependencies.mutate(cache: .libSession) { $0.getCurrentUserProProof() }, + isReupload: false + ) + } + @MainActor fileprivate func updateProfile( displayNameUpdate: Profile.DisplayNameUpdate = .none, - displayPictureUpdate: DisplayPictureManager.Update = .none, + displayPictureUpdateGenerator generator: @escaping () async throws -> DisplayPictureManager.Update = { .none }, onComplete: @escaping () -> () ) { - let viewController = ModalActivityIndicatorViewController(canCancel: false) { [weak self, dependencies] modalActivityIndicator in - Profile - .updateLocal( + let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController() + self.transitionToScreen(indicator, transitionType: .present) + + Task.detached(priority: .userInitiated) { [weak self, indicator, dependencies] in + var displayPictureUpdate: DisplayPictureManager.Update = .none + + do { + displayPictureUpdate = try await generator() + try await Profile.updateLocal( displayNameUpdate: displayNameUpdate, displayPictureUpdate: displayPictureUpdate, using: dependencies ) - .subscribe(on: DispatchQueue.global(qos: .default), using: dependencies) - .receive(on: DispatchQueue.main, using: dependencies) - .sinkUntilComplete( - receiveCompletion: { result in - modalActivityIndicator.dismiss { - switch result { - case .finished: onComplete() - case .failure(let error): - let message: String = { - switch (displayPictureUpdate, error) { - case (.currentUserRemove, _): return "profileDisplayPictureRemoveError".localized() - case (_, .uploadMaxFileSizeExceeded): - return "profileDisplayPictureSizeError".localized() - - default: return "errorConnection".localized() - } - }() - - self?.transitionToScreen( - ConfirmationModal( - info: ConfirmationModal.Info( - title: "profileErrorUpdate".localized(), - body: .text(message), - cancelTitle: "okay".localized(), - cancelStyle: .alert_text, - dismissType: .single - ) - ), - transitionType: .present - ) - } - } + + await indicator.dismiss { + onComplete() + } + } + catch { + let message: String = { + switch (displayPictureUpdate, error) { + case (.currentUserRemove, _): return "profileDisplayPictureRemoveError".localized() + case (_, AttachmentError.fileSizeTooLarge): + return "profileDisplayPictureSizeError".localized() + + default: return "errorConnection".localized() } - ) + }() + + await indicator.dismiss { + self?.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "profileErrorUpdate".localized(), + body: .text(message), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text, + dismissType: .single + ) + ), + transitionType: .present + ) + } + } } - - self.transitionToScreen(viewController, transitionType: .present) } private func copySessionId(_ sessionId: String, button: SessionButton?) { diff --git a/Session/Settings/Views/ThemeMessagePreviewView.swift b/Session/Settings/Views/ThemeMessagePreviewView.swift index e82f5b444a..5d833e6363 100644 --- a/Session/Settings/Views/ThemeMessagePreviewView.swift +++ b/Session/Settings/Views/ThemeMessagePreviewView.swift @@ -18,12 +18,7 @@ final class ThemeMessagePreviewView: UIView { with: MessageViewModel( variant: .standardIncoming, body: "appearancePreview2".localized(), - quote: Quote( - interactionId: -1, - authorId: "", - timestampMs: 0, - body: "appearancePreview1".localized() - ), + quotedInfo: MessageViewModel.QuotedInfo(previewBody: "appearancePreview1".localized()), cellType: .textOnlyMessage ), playbackInfo: nil, diff --git a/Session/Shared/BaseVC.swift b/Session/Shared/BaseVC.swift index 2e3d122f45..531395ed97 100644 --- a/Session/Shared/BaseVC.swift +++ b/Session/Shared/BaseVC.swift @@ -2,8 +2,10 @@ import UIKit import SessionUIKit +import Combine public class BaseVC: UIViewController { + private var disposables: Set = Set() public var onViewWillAppear: ((UIViewController) -> Void)? public var onViewWillDisappear: ((UIViewController) -> Void)? public var onViewDidDisappear: ((UIViewController) -> Void)? @@ -81,16 +83,36 @@ public class BaseVC: UIViewController { navigationItem.titleView = container } - internal func setUpNavBarSessionHeading() { + internal func setUpNavBarSessionHeading(currentUserSessionProState: SessionProManagerType) { let headingImageView = UIImageView( image: UIImage(named: "SessionHeading")? .withRenderingMode(.alwaysTemplate) ) headingImageView.themeTintColor = .textPrimary headingImageView.contentMode = .scaleAspectFit - headingImageView.set(.width, to: 150) + headingImageView.set(.width, to: 140) headingImageView.set(.height, to: Values.mediumFontSize) - navigationItem.titleView = headingImageView + let sessionProBadge: SessionProBadge = SessionProBadge(size: .medium) + sessionProBadge.isHidden = !currentUserSessionProState.isSessionProSubject.value + + let stackView: UIStackView = UIStackView( + arrangedSubviews: MainAppContext.determineDeviceRTL() ? [ sessionProBadge, headingImageView ] : [ headingImageView, sessionProBadge ] + ) + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = 0 + + currentUserSessionProState.isSessionProPublisher + .subscribe(on: DispatchQueue.main) + .receive(on: DispatchQueue.main) + .sink( + receiveValue: { [weak sessionProBadge] isPro in + sessionProBadge?.isHidden = !isPro + } + ) + .store(in: &disposables) + + navigationItem.titleView = stackView } } diff --git a/Session/Shared/CaptionView.swift b/Session/Shared/CaptionView.swift deleted file mode 100644 index bc1d4a2011..0000000000 --- a/Session/Shared/CaptionView.swift +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. - -import UIKit -import SessionUIKit -import SessionUtilitiesKit - -public protocol CaptionContainerViewDelegate: AnyObject { - func captionContainerViewDidUpdateText(_ captionContainerView: CaptionContainerView) -} - -public class CaptionContainerView: UIView { - - weak var delegate: CaptionContainerViewDelegate? - - var currentText: String? { - get { return currentCaptionView.text } - set { - currentCaptionView.text = newValue - delegate?.captionContainerViewDidUpdateText(self) - } - } - - var pendingText: String? { - get { return pendingCaptionView.text } - set { - pendingCaptionView.text = newValue - delegate?.captionContainerViewDidUpdateText(self) - } - } - - func updatePagerTransition(ratioComplete: CGFloat) { - if let currentText = self.currentText, currentText.count > 0 { - currentCaptionView.alpha = 1 - ratioComplete - } else { - currentCaptionView.alpha = 0 - } - - if let pendingText = self.pendingText, pendingText.count > 0 { - pendingCaptionView.alpha = ratioComplete - } else { - pendingCaptionView.alpha = 0 - } - } - - func completePagerTransition() { - updatePagerTransition(ratioComplete: 1) - - // promote "pending" to "current" caption view. - let oldCaptionView = self.currentCaptionView - self.currentCaptionView = self.pendingCaptionView - self.pendingCaptionView = oldCaptionView - self.pendingText = nil - self.currentCaptionView.alpha = 1 - self.pendingCaptionView.alpha = 0 - } - - // MARK: Initializers - - override init(frame: CGRect) { - super.init(frame: frame) - - setContentHugging(to: .required) - setCompressionResistance(to: .required) - - addSubview(currentCaptionView) - currentCaptionView.pin(.top, greaterThanOrEqualTo: .top, of: self) - currentCaptionView.pin(.leading, to: .leading, of: self) - currentCaptionView.pin(.trailing, to: .trailing, of: self) - currentCaptionView.pin(.bottom, to: .bottom, of: self) - - pendingCaptionView.alpha = 0 - addSubview(pendingCaptionView) - pendingCaptionView.pin(.top, greaterThanOrEqualTo: .top, of: self) - pendingCaptionView.pin(.leading, to: .leading, of: self) - pendingCaptionView.pin(.trailing, to: .trailing, of: self) - pendingCaptionView.pin(.bottom, to: .bottom, of: self) - } - - public required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Subviews - - private var pendingCaptionView: CaptionView = CaptionView() - private var currentCaptionView: CaptionView = CaptionView() -} - -private class CaptionView: UIView { - - var text: String? { - get { return textView.text } - - set { - if let captionText = newValue, captionText.count > 0 { - textView.text = captionText - } else { - textView.text = nil - } - } - } - - // MARK: Subviews - - let textView: CaptionTextView = { - let textView = CaptionTextView() - - textView.font = UIFont.preferredFont(forTextStyle: .body) - textView.themeTextColor = .textPrimary - textView.themeBackgroundColor = .clear - textView.isEditable = false - textView.isSelectable = false - - return textView - }() - - let scrollFadeView: GradientView = { - let result: GradientView = GradientView() - result.themeBackgroundGradient = [ - .clear, - .black - ] - - return result - }() - - // MARK: Initializers - - override init(frame: CGRect) { - super.init(frame: frame) - - addSubview(textView) - textView.pin(toMarginsOf: self) - - addSubview(scrollFadeView) - scrollFadeView.pin(.leading, to: .leading, of: self) - scrollFadeView.pin(.trailing, to: .trailing, of: self) - scrollFadeView.pin(.bottom, to: .bottom, of: self) - scrollFadeView.set(.height, to: 20) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: UIView overrides - - override func layoutSubviews() { - super.layoutSubviews() - scrollFadeView.isHidden = !textView.doesContentNeedScroll - } - - // MARK: - - - class CaptionTextView: UITextView { - var kMaxHeight: CGFloat = Values.scaleFromIPhone5(200) - - override var text: String! { - didSet { - invalidateIntrinsicContentSize() - } - } - - override var font: UIFont? { - didSet { - invalidateIntrinsicContentSize() - } - } - - var doesContentNeedScroll: Bool { - return self.bounds.height == kMaxHeight - } - - override func layoutSubviews() { - super.layoutSubviews() - - // Enable/disable scrolling depending on wether we've clipped - // content in `intrinsicContentSize` - isScrollEnabled = doesContentNeedScroll - } - - override var intrinsicContentSize: CGSize { - var size = super.intrinsicContentSize - - if size.height == UIView.noIntrinsicMetric { - size.height = layoutManager.usedRect(for: textContainer).height + textContainerInset.top + textContainerInset.bottom - } - size.height = min(kMaxHeight, size.height) - - return size - } - } -} diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index dc3aecf9d1..3065e50df8 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -26,11 +26,15 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC dataManager: nil ) - private lazy var displayNameLabel: UILabel = { - let result: UILabel = UILabel() + private lazy var displayNameLabel: SessionLabelWithProBadge = { + let result: SessionLabelWithProBadge = SessionLabelWithProBadge( + proBadgeSize: .small, + withStretchingSpacer: false + ) result.font = .boldSystemFont(ofSize: Values.mediumFontSize) result.themeTextColor = .textPrimary result.lineBreakMode = .byTruncatingTail + result.isProBadgeHidden = true return result }() @@ -300,6 +304,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC string: cellViewModel.displayName, attributes: [ .themeForegroundColor: ThemeValue.textPrimary ] ) + displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) } public func updateForMessageSearchResult( @@ -328,6 +333,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC string: cellViewModel.displayName, attributes: [ .themeForegroundColor: ThemeValue.textPrimary ] ) + displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) snippetLabel.themeAttributedText = getHighlightedSnippet( content: Interaction.previewText( variant: (cellViewModel.interactionVariant ?? .standardIncoming), @@ -378,6 +384,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC textColor: .textPrimary, using: dependencies ) + displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) switch cellViewModel.threadVariant { case .contact, .community: bottomLabelStackView.isHidden = true @@ -440,6 +447,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC using: dependencies ) displayNameLabel.text = cellViewModel.displayName + displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay if cellViewModel.threadContactIsTyping == true { @@ -597,7 +605,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC string: "messageSnippetGroup" .put(key: "author", value: authorName) .put(key: "message_snippet", value: "") - .localized(), + .localizedDeformatted(), attributes: [ .themeForegroundColor: textColor ] )) } @@ -607,7 +615,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC case .infoGroupCurrentUserErrorLeaving: return "groupLeaveErrorFailed" .put(key: "group_name", value: cellViewModel.displayName) - .localized() + .localizedDeformatted() default: return Interaction.previewText( @@ -619,7 +627,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC attachmentCount: cellViewModel.interactionAttachmentCount, isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true), using: dependencies - ) + ).localizedDeformatted() } }() diff --git a/Session/Shared/ScreenLockWindow.swift b/Session/Shared/ScreenLockWindow.swift index 0a2c4e9a9e..d41dfb1ef5 100644 --- a/Session/Shared/ScreenLockWindow.swift +++ b/Session/Shared/ScreenLockWindow.swift @@ -118,7 +118,9 @@ 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.mutate(cache: .libSession, { $0.get(.isScreenLockEnabled) }) + if dependencies[cache: .general].userExists { + self?.isScreenLockLocked = dependencies.mutate(cache: .libSession, { $0.get(.isScreenLockEnabled) }) + } switch Thread.isMainThread { case true: self?.ensureUI() @@ -127,6 +129,9 @@ public class ScreenLockWindow { } } + /// Checks if app has been unlocked + public func checkIfScreenIsUnlocked() -> Bool { !isScreenLockLocked } + // MARK: - Functions private func determineDesiredUIState() -> ScreenLockViewController.State { diff --git a/Session/Shared/SessionTableViewController.swift b/Session/Shared/SessionTableViewController.swift index 087091c28f..d0dcd68d58 100644 --- a/Session/Shared/SessionTableViewController.swift +++ b/Session/Shared/SessionTableViewController.swift @@ -96,7 +96,7 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa result.delegate = self result.sectionHeaderTopPadding = 0 result.rowHeight = UITableView.automaticDimension - result.estimatedRowHeight = 56 // Approximate size of an [{Icon} {Text}] SessionCell + result.estimatedRowHeight = UITableView.automaticDimension return result }() @@ -558,7 +558,7 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa guard info.isEnabled else { return } // Get the view that was tapped (for presenting on iPad) - let tappedView: UIView? = { + let tappedView: UIView? = { () -> UIView? in guard let cell: SessionCell = tableView.cellForRow(at: indexPath) as? SessionCell else { return nil } @@ -567,6 +567,15 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa let touchLocation: UITouch? = cell.lastTouchLocation cell.lastTouchLocation = nil + if + info.title?.trailingImage != nil, + let localPoint: CGPoint = touchLocation?.location(in: cell.titleLabel), + cell.titleLabel.bounds.contains(localPoint), + cell.titleLabel.isPointOnTrailingAttachment(localPoint) == true + { + return SessionProBadge(size: .large) + } + switch (info.leadingAccessory, info.trailingAccessory) { case (_, is SessionCell.AccessoryConfig.HighlightingBackgroundLabel): return (!cell.trailingAccessoryView.isHidden ? cell.trailingAccessoryView : cell) @@ -582,7 +591,10 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa return cell.trailingAccessoryView.touchedView(touchLocation) - case (is SessionCell.AccessoryConfig.HighlightingBackgroundLabelAndRadio, _): + case + (is SessionCell.AccessoryConfig.HighlightingBackgroundLabelAndRadio, _), + (is SessionCell.AccessoryConfig.DisplayPicture, _), + (is SessionCell.AccessoryConfig.QRCode, _): guard let touchLocation: UITouch = touchLocation, !cell.leadingAccessoryView.isHidden diff --git a/Session/Shared/Types/NavigatableState.swift b/Session/Shared/Types/NavigatableState.swift index 89a7d3f895..97aed06d64 100644 --- a/Session/Shared/Types/NavigatableState.swift +++ b/Session/Shared/Types/NavigatableState.swift @@ -124,7 +124,7 @@ public extension Publisher { return Deferred { Future { promise in - Task { @MainActor in + DispatchQueue.main.async { promise(.success(ModalActivityIndicatorViewController(onAppear: { _ in }))) } } @@ -140,9 +140,11 @@ public extension Publisher { .flatMap { result -> AnyPublisher in Deferred { Future { resolver in - indicator.dismiss(completion: { - resolver(result) - }) + DispatchQueue.main.async { + indicator.dismiss(completion: { + resolver(result) + }) + } } }.eraseToAnyPublisher() } diff --git a/Session/Shared/Types/SessionCell+Accessory.swift b/Session/Shared/Types/SessionCell+Accessory.swift index 63025785d7..e618758e95 100644 --- a/Session/Shared/Types/SessionCell+Accessory.swift +++ b/Session/Shared/Types/SessionCell+Accessory.swift @@ -35,6 +35,28 @@ public extension SessionCell { // MARK: - DSL public extension SessionCell.Accessory { + static func qrCode( + for string: String, + hasBackground: Bool, + logo: String? = nil, + themeStyle: UIUserInterfaceStyle + ) -> SessionCell.Accessory { + return SessionCell.AccessoryConfig.QRCode( + for: string, + hasBackground: hasBackground, + logo: logo, + themeStyle: themeStyle + ) + } + + static func proBadge( + size: SessionProBadge.Size + ) -> SessionCell.Accessory { + return SessionCell.AccessoryConfig.ProBadge( + proBadgeSize: size + ) + } + static func icon( _ icon: Lucide.Icon, size: IconSize = .medium, @@ -220,6 +242,80 @@ public extension SessionCell.Accessory { // stringlint:ignore_contents public extension SessionCell.AccessoryConfig { + // MARK: - QRCode + + class QRCode: SessionCell.Accessory { + override public var viewIdentifier: String { + "qr-code" + } + + public let string: String + public let hasBackground: Bool + public let logo: String? + public let themeStyle: UIUserInterfaceStyle + + fileprivate init( + for string: String, + hasBackground: Bool, + logo: String? = nil, + themeStyle: UIUserInterfaceStyle + ) { + self.string = string + self.hasBackground = hasBackground + self.logo = logo + self.themeStyle = themeStyle + + super.init(accessibility: Accessibility(identifier: "Session QRCode")) + } + + // MARK: - Conformance + + override public func hash(into hasher: inout Hasher) { + string.hash(into: &hasher) + hasBackground.hash(into: &hasher) + logo?.hash(into: &hasher) + themeStyle.hash(into: &hasher) + } + + override fileprivate func isEqual(to other: SessionCell.Accessory) -> Bool { + guard let rhs: QRCode = other as? QRCode else { return false } + + return ( + string == rhs.string && + hasBackground == rhs.hasBackground && + logo == rhs.logo && + themeStyle == rhs.themeStyle + ) + } + } + + // MARK: - Pro Badge + + class ProBadge: SessionCell.Accessory { + override public var viewIdentifier: String { + "pro-badge" + } + + public let proBadgeSize: SessionProBadge.Size + + fileprivate init(proBadgeSize: SessionProBadge.Size) { + self.proBadgeSize = proBadgeSize + super.init(accessibility: Accessibility(identifier: "Session Pro Badge")) + } + + // MARK: - Conformance + + override public func hash(into hasher: inout Hasher) { + proBadgeSize.hash(into: &hasher) + } + + override fileprivate func isEqual(to other: SessionCell.Accessory) -> Bool { + guard let rhs: ProBadge = other as? ProBadge else { return false } + + return (proBadgeSize == rhs.proBadgeSize) + } + } + // MARK: - Icon class Icon: SessionCell.Accessory { @@ -580,8 +676,6 @@ public extension SessionCell.AccessoryConfig { public let additionalProfile: Profile? public let additionalProfileIcon: ProfilePictureView.ProfileIcon - override public var shouldFitToEdge: Bool { true } - fileprivate init( id: String, size: ProfilePictureView.Size, diff --git a/Session/Shared/Types/SessionCell+Info.swift b/Session/Shared/Types/SessionCell+Info.swift index cd32ff0e8a..0d499b8752 100644 --- a/Session/Shared/Types/SessionCell+Info.swift +++ b/Session/Shared/Types/SessionCell+Info.swift @@ -126,7 +126,8 @@ public extension SessionCell.Info { isEnabled: Bool = true, accessibility: Accessibility? = nil, confirmationInfo: ConfirmationModal.Info? = nil, - onTap: (@MainActor () -> Void)? = nil + onTap: (@MainActor () -> Void)? = nil, + onTapView: (@MainActor (UIView?) -> Void)? = nil ) { self.id = id self.position = position @@ -140,7 +141,7 @@ public extension SessionCell.Info { self.accessibility = accessibility self.confirmationInfo = confirmationInfo self.onTap = onTap - self.onTapView = nil + self.onTapView = onTapView } // leadingAccessory, trailingAccessory diff --git a/Session/Shared/Types/SessionCell+Styling.swift b/Session/Shared/Types/SessionCell+Styling.swift index 8df10aa317..6eb1048924 100644 --- a/Session/Shared/Types/SessionCell+Styling.swift +++ b/Session/Shared/Types/SessionCell+Styling.swift @@ -18,6 +18,7 @@ public extension SessionCell { let editingPlaceholder: String? let interaction: Interaction let accessibility: Accessibility? + let trailingImage: (id: String, imageGenerator: (() -> UIImage))? let extraViewGenerator: (() -> UIView)? private let fontStyle: FontStyle @@ -30,6 +31,7 @@ public extension SessionCell { editingPlaceholder: String? = nil, interaction: Interaction = .none, accessibility: Accessibility? = nil, + trailingImage: (id: String, imageGenerator: (() -> UIImage))? = nil, extraViewGenerator: (() -> UIView)? = nil ) { self.text = text @@ -38,6 +40,7 @@ public extension SessionCell { self.editingPlaceholder = editingPlaceholder self.interaction = interaction self.accessibility = accessibility + self.trailingImage = trailingImage self.extraViewGenerator = extraViewGenerator } @@ -50,6 +53,7 @@ public extension SessionCell { interaction.hash(into: &hasher) editingPlaceholder.hash(into: &hasher) accessibility.hash(into: &hasher) + trailingImage?.id.hash(into: &hasher) } public static func == (lhs: TextInfo, rhs: TextInfo) -> Bool { @@ -59,7 +63,8 @@ public extension SessionCell { lhs.textAlignment == rhs.textAlignment && lhs.interaction == rhs.interaction && lhs.editingPlaceholder == rhs.editingPlaceholder && - lhs.accessibility == rhs.accessibility + lhs.accessibility == rhs.accessibility && + lhs.trailingImage?.id == rhs.trailingImage?.id ) } } @@ -110,16 +115,14 @@ public extension SessionCell { var font: UIFont { switch self { case .title: return .boldSystemFont(ofSize: 16) - case .titleLarge: return .systemFont(ofSize: Values.veryLargeFontSize, weight: .medium) + case .titleLarge: return Fonts.Headings.H4 case .titleRegular: return .systemFont(ofSize: 16) case .subtitle: return .systemFont(ofSize: 14) case .subtitleBold: return .boldSystemFont(ofSize: 14) case .monoSmall: return Fonts.spaceMono(ofSize: Values.smallFontSize) - case .monoLarge: return Fonts.spaceMono( - ofSize: (isIPhone5OrSmaller ? Values.mediumFontSize : Values.largeFontSize) - ) + case .monoLarge: return Fonts.Display.extraLarge } } } diff --git a/Session/Shared/UserListViewModel.swift b/Session/Shared/UserListViewModel.swift index ad543a2ea0..6928938b09 100644 --- a/Session/Shared/UserListViewModel.swift +++ b/Session/Shared/UserListViewModel.swift @@ -149,8 +149,15 @@ class UserListViewModel: SessionTableVie profile: userInfo.profile, profileIcon: (showProfileIcons ? userInfo.value.profileIcon : .none) ), - title: title, - subtitle: userInfo.itemDescription(using: dependencies), + title: SessionCell.TextInfo( + title, + font: .title, + trailingImage: { + guard (dependencies.mutate(cache: .libSession) { $0.validateProProof(for: userInfo.profile) }) else { return nil } + return ("ProBadge", { [dependencies] in SessionProBadge(size: .small).toImage(using: dependencies) }) + }() + ), + subtitle: SessionCell.TextInfo(userInfo.itemDescription(using: dependencies), font: .subtitle), trailingAccessory: trailingAccessory, styling: SessionCell.StyleInfo( subtitleTintColor: userInfo.itemDescriptionColor(using: dependencies), diff --git a/Session/Shared/Views/SessionCell+AccessoryView.swift b/Session/Shared/Views/SessionCell+AccessoryView.swift index a3815587ae..1b23ef69d8 100644 --- a/Session/Shared/Views/SessionCell+AccessoryView.swift +++ b/Session/Shared/Views/SessionCell+AccessoryView.swift @@ -39,6 +39,8 @@ extension SessionCell { minWidthConstraint.isActive = false fixedWidthConstraint.constant = AccessoryView.minWidth fixedWidthConstraint.isActive = false + + invalidateIntrinsicContentSize() } public func update( @@ -92,12 +94,21 @@ extension SessionCell { // MARK: - Interaction func touchedView(_ touch: UITouch) -> UIView { - switch (currentContentView, currentContentView?.subviews.first) { - case (let label as SessionHighlightingBackgroundLabel, _), - (_, let label as SessionHighlightingBackgroundLabel): + switch (currentContentView, currentContentView?.subviews.first, currentContentView?.subviews.last) { + case (let label as SessionHighlightingBackgroundLabel, _, _), + (_, let label as SessionHighlightingBackgroundLabel, _): let localPoint: CGPoint = touch.location(in: label) return (label.bounds.contains(localPoint) ? label : self) + case (let profilePictureView as ProfilePictureView, _, _): + let localPoint: CGPoint = touch.location(in: profilePictureView) + + return profilePictureView.getTouchedView(from: localPoint) + + case (_, let qrCodeImageView as UIImageView , .some(let profileIcon)): + let localPoint: CGPoint = touch.location(in: profileIcon) + + return (profileIcon.bounds.contains(localPoint) ? profileIcon : qrCodeImageView) default: return self } @@ -155,6 +166,12 @@ extension SessionCell { using dependencies: Dependencies ) -> UIView? { switch accessory { + case is SessionCell.AccessoryConfig.QRCode: + return createQRCodeView() + + case is SessionCell.AccessoryConfig.ProBadge: + return SessionProBadge(size: .small) + case is SessionCell.AccessoryConfig.Icon: return createIconView(using: dependencies) @@ -192,6 +209,12 @@ extension SessionCell { private func layout(view: UIView?, accessory: Accessory) { switch accessory { + case let accessory as SessionCell.AccessoryConfig.QRCode: + layoutQRCodeView(view) + + case let accessory as SessionCell.AccessoryConfig.ProBadge: + layoutProBadgeView(view, size: accessory.proBadgeSize) + case let accessory as SessionCell.AccessoryConfig.Icon: layoutIconView( view, @@ -241,6 +264,12 @@ extension SessionCell { using dependencies: Dependencies ) { switch accessory { + case let accessory as SessionCell.AccessoryConfig.QRCode: + configureQRCodeView(view, accessory) + + case let accessory as SessionCell.AccessoryConfig.ProBadge: + configureProBadgeView(view, tintColor: .primary) + case let accessory as SessionCell.AccessoryConfig.Icon: configureIconView(view, accessory, tintColor: tintColor) @@ -284,6 +313,87 @@ extension SessionCell { } } + // MARK: -- QRCode + + private func createQRCodeView() -> UIView { + let result: UIView = UIView() + result.layer.cornerRadius = 10 + + let qrCodeImageView: UIImageView = UIImageView() + qrCodeImageView.contentMode = .scaleAspectFit + + result.addSubview(qrCodeImageView) + qrCodeImageView.pin(to: result, withInset: Values.smallSpacing) + result.set(.width, to: 190) + result.set(.height, to: 190) + + let iconImageView: UIImageView = UIImageView( + image: UIImage(named: "ic_user_round_fill")? + .withRenderingMode(.alwaysTemplate) + ) + iconImageView.contentMode = .scaleAspectFit + iconImageView.set(.width, to: 18) + iconImageView.set(.height, to: 18) + iconImageView.themeTintColor = .black + + let iconBackgroudView: UIView = UIView() + iconBackgroudView.themeBackgroundColor = .primary + iconBackgroudView.set(.width, to: 33) + iconBackgroudView.set(.height, to: 33) + iconBackgroudView.layer.cornerRadius = 16.5 + iconBackgroudView.layer.masksToBounds = true + + iconBackgroudView.addSubview(iconImageView) + iconImageView.center(in: iconBackgroudView) + + result.addSubview(iconBackgroudView) + iconBackgroudView.pin(.top, to: .top, of: result, withInset: -10) + iconBackgroudView.pin(.trailing, to: .trailing, of: result, withInset: 17) + + return result + } + + private func layoutQRCodeView(_ view: UIView?) { + guard let view: UIView = view else { return } + + view.pin(to: self) + fixedWidthConstraint.constant = 190 + fixedWidthConstraint.isActive = true + } + + private func configureQRCodeView(_ view: UIView?, _ accessory: SessionCell.AccessoryConfig.QRCode) { + guard + let backgroundView: UIView = view, + let qrCodeImageView: UIImageView = view?.subviews.first as? UIImageView + else { return } + + let backgroundThemeColor: ThemeValue = (accessory.themeStyle == .light ? .backgroundSecondary : .textPrimary) + let qrCodeThemeColor: ThemeValue = (accessory.themeStyle == .light ? .textPrimary : .backgroundPrimary) + let qrCodeImage: UIImage = QRCode + .generate(for: accessory.string, hasBackground: accessory.hasBackground, iconName: accessory.logo) + .withRenderingMode(.alwaysTemplate) + + qrCodeImageView.image = qrCodeImage + qrCodeImageView.themeTintColor = qrCodeThemeColor + backgroundView.themeBackgroundColor = backgroundThemeColor + } + + // MARK: -- Pro Badge + + private func layoutProBadgeView(_ view: UIView?, size: SessionProBadge.Size) { + guard let badgeView: SessionProBadge = view as? SessionProBadge else { return } + badgeView.size = size + badgeView.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing) + badgeView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing) + badgeView.pin(.top, to: .top, of: self) + badgeView.pin(.bottom, to: .bottom, of: self) + } + + private func configureProBadgeView(_ view: UIView?, tintColor: ThemeValue) { + guard let badgeView: SessionProBadge = view as? SessionProBadge else { return } + badgeView.themeBackgroundColor = tintColor + } + // MARK: -- Icon private func createIconView(using dependencies: Dependencies) -> SessionImageView { @@ -614,6 +724,8 @@ extension SessionCell { radioBorderView.center(.vertical, in: self) radioBorderView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing) minWidthConstraint.isActive = true + + view.pin(to: self) } private func configureHighlightingBackgroundLabelAndRadioView( @@ -688,7 +800,7 @@ extension SessionCell { profilePictureView.pin(.leading, to: .leading, of: self) profilePictureView.pin(.trailing, to: .trailing, of: self) profilePictureView.pin(.bottom, to: .bottom, of: self).setting(priority: .defaultHigh) - fixedWidthConstraint.constant = size.viewSize + fixedWidthConstraint.constant = (size.viewSize) fixedWidthConstraint.isActive = true } diff --git a/Session/Shared/Views/SessionCell.swift b/Session/Shared/Views/SessionCell.swift index 768773ff44..8a2aa511ef 100644 --- a/Session/Shared/Views/SessionCell.swift +++ b/Session/Shared/Views/SessionCell.swift @@ -94,7 +94,7 @@ public class SessionCell: UITableViewCell { return result }() - fileprivate let titleLabel: SRCopyableLabel = { + public let titleLabel: SRCopyableLabel = { let result: SRCopyableLabel = SRCopyableLabel() result.translatesAutoresizingMaskIntoConstraints = false result.isUserInteractionEnabled = false @@ -217,12 +217,25 @@ public class SessionCell: UITableViewCell { public override func layoutSubviews() { super.layoutSubviews() + if titleLabel.preferredMaxLayoutWidth != titleLabel.bounds.width { + titleLabel.preferredMaxLayoutWidth = titleLabel.bounds.width + } + + if subtitleLabel.preferredMaxLayoutWidth != subtitleLabel.bounds.width { + subtitleLabel.preferredMaxLayoutWidth = subtitleLabel.bounds.width + } + + if expandableDescriptionLabel.preferredMaxLayoutWidth != expandableDescriptionLabel.bounds.width { + expandableDescriptionLabel.preferredMaxLayoutWidth = expandableDescriptionLabel.bounds.width + } + // Need to force the contentStackView to layout if needed as it might not have updated it's // sizing yet self.contentStackView.layoutIfNeeded() repositionExtraView(titleExtraView, for: titleLabel) repositionExtraView(subtitleExtraView, for: subtitleLabel) self.titleStackView.layoutIfNeeded() + self.layoutIfNeeded() } private func repositionExtraView(_ targetView: UIView?, for label: UILabel) { @@ -319,6 +332,8 @@ public class SessionCell: UITableViewCell { subtitleLabel.isHidden = true expandableDescriptionLabel.isHidden = true botSeparator.isHidden = true + + invalidateIntrinsicContentSize() } @MainActor public func update( @@ -527,6 +542,7 @@ public class SessionCell: UITableViewCell { titleLabel.accessibilityIdentifier = info.title?.accessibility?.identifier titleLabel.accessibilityLabel = info.title?.accessibility?.label titleLabel.isHidden = (info.title == nil) + titleLabel.attachTrailing(info.title?.trailingImage?.imageGenerator) subtitleLabel.isUserInteractionEnabled = (info.subtitle?.interaction == .copy) subtitleLabel.font = info.subtitle?.font subtitleLabel.themeTextColor = info.styling.subtitleTintColor diff --git a/Session/Shared/Views/SessionProBadge+Utilities.swift b/Session/Shared/Views/SessionProBadge+Utilities.swift new file mode 100644 index 0000000000..81f7ccae2b --- /dev/null +++ b/Session/Shared/Views/SessionProBadge+Utilities.swift @@ -0,0 +1,63 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit +import SessionUtilitiesKit + +public extension SessionProBadge.Size{ + // stringlint:ignore_contents + var cacheKey: String { + switch self { + case .mini: return "SessionProBadge.Mini" + case .small: return "SessionProBadge.Small" + case .medium: return "SessionProBadge.Medium" + case .large: return "SessionProBadge.Large" + } + } +} + +public extension SessionProBadge { + func toImage(using dependencies: Dependencies) -> UIImage { + let themePrimaryColor: Theme.PrimaryColor = dependencies + .mutate(cache: .libSession) { $0.get(.themePrimaryColor) } + .defaulting(to: .defaultPrimaryColor) + let cacheKey: String = "\(self.size.cacheKey).\(themePrimaryColor)" // stringlint:ignore + + if let cachedImage = dependencies[cache: .generalUI].get(for: cacheKey) { + return cachedImage + } + + let renderedImage = self.toImage(isOpaque: self.isOpaque, scale: UIScreen.main.scale) + dependencies.mutate(cache: .generalUI) { $0.cache(renderedImage, for: cacheKey) } + return renderedImage + } +} + +public extension String { + enum SessionProBadgePosition { + case leading, trailing + } + + func addProBadge( + at postion: SessionProBadgePosition, + font: UIFont, + textColor: ThemeValue = .textPrimary, + proBadgeSize: SessionProBadge.Size, + spacing: String = " ", + using dependencies: Dependencies + ) -> ThemedAttributedString { + let base = ThemedAttributedString() + switch postion { + case .leading: + base.append(ThemedAttributedString(imageAttachmentGenerator: { SessionProBadge(size: proBadgeSize).toImage(using: dependencies) }, referenceFont: font)) + base.append(ThemedAttributedString(string: spacing)) + base.append(ThemedAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) + case .trailing: + base.append(ThemedAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) + base.append(ThemedAttributedString(string: spacing)) + base.append(ThemedAttributedString(imageAttachmentGenerator: { SessionProBadge(size: proBadgeSize).toImage(using: dependencies) }, referenceFont: font)) + } + + return base + } +} diff --git a/Session/Utilities/IP2Country.swift b/Session/Utilities/IP2Country.swift index 56b9102bba..a5275d714c 100644 --- a/Session/Utilities/IP2Country.swift +++ b/Session/Utilities/IP2Country.swift @@ -217,10 +217,21 @@ fileprivate class IP2Country: IP2CountryCacheType { guard nameCache["\(ip)-\(currentLocale)"] == nil else { return } + /// Code block checks if IP passed is unknown, not supported or blocked guard let ipAsInt: Int64 = IPv4.toInt(ip), - let countryBlockGeonameIdIndex: Int = cache.countryBlocksIPInt.firstIndex(where: { $0 > ipAsInt }).map({ $0 - 1 }), - let localeStartIndex: Int = cache.countryLocationsLocaleCode.firstIndex(where: { $0 == currentLocale }), + let countryBlockGeonameIdIndex: Int = cache.countryBlocksIPInt.firstIndex(where: { $0 > ipAsInt }).map({ $0 - 1 }) + else { return } + + /// Get local index for the current locale + /// When index is not found it should fallback to english + var validLocaleStartIndex: Int? { + cache.countryLocationsLocaleCode.firstIndex(of: currentLocale) + ?? cache.countryLocationsLocaleCode.firstIndex(of: "en") + } + + guard + let localeStartIndex: Int = validLocaleStartIndex, let countryNameIndex: Int = Array(cache.countryLocationsGeonameId[localeStartIndex...]).firstIndex(where: { geonameId in geonameId == cache.countryBlocksGeonameId[countryBlockGeonameIdIndex] }), diff --git a/Session/Utilities/ImageLoading+Convenience.swift b/Session/Utilities/ImageLoading+Convenience.swift index 1b1ef57fd3..2def00a7aa 100644 --- a/Session/Utilities/ImageLoading+Convenience.swift +++ b/Session/Utilities/ImageLoading+Convenience.swift @@ -2,6 +2,7 @@ import UIKit import SwiftUI +import UniformTypeIdentifiers import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit @@ -23,7 +24,7 @@ public extension ImageDataManager.DataSource { /// Videos need special handling so handle those specially return .videoUrl( URL(fileURLWithPath: path), - attachment.contentType, + (UTType(sessionMimeType: attachment.contentType) ?? .invalid), attachment.sourceFilename, dependencies[singleton: .attachmentManager] ) @@ -52,7 +53,7 @@ public extension ImageDataManager.DataSource { if attachment.isVideo { return .videoUrl( URL(fileURLWithPath: path), - attachment.contentType, + (UTType(sessionMimeType: attachment.contentType) ?? .invalid), attachment.sourceFilename, dependencies[singleton: .attachmentManager] ) @@ -73,7 +74,7 @@ public extension ImageDataManagerType { func loadImage( attachment: Attachment, using dependencies: Dependencies, - onComplete: @MainActor @escaping (ImageDataManager.ProcessedImageData?) -> Void = { _ in } + onComplete: @MainActor @escaping (ImageDataManager.FrameBuffer?) -> Void = { _ in } ) { guard let source: ImageDataManager.DataSource = ImageDataManager.DataSource.from( attachment: attachment, @@ -88,7 +89,7 @@ public extension ImageDataManagerType { size: ImageDataManager.ThumbnailSize, attachment: Attachment, using dependencies: Dependencies, - onComplete: @MainActor @escaping (ImageDataManager.ProcessedImageData?) -> Void = { _ in } + onComplete: @MainActor @escaping (ImageDataManager.FrameBuffer?) -> Void = { _ in } ) { guard let source: ImageDataManager.DataSource = ImageDataManager.DataSource.thumbnailFrom( attachment: attachment, @@ -104,7 +105,7 @@ public extension ImageDataManagerType { public extension SessionImageView { @MainActor - func loadImage(from path: String, onComplete: (@MainActor (ImageDataManager.ProcessedImageData?) -> Void)? = nil) { + func loadImage(from path: String, onComplete: (@MainActor (ImageDataManager.FrameBuffer?) -> Void)? = nil) { loadImage(.url(URL(fileURLWithPath: path)), onComplete: onComplete) } @@ -112,7 +113,7 @@ public extension SessionImageView { func loadImage( attachment: Attachment, using dependencies: Dependencies, - onComplete: (@MainActor (ImageDataManager.ProcessedImageData?) -> Void)? = nil + onComplete: (@MainActor (ImageDataManager.FrameBuffer?) -> Void)? = nil ) { guard let source: ImageDataManager.DataSource = ImageDataManager.DataSource.from( attachment: attachment, @@ -130,7 +131,7 @@ public extension SessionImageView { size: ImageDataManager.ThumbnailSize, attachment: Attachment, using dependencies: Dependencies, - onComplete: (@MainActor (ImageDataManager.ProcessedImageData?) -> Void)? = nil + onComplete: (@MainActor (ImageDataManager.FrameBuffer?) -> Void)? = nil ) { guard let source: ImageDataManager.DataSource = ImageDataManager.DataSource.thumbnailFrom( attachment: attachment, @@ -145,7 +146,7 @@ public extension SessionImageView { } @MainActor - func loadPlaceholder(seed: String, text: String, size: CGFloat, onComplete: (@MainActor (ImageDataManager.ProcessedImageData?) -> Void)? = nil) { + func loadPlaceholder(seed: String, text: String, size: CGFloat, onComplete: (@MainActor (ImageDataManager.FrameBuffer?) -> Void)? = nil) { loadImage(.placeholderIcon(seed: seed, text: text, size: size), onComplete: onComplete) } } diff --git a/Session/Utilities/MentionUtilities+Attributes.swift b/Session/Utilities/MentionUtilities+Attributes.swift new file mode 100644 index 0000000000..08169965d6 --- /dev/null +++ b/Session/Utilities/MentionUtilities+Attributes.swift @@ -0,0 +1,100 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit +import SessionUtilitiesKit + + +public extension MentionUtilities { + static func highlightMentions( + in string: String, + currentUserSessionIds: Set, + location: MentionLocation, + textColor: ThemeValue, + attributes: [NSAttributedString.Key: Any], + displayNameRetriever: (String, Bool) -> String?, + using dependencies: Dependencies + ) -> ThemedAttributedString { + let (string, mentions) = getMentions( + in: string, + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: displayNameRetriever + ) + + let sizeDiff: CGFloat = (Values.smallFontSize / Values.mediumFontSize) + let result = ThemedAttributedString(string: string, attributes: attributes) + let mentionFont = UIFont.boldSystemFont(ofSize: Values.smallFontSize) + // Iterate in reverse so index ranges remain valid while replacing + for mention in mentions.sorted(by: { $0.range.location > $1.range.location }) { + if mention.isCurrentUser && location == .incomingMessage { + // Build the rendered chip image + let image = HighlightMentionView( + mentionText: (result.string as NSString).substring(with: mention.range), + font: mentionFont, + themeTextColor: .dynamicForInterfaceStyle(light: textColor, dark: .black), + themeBackgroundColor: .primary, + backgroundCornerRadius: (8 * sizeDiff), + backgroundPadding: (3 * sizeDiff) + ).toImage(using: dependencies) + + let attachment = NSTextAttachment() + let offsetY = (mentionFont.capHeight - image.size.height) / 2 + attachment.image = image + attachment.bounds = CGRect( + x: 0, + y: offsetY, + width: image.size.width, + height: image.size.height + ) + + let attachmentString = NSMutableAttributedString(attachment: attachment) + + // Replace the mention text with the image attachment + result.replaceCharacters(in: mention.range, with: attachmentString) + + let insertIndex = mention.range.location + attachmentString.length + if insertIndex < result.length { + result.addAttribute(.kern, value: (3 * sizeDiff), range: NSRange(location: insertIndex, length: 1)) + } + continue + } + + result.addAttribute(.font, value: mentionFont, range: mention.range) + + var targetColor: ThemeValue = textColor + switch location { + case .incomingMessage: + targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .primary) + case .outgoingMessage: + targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .black) + case .outgoingQuote: + targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .black) + case .incomingQuote: + targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .primary) + case .quoteDraft, .styleFree: + targetColor = .dynamicForInterfaceStyle(light: textColor, dark: textColor) + } + + result.addAttribute(.themeForegroundColor, value: targetColor, range: mention.range) + } + + return result + } +} + +public extension HighlightMentionView { + func toImage(using dependencies: Dependencies) -> UIImage { + let themePrimaryColor: Theme.PrimaryColor = dependencies + .mutate(cache: .libSession) { $0.get(.themePrimaryColor) } + .defaulting(to: .defaultPrimaryColor) + let cacheKey: String = "Mention.CurrentUser.\(themePrimaryColor)" // stringlint:ignore + + if let cachedImage = dependencies[cache: .generalUI].get(for: cacheKey) { + return cachedImage + } + + let renderedImage = self.toImage(isOpaque: self.isOpaque, scale: UIScreen.main.scale) + dependencies.mutate(cache: .generalUI) { $0.cache(renderedImage, for: cacheKey) } + return renderedImage + } +} diff --git a/Session/Utilities/MentionUtilities+DisplayName.swift b/Session/Utilities/MentionUtilities+DisplayName.swift index a2059109dc..18adfe2c99 100644 --- a/Session/Utilities/MentionUtilities+DisplayName.swift +++ b/Session/Utilities/MentionUtilities+DisplayName.swift @@ -49,7 +49,8 @@ public extension MentionUtilities { threadVariant: threadVariant, using: dependencies ) - } + }, + using: dependencies ) } } diff --git a/Session/Utilities/Permissions.swift b/Session/Utilities/Permissions.swift index 96fc52c505..7443b67958 100644 --- a/Session/Utilities/Permissions.swift +++ b/Session/Utilities/Permissions.swift @@ -12,6 +12,7 @@ import Network extension Permissions { @MainActor @discardableResult public static func requestCameraPermissionIfNeeded( presentingViewController: UIViewController? = nil, + useCustomDeniedAlert: Bool = false, using dependencies: Dependencies, onAuthorized: ((Bool) -> Void)? = nil ) -> Bool { @@ -22,8 +23,12 @@ extension Permissions { case .denied, .restricted: guard - let presentingViewController: UIViewController = (presentingViewController ?? dependencies[singleton: .appContext].frontMostViewController) - else { return false } + let presentingViewController: UIViewController = (presentingViewController ?? dependencies[singleton: .appContext].frontMostViewController), + !useCustomDeniedAlert + else { + onAuthorized?(false) + return false + } let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( @@ -337,5 +342,61 @@ extension Permissions { ) } } + + // MARK: - Custom camera permission request dialog + public static func remindCameraAccessRequirement(using dependencies: Dependencies) { + /* + Only show when the folliwing conditions are true + - Remind me later is tapped when trying to enable camera on calls + - Not in background state + - Camera permission is not yet allowed + */ + guard + dependencies[defaults: .standard, key: .shouldRemindGrantingCameraPermissionForCalls], + !dependencies[singleton: .appContext].isInBackground, + Permissions.camera == .denied + else { + return + } + + DispatchQueue.main.async { [dependencies] in + guard let controller = dependencies[singleton: .appContext].frontMostViewController else { + return + } + + dependencies[defaults: .standard, key: .shouldRemindGrantingCameraPermissionForCalls] = false + + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "enableCameraAccess".localized(), + body: .text( + "cameraAccessReminderMessage".localized(), + scrollMode: .never + ), + confirmTitle: "openSettings".localized(), + onConfirm: { _ in UIApplication.shared.openSystemSettings() } + ) + ) + controller.present(confirmationModal, animated: true, completion: nil) + } + } + + public static func showEnableCameraAccessInstructions(using dependencies: Dependencies) { + DispatchQueue.main.async { + guard let controller = dependencies[singleton: .appContext].frontMostViewController + else { return } + + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "enableCameraAccess".localized(), + body: .text("cameraAccessInstructions" + .localized()), + confirmTitle: "openSettings".localized(), + onConfirm: { _ in UIApplication.shared.openSystemSettings() } + ) + ) + controller.present(confirmationModal, animated: true, completion: nil) + } + } } diff --git a/Session/Utilities/UIContextualAction+Utilities.swift b/Session/Utilities/UIContextualAction+Utilities.swift index c0edbc3b20..e9c932b671 100644 --- a/Session/Utilities/UIContextualAction+Utilities.swift +++ b/Session/Utilities/UIContextualAction+Utilities.swift @@ -122,7 +122,7 @@ public extension UIContextualAction { case .clear: return UIContextualAction( title: "clear".localized(), - icon: Lucide.image(icon: .trash2, size: 24, color: .white), + icon: Lucide.image(icon: .trash2, size: 24), themeTintColor: .white, themeBackgroundColor: themeBackgroundColor, side: side, @@ -615,7 +615,7 @@ public extension UIContextualAction { case .delete: return UIContextualAction( title: "delete".localized(), - icon: Lucide.image(icon: .trash2, size: 24, color: .white), + icon: Lucide.image(icon: .trash2, size: 24), themeTintColor: .white, themeBackgroundColor: themeBackgroundColor, accessibility: Accessibility(identifier: "Delete button"), diff --git a/Session/Utilities/UIImage+Scaling.swift b/Session/Utilities/UIImage+Scaling.swift deleted file mode 100644 index bc6208071f..0000000000 --- a/Session/Utilities/UIImage+Scaling.swift +++ /dev/null @@ -1,18 +0,0 @@ -import UIKit - -extension UIImage { - - func scaled(to size: CGSize) -> UIImage { - var rect = CGRect.zero - let aspectRatio = min(size.width / self.size.width, size.height / self.size.height) - rect.size.width = self.size.width * aspectRatio - rect.size.height = self.size.height * aspectRatio - rect.origin.x = (size.width - rect.size.width) / 2 - rect.origin.y = (size.height - rect.size.height) / 2 - UIGraphicsBeginImageContextWithOptions(size, false, 0) - draw(in: rect) - let result = UIGraphicsGetImageFromCurrentImageContext()! - UIGraphicsEndImageContext() - return result - } -} diff --git a/Session/Utilities/UILabel+Interaction.swift b/Session/Utilities/UILabel+Interaction.swift deleted file mode 100644 index 4c7a5e92c5..0000000000 --- a/Session/Utilities/UILabel+Interaction.swift +++ /dev/null @@ -1,16 +0,0 @@ -import UIKit - -extension UILabel { - - func characterIndex(for point: CGPoint) -> Int { - let textStorage = NSTextStorage(attributedString: attributedText!) - let layoutManager = NSLayoutManager() - textStorage.addLayoutManager(layoutManager) - let textContainer = NSTextContainer(size: bounds.size) - textContainer.lineFragmentPadding = 0 - textContainer.maximumNumberOfLines = numberOfLines - textContainer.lineBreakMode = lineBreakMode - layoutManager.addTextContainer(textContainer) - return layoutManager.characterIndex(for: point, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil) - } -} diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index f95a65aa42..4e71f0f955 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -48,7 +48,8 @@ public enum SNMessagingKit { _042_MoveSettingsToLibSession.self, _043_RenameAttachments.self, _044_AddProMessageFlag.self, - _045_LastProfileUpdateTimestamp.self + _045_LastProfileUpdateTimestamp.self, + _046_RemoveQuoteUnusedColumnsAndForeignKeys.self ] public static func configure(using dependencies: Dependencies) { @@ -57,7 +58,7 @@ public enum SNMessagingKit { .disappearingMessages: DisappearingMessagesJob.self, .failedMessageSends: FailedMessageSendsJob.self, .failedAttachmentDownloads: FailedAttachmentDownloadsJob.self, - .updateProfilePicture: UpdateProfilePictureJob.self, + .reuploadUserDisplayPicture: ReuploadUserDisplayPictureJob.self, .retrieveDefaultOpenGroupRooms: RetrieveDefaultOpenGroupRoomsJob.self, .garbageCollection: GarbageCollectionJob.self, .messageSend: MessageSendJob.self, @@ -88,7 +89,7 @@ public enum SNMessagingKit { (.disappearingMessages, .recurringOnLaunch, true, false), (.failedMessageSends, .recurringOnLaunch, true, false), (.failedAttachmentDownloads, .recurringOnLaunch, true, false), - (.updateProfilePicture, .recurringOnActive, false, false), + (.reuploadUserDisplayPicture, .recurringOnActive, false, false), (.retrieveDefaultOpenGroupRooms, .recurringOnActive, false, false), (.garbageCollection, .recurringOnActive, false, false), (.failedGroupInvitesAndPromotions, .recurringOnLaunch, true, false) diff --git a/SessionMessagingKit/Crypto/Crypto+Attachments.swift b/SessionMessagingKit/Crypto/Crypto+Attachments.swift index ceb7cca191..76ecf208d1 100644 --- a/SessionMessagingKit/Crypto/Crypto+Attachments.swift +++ b/SessionMessagingKit/Crypto/Crypto+Attachments.swift @@ -3,39 +3,108 @@ // stringlint:disable import Foundation +import CryptoKit import CommonCrypto +import SessionUtil import SessionNetworkingKit import SessionUtilitiesKit // MARK: - Encryption +public extension Crypto { + enum AttachmentDomain: Sendable, Equatable, Hashable { + case attachment + case profilePicture + + fileprivate var libSessionValue: ATTACHMENT_DOMAIN { + switch self { + case .attachment: return ATTACHMENT_DOMAIN_ATTACHMENT + case .profilePicture: return ATTACHMENT_DOMAIN_PROFILE_PIC + } + } + } +} + public extension Crypto.Generator { private static var hmac256KeyLength: Int { 32 } private static var hmac256OutputLength: Int { 32 } private static var aesCBCIvLength: Int { 16 } private static var aesKeySize: Int { 32 } + static func expectedEncryptedAttachmentSize(plaintextSize: Int) -> Crypto.Generator { + return Crypto.Generator( + id: "expectedEncryptedAttachmentSize", + args: [plaintextSize] + ) { dependencies in + return session_attachment_encrypted_size(plaintextSize) + } + } + static func encryptAttachment( - plaintext: Data - ) -> Crypto.Generator<(ciphertext: Data, encryptionKey: Data, digest: Data)> { + plaintext: Data, + domain: Crypto.AttachmentDomain + ) -> Crypto.Generator<(ciphertext: Data, encryptionKey: 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.") + guard !dependencies[cache: .general].ed25519Seed.isEmpty else { + Log.error(.crypto, "Invalid seed.") throw CryptoError.encryptionFailed } + let cPlaintext: [UInt8] = Array(plaintext) + let encryptedSize: Int = session_attachment_encrypted_size(cPlaintext.count) + var cEncryptionKey: [UInt8] = [UInt8](repeating: 0, count: 32) + var cEncryptedData: [UInt8] = [UInt8](repeating: 0, count: encryptedSize) + var cError: [CChar] = [CChar](repeating: 0, count: 256) + guard - var iv: [UInt8] = dependencies[singleton: .crypto].generate(.randomBytes(aesCBCIvLength)), - var encryptionKey: [UInt8] = dependencies[singleton: .crypto].generate(.randomBytes(aesKeySize)), - var hmacKey: [UInt8] = dependencies[singleton: .crypto].generate(.randomBytes(hmac256KeyLength)) + session_attachment_encrypt( + dependencies[cache: .general].ed25519Seed, + cPlaintext, + cPlaintext.count, + domain.libSessionValue, + &cEncryptionKey, + &cEncryptedData, + &cError + ) else { - Log.error("[Crypto] Failed to generate random data.") + Log.error(.crypto, "Attachment encryption failed due to error: \(String(cString: cError))") throw CryptoError.encryptionFailed } + + return (Data(cEncryptedData), Data(cEncryptionKey)) + } + } + + @available(*, deprecated, message: "This encryption method is deprecated and will be removed in a future release.") + static func legacyExpectedEncryptedAttachmentSize( + plaintextSize: Int + ) -> Crypto.Generator { + return Crypto.Generator( + id: "legacyExpectedEncryptedAttachmentSize", + args: [plaintextSize] + ) { dependencies in + return max(541, Int(floor(pow(1.05, ceil(log(Double(plaintextSize)) / log(1.05)))))) + } + } + + @available(*, deprecated, message: "This encryption method is deprecated and will be removed in a future release.") + static func legacyEncryptedAttachment( + plaintext: Data + ) -> Crypto.Generator<(ciphertext: Data, encryptionKey: Data, digest: Data)> { + return Crypto.Generator( + id: "legacyEncryptedAttachment", + args: [plaintext] + ) { dependencies in + // Due to paddedSize, we need to divide by two. + guard + plaintext.count < (UInt.max / 2), + var iv: [UInt8] = dependencies[singleton: .crypto].generate(.randomBytes(aesCBCIvLength)), + var encryptionKey: [UInt8] = dependencies[singleton: .crypto].generate(.randomBytes(aesKeySize)), + var hmacKey: [UInt8] = dependencies[singleton: .crypto].generate(.randomBytes(hmac256KeyLength)) + else { throw AttachmentError.legacyEncryptionFailed } // The concatenated key for storage var outKey: Data = Data() @@ -62,33 +131,19 @@ public extension Crypto.Generator { &numBytesEncrypted ) - guard cryptStatus == kCCSuccess else { - Log.error("[Crypto] Failed to encrypt attachment with status: \(cryptStatus).") - throw CryptoError.encryptionFailed - } - - guard cryptStatus == kCCSuccess else { - Log.error("[Crypto] Failed to encrypt attachment with status: \(cryptStatus).") - throw CryptoError.encryptionFailed - } - - guard bufferData.count >= numBytesEncrypted else { - Log.error("[Crypto] ciphertext has unexpected length: \(bufferData.count) < \(numBytesEncrypted).") - throw CryptoError.encryptionFailed - } + guard + cryptStatus == kCCSuccess, + bufferData.count >= numBytesEncrypted + else { throw AttachmentError.legacyEncryptionFailed } let ciphertext: [UInt8] = Array(bufferData[0.. Crypto.Generator { + return Crypto.Generator( + id: "legacyEncryptedDisplayPictureSize", + args: [plaintextSize] + ) { dependencies in + return (plaintextSize + DisplayPictureManager.nonceLength + DisplayPictureManager.tagLength) + } + } + + @available(*, deprecated, message: "This encryption method is deprecated and will be removed in a future release.") + static func legacyEncryptedDisplayPicture( + data: Data, + key: Data + ) -> Crypto.Generator { + return Crypto.Generator( + id: "legacyEncryptedDisplayPicture", + args: [data, key] + ) { dependencies in + // The key structure is: nonce || ciphertext || authTag + guard + key.count == DisplayPictureManager.encryptionKeySize, + let nonceData: Data = dependencies[singleton: .crypto] + .generate(.randomBytes(DisplayPictureManager.nonceLength)), + let nonce: AES.GCM.Nonce = try? AES.GCM.Nonce(data: nonceData), + let sealedData: AES.GCM.SealedBox = try? AES.GCM.seal( + data, + using: SymmetricKey(data: key), + nonce: nonce + ), + let encryptedContent: Data = sealedData.combined + else { throw CryptoError.failedToGenerateOutput } + + return encryptedContent + } + } } // MARK: - Decryption public extension Crypto.Generator { static func decryptAttachment( + ciphertext: Data, + key: Data + ) -> Crypto.Generator { + return Crypto.Generator( + id: "decryptAttachment", + args: [ciphertext, key] + ) { dependencies in + let cCiphertext: [UInt8] = Array(ciphertext) + let expectedDecryptedSize: Int = session_attachment_decrypted_max_size(cCiphertext.count) + let cDecryptionKey: [UInt8] = Array(key) + var cDecryptedData: [UInt8] = [UInt8](repeating: 0, count: expectedDecryptedSize) + var cDecryptedSize: Int = 0 + var cError: [CChar] = [CChar](repeating: 0, count: 256) + + guard + session_attachment_decrypt( + cCiphertext, + cCiphertext.count, + cDecryptionKey, + &cDecryptedData, + &cDecryptedSize, + &cError + ) + else { + Log.error(.crypto, "Attachment decryption failed due to error: \(String(cString: cError))") + throw CryptoError.decryptionFailed + } + + return Data(cDecryptedData) + } + } + + static func legacyDecryptAttachment( ciphertext: Data, key: Data, digest: Data, unpaddedSize: UInt ) -> Crypto.Generator { return Crypto.Generator( - id: "decryptAttachment", + id: "legacyDecryptAttachment", args: [ciphertext, key, digest, unpaddedSize] ) { guard ciphertext.count >= aesCBCIvLength + hmac256OutputLength else { - Log.error("[Crypto] Attachment shorter than crypto overhead."); - throw CryptoError.decryptionFailed + throw AttachmentError.legacyDecryptionFailed } // key: 32 byte AES key || 32 byte Hmac-SHA256 key. @@ -168,10 +293,7 @@ public extension Crypto.Generator { return (isEqual == 0) }() - guard isHmacEqual else { - Log.error("[Crypto] Bad HMAC on decrypting payload.") - throw CryptoError.decryptionFailed - } + guard isHmacEqual else { throw AttachmentError.legacyDecryptionFailed } // Verify digest of: iv || encrypted data || hmac dataToAuth += generatedHmac @@ -190,10 +312,7 @@ public extension Crypto.Generator { return (isEqual == 0) }() - guard isDigestEqual else { - Log.error("[Crypto] Bad digest on decrypting payload.") - throw CryptoError.decryptionFailed - } + guard isDigestEqual else { throw AttachmentError.legacyDecryptionFailed } var numBytesDecrypted: size_t = 0 var bufferData: [UInt8] = Array(Data(count: ciphertext.count + kCCBlockSizeAES128)) @@ -208,14 +327,10 @@ public extension Crypto.Generator { &numBytesDecrypted ) - guard cryptStatus == kCCSuccess else { - Log.error("[Crypto] Failed to decrypt attachment with status: \(cryptStatus).") - throw CryptoError.decryptionFailed - } - guard bufferData.count >= numBytesDecrypted else { - Log.error("[Crypto] Attachment paddedPlaintext has unexpected length: \(bufferData.count) < \(numBytesDecrypted).") - throw CryptoError.decryptionFailed - } + guard + cryptStatus == kCCSuccess, + bufferData.count >= numBytesDecrypted + else { throw AttachmentError.legacyDecryptionFailed } let paddedPlaintext: [UInt8] = Array(bufferData[0.. Crypto.Generator { + return Crypto.Generator( + id: "legacyDecryptedDisplayPicture", + args: [data, key] + ) { dependencies in + guard key.count == DisplayPictureManager.encryptionKeySize else { + throw CryptoError.failedToGenerateOutput + } + + // The key structure is: nonce || ciphertext || authTag + let cipherTextLength: Int = (data.count - (DisplayPictureManager.nonceLength + DisplayPictureManager.tagLength)) + + guard + cipherTextLength > 0, + let sealedData: AES.GCM.SealedBox = try? AES.GCM.SealedBox( + nonce: AES.GCM.Nonce(data: data.subdata(in: 0.. Crypto.Generator { - return Crypto.Generator( - id: "encryptedDataDisplayPicture", - args: [data, key] - ) { dependencies in - // The key structure is: nonce || ciphertext || authTag - guard - key.count == DisplayPictureManager.aes256KeyByteLength, - let nonceData: Data = dependencies[singleton: .crypto] - .generate(.randomBytes(DisplayPictureManager.nonceLength)), - let nonce: AES.GCM.Nonce = try? AES.GCM.Nonce(data: nonceData), - let sealedData: AES.GCM.SealedBox = try? AES.GCM.seal( - data, - using: SymmetricKey(data: key), - nonce: nonce - ), - let encryptedContent: Data = sealedData.combined - else { throw CryptoError.failedToGenerateOutput } - - return encryptedContent - } - } - - static func decryptedDataDisplayPicture( - data: Data, - key: Data - ) -> Crypto.Generator { - 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)) - - guard - cipherTextLength > 0, - let sealedData: AES.GCM.SealedBox = try? AES.GCM.SealedBox( - nonce: AES.GCM.Nonce(data: data.subdata(in: 0.. String { + static func generateFilename(utType: UTType, using dependencies: Dependencies) -> String { return dependencies[singleton: .crypto] .generate(.uuid()) .defaulting(to: UUID()) .uuidString - .appendingFileExtension(format.fileExtension) + .appendingFileExtension(utType.sessionFileExtension(sourceFilename: nil) ?? "jpg") + } +} + +private extension String { + func appendingFileExtension(_ fileExtension: String) -> String { + guard let result = (self as NSString).appendingPathExtension(fileExtension) else { + return self + } + return result } } diff --git a/SessionMessagingKit/Database/Migrations/_043_RenameAttachments.swift b/SessionMessagingKit/Database/Migrations/_043_RenameAttachments.swift index ff94b962c2..1c3e216833 100644 --- a/SessionMessagingKit/Database/Migrations/_043_RenameAttachments.swift +++ b/SessionMessagingKit/Database/Migrations/_043_RenameAttachments.swift @@ -150,7 +150,10 @@ enum _043_RenameAttachments: Migration { return urlString } - return Network.FileServer.downloadUrlString(for: "invalid-legacy-file-\(index)") + return Network.FileServer.downloadUrlString( + for: "invalid-legacy-file-\(index)", + using: dependencies + ) }() guard diff --git a/SessionMessagingKit/Database/Migrations/_045_LastProfileUpdateTimestamp.swift b/SessionMessagingKit/Database/Migrations/_045_LastProfileUpdateTimestamp.swift index 89163827fb..d35fb91035 100644 --- a/SessionMessagingKit/Database/Migrations/_045_LastProfileUpdateTimestamp.swift +++ b/SessionMessagingKit/Database/Migrations/_045_LastProfileUpdateTimestamp.swift @@ -10,7 +10,7 @@ enum _045_LastProfileUpdateTimestamp: Migration { static var createdTables: [(FetchableRecord & TableRecord).Type] = [] static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { - try db.alter(table: "Profile") { t in + try db.alter(table: "profile") { t in t.drop(column: "lastNameUpdate") t.drop(column: "lastBlocksCommunityMessageRequests") t.rename(column: "displayPictureLastUpdated", to: "profileLastUpdated") diff --git a/SessionMessagingKit/Database/Migrations/_046_RemoveQuoteUnusedColumnsAndForeignKeys.swift b/SessionMessagingKit/Database/Migrations/_046_RemoveQuoteUnusedColumnsAndForeignKeys.swift new file mode 100644 index 0000000000..e38eddd742 --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_046_RemoveQuoteUnusedColumnsAndForeignKeys.swift @@ -0,0 +1,40 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +enum _046_RemoveQuoteUnusedColumnsAndForeignKeys: Migration { + static let identifier: String = "RemoveQuoteUnusedColumnsAndForeignKeys" + static let minExpectedRunDuration: TimeInterval = 0.1 + static var createdTables: [(FetchableRecord & TableRecord).Type] = [] + + 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: "tmpQuote") { t in + t.column("interactionId", .integer) + .notNull() + .primaryKey() + .references("interaction", onDelete: .cascade) // Delete if interaction deleted + t.column("authorId", .text).notNull() + t.column("timestampMs", .double).notNull() + } + + // Insert into the new table, drop the old table and rename the new table to be the old one + try db.execute(literal: """ + INSERT INTO tmpQuote + SELECT interactionId, authorId, timestampMs + FROM quote + """) + + try db.drop(table: "quote") + try db.rename(table: "tmpQuote", to: "quote") + + // Need to create the indexes separately from creating 'tmpQuote' to ensure they have the + // correct names + try db.create(indexOn: "quote", columns: ["authorId", "timestampMs"]) + + MigrationExecution.updateProgress(1) + } +} diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 072306a4d3..f5bff7ce4a 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -10,7 +10,7 @@ import SessionUtilitiesKit import SessionNetworkingKit import SessionUIKit -public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct Attachment: Sendable, Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "attachment" } internal static let linkPreviewForeignKey = ForeignKey([Columns.id], to: [LinkPreview.Columns.attachmentId]) public static let interactionAttachments = hasOne(InteractionAttachment.self) @@ -42,12 +42,12 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR case caption } - public enum Variant: Int, Codable, DatabaseValueConvertible { + public enum Variant: Int, Sendable, Codable, DatabaseValueConvertible { case standard case voiceMessage } - public enum State: Int, Codable, DatabaseValueConvertible { + public enum State: Int, Sendable, Codable, DatabaseValueConvertible { case failedDownload case pendingDownload case downloading @@ -57,6 +57,23 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR case uploaded case invalid = 100 + + public static func fromDatabaseValue(_ dbValue: DatabaseValue) -> State? { + /// There was an odd issue where there were values of `50` in the `state` column in the database, though it doesn't + /// seem like that has ever been an option. Unfortunately this results in all attachments in a conversation being broken so + /// instead we custom handle the conversion to the `State` enum and consider anything invalid as `invalid` + switch dbValue.storage { + case .int64(let value): + guard let result: State = State(rawValue: Int(value)) else { + return .invalid + } + + return result + + default: return .invalid + } + + } } /// A unique identifier for the attachment @@ -120,7 +137,8 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR public let digest: Data? /// Caption for the attachment - public let caption: String? + @available(*, deprecated, message: "This field is no longer sent or rendered by the clients") + public let caption: String? = nil // MARK: - Initialization @@ -140,8 +158,7 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR isVisualMedia: Bool? = nil, isValid: Bool = false, encryptionKey: Data? = nil, - digest: Data? = nil, - caption: String? = nil + digest: Data? = nil ) { self.id = id self.serverId = serverId @@ -159,55 +176,6 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR self.isValid = isValid self.encryptionKey = encryptionKey self.digest = digest - self.caption = caption - } - - /// This initializer should only be used when converting from either a LinkPreview or a SignalAttachment to an Attachment (prior to upload) - public init?( - id: String = UUID().uuidString, - variant: Variant = .standard, - contentType: String, - dataSource: any DataSource, - sourceFilename: String? = nil, - caption: String? = nil, - using dependencies: Dependencies - ) { - 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? = MediaUtils.unrotatedSize( - for: uploadInfo.path, - type: UTType(sessionMimeType: contentType), - mimeType: contentType, - sourceFilename: sourceFilename, - using: dependencies - ) - let (isValid, duration): (Bool, TimeInterval?) = dependencies[singleton: .attachmentManager].determineValidityAndDuration( - contentType: contentType, - downloadUrl: uploadInfo.url, - sourceFilename: sourceFilename - ) - - self.id = id - self.serverId = nil - self.variant = variant - self.state = .uploading - self.contentType = contentType - self.byteCount = UInt(dataSource.dataLength) - self.creationTimestamp = nil - self.sourceFilename = sourceFilename - 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 - self.isVisualMedia = UTType.isVisualMedia(contentType) - self.isValid = isValid - self.encryptionKey = nil - self.digest = nil - self.caption = caption } } @@ -343,57 +311,31 @@ extension Attachment: CustomStringConvertible { extension Attachment { public func with( - serverId: String? = nil, state: State? = nil, creationTimestamp: TimeInterval? = nil, - downloadUrl: 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 {} - } - } + ) throws -> Attachment { + guard let downloadUrl: String = self.downloadUrl else { throw AttachmentError.invalidPath } let (isValid, duration): (Bool, TimeInterval?) = { switch (self.state, state) { case (_, .downloaded): return dependencies[singleton: .attachmentManager].determineValidityAndDuration( contentType: contentType, - downloadUrl: finalDownloadUrl, + downloadUrl: downloadUrl, sourceFilename: sourceFilename ) - // Assume the data is already correct for "uploading" attachments (and don't override it) + /// Assume the data is already correct for "uploading" attachments (and don't override it) case (.uploading, _), (.uploaded, _), (.failedUpload, _): return (self.isValid, self.duration) case (_, .failedDownload): return (false, nil) default: return (self.isValid, self.duration) } }() - // Regenerate this just in case we added support since the attachment was inserted into - // the database (eg. manually downloaded in a later update) + + /// Regenerate this just in case we added support since the attachment was inserted into the database (eg. manually + /// downloaded in a later update) let isVisualMedia: Bool = UTType.isVisualMedia(contentType) let attachmentResolution: CGSize? = { if let width: UInt = self.width, let height: UInt = self.height, width > 0, height > 0 { @@ -403,40 +345,34 @@ extension Attachment { isVisualMedia, state == .downloaded, let path: String = try? dependencies[singleton: .attachmentManager] - .path(for: finalDownloadUrl) + .path(for: downloadUrl) else { return nil } - return MediaUtils.unrotatedSize( + return MediaUtils.displaySize( for: path, - type: UTType(sessionMimeType: contentType), - mimeType: contentType, + utType: UTType(sessionMimeType: contentType), sourceFilename: sourceFilename, using: dependencies ) }() return Attachment( - id: self.id, - serverId: (serverId ?? self.serverId), + id: id, + serverId: serverId, variant: variant, state: (state ?? self.state), contentType: contentType, byteCount: byteCount, creationTimestamp: (creationTimestamp ?? self.creationTimestamp), sourceFilename: sourceFilename, - downloadUrl: finalDownloadUrl, + downloadUrl: downloadUrl, width: attachmentResolution.map { UInt($0.width) }, height: attachmentResolution.map { UInt($0.height) }, duration: duration, - isVisualMedia: ( - // Regenerate this just in case we added support since the attachment was inserted into - // the database (eg. manually downloaded in a later update) - UTType.isVisualMedia(contentType) - ), + isVisualMedia: isVisualMedia, isValid: isValid, - encryptionKey: (encryptionKey ?? self.encryptionKey), - digest: (digest ?? self.digest), - caption: self.caption + encryptionKey: encryptionKey, + digest: digest ) } } @@ -480,7 +416,6 @@ extension Attachment { self.isValid = false // Needs to be downloaded to be set self.encryptionKey = proto.key self.digest = proto.digest - self.caption = (proto.hasCaption ? proto.caption : nil) } public func buildProto() -> SNProtoAttachmentPointer? { @@ -497,18 +432,16 @@ extension Attachment { builder.setFileName(sourceFilename) } - if let caption: String = self.caption, !caption.isEmpty { - builder.setCaption(caption) - } - builder.setSize(UInt32(byteCount)) builder.setFlags(variant == .voiceMessage ? UInt32(SNProtoAttachmentPointer.SNProtoAttachmentPointerFlags.voiceMessage.rawValue) : 0 ) - if let encryptionKey: Data = encryptionKey, let digest: Data = digest { + if let encryptionKey: Data = encryptionKey { builder.setKey(encryptionKey) + } + if let digest: Data = digest { builder.setDigest(digest) } diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 43b0dfdcb7..5df57bec91 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -23,7 +23,6 @@ public struct Interaction: Codable, Identifiable, Equatable, Hashable, Fetchable through: interactionAttachments, using: InteractionAttachment.attachment ) - public static let quote = hasOne(Quote.self, using: Quote.interactionForeignKey) /// Whenever using this `linkPreview` association make sure to filter the result using /// `.filter(literal: Interaction.linkPreviewFilterLiteral)` to ensure the correct LinkPreview is returned @@ -248,10 +247,6 @@ public struct Interaction: Codable, Identifiable, Equatable, Hashable, Fetchable .order(interactionAttachment[.albumIndex]) } - public var quote: QueryInterfaceRequest { - request(for: Interaction.quote) - } - public var linkPreview: QueryInterfaceRequest { /// **Note:** This equation **MUST** match the `linkPreviewFilterLiteral` logic let halfResolution: Double = LinkPreview.timstampResolution @@ -536,6 +531,9 @@ public extension Interaction { _ db: ObservingDatabase, using dependencies: Dependencies ) throws -> Int { + /// If we don't have an account yet then no need to do any queries + guard dependencies[cache: .general].userExists else { return 0 } + // TODO: [Database Relocation] Should be able to clean this up by getting the conversation list and filtering struct ThreadIdVariant: Decodable, Hashable, FetchableRecord { let id: String diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 0c8cada73f..9f53541edb 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -6,6 +6,7 @@ import Foundation import Combine import UniformTypeIdentifiers import GRDB +import SessionUIKit import SessionUtilitiesKit import SessionNetworkingKit @@ -20,6 +21,7 @@ public struct LinkPreview: Codable, Equatable, Hashable, FetchableRecord, Persis /// We want to cache url previews to the nearest 100,000 seconds (~28 hours - simpler than 86,400) to ensure the user isn't shown a preview that is too stale internal static let timstampResolution: Double = 100000 + internal static let maxImageDimension: CGFloat = 600 public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -131,21 +133,32 @@ public extension LinkPreview { return (floor(sentTimestampMs / 1000 / LinkPreview.timstampResolution) * LinkPreview.timstampResolution) } - static func generateAttachmentIfPossible(imageData: Data?, type: UTType, using dependencies: Dependencies) throws -> Attachment? { - guard let imageData: Data = imageData, !imageData.isEmpty else { return nil } - guard let fileExtension: String = type.sessionFileExtension(sourceFilename: nil) else { return nil } - guard let mimeType: String = type.preferredMIMEType else { return nil } - - let filePath = dependencies[singleton: .fileManager].temporaryFilePath(fileExtension: fileExtension) - try imageData.write(to: NSURL.fileURL(withPath: filePath), options: .atomicWrite) - let dataSource: DataSourcePath = DataSourcePath( - filePath: filePath, - sourceFilename: nil, - shouldDeleteOnDeinit: true, + static func prepareAttachmentIfPossible( + urlString: String, + imageSource: ImageDataManager.DataSource?, + using dependencies: Dependencies + ) async throws -> PreparedAttachment? { + guard let imageSource: ImageDataManager.DataSource = imageSource, imageSource.contentExists else { + return nil + } + + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .media(imageSource), using: dependencies ) + let targetFormat: PendingAttachment.ConversionFormat = (dependencies[feature: .usePngInsteadOfWebPForFallbackImageType] ? + .png(maxDimension: LinkPreview.maxImageDimension) : .webPLossy(maxDimension: LinkPreview.maxImageDimension) + ) - return Attachment(contentType: mimeType, dataSource: dataSource, using: dependencies) + return try await pendingAttachment.prepare( + operations: [ + .convert(to: targetFormat), + .stripImageMetadata + ], + /// We only call `prepareAttachmentIfPossible` before sending so always store at the pending upload path + storeAtPendingAttachmentUploadPath: true, + using: dependencies + ) } static func isValidLinkUrl(_ urlString: String) -> Bool { @@ -314,49 +327,43 @@ public extension LinkPreview { previewUrl: String?, skipImageDownload: Bool, using dependencies: Dependencies - ) -> AnyPublisher { + ) async throws -> LinkPreviewDraft { guard dependencies.mutate(cache: .libSession, { $0.get(.areLinkPreviewsEnabled) }) else { - return Fail(error: LinkPreviewError.featureDisabled) - .eraseToAnyPublisher() + throw LinkPreviewError.featureDisabled } // Force the url to lowercase to ensure we casing doesn't result in redownloading the // details guard let previewUrl: String = previewUrl?.lowercased() else { - return Fail(error: LinkPreviewError.invalidInput) - .eraseToAnyPublisher() + throw LinkPreviewError.invalidInput } if let cachedInfo = cachedLinkPreview(forPreviewUrl: previewUrl) { - return Just(cachedInfo) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - return downloadLink(url: previewUrl) - .flatMap { [dependencies] data, response in - parseLinkDataAndBuildDraft( - linkData: data, - response: response, - linkUrlString: previewUrl, - skipImageDownload: skipImageDownload, - using: dependencies - ) - } - .tryMap { [dependencies] linkPreviewDraft -> LinkPreviewDraft in - guard linkPreviewDraft.isValid() else { throw LinkPreviewError.noPreview } + return cachedInfo + } + + let (data, response) = try await downloadLink(url: previewUrl) + try Task.checkCancellation() /// No use trying to parse and potentially download an image if the task was cancelled + + let linkPreviewDraft: LinkPreviewDraft = try await parseLinkDataAndBuildDraft( + linkData: data, + response: response, + linkUrlString: previewUrl, + skipImageDownload: skipImageDownload, + using: dependencies + ) + + guard linkPreviewDraft.isValid() else { throw LinkPreviewError.noPreview } - setCachedLinkPreview(linkPreviewDraft, forPreviewUrl: previewUrl, using: dependencies) - - return linkPreviewDraft - } - .eraseToAnyPublisher() + setCachedLinkPreview(linkPreviewDraft, forPreviewUrl: previewUrl, using: dependencies) + + return linkPreviewDraft } private static func downloadLink( url urlString: String, remainingRetries: UInt = 3 - ) -> AnyPublisher<(Data, URLResponse), Error> { + ) async throws -> (Data, URLResponse) { Log.verbose("[LinkPreview] Download url: \(urlString)") // let sessionConfiguration = ContentProxy.sessionConfiguration() // Loki: Signal's proxy appears to have been banned by YouTube @@ -368,45 +375,42 @@ public extension LinkPreview { guard var request: URLRequest = URL(string: urlString).map({ URLRequest(url: $0) }), + request.url?.scheme != nil, + (request.url?.host ?? "").isEmpty == false, ContentProxy.configureProxiedRequest(request: &request) - else { - return Fail(error: LinkPreviewError.assertionFailure) - .eraseToAnyPublisher() - } + else { throw LinkPreviewError.assertionFailure } request.setValue(self.userAgentString, forHTTPHeaderField: "User-Agent") // Set a fake value let session: URLSession = URLSession(configuration: sessionConfiguration) - return session - .dataTaskPublisher(for: request) - .mapError { _ -> Error in NetworkError.unknown } // URLError codes are negative values - .tryMap { data, response -> (Data, URLResponse) in - guard let urlResponse: HTTPURLResponse = response as? HTTPURLResponse else { - throw LinkPreviewError.assertionFailure - } - if let contentType: String = urlResponse.allHeaderFields["Content-Type"] as? String { - guard contentType.lowercased().hasPrefix("text/") else { - throw LinkPreviewError.invalidContent - } - } - guard data.count > 0 else { throw LinkPreviewError.invalidContent } - - return (data, response) + do { + let (data, response) = try await session.data(for: request) + + guard let urlResponse: HTTPURLResponse = response as? HTTPURLResponse else { + throw LinkPreviewError.assertionFailure } - .catch { error -> AnyPublisher<(Data, URLResponse), Error> in - guard isRetryable(error: error), remainingRetries > 0 else { - return Fail(error: LinkPreviewError.couldNotDownload) - .eraseToAnyPublisher() + + if let contentType: String = urlResponse.allHeaderFields["Content-Type"] as? String { + guard contentType.lowercased().hasPrefix("text/") else { + throw LinkPreviewError.invalidContent } - - return LinkPreview - .downloadLink( - url: urlString, - remainingRetries: (remainingRetries - 1) - ) } - .eraseToAnyPublisher() + + guard data.count > 0 else { throw LinkPreviewError.invalidContent } + + return (data, response) + } + catch { + guard isRetryable(error: error), remainingRetries > 0 else { + throw LinkPreviewError.couldNotDownload + } + + return try await LinkPreview.downloadLink( + url: urlString, + remainingRetries: (remainingRetries - 1) + ) + } } private static func parseLinkDataAndBuildDraft( @@ -415,51 +419,35 @@ public extension LinkPreview { linkUrlString: String, skipImageDownload: Bool, using dependencies: Dependencies - ) -> AnyPublisher { + ) async throws -> LinkPreviewDraft { + let contents: LinkPreview.Contents = try parse(linkData: linkData, response: response) + let title: String? = contents.title + + /// If we don't want to download the image then just return the non-image content + guard !skipImageDownload else { + return LinkPreviewDraft(urlString: linkUrlString, title: title) + } + do { - let contents = try parse(linkData: linkData, response: response) - let title = contents.title - - // If we don't want to download the image then just return the non-image content - guard !skipImageDownload else { - return Just(LinkPreviewDraft(urlString: linkUrlString, title: title)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } + /// If the image isn't valid then just return the non-image content + let imageUrl: URL = try contents.imageUrl.map({ URL(string: $0) }) ?? { + throw LinkPreviewError.invalidContent + }() + let imageSource: ImageDataManager.DataSource = try await downloadImage( + url: imageUrl, + using: dependencies + ) - // If the image isn't valid then just return the non-image content - guard - let imageUrl: String = contents.imageUrl, - URL(string: imageUrl) != nil, - let imageFileExtension: String = fileExtension(forImageUrl: imageUrl), - let imageMimeType: String = UTType(sessionFileExtension: imageFileExtension)?.preferredMIMEType - else { - return Just(LinkPreviewDraft(urlString: linkUrlString, title: title)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - return LinkPreview - .downloadImage(url: imageUrl, imageMimeType: imageMimeType, using: dependencies) - .map { imageData -> LinkPreviewDraft in - // We always recompress images to Jpeg - LinkPreviewDraft(urlString: linkUrlString, title: title, jpegImageData: imageData) - } - .catch { _ -> AnyPublisher in - return Just(LinkPreviewDraft(urlString: linkUrlString, title: title)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } catch { - return Fail(error: error) - .eraseToAnyPublisher() + return LinkPreviewDraft(urlString: linkUrlString, title: title, imageSource: imageSource) + } + catch { + return LinkPreviewDraft(urlString: linkUrlString, title: title) } } private static func parse(linkData: Data, response: URLResponse) throws -> Contents { guard let linkText = String(bytes: linkData, encoding: response.stringEncoding ?? .utf8) else { - print("Could not parse link text.") + Log.verbose("[LinkPreview] Could not parse link text.") throw LinkPreviewError.invalidInput } @@ -488,71 +476,32 @@ public extension LinkPreview { } private static func downloadImage( - url urlString: String, - imageMimeType: String, + url: URL, using dependencies: Dependencies - ) -> AnyPublisher { - guard - let url = URL(string: urlString), - let assetDescription: ProxiedContentAssetDescription = ProxiedContentAssetDescription( - url: url as NSURL - ) - else { - return Fail(error: LinkPreviewError.invalidInput) - .eraseToAnyPublisher() - } + ) async throws -> ImageDataManager.DataSource { + guard let assetDescription: ProxiedContentAssetDescription = ProxiedContentAssetDescription( + url: url as NSURL + ) else { throw LinkPreviewError.invalidInput } - return dependencies[singleton: .proxiedContentDownloader] - .requestAsset( - assetDescription: assetDescription, - priority: .high, - shouldIgnoreSignalProxy: true - ) - .tryMap { asset, _ -> Data in - let type: UTType? = UTType(sessionMimeType: imageMimeType) - let imageSize = MediaUtils.unrotatedSize( - for: asset.filePath, - type: type, - mimeType: imageMimeType, - sourceFilename: nil, - using: dependencies + do { + let asset: ProxiedContentAsset = try await dependencies[singleton: .proxiedContentDownloader] + .requestAsset( + assetDescription: assetDescription, + priority: .high, + shouldIgnoreSignalProxy: true ) - - guard imageSize.width > 0, imageSize.height > 0 else { - throw LinkPreviewError.invalidContent - } - - // Loki: If it's a GIF then ensure its validity and don't download it as a JPG - if type == .gif && MediaUtils.isValidImage(at: asset.filePath, type: .gif, using: dependencies) { - return try Data(contentsOf: URL(fileURLWithPath: asset.filePath)) - } - - guard let data: Data = try? Data(contentsOf: URL(fileURLWithPath: asset.filePath)) else { - throw LinkPreviewError.assertionFailure - } - - guard let srcImage = UIImage(data: data) else { throw LinkPreviewError.invalidContent } - - let maxImageSize: CGFloat = 1024 - let shouldResize = imageSize.width > maxImageSize || imageSize.height > maxImageSize - - guard shouldResize else { - guard let dstData = srcImage.jpegData(compressionQuality: 0.8) else { - throw LinkPreviewError.invalidContent - } - - return dstData - } - - guard - let dstImage = srcImage.resized(maxDimensionPoints: maxImageSize), - let dstData = dstImage.jpegData(compressionQuality: 0.8) - else { throw LinkPreviewError.invalidContent } - - return dstData - } - .mapError { _ -> Error in LinkPreviewError.couldNotDownload } - .eraseToAnyPublisher() + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .media(.url(URL(fileURLWithPath: asset.filePath))), + using: dependencies + ) + let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare( + operations: [.convert(to: .webPLossy(maxDimension: 1024))], + using: dependencies + ) + + return .url(URL(fileURLWithPath: preparedAttachment.filePath)) + } + catch { throw LinkPreviewError.couldNotDownload } } private static func isRetryable(error: Error) -> Bool { diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index d9dd41f57e..6faf2e0234 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -55,8 +55,9 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet public let blocksCommunityMessageRequests: Bool? /// The Pro Proof for when this profile is updated - // TODO: Implement this when the structure of Session Pro Proof is determined + // TODO: Implement these when the structure of Session Pro Proof is determined public let sessionProProof: String? + public var showProBadge: Bool? // MARK: - Initialization @@ -68,7 +69,8 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet displayPictureEncryptionKey: Data? = nil, profileLastUpdated: TimeInterval? = nil, blocksCommunityMessageRequests: Bool? = nil, - sessionProProof: String? = nil + sessionProProof: String? = nil, + showProBadge: Bool? = nil ) { self.id = id self.name = name @@ -78,6 +80,7 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet self.profileLastUpdated = profileLastUpdated self.blocksCommunityMessageRequests = blocksCommunityMessageRequests self.sessionProProof = sessionProProof + self.showProBadge = showProBadge } } @@ -130,11 +133,11 @@ public extension Profile { self = Profile( id: try container.decode(String.self, forKey: .id), name: try container.decode(String.self, forKey: .name), - nickname: try? container.decode(String?.self, forKey: .nickname), + nickname: try container.decodeIfPresent(String.self, forKey: .nickname), displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: displayPictureKey, - profileLastUpdated: try? container.decode(TimeInterval?.self, forKey: .profileLastUpdated), - blocksCommunityMessageRequests: try? container.decode(Bool?.self, forKey: .blocksCommunityMessageRequests) + profileLastUpdated: try container.decodeIfPresent(TimeInterval.self, forKey: .profileLastUpdated), + blocksCommunityMessageRequests: try container.decodeIfPresent(Bool.self, forKey: .blocksCommunityMessageRequests) ) } @@ -190,10 +193,11 @@ public extension Profile { _ db: ObservingDatabase, id: ID, threadVariant: SessionThread.Variant = .contact, + suppressId: Bool = false, customFallback: String? = nil ) -> String { let existingDisplayName: String? = (try? Profile.fetchOne(db, id: id))? - .displayName(for: threadVariant) + .displayName(for: threadVariant, suppressId: suppressId) return (existingDisplayName ?? (customFallback ?? id)) } @@ -201,10 +205,11 @@ public extension Profile { static func displayNameNoFallback( _ db: ObservingDatabase, id: ID, - threadVariant: SessionThread.Variant = .contact + threadVariant: SessionThread.Variant = .contact, + suppressId: Bool = false ) -> String? { return (try? Profile.fetchOne(db, id: id))? - .displayName(for: threadVariant) + .displayName(for: threadVariant, suppressId: suppressId) } // MARK: - Fetch or Create @@ -218,7 +223,8 @@ public extension Profile { displayPictureEncryptionKey: nil, profileLastUpdated: nil, blocksCommunityMessageRequests: nil, - sessionProProof: nil + sessionProProof: nil, + showProBadge: nil ) } @@ -241,13 +247,14 @@ public extension Profile { static func displayName( id: ID, threadVariant: SessionThread.Variant = .contact, + suppressId: Bool = false, 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) }, + retrieve: { db in Profile.displayName(db, id: id, threadVariant: threadVariant, suppressId: suppressId) }, completion: { result in switch result { case .failure: break @@ -264,12 +271,13 @@ public extension Profile { static func displayNameNoFallback( id: ID, threadVariant: SessionThread.Variant = .contact, + suppressId: Bool = false, 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) }, + retrieve: { db in Profile.displayNameNoFallback(db, id: id, threadVariant: threadVariant, suppressId: suppressId) }, completion: { result in switch result { case .failure: break @@ -403,6 +411,7 @@ public extension ProfileAssociated { public extension FetchRequest where RowDecoder: FetchableRecord & ProfileAssociated { 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()) .reduce(into: [:]) { result, next in result[next.id] = next } @@ -422,17 +431,20 @@ public extension FetchRequest where RowDecoder: FetchableRecord & ProfileAssocia public extension Profile { func with( name: String? = nil, - nickname: String?? = nil, - displayPictureUrl: String?? = nil + nickname: Update = .useExisting, + displayPictureUrl: Update = .useExisting, + displayPictureEncryptionKey: Update = .useExisting, + profileLastUpdated: Update = .useExisting, + blocksCommunityMessageRequests: Update = .useExisting ) -> Profile { return Profile( id: id, name: (name ?? self.name), - nickname: (nickname ?? self.nickname), - displayPictureUrl: (displayPictureUrl ?? self.displayPictureUrl), - displayPictureEncryptionKey: displayPictureEncryptionKey, - profileLastUpdated: profileLastUpdated, - blocksCommunityMessageRequests: blocksCommunityMessageRequests, + nickname: nickname.or(self.nickname), + displayPictureUrl: displayPictureUrl.or(self.displayPictureUrl), + displayPictureEncryptionKey: displayPictureEncryptionKey.or(self.displayPictureEncryptionKey), + profileLastUpdated: profileLastUpdated.or(self.profileLastUpdated), + blocksCommunityMessageRequests: blocksCommunityMessageRequests.or(self.blocksCommunityMessageRequests), sessionProProof: self.sessionProProof ) } diff --git a/SessionMessagingKit/Database/Models/Quote.swift b/SessionMessagingKit/Database/Models/Quote.swift index 04d43134ef..c13fde3ebc 100644 --- a/SessionMessagingKit/Database/Models/Quote.swift +++ b/SessionMessagingKit/Database/Models/Quote.swift @@ -6,22 +6,12 @@ import SessionUtilitiesKit public struct Quote: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "quote" } - public static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) - internal static let originalInteractionForeignKey = ForeignKey( - [Columns.timestampMs, Columns.authorId], - to: [Interaction.Columns.timestampMs, Interaction.Columns.authorId] - ) - internal static let profileForeignKey = ForeignKey([Columns.authorId], to: [Profile.Columns.id]) - 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 typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { case interactionId case authorId case timestampMs - case body } /// The id for the interaction this Quote belongs to @@ -33,35 +23,16 @@ public struct Quote: Codable, Equatable, Hashable, FetchableRecord, PersistableR /// The timestamp in milliseconds since epoch when the quoted interaction was sent public let timestampMs: Int64 - /// The body of the quoted message if the user is quoting a text message or an attachment with a caption - public let body: String? - - // MARK: - Relationships - - public var interaction: QueryInterfaceRequest { - request(for: Quote.interaction) - } - - public var profile: QueryInterfaceRequest { - request(for: Quote.profile) - } - - public var originalInteraction: QueryInterfaceRequest { - request(for: Quote.quotedInteraction) - } - // MARK: - Interaction public init( interactionId: Int64, authorId: String, - timestampMs: Int64, - body: String? + timestampMs: Int64 ) { self.interactionId = interactionId self.authorId = authorId self.timestampMs = timestampMs - self.body = body } } @@ -71,14 +42,12 @@ public extension Quote { func with( interactionId: Int64? = nil, authorId: String? = nil, - timestampMs: Int64? = nil, - body: String? = nil + timestampMs: Int64? = nil ) -> Quote { return Quote( interactionId: interactionId ?? self.interactionId, authorId: authorId ?? self.authorId, - timestampMs: timestampMs ?? self.timestampMs, - body: body ?? self.body + timestampMs: timestampMs ?? self.timestampMs ) } @@ -86,8 +55,7 @@ public extension Quote { return Quote( interactionId: self.interactionId, authorId: self.authorId, - timestampMs: self.timestampMs, - body: nil + timestampMs: self.timestampMs ) } } @@ -105,6 +73,5 @@ public extension Quote { self.interactionId = interactionId self.timestampMs = Int64(quoteProto.id) self.authorId = quoteProto.author - self.body = nil } } diff --git a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift index 210d629967..d6f8402365 100644 --- a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift @@ -25,239 +25,253 @@ public enum AttachmentDownloadJob: JobExecutor { let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData) else { return failure(job, JobRunnerError.missingRequiredDetails, true) } - dependencies[singleton: .storage] - .writePublisher { db -> Attachment in - guard let attachment: Attachment = try? Attachment.fetchOne(db, id: details.attachmentId) else { - throw JobRunnerError.missingRequiredDetails - } - - // 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 + Task { + do { + /// Validate and retrieve the attachment state + let attachment: Attachment = try await dependencies[singleton: .storage].writeAsync { db -> Attachment in + guard let attachment: Attachment = try? Attachment.fetchOne(db, id: details.attachmentId) else { + throw JobRunnerError.missingRequiredDetails + } + + /// 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 or + /// uploaded so in those cases just succeed immediately + let fileAlreadyDownloaded: Bool = try { + guard attachment.state == .downloaded || attachment.state == .uploaded else { + return false } - .asSet() + + /// If the attachment should have been downloaded then check to ensure the file exists (if it doesn't then + /// wr should try to download it again - this will result in the file going into a "failed" state if not which is + /// better than the "file is downloaded but doesn't exist" state which is handled poorly + let path: String = try dependencies[singleton: .attachmentManager].path(for: attachment.downloadUrl) + + return dependencies[singleton: .fileManager].fileExists(atPath: path) + }() + + guard !fileAlreadyDownloaded else { throw AttachmentDownloadError.alreadyDownloaded } - // 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)) + /// 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) ) } - // 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 } + try Task.checkCancellation() - // 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 - } - .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 - ) + /// Download the attachment data + let maybeAuthMethod: AuthenticationMethod? = try await dependencies[singleton: .storage].readAsync { db in + try? Authentication.with( + db, + threadId: threadId, + threadVariant: .community, + using: dependencies + ) + } + let request: Network.PreparedRequest - 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) + switch maybeAuthMethod { + case let authMethod as Authentication.community: + request = try Network.SOGS.preparedDownload( + url: downloadUrl, + roomToken: authMethod.roomToken, + authMethod: authMethod, + using: dependencies + ) + + default: + request = try Network.FileServer.preparedDownload( + url: downloadUrl, + using: dependencies + ) + } - switch maybeRoomToken { - case .some(let roomToken): - return try Network.SOGS - .preparedDownload( - url: info.downloadUrl, - roomToken: roomToken, - authMethod: try Authentication.with( - db, - threadId: threadId, - threadVariant: .community, - using: dependencies - ), - using: dependencies + // FIXME: Make this async/await when the refactored networking is merged + let response: Data = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw AttachmentError.downloadFailed }() + try Task.checkCancellation() + + /// Store the encrypted data temporarily + let temporaryFilePath: String = dependencies[singleton: .fileManager].temporaryFilePath() + try response.write(to: URL(fileURLWithPath: temporaryFilePath), options: .atomic) + defer { + /// Remove the temporary file regardless of the outcome (it'll get recreated if we try again) + try? dependencies[singleton: .fileManager].removeItem(atPath: temporaryFilePath) + } + + /// Decrypt the data if needed + let plaintext: Data + let usesDeterministicEncryption: Bool = Network.FileServer + .usesDeterministicEncryption(attachment.downloadUrl) + + switch (attachment.encryptionKey, attachment.digest, usesDeterministicEncryption) { + case (.some(let key), .some(let digest), false) where !key.isEmpty: + plaintext = try dependencies[singleton: .crypto].tryGenerate( + .legacyDecryptAttachment( + ciphertext: response, + key: key, + digest: digest, + unpaddedSize: attachment.byteCount ) - .map { _, data in (data, info.attachment, info.temporaryFileUrl) } + ) - case .none: - return try Network - .preparedDownload( - url: info.downloadUrl, - using: dependencies + case (.some(let key), _, true) where !key.isEmpty: + plaintext = try dependencies[singleton: .crypto].tryGenerate( + .decryptAttachment( + ciphertext: response, + key: key ) - .map { _, data in (data, info.attachment, info.temporaryFileUrl) } + ) + + default: plaintext = response } - }) - .flatMap { downloadRequest in - downloadRequest.send(using: dependencies).map { _, response in - (response.attachment, response.temporaryFileUrl, response.data) + try Task.checkCancellation() + + /// Write the decrypted data to disk + guard try attachment.write(data: plaintext, using: dependencies) else { + throw AttachmentDownloadError.failedToSaveFile } - } - .subscribe(on: scheduler, using: dependencies) - .receive(on: scheduler, using: dependencies) - .tryMap { attachment, temporaryFileUrl, data -> Attachment in - // Store the encrypted data temporarily - try data.write(to: temporaryFileUrl, options: .atomic) + try Task.checkCancellation() - // Decrypt the data - let plaintext: Data = try { - guard - let key: Data = attachment.encryptionKey, - let digest: Data = attachment.digest, - key.count > 0, - digest.count > 0 - else { return data } // Open group attachments are unencrypted - - return try dependencies[singleton: .crypto].tryGenerate( - .decryptAttachment( - ciphertext: data, - key: key, - digest: digest, - unpaddedSize: attachment.byteCount + /// 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 + try await dependencies[singleton: .storage].writeAsync { db in + 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)) ) - }() + } - // Write the data to disk - guard try attachment.write(data: plaintext, using: dependencies) else { - throw AttachmentDownloadError.failedToSaveFile + return scheduler.schedule { + success(job, false) + } + } + catch AttachmentDownloadError.alreadyDownloaded { + return scheduler.schedule { + success(job, false) + } + } + catch JobRunnerError.missingRequiredDetails { + return scheduler.schedule { + failure(job, JobRunnerError.missingRequiredDetails, true) } + } + catch JobRunnerError.possibleDuplicateJob(let permanentFailure) { + return scheduler.schedule { + failure(job, JobRunnerError.possibleDuplicateJob(permanentFailure: permanentFailure), permanentFailure) + } + } + catch { + let targetState: Attachment.State + let permanentFailure: Bool - // Remove the temporary file - try? dependencies[singleton: .fileManager].removeItem(atPath: temporaryFileUrl.path) + switch error { + /// If we get a 404 then we got a successful response from the server but the attachment doesn't + /// exist, in this case update the attachment to an "invalid" state so the user doesn't get stuck in + /// a retry download loop + case NetworkError.notFound: + targetState = .invalid + permanentFailure = true + + /// If we got a 400 or a 401 then we want to fail the download in a way that has to be manually retried as it's + /// likely something else is going on that caused the failure + case NetworkError.badRequest, NetworkError.unauthorised, + SnodeAPIError.signatureVerificationFailed: + targetState = .failedDownload + permanentFailure = true + + /// For any other error it's likely either the server is down or something weird just happened with the request + /// so we want to automatically retry + default: + targetState = .failedDownload + permanentFailure = false + } - return attachment - } - .flatMapStorageWritePublisher(using: dependencies) { db, attachment in - /// Update the attachment state + /// To prevent the attachment from showing a state of downloading forever, we need to update the attachment + /// state here based on the type of error that occurred /// /// **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 + try? await dependencies[singleton: .storage].writeAsync { 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)) ) - .upserted(db) - db.addAttachmentEvent( - id: attachment.id, - messageId: job.interactionId, - type: .updated(.state(.downloaded)) - ) + } - return updatedAttachment - } - .sinkUntilComplete( - receiveCompletion: { result in - 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 (_, _, .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 - - switch error { - /// If we get a 404 then we got a successful response from the server but the attachment doesn't - /// exist, in this case update the attachment to an "invalid" state so the user doesn't get stuck in - /// a retry download loop - case NetworkError.notFound: - targetState = .invalid - permanentFailure = true - - /// If we got a 400 or a 401 then we want to fail the download in a way that has to be manually retried as it's - /// likely something else is going on that caused the failure - case NetworkError.badRequest, NetworkError.unauthorised, - SnodeAPIError.signatureVerificationFailed: - targetState = .failedDownload - permanentFailure = true - - /// For any other error it's likely either the server is down or something weird just happened with the request - /// so we want to automatically retry - default: - targetState = .failedDownload - permanentFailure = false - } - - /// To prevent the attachment from showing a state of downloading forever, we need to update the attachment - /// state here based on the type of error that occurred - /// - /// **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].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) - } - ) - } + /// Trigger the failure and provide the `permanentFailure` value defined above + return scheduler.schedule { + failure(job, error, permanentFailure) } - ) + } + } } } diff --git a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift index a3cd20c22a..320090f52b 100644 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import UniformTypeIdentifiers import Combine import GRDB import SessionNetworkingKit @@ -34,175 +35,117 @@ public enum AttachmentUploadJob: JobExecutor { let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData) else { return failure(job, JobRunnerError.missingRequiredDetails, true) } - dependencies[singleton: .storage] - .readPublisher { db -> 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 + Task { + do { + let attachment: Attachment = try await dependencies[singleton: .storage].readAsync { db 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 } + try Task.checkCancellation() - /// 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 + let authMethod: AuthenticationMethod = try await dependencies[singleton: .storage].readAsync { db in + let threadVariant: SessionThread.Variant = try SessionThread + .select(.variant) + .filter(id: threadId) + .asRequest(of: SessionThread.Variant.self) + .fetchOne(db, orThrow: StorageError.objectNotFound) + return try Authentication.with( + db, + threadId: threadId, + threadVariant: threadVariant, + using: dependencies + ) } + try Task.checkCancellation() - 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, + try await upload( + attachment: attachment, threadId: threadId, - message: details.message, - destination: details.destination, interactionId: interactionId, + messageSendJobId: details.messageSendJobId, + authMethod: authMethod, + onEvent: standardEventHandling(using: dependencies), using: dependencies ) + try Task.checkCancellation() - return (attachment, authMethod) + return scheduler.schedule { + success(job, false) + } } - .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 - ) + catch JobRunnerError.missingRequiredDetails { + return scheduler.schedule { + failure(job, JobRunnerError.missingRequiredDetails, true) + } } - .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 + catch StorageError.objectNotFound { + return scheduler.schedule { + Log.info(.cat, "Failed due to missing interaction") + failure(job, StorageError.objectNotFound, true) + } } - .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)) - ) - } + catch AttachmentError.uploadIsStillPendingDownload { + return scheduler.schedule { + Log.info(.cat, "Deferred as attachment is still being downloaded") + return deferred(job) } - ) - .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, result.errorOrNull) { - case (.finished, _): success(job, false) - - case (_, let error as JobRunnerError) where error == .missingRequiredDetails: - failure(job, error, true) - - 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) - } - ) + catch { + let triggeredMessageSendFailure: Bool? = try? await dependencies[singleton: .storage].writeAsync { 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 + } + + return scheduler.schedule { + if triggeredMessageSendFailure == false { + Log.error(.cat, "Failed due to error: \(error)") } + + failure(job, error, false) } - ) + } + } } } @@ -226,3 +169,280 @@ extension AttachmentUploadJob { } } } + +// MARK: - Uploading + +public extension AttachmentUploadJob { + typealias PreparedUpload = ( + request: Network.PreparedRequest, + attachment: Attachment, + preparedAttachment: PreparedAttachment + ) + + enum Event { + case willUpload(Attachment, threadId: String, interactionId: Int64?, messageSendJobId: Int64?) + case success(Attachment, interactionId: Int64?) + } + + static func preparePriorToUpload( + attachments: [PendingAttachment], + using dependencies: Dependencies + ) async throws -> [Attachment] { + var result: [Attachment] = [] + + for pendingAttachment in attachments { + /// Strip any metadata from the attachment and store at a "Pending Upload" file path + let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare( + operations: [ + .stripImageMetadata + ], + storeAtPendingAttachmentUploadPath: true, + using: dependencies + ) + + result.append(preparedAttachment.attachment) + } + + return result + } + + static func link( + _ db: ObservingDatabase, + attachments: [Attachment]?, + toInteractionWithId 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) + } + } + + @discardableResult + static func upload( + attachment: Attachment, + threadId: String, + interactionId: Int64?, + messageSendJobId: Int64?, + authMethod: AuthenticationMethod, + onEvent: ((Event) async throws -> Void)?, + using dependencies: Dependencies + ) async throws -> (attachment: Attachment, response: FileUploadResponse) { + let shouldEncrypt: Bool = { + switch authMethod { + case is Authentication.community: return false + default: return true + } + }() + + /// 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 = Network.FileServer.fileId(for: attachment.downloadUrl), + !dependencies[singleton: .attachmentManager].isPlaceholderUploadUrl(attachment.downloadUrl) + { + return (attachment, FileUploadResponse(id: fileId, uploaded: nil, expires: nil)) + } + + /// 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 currently encrypted + /// + /// **Note:** The most common cases for this will be for `LinkPreviews` + if + attachment.state == .downloaded, + let fileId: String = Network.FileServer.fileId(for: attachment.downloadUrl), + !dependencies[singleton: .attachmentManager].isPlaceholderUploadUrl(attachment.downloadUrl), + ( + !shouldEncrypt || + attachment.encryptionKey != nil + ) + { + return (attachment, FileUploadResponse(id: fileId, uploaded: nil, expires: nil)) + } + + /// If we have gotten here then we need to upload + try await onEvent?(.willUpload(attachment, threadId: threadId, interactionId: interactionId, messageSendJobId: messageSendJobId)) + try Task.checkCancellation() + + /// Encrypt the attachment if needed + let pendingAttachment: PendingAttachment = try PendingAttachment( + attachment: attachment, + using: dependencies + ) + let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare( + operations: (shouldEncrypt ? [.encrypt(domain: .attachment)] : []), + using: dependencies + ) + let maybePreparedData: Data? = dependencies[singleton: .fileManager] + .contents(atPath: preparedAttachment.filePath) + try Task.checkCancellation() + + guard let preparedData: Data = maybePreparedData else { + Log.error(.cat, "Couldn't retrieve prepared attachment data.") + throw AttachmentError.invalidData + } + + /// Ensure the file size is smaller than our upload limit + Log.info(.cat, "File size: \(preparedData.count) bytes.") + guard preparedData.count <= Network.maxFileSize else { + throw NetworkError.maxFileSizeExceeded + } + + let request: Network.PreparedRequest + + /// Return the request and the prepared attachment + switch authMethod { + case let communityAuth as Authentication.community: + request = try Network.SOGS.preparedUpload( + data: preparedData, + roomToken: communityAuth.roomToken, + authMethod: communityAuth, + using: dependencies + ) + + default: + request = try Network.FileServer.preparedUpload( + data: preparedData, + using: dependencies + ) + } + + // FIXME: Make this async/await when the refactored networking is merged + let response: FileUploadResponse = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw AttachmentError.uploadFailed }() + try Task.checkCancellation() + + /// 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 + /// + /// **Note:** Attachments are currently stored unencrypted so we need to move the original `attachment` file to the + /// `finalFilePath` rather than the encrypted one + // FIXME: Should probably store display pictures encrypted and decrypt on load + let finalDownloadUrl: String = { + let isPlaceholderUploadUrl: Bool = dependencies[singleton: .attachmentManager] + .isPlaceholderUploadUrl(attachment.downloadUrl) + + switch (attachment.downloadUrl, isPlaceholderUploadUrl, authMethod) { + case (.some(let downloadUrl), false, _): return downloadUrl + case (_, _, let community as Authentication.community): + return Network.SOGS.downloadUrlString( + for: response.id, + server: community.server, + roomToken: community.roomToken + ) + + default: + return Network.FileServer.downloadUrlString( + for: response.id, + using: dependencies + ) + } + }() + + if + let oldUrl: String = attachment.downloadUrl, + finalDownloadUrl != oldUrl, + let oldPath: String = try? dependencies[singleton: .attachmentManager].path(for: oldUrl), + let newPath: String = try? dependencies[singleton: .attachmentManager].path(for: finalDownloadUrl) + { + if !dependencies[singleton: .fileManager].fileExists(atPath: newPath) { + try dependencies[singleton: .fileManager].moveItem(atPath: oldPath, toPath: newPath) + } + else { + try? dependencies[singleton: .fileManager].removeItem(atPath: oldPath) + Log.info(.cat, "File already existed at final path, assuming re-upload of existing attachment") + } + } + + /// Generate the final uploaded attachment data and trigger the success callback + let uploadedAttachment: Attachment = Attachment( + id: preparedAttachment.attachment.id, + serverId: response.id, + variant: preparedAttachment.attachment.variant, + state: .uploaded, + contentType: preparedAttachment.attachment.contentType, + byteCount: preparedAttachment.attachment.byteCount, + creationTimestamp: ( + preparedAttachment.attachment.creationTimestamp ?? + (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + ), + sourceFilename: preparedAttachment.attachment.sourceFilename, + downloadUrl: finalDownloadUrl, + width: preparedAttachment.attachment.width, + height: preparedAttachment.attachment.height, + duration: preparedAttachment.attachment.duration, + isVisualMedia: preparedAttachment.attachment.isVisualMedia, + isValid: preparedAttachment.attachment.isValid, + encryptionKey: preparedAttachment.attachment.encryptionKey, + digest: preparedAttachment.attachment.digest + ) + try await onEvent?(.success(uploadedAttachment, interactionId: interactionId)) + try Task.checkCancellation() + + return (uploadedAttachment, response) + } + + /// This function performs the standard database actions when various upload events occur + /// + /// Returns `true` if the event resulted in a `MessageSendJob` being updated + static func standardEventHandling(using dependencies: Dependencies) -> ((Event) async throws -> Void) { + return { event in + try await dependencies[singleton: .storage].writeAsync { db in + switch event { + case .willUpload(let attachment, let threadId, let interactionId, let messageSendJobId): + _ = try? Attachment + .filter(id: attachment.id) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading)) + db.addAttachmentEvent( + id: attachment.id, + messageId: interactionId, + type: .updated(.state(.uploading)) + ) + + /// 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 + guard + let sendJob: Job = try Job.fetchOne(db, id: 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, + threadId: threadId, + message: details.message, + destination: details.destination, + interactionId: interactionId, + using: dependencies + ) + + case .success(let updatedAttachment, let interactionId): + try updatedAttachment.upsert(db) + + db.addAttachmentEvent( + id: updatedAttachment.id, + messageId: interactionId, + type: .updated(.state(updatedAttachment.state)) + ) + } + } + } + } +} diff --git a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift index b687c07f66..483be09a87 100644 --- a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift +++ b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift @@ -34,155 +34,221 @@ public enum DisplayPictureDownloadJob: JobExecutor { let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData) else { return failure(job, JobRunnerError.missingRequiredDetails, true) } - dependencies[singleton: .storage] - .readPublisher { db -> Network.PreparedRequest in - switch details.target { - case .profile(_, let url, _), .group(_, let url, _): - guard - let fileId: String = Network.FileServer.fileId(for: url), - let downloadUrl: URL = URL(string: Network.FileServer.downloadUrlString(for: url, fileId: fileId)) - else { throw NetworkError.invalidURL } - - return try Network.preparedDownload( - url: downloadUrl, - using: dependencies - ) - - case .community(let fileId, let roomToken, let server, let skipAuthentication): - guard - let info: LibSession.OpenGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo - .fetchOne(db, id: OpenGroup.idFor(roomToken: roomToken, server: server)) - else { throw JobRunnerError.missingRequiredDetails } - - return try Network.SOGS.preparedDownload( - fileId: fileId, - roomToken: roomToken, - authMethod: Authentication.community(info: info), - skipAuthentication: skipAuthentication, - using: dependencies - ) + Task { + do { + let request: Network.PreparedRequest = try await dependencies[singleton: .storage].readAsync { db in + switch details.target { + case .profile(_, let url, _), .group(_, let url, _): + guard let downloadUrl: URL = URL(string: url) else { + throw NetworkError.invalidURL + } + + return try Network.FileServer.preparedDownload( + url: downloadUrl, + using: dependencies + ) + + case .community(let fileId, let roomToken, let server, let skipAuthentication): + guard + let info: LibSession.OpenGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo + .fetchOne(db, id: OpenGroup.idFor(roomToken: roomToken, server: server)) + else { throw JobRunnerError.missingRequiredDetails } + + return try Network.SOGS.preparedDownload( + fileId: fileId, + roomToken: roomToken, + authMethod: Authentication.community(info: info), + skipAuthentication: skipAuthentication, + using: dependencies + ) + } } - } - .tryMap { (preparedDownload: Network.PreparedRequest) -> Network.PreparedRequest<(Data, String, URL?, Date?)> in - guard - let filePath: String = try? dependencies[singleton: .displayPictureManager].path( - for: (preparedDownload.destination.url?.absoluteString) - .defaulting(to: preparedDownload.destination.urlPathAndParamsString) - ) - else { throw DisplayPictureError.invalidPath } + try Task.checkCancellation() - guard !dependencies[singleton: .fileManager].fileExists(atPath: filePath) else { - throw DisplayPictureError.alreadyDownloaded(preparedDownload.destination.url) + /// Check to make sure this download is a valid update before starting to download + try await dependencies[singleton: .storage].readAsync { db in + try details.ensureValidUpdate(db, using: dependencies) } - return preparedDownload.map { info, data in - (data, filePath, preparedDownload.destination.url, Date.fromHTTPExpiresHeaders(info.headers["Expires"])) + let downloadUrl: String = details.target.downloadUrl + let filePath: String = try dependencies[singleton: .displayPictureManager] + .path(for: downloadUrl) + + guard !dependencies[singleton: .fileManager].fileExists(atPath: filePath) else { + throw AttachmentError.alreadyDownloaded(downloadUrl) } - } - .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?, Date?)) -> (Data, String, URL?, Date?) in + + // FIXME: Make this async/await when the refactored networking is merged + let response: Data = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw AttachmentError.downloadFailed }() + try Task.checkCancellation() + /// Check to make sure this download is still a valid update - guard details.isValidUpdate(db, using: dependencies) else { - throw DisplayPictureError.updateNoLongerValid + try await dependencies[singleton: .storage].readAsync { db in + try details.ensureValidUpdate(db, using: dependencies) } - return result - } - .tryMap { (data: Data, filePath: String, downloadUrl: URL?, expires: Date?) -> (URL?, Date?) in + /// Get the decrypted data guard let decryptedData: Data = { - switch details.target { - case .community: return data // Community data is unencrypted - case .profile(_, _, let encryptionKey), .group(_, _, let encryptionKey): + switch (details.target, details.target.usesDeterministicEncryption) { + case (.community, _): return response /// Community data is unencrypted + case (.profile(_, _, let encryptionKey), false), (.group(_, _, let encryptionKey), false): + return dependencies[singleton: .crypto].generate( + .legacyDecryptedDisplayPicture(data: response, key: encryptionKey) + ) + + case (.profile(_, _, let encryptionKey), true), (.group(_, _, let encryptionKey), true): return dependencies[singleton: .crypto].generate( - .decryptedDataDisplayPicture(data: data, key: encryptionKey) + .decryptAttachment(ciphertext: response, key: encryptionKey) ) } }() - else { throw DisplayPictureError.writeFailed } + else { throw AttachmentError.writeFailed } + /// Ensure it's a valid image guard UIImage(data: decryptedData) != nil, dependencies[singleton: .fileManager].createFile( atPath: filePath, contents: decryptedData ) - else { throw DisplayPictureError.loadFailed } + else { throw AttachmentError.invalidData } /// Kick off a task to load the image into the cache (assuming we want to render it soon) - Task(priority: .userInitiated) { + Task.detached(priority: .userInitiated) { [dependencies] in await dependencies[singleton: .imageDataManager].load( .url(URL(fileURLWithPath: filePath)) ) } - return (downloadUrl, expires) - } - .flatMapStorageWritePublisher(using: dependencies) { (db: ObservingDatabase, result: (downloadUrl: URL?, expires: Date?)) 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: result.downloadUrl, - expires: result.expires, - using: dependencies - ) - } - .sinkUntilComplete( - receiveCompletion: { result in - 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, - details: details, - downloadUrl: downloadUrl, - expires: nil, - using: dependencies - ) - }, - completion: { result in - switch result { - case .success: success(job, false) - case .failure(let error): failure(job, error, true) - } - } + /// Remove the old display picture (since we are replacing it) + let existingProfileUrl: String? = try? await dependencies[singleton: .storage].readAsync { db in + switch details.target { + case .profile(let id, _, _): + /// We should consider `libSession` the source-of-truth for profile data for contacts so try to retrieve the profile data from + /// there before falling back to the one fetched from the database + return try? ( + dependencies.mutate(cache: .libSession) { + $0.profile(contactId: id)?.displayPictureUrl + } ?? + Profile + .filter(id: id) + .select(.displayPictureUrl) + .asRequest(of: String.self) + .fetchOne(db) ) - 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 .group(let id, _, _): + return try? ClosedGroup + .filter(id: id) + .select(.displayPictureUrl) + .asRequest(of: String.self) + .fetchOne(db) - 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) + case .community(_, let roomToken, let server, _): + return try? OpenGroup + .filter(id: OpenGroup.idFor(roomToken: roomToken, server: server)) + .select(.displayPictureOriginalUrl) + .asRequest(of: String.self) + .fetchOne(db) + } + } + + /// Store the updated information in the database (this will generally result in the UI refreshing as it'll observe + /// the `downloadUrl` changing) + try await dependencies[singleton: .storage].writeAsync { db in + try writeChanges( + db, + details: details, + downloadUrl: downloadUrl, + using: dependencies + ) + } + + /// Remove the old display picture (as long as it's different from the new one) + if + let existingProfileUrl: String = existingProfileUrl, + existingProfileUrl != downloadUrl, + let existingFilePath: String = try? dependencies[singleton: .displayPictureManager] + .path(for: existingProfileUrl) + { + Task.detached(priority: .low) { + await dependencies[singleton: .imageDataManager].removeImage( + identifier: existingFilePath + ) + try? dependencies[singleton: .fileManager].removeItem(atPath: existingFilePath) + } + } + + return scheduler.schedule { + success(job, false) + } + } + catch AttachmentError.downloadNoLongerValid { + return scheduler.schedule { + success(job, false) + } + } + catch AttachmentError.alreadyDownloaded(let downloadUrl) { + /// If the file already exists then write the changes to the database + do { + try await dependencies[singleton: .storage].writeAsync { db in + try writeChanges( + db, + details: details, + downloadUrl: downloadUrl, + using: dependencies + ) + } + + return scheduler.schedule { + success(job, false) + } + } + catch { + return scheduler.schedule { + failure(job, error, true) } } - ) + } + catch JobRunnerError.missingRequiredDetails { + return scheduler.schedule { + failure(job, JobRunnerError.missingRequiredDetails, true) + } + } + catch AttachmentError.invalidPath { + return scheduler.schedule { + Log.error(.cat, "Failed to generate display picture file path for \(details.target)") + failure(job, AttachmentError.invalidPath, true) + } + } + catch AttachmentError.writeFailed { + return scheduler.schedule { + Log.error(.cat, "Failed to decrypt display picture for \(details.target)") + failure(job, AttachmentError.writeFailed, true) + } + } + catch AttachmentError.invalidData { + return scheduler.schedule { + Log.error(.cat, "Failed to load display picture for \(details.target)") + failure(job, AttachmentError.invalidData, true) + } + } + catch { + return scheduler.schedule { + failure(job, error, true) + } + } + } } private static func writeChanges( _ db: ObservingDatabase, details: Details, - downloadUrl: URL?, - expires: Date?, + downloadUrl: String?, using dependencies: Dependencies ) throws { switch details.target { @@ -196,12 +262,9 @@ public enum DisplayPictureDownloadJob: JobExecutor { Profile.Columns.profileLastUpdated.set(to: details.timestamp), using: dependencies ) + db.addProfileEvent(id: id, change: .displayPictureUrl(url)) db.addConversationEvent(id: id, type: .updated(.displayPictureUrl(url))) - - if dependencies[cache: .general].sessionId.hexString == id, let expires: Date = expires { - dependencies[defaults: .standard, key: .profilePictureExpiresDate] = expires - } case .group(let id, let url, let encryptionKey): _ = try? ClosedGroup @@ -224,7 +287,7 @@ public enum DisplayPictureDownloadJob: JobExecutor { ) db.addConversationEvent( id: OpenGroup.idFor(roomToken: roomToken, server: server), - type: .updated(.displayPictureUrl(downloadUrl?.absoluteString)) + type: .updated(.displayPictureUrl(downloadUrl)) ) } } @@ -240,14 +303,29 @@ extension DisplayPictureDownloadJob { var isValid: Bool { switch self { + case .community(let imageId, _, _, _): return !imageId.isEmpty case .profile(_, let url, let encryptionKey), .group(_, let url, let encryptionKey): return ( !url.isEmpty && Network.FileServer.fileId(for: url) != nil && - encryptionKey.count == DisplayPictureManager.aes256KeyByteLength + encryptionKey.count == DisplayPictureManager.encryptionKeySize ) - - case .community(let imageId, _, _, _): return !imageId.isEmpty + } + } + + var usesDeterministicEncryption: Bool { + switch self { + case .community: return false + case .profile(_, let url, _), .group(_, let url, _): + return Network.FileServer.usesDeterministicEncryption(url) + } + } + + var downloadUrl: String { + switch self { + case .profile(_, let url, _), .group(_, let url, _): return url + case .community(let fileId, let roomToken, let server, _): + return Network.SOGS.downloadUrlString(for: fileId, server: server, roomToken: roomToken) } } @@ -304,7 +382,7 @@ extension DisplayPictureDownloadJob { let key: Data = profile.displayPictureEncryptionKey, let details: Details = Details( target: .profile(id: profile.id, url: url, encryptionKey: key), - timestamp: (profile.profileLastUpdated ?? 0) + timestamp: profile.profileLastUpdated ) else { return nil } @@ -316,7 +394,7 @@ extension DisplayPictureDownloadJob { let key: Data = group.displayPictureEncryptionKey, let details: Details = Details( target: .group(id: group.id, url: url, encryptionKey: key), - timestamp: 0 + timestamp: nil ) else { return nil } @@ -331,7 +409,7 @@ extension DisplayPictureDownloadJob { roomToken: openGroup.roomToken, server: openGroup.server ), - timestamp: 0 + timestamp: nil ) else { return nil } @@ -343,10 +421,19 @@ extension DisplayPictureDownloadJob { // MARK: - Functions - fileprivate func isValidUpdate(_ db: ObservingDatabase, using dependencies: Dependencies) -> Bool { + fileprivate func ensureValidUpdate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { switch self.target { case .profile(let id, let url, let encryptionKey): - guard let latestProfile: Profile = try? Profile.fetchOne(db, id: id) else { return false } + /// We should consider `libSession` the source-of-truth for profile data for contacts so try to retrieve the profile data from + /// there before falling back to the one fetched from the database + let maybeLatestProfile: Profile? = try? ( + dependencies.mutate(cache: .libSession) { $0.profile(contactId: id) } ?? + Profile.fetchOne(db, id: id) + ) + + guard let latestProfile: Profile = maybeLatestProfile else { + throw AttachmentError.downloadNoLongerValid + } /// If the data matches what is stored in the database then we should be fine to consider it valid (it may be that /// we are re-downloading a profile due to some invalid state) @@ -354,12 +441,17 @@ extension DisplayPictureDownloadJob { encryptionKey == latestProfile.displayPictureEncryptionKey && url == latestProfile.displayPictureUrl ) - - return ( - Profile.shouldUpdateProfile(timestamp, profile: latestProfile, using: dependencies) || - dataMatches + let updateStatus: Profile.UpdateStatus = Profile.UpdateStatus( + updateTimestamp: timestamp, + cachedProfile: latestProfile ) + guard dataMatches || updateStatus == .shouldUpdate || updateStatus == .matchesCurrent else { + throw AttachmentError.downloadNoLongerValid + } + + break + 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` @@ -367,10 +459,11 @@ extension DisplayPictureDownloadJob { guard let latestDisplayPictureUrl: String = dependencies.mutate(cache: .libSession, { cache in cache.displayPictureUrl(threadId: id, threadVariant: .group) - }) - else { return false } + }), + url == latestDisplayPictureUrl + else { throw AttachmentError.downloadNoLongerValid } - return (url == latestDisplayPictureUrl) + break case .community(let imageId, let roomToken, let server, _): guard @@ -378,10 +471,11 @@ extension DisplayPictureDownloadJob { .select(.imageId) .filter(id: OpenGroup.idFor(roomToken: roomToken, server: server)) .asRequest(of: String.self) - .fetchOne(db) - else { return false } + .fetchOne(db), + imageId == latestImageId + else { throw AttachmentError.downloadNoLongerValid } - return (imageId == latestImageId) + break } } } diff --git a/SessionMessagingKit/Jobs/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/GarbageCollectionJob.swift index aacaa48e5c..9a85a06a1b 100644 --- a/SessionMessagingKit/Jobs/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/GarbageCollectionJob.swift @@ -385,7 +385,7 @@ public enum GarbageCollectionJob: JobExecutor { .filter(Profile.Columns.displayPictureUrl != nil) .asRequest(of: String.self) .fetchSet(db) - .compactMap { try? dependencies[singleton: .displayPictureManager].path(for: $0) }) + .compactMap { try? dependencies[singleton: .displayPictureManager].path(for: $0) }) ) displayPictureFilePaths.insert( contentsOf: Set(try ClosedGroup @@ -393,7 +393,7 @@ public enum GarbageCollectionJob: JobExecutor { .filter(ClosedGroup.Columns.displayPictureUrl != nil) .asRequest(of: String.self) .fetchSet(db) - .compactMap { try? dependencies[singleton: .displayPictureManager].path(for: $0) }) + .compactMap { try? dependencies[singleton: .displayPictureManager].path(for: $0) }) ) displayPictureFilePaths.insert( contentsOf: Set(try OpenGroup @@ -401,7 +401,7 @@ public enum GarbageCollectionJob: JobExecutor { .filter(OpenGroup.Columns.displayPictureOriginalUrl != nil) .asRequest(of: String.self) .fetchSet(db) - .compactMap { try? dependencies[singleton: .displayPictureManager].path(for: $0) }) + .compactMap { try? dependencies[singleton: .displayPictureManager].path(for: $0) }) ) } diff --git a/SessionMessagingKit/Jobs/MessageSendJob.swift b/SessionMessagingKit/Jobs/MessageSendJob.swift index bbb2b1c7fe..5ec6e54d81 100644 --- a/SessionMessagingKit/Jobs/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/MessageSendJob.swift @@ -85,7 +85,13 @@ public enum MessageSendJob: JobExecutor { /// Retrieve the current attachment state let attachmentState: AttachmentState = dependencies[singleton: .storage] - .read { db in try MessageSendJob.fetchAttachmentState(db, interactionId: interactionId) } + .read { db in + try MessageSendJob.fetchAttachmentState( + db, + interactionId: interactionId, + using: dependencies + ) + } .defaulting(to: AttachmentState(error: MessageSenderError.invalidMessage)) /// If we got an error when trying to retrieve the attachment state then this job is actually invalid so it @@ -301,7 +307,8 @@ public extension MessageSendJob { static func fetchAttachmentState( _ db: ObservingDatabase, - interactionId: Int64 + interactionId: Int64, + using dependencies: Dependencies ) throws -> AttachmentState { // If the original interaction no longer exists then don't bother sending the message (ie. the // message was deleted before it even got sent) @@ -352,6 +359,8 @@ public extension MessageSendJob { .compactMap { info in guard let attachment: Attachment = attachments[info.attachmentId], + !dependencies[singleton: .attachmentManager] + .isPlaceholderUploadUrl(attachment.downloadUrl), let fileId: String = Network.FileServer.fileId(for: info.downloadUrl) else { return nil } diff --git a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift new file mode 100644 index 0000000000..e7acb1067f --- /dev/null +++ b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift @@ -0,0 +1,194 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import GRDB +import SessionUtilitiesKit +import SessionNetworkingKit + +// MARK: - Log.Category + +private extension Log.Category { + static let cat: Log.Category = .create("ReuploadUserDisplayPictureJob", defaultLevel: .info) +} + +// MARK: - ReuploadUserDisplayPictureJob + +public enum ReuploadUserDisplayPictureJob: JobExecutor { + public static let maxFailureCount: Int = -1 + public static let requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false + private static let maxExtendTTLFrequency: TimeInterval = (60 * 60 * 2) + private static let maxDisplayPictureTTL: TimeInterval = (60 * 60 * 24 * 14) + private static let maxReuploadFrequency: TimeInterval = (maxDisplayPictureTTL - (60 * 60 * 24 * 2)) + + public static func run( + _ job: Job, + scheduler: S, + success: @escaping (Job, Bool) -> Void, + failure: @escaping (Job, Error, Bool) -> Void, + deferred: @escaping (Job) -> Void, + using dependencies: Dependencies + ) { + /// Don't run when inactive or not in main app + guard dependencies[defaults: .appGroup, key: .isMainAppActive] else { + return deferred(job) + } + + Task { + guard + await dependencies[singleton: .currentUserPoller].successfulPollCount + .first(where: { $0 > 0 }) != nil + else { + Log.info(.cat, "Deferred due to never receiving an initial poll response") + return scheduler.schedule { + deferred(job) + } + } + + /// Retrieve the users profile data + let profile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } + + /// If we don't have a display pic then no need to do anything + guard + let displayPictureUrl: URL = profile.displayPictureUrl.map({ URL(string: $0) }), + let displayPictureEncryptionKey: Data = profile.displayPictureEncryptionKey + else { + Log.info(.cat, "User has no display picture") + return scheduler.schedule { + success(job, false) + } + } + + guard + let filePath: String = try? dependencies[singleton: .displayPictureManager] + .path(for: displayPictureUrl.absoluteString), + dependencies[singleton: .fileManager].fileExists(atPath: filePath) + else { + Log.warn(.cat, "User has display picture but file was not found") + return scheduler.schedule { + success(job, false) + } + } + + /// Only try to extend the TTL of the users display pic if enough time has passed since it was last updated + let lastUpdated: Date = Date(timeIntervalSince1970: profile.profileLastUpdated ?? 0) + + guard dependencies.dateNow.timeIntervalSince(lastUpdated) > maxExtendTTLFrequency || dependencies[feature: .shortenFileTTL] else { + /// Reset the `nextRunTimestamp` value just in case the last run failed so we don't get stuck in a loop endlessly + /// deferring the job + if let jobId: Int64 = job.id { + try await dependencies[singleton: .storage].writeAsync { db in + try Job + .filter(id: jobId) + .updateAll(db, Job.Columns.nextRunTimestamp.set(to: 0)) + } + + } + Log.info(.cat, "Deferred as not enough time has passed since the last update") + return scheduler.schedule { + deferred(job) + } + } + + /// Try to extend the TTL of the existing profile pic first (default to providing no TTL which will extend to the server + /// configuration) + do { + let targetTTL: TimeInterval? = (dependencies[feature: .shortenFileTTL] ? 60 : nil) + let request: Network.PreparedRequest = try Network.FileServer.preparedExtend( + url: displayPictureUrl, + customTtl: targetTTL, + using: dependencies + ) + + // FIXME: Make this async/await when the refactored networking is merged + _ = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw AttachmentError.uploadFailed }() + + /// Even though the data hasn't changed, we need to trigger `Profile.UpdateLocal` in order for the + /// `profileLastUpdated` value to be updated correctly + try await Profile.updateLocal( + displayPictureUpdate: .currentUserUpdateTo( + url: displayPictureUrl.absoluteString, + key: displayPictureEncryptionKey, + sessionProProof: dependencies.mutate(cache: .libSession) { $0.getCurrentUserProProof() }, + isReupload: true + ), + using: dependencies + ) + Log.info(.cat, "Existing profile expiration extended") + + return scheduler.schedule { + success(job, false) + } + } catch NetworkError.notFound { + /// If we get a `404` it means we couldn't extend the TTL of the file so need to re-upload + } catch { + return scheduler.schedule { + failure(job, error, false) + } + } + + /// Since we made it here it means that refreshing the TTL failed so we may need to reupload the display picture + do { + let pendingDisplayPicture: PendingAttachment = PendingAttachment( + source: .media(.url(URL(fileURLWithPath: filePath))), + using: dependencies + ) + + guard + profile.profileLastUpdated == 0 || + dependencies.dateNow.timeIntervalSince(lastUpdated) > maxReuploadFrequency || + dependencies[feature: .shortenFileTTL] || + dependencies[singleton: .displayPictureManager].reuploadNeedsPreparation( + attachment: pendingDisplayPicture + ) + else { + /// Reset the `nextRunTimestamp` value just in case the last run failed so we don't get stuck in a loop endlessly + /// deferring the job + if let jobId: Int64 = job.id { + dependencies[singleton: .storage].write { db in + try Job + .filter(id: jobId) + .updateAll(db, Job.Columns.nextRunTimestamp.set(to: 0)) + } + } + + return scheduler.schedule { + Log.info(.cat, "Deferred as not enough time has passed since the last update") + deferred(job) + } + } + + /// Prepare and upload the display picture + let preparedAttachment: PreparedAttachment = try await dependencies[singleton: .displayPictureManager] + .prepareDisplayPicture(attachment: pendingDisplayPicture) + let result = try await dependencies[singleton: .displayPictureManager] + .uploadDisplayPicture(preparedAttachment: preparedAttachment) + + /// Update the local state now that the display picture has finished uploading + try await Profile.updateLocal( + displayPictureUpdate: .currentUserUpdateTo( + url: result.downloadUrl, + key: result.encryptionKey, + sessionProProof: dependencies.mutate(cache: .libSession) { $0.getCurrentUserProProof() }, + isReupload: true + ), + using: dependencies + ) + + return scheduler.schedule { + Log.info(.cat, "Profile successfully updated") + success(job, false) + } + } + catch { + return scheduler.schedule { + failure(job, error, false) + } + } + } + } +} diff --git a/SessionMessagingKit/Jobs/UpdateProfilePictureJob.swift b/SessionMessagingKit/Jobs/UpdateProfilePictureJob.swift deleted file mode 100644 index 6b1051be05..0000000000 --- a/SessionMessagingKit/Jobs/UpdateProfilePictureJob.swift +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Combine -import GRDB -import SessionUtilitiesKit -import SessionUIKit - -// MARK: - Log.Category - -private extension Log.Category { - static let cat: Log.Category = .create("UpdateProfilePictureJob", defaultLevel: .info) -} - -// MARK: - UpdateProfilePictureJob - -public enum UpdateProfilePictureJob: JobExecutor { - public static let maxFailureCount: Int = -1 - public static let requiresThreadId: Bool = false - public static let requiresInteractionId: Bool = false - public static let maxTTL: TimeInterval = (14 * 24 * 60 * 60) - - public static func run( - _ job: Job, - scheduler: S, - success: @escaping (Job, Bool) -> Void, - failure: @escaping (Job, Error, Bool) -> Void, - deferred: @escaping (Job) -> Void, - using dependencies: Dependencies - ) { - // Don't run when inactive or not in main app - guard dependencies[defaults: .appGroup, key: .isMainAppActive] else { - return deferred(job) // Don't need to do anything if it's not the main app - } - - let expirationDate: Date? = dependencies[defaults: .standard, key: .profilePictureExpiresDate] - let lastUploadDate: Date? = dependencies[defaults: .standard, key: .lastProfilePictureUpload] - let expired: Bool = (expirationDate.map({ dependencies.dateNow.timeIntervalSince($0) > 0 }) == true) - let exceededMaxTTL: Bool = (lastUploadDate.map({ dependencies.dateNow.timeIntervalSince($0) > Self.maxTTL }) == true) - - if (expired || exceededMaxTTL) { - /// **Note:** The `lastProfilePictureUpload` value is updated in `DisplayPictureManager` - let 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(data: $0, isReupload: true)} - .defaulting(to: .none) - - Profile - .updateLocal( - displayPictureUpdate: displayPictureUpdate, - using: dependencies - ) - .subscribe(on: scheduler, using: dependencies) - .receive(on: scheduler, using: dependencies) - .sinkUntilComplete( - receiveCompletion: { result in - switch result { - case .failure(let error): failure(job, error, false) - case .finished: - Log.info(.cat, "Profile successfully updated") - success(job, false) - } - } - ) - } else { - // Reset the `nextRunTimestamp` value just in case the last run failed so we don't get stuck - // in a loop endlessly deferring the job - if let jobId: Int64 = job.id { - dependencies[singleton: .storage].write { db in - try Job - .filter(id: jobId) - .updateAll(db, Job.Columns.nextRunTimestamp.set(to: 0)) - } - } - - if expirationDate != nil { - Log.info(.cat, "Deferred as current picture hasn't expired") - } else { - Log.info(.cat, "Deferred as not enough time has passed since the last update") - } - - return deferred(job) - } - } -} diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift index 63a69e9de1..73f287b5a2 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift @@ -59,65 +59,33 @@ internal extension LibSessionCacheType { // Note: We only update the contact and profile records if the data has actually changed // in order to avoid triggering UI updates for every thread on the home screen (the DB // observation system can't differ between update calls which do and don't change anything) - let contact: Contact = Contact.fetchOrCreate(db, id: sessionId, using: dependencies) - let profile: Profile = Profile.fetchOrCreate(db, id: sessionId) - let profileUpdated: Bool = ((profile.profileLastUpdated ?? 0) < (data.profile.profileLastUpdated ?? 0)) - - if (profileUpdated || (profile.nickname != data.profile.nickname)) { - let profileNameShouldBeUpdated: Bool = ( - !data.profile.name.isEmpty && - profile.name != data.profile.name - ) - let profilePictureShouldBeUpdated: Bool = ( - profile.displayPictureUrl != data.profile.displayPictureUrl || - profile.displayPictureEncryptionKey != data.profile.displayPictureEncryptionKey - ) - - try profile.upsert(db) - try Profile - .filter(id: sessionId) - .updateAllAndConfig( - db, - [ - (!profileNameShouldBeUpdated ? nil : - Profile.Columns.name.set(to: data.profile.name) - ), - (profile.nickname == data.profile.nickname ? nil : - Profile.Columns.nickname.set(to: data.profile.nickname) - ), - (profile.displayPictureUrl != data.profile.displayPictureUrl ? nil : - Profile.Columns.displayPictureUrl.set(to: data.profile.displayPictureUrl) - ), - (profile.displayPictureEncryptionKey != data.profile.displayPictureEncryptionKey ? nil : - Profile.Columns.displayPictureEncryptionKey.set(to: data.profile.displayPictureEncryptionKey) - ), - (!profileUpdated ? nil : - Profile.Columns.profileLastUpdated.set(to: data.profile.profileLastUpdated) - ) - ].compactMap { $0 }, - using: dependencies - ) - - if profileNameShouldBeUpdated { - db.addProfileEvent(id: sessionId, change: .name(data.profile.name)) + try Profile.updateIfNeeded( + db, + publicKey: sessionId, + displayNameUpdate: .contactUpdate(data.profile.name), + displayPictureUpdate: { + guard + let displayPictureUrl: String = data.profile.displayPictureUrl, + let displayPictureEncryptionKey: Data = data.profile.displayPictureEncryptionKey + else { return .currentUserRemove } - 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)) + return .contactUpdateTo( + url: displayPictureUrl, + key: displayPictureEncryptionKey, + contactProProof: getContanctProProof(for: sessionId) // TODO: double check if this is needed after Pro Proof is implemented ) - } - } + }(), + nicknameUpdate: .set(to: data.profile.nickname), + profileUpdateTimestamp: data.profile.profileLastUpdated, + cacheSource: .database, + using: dependencies + ) /// Since message requests have no reverse, we should only handle setting `isApproved` /// and `didApproveMe` to `true`. This may prevent some weird edge cases where a config message /// swapping `isApproved` and `didApproveMe` to `false` + let contact: Contact = Contact.fetchOrCreate(db, id: sessionId, using: dependencies) + if (contact.isApproved != data.contact.isApproved) || (contact.isBlocked != data.contact.isBlocked) || @@ -337,6 +305,10 @@ public extension LibSession { contact.set(\.profile_updated, to: profileLastUpdated) } + if let profileLastUpdated: Int64 = info.profileLastUpdated { + contact.set(\.profile_updated, to: profileLastUpdated) + } + // Attempts retrieval of the profile picture (will schedule a download if // needed via a throttled subscription on another thread to prevent blocking) // @@ -346,7 +318,7 @@ public extension LibSession { let updatedProfile: Profile = info.profile, dependencies[singleton: .appContext].isMainApp && ( oldAvatarUrl != (info.displayPictureUrl ?? "") || - oldAvatarKey != (info.displayPictureEncryptionKey ?? Data(repeating: 0, count: DisplayPictureManager.aes256KeyByteLength)) + oldAvatarKey != (info.displayPictureEncryptionKey ?? Data()) ) { dependencies[singleton: .displayPictureManager].scheduleDownload( @@ -756,7 +728,7 @@ extension LibSession { nickname: profile?.nickname, displayPictureUrl: profile?.displayPictureUrl, displayPictureEncryptionKey: profile?.displayPictureEncryptionKey, - profileLastUpdated: profile?.profileLastUpdated.map({ Int64($0) }), + profileLastUpdated: profile?.profileLastUpdated.map { Int64($0) }, disappearingMessagesInfo: disappearingMessagesConfig.map { DisappearingMessageInfo( isEnabled: $0.isEnabled, diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift index 779b4963c7..f8459b524a 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift @@ -124,8 +124,7 @@ internal extension LibSessionCacheType { let groupProfiles: Set? = try? LibSession.extractProfiles( from: conf, - groupSessionId: groupSessionId, - serverTimestampMs: serverTimestampMs + groupSessionId: groupSessionId ) groupProfiles?.forEach { profile in @@ -134,7 +133,7 @@ internal extension LibSessionCacheType { publicKey: profile.id, displayNameUpdate: .contactUpdate(profile.name), displayPictureUpdate: .from(profile, fallback: .none, using: dependencies), - profileUpdateTimestamp: (profile.profileLastUpdated ?? 0), + profileUpdateTimestamp: profile.profileLastUpdated, using: dependencies ) } @@ -216,7 +215,7 @@ internal extension LibSession { let picUrl: String = profile?.displayPictureUrl, let picKey: Data = profile?.displayPictureEncryptionKey, !picUrl.isEmpty, - picKey.count == DisplayPictureManager.aes256KeyByteLength + picKey.count == DisplayPictureManager.encryptionKeySize { member.set(\.profile_pic.url, to: picUrl) member.set(\.profile_pic.key, to: picKey) @@ -501,8 +500,7 @@ internal extension LibSession { static func extractProfiles( from conf: UnsafeMutablePointer?, - groupSessionId: SessionId, - serverTimestampMs: Int64 + groupSessionId: SessionId ) throws -> Set { var infiniteLoopGuard: Int = 0 var result: [Profile] = [] diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift index 31ba81eb19..4fe20e752b 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift @@ -28,18 +28,51 @@ public extension LibSessionCacheType { func validateProProof(for message: Message?) -> Bool { guard let message = message, dependencies[feature: .sessionProEnabled] else { return false } - return dependencies[feature: .treatAllIncomingMessagesAsProMessages] + return dependencies[feature: .allUsersSessionPro] } func validateProProof(for profile: Profile?) -> Bool { guard let profile = profile, dependencies[feature: .sessionProEnabled] else { return false } - return dependencies[feature: .treatAllIncomingMessagesAsProMessages] + return dependencies[feature: .allUsersSessionPro] } - func getProProof() -> String? { + func validateSessionProState(for threadId: String?) -> Bool { + guard let threadId = threadId, dependencies[feature: .sessionProEnabled] else { return false } + let threadVariant = dependencies[singleton: .storage].read { db in + try SessionThread + .select(SessionThread.Columns.variant) + .filter(id: threadId) + .asRequest(of: SessionThread.Variant.self) + .fetchOne(db) + } + guard threadVariant != .community else { return false } + if threadId == dependencies[cache: .general].sessionId.hexString { + return dependencies[feature: .mockCurrentUserSessionPro] + } else { + return dependencies[feature: .allUsersSessionPro] + } + } + + func shouldShowProBadge(for profile: Profile?) -> Bool { + guard let profile = profile, dependencies[feature: .sessionProEnabled] else { return false } + return ( + dependencies[feature: .allUsersSessionPro] && + dependencies[feature: .messageFeatureProBadge] || + (profile.showProBadge == true) + ) + } + + func getCurrentUserProProof() -> String? { guard isSessionPro else { return nil } return "" } + + func getContanctProProof(for sessionId: String) -> String? { + guard dependencies[feature: .allUsersSessionPro] else { + return nil + } + return "" + } } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift index 2d69301415..0445e334ad 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift @@ -779,6 +779,7 @@ public extension LibSession.Cache { let displayPic: user_profile_pic = user_profile_get_pic(conf) let displayPictureUrl: String? = displayPic.get(\.url, nullIfEmpty: true) + let lastUpdated: TimeInterval = max((profileLastUpdatedInMessage ?? 0), TimeInterval(user_profile_get_profile_updated(conf))) return Profile( id: contactId, @@ -786,7 +787,7 @@ public extension LibSession.Cache { nickname: nil, displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : displayPic.get(\.key)), - profileLastUpdated: profileLastUpdatedInMessage + profileLastUpdated: (lastUpdated > 0 ? lastUpdated : nil) ) } @@ -807,6 +808,7 @@ public extension LibSession.Cache { } let displayPictureUrl: String? = member.get(\.profile_pic.url, nullIfEmpty: true) + let lastUpdated: TimeInterval = max((profileLastUpdatedInMessage ?? 0), TimeInterval(member.get( \.profile_updated))) /// The `displayNameInMessage` value is likely newer than the `name` value in the config so use that if available return Profile( @@ -815,7 +817,7 @@ public extension LibSession.Cache { nickname: nil, displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : member.get(\.profile_pic.key)), - profileLastUpdated: TimeInterval(member.get(\.profile_updated)) + profileLastUpdated: (lastUpdated > 0 ? lastUpdated : nil) ) } @@ -832,6 +834,7 @@ public extension LibSession.Cache { } let displayPictureUrl: String? = contact.get(\.profile_pic.url, nullIfEmpty: true) + let lastUpdated: TimeInterval = max((profileLastUpdatedInMessage ?? 0), TimeInterval(contact.get( \.profile_updated))) /// The `displayNameInMessage` value is likely newer than the `name` value in the config so use that if available return Profile( @@ -840,7 +843,7 @@ public extension LibSession.Cache { nickname: contact.get(\.nickname, nullIfEmpty: true), displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : contact.get(\.profile_pic.key)), - profileLastUpdated: TimeInterval(contact.get( \.profile_updated)) + profileLastUpdated: (lastUpdated > 0 ? lastUpdated : nil) ) } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift index 64a1f5260a..ac14439634 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift @@ -128,7 +128,7 @@ internal extension LibSession { let picUrl: String = memberInfo.profile?.displayPictureUrl, let picKey: Data = memberInfo.profile?.displayPictureEncryptionKey, !picUrl.isEmpty, - picKey.count == DisplayPictureManager.aes256KeyByteLength + picKey.count == DisplayPictureManager.encryptionKeySize { member.set(\.profile_pic.url, to: picUrl) member.set(\.profile_pic.key, to: picKey) diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index 3dae469958..c8d324f46d 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -39,23 +39,8 @@ internal extension LibSessionCacheType { let profileName: String = String(cString: profileNamePtr) let displayPic: user_profile_pic = user_profile_get_pic(conf) let displayPictureUrl: String? = displayPic.get(\.url, nullIfEmpty: true) + let displayPictureEncryptionKey: Data? = displayPic.get(\.key, nullIfEmpty: true) let profileLastUpdateTimestamp: TimeInterval = TimeInterval(user_profile_get_profile_updated(conf)) - let updatedProfile: Profile = Profile( - id: userSessionId.hexString, - name: profileName, - displayPictureUrl: (oldState[.profile(userSessionId.hexString)] as? Profile)?.displayPictureUrl, - profileLastUpdated: profileLastUpdateTimestamp - ) - - if let profile: Profile = oldState[.profile(userSessionId.hexString)] as? Profile { - if profile.name != updatedProfile.name { - db.addProfileEvent(id: updatedProfile.id, change: .name(updatedProfile.name)) - } - - if profile.displayPictureUrl != updatedProfile.displayPictureUrl { - db.addProfileEvent(id: updatedProfile.id, change: .displayPictureUrl(updatedProfile.displayPictureUrl)) - } - } // Handle user profile changes try Profile.updateIfNeeded( @@ -65,21 +50,41 @@ internal extension LibSessionCacheType { displayPictureUpdate: { guard let displayPictureUrl: String = displayPictureUrl, - let filePath: String = try? dependencies[singleton: .displayPictureManager] - .path(for: displayPictureUrl) + let displayPictureEncryptionKey: Data = displayPictureEncryptionKey else { return .currentUserRemove } return .currentUserUpdateTo( url: displayPictureUrl, - key: displayPic.get(\.key), - filePath: filePath, - sessionProProof: getProProof() // TODO: double check if this is needed after Pro Proof is implemented + key: displayPictureEncryptionKey, + sessionProProof: getCurrentUserProProof(), // TODO: double check if this is needed after Pro Proof is implemented + isReupload: false ) }(), profileUpdateTimestamp: profileLastUpdateTimestamp, + cacheSource: .value((oldState[.profile(userSessionId.hexString)] as? Profile), fallback: .database), + suppressUserProfileConfigUpdate: true, using: dependencies ) + // Kick off a job to download the display picture + if + let url: String = displayPictureUrl, + let key: Data = displayPictureEncryptionKey + { + dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .displayPictureDownload, + shouldBeUnique: true, + details: DisplayPictureDownloadJob.Details( + target: .profile(id: userSessionId.hexString, url: url, encryptionKey: key), + timestamp: profileLastUpdateTimestamp + ) + ), + canStartJob: dependencies[singleton: .appContext].isMainApp + ) + } + // Extract the 'Note to Self' conversation settings let targetPriority: Int32 = user_profile_get_nts_priority(conf) let targetExpiry: Int32 = user_profile_get_nts_expiry(conf) @@ -209,9 +214,9 @@ public extension LibSession.Cache { } func updateProfile( - displayName: String, - displayPictureUrl: String?, - displayPictureEncryptionKey: Data?, + displayName: Update, + displayPictureUrl: Update, + displayPictureEncryptionKey: Update, isReuploadProfilePicture: Bool ) throws { guard let config: LibSession.Config = config(for: .userProfile, sessionId: userSessionId) else { @@ -221,42 +226,56 @@ public extension LibSession.Cache { throw LibSessionError.invalidConfigObject(wanted: .userProfile, got: config) } - // Get the old values to determine if something changed + /// Get the old values to determine if something changed let oldName: String? = user_profile_get_name(conf).map { String(cString: $0) } + let oldNameFallback: String = (oldName ?? "") let oldDisplayPic: user_profile_pic = user_profile_get_pic(conf) let oldDisplayPictureUrl: String? = oldDisplayPic.get(\.url, nullIfEmpty: true) + let oldDisplayPictureKey: Data? = oldDisplayPic.get(\.key, 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) - if isReuploadProfilePicture { - user_profile_set_reupload_pic(conf, profilePic) - } else { - 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 { + /// Either assign the updated profile pic, or sent a blank profile pic (to remove the current one) + /// + /// **Note:** We **MUST** update the profile picture first because doing so will result in any subsequent profile changes + /// which impact the `profile_updated` timestamp being routed to the "reupload" storage instead of the "standard" + /// storage - if we don't do this first then the "standard" timestamp will also get updated which can result in both timestamps + /// matching (in which case the "standard" profile wins and the re-uploaded content would be ignored) + if displayPictureUrl.or(oldDisplayPictureUrl) != oldDisplayPictureUrl { + var profilePic: user_profile_pic = user_profile_pic() + profilePic.set(\.url, to: displayPictureUrl.or(oldDisplayPictureUrl)) + profilePic.set(\.key, to: displayPictureEncryptionKey.or(oldDisplayPictureKey)) + + switch isReuploadProfilePicture { + case true: user_profile_set_reupload_pic(conf, profilePic) + case false: 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 addEvent( key: .profile(userSessionId.hexString), - value: ProfileEvent(id: userSessionId.hexString, change: .name(displayName)) + value: ProfileEvent( + id: userSessionId.hexString, + change: .displayPictureUrl(displayPictureUrl.or(oldDisplayPictureUrl)) + ) ) } - if displayPictureUrl != oldDisplayPictureUrl { + /// Update the nam + /// + /// **Note:** Setting the name (even if it hasn't changed) currently results in a timestamp change so only do this if it was + /// changed (this will be fixed in `libSession v1.5.8`) + if displayName.or("") != oldName { + var cUpdatedName: [CChar] = try displayName.or(oldNameFallback).cString(using: .utf8) ?? { + throw LibSessionError.invalidCConversion + }() + user_profile_set_name(conf, &cUpdatedName) + try LibSessionError.throwIfNeeded(conf) + + /// Add a pending observation to notify any observers of the change once it's committed addEvent( key: .profile(userSessionId.hexString), - value: ProfileEvent(id: userSessionId.hexString, change: .displayPictureUrl(displayPictureUrl)) + value: ProfileEvent(id: userSessionId.hexString, change: .name(displayName.or(oldNameFallback))) ) } } diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index 93df1e6c7a..76466c3493 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -18,9 +18,12 @@ public extension Cache { ) } - // MARK: - Convenience +public extension LibSession { + static var attachmentEncryptionKeySize: Int { ATTACHMENT_ENCRYPT_KEY_SIZE } +} + public extension LibSession { static func parseCommunity(url: String) -> (room: String, server: String, publicKey: String)? { var cBaseUrl: [CChar] = [CChar](repeating: 0, count: COMMUNITY_BASE_URL_MAX_LENGTH) @@ -282,6 +285,42 @@ public extension LibSession { ) } + /// There is a bit of an odd discrepancy between `libSession` and the database for the users profile where `libSession` + /// could have updated display picture information but the database could have old data - this is because we don't update + /// the values in the database until after the display picture is downloaded + /// + /// Due to this we should schedule a `DispalyPictureDownloadJob` for the current users display picture if it happens + /// to be different from the database value (or the file doesn't exist) to ensure it gets downloaded + let libSessionProfile: Profile = profile + let databaseProfile: Profile = Profile.fetchOrCreate(db, id: libSessionProfile.id) + + if + let url: String = libSessionProfile.displayPictureUrl, + let key: Data = libSessionProfile.displayPictureEncryptionKey, + !key.isEmpty, + ( + databaseProfile.displayPictureUrl != url || + databaseProfile.displayPictureEncryptionKey != key + ), + let path: String = try? dependencies[singleton: .displayPictureManager] + .path(for: libSessionProfile.displayPictureUrl), + !dependencies[singleton: .fileManager].fileExists(atPath: path) + { + Log.info(.libSession, "Scheduling display picture download due to discrepancy with database") + dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .displayPictureDownload, + shouldBeUnique: true, + details: DisplayPictureDownloadJob.Details( + target: .profile(id: libSessionProfile.id, url: url, encryptionKey: key), + timestamp: libSessionProfile.profileLastUpdated + ) + ), + canStartJob: dependencies[singleton: .appContext].isMainApp + ) + } + Log.info(.libSession, "Completed loadState\(requestId.map { " for \($0)" } ?? "")") } @@ -1040,9 +1079,9 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT var displayName: String? { get } func updateProfile( - displayName: String, - displayPictureUrl: String?, - displayPictureEncryptionKey: Data?, + displayName: Update, + displayPictureUrl: Update, + displayPictureEncryptionKey: Update, isReuploadProfilePicture: Bool ) throws @@ -1170,23 +1209,23 @@ public extension LibSessionCacheType { loadState(db, requestId: nil) } - func addEvent(key: ObservableKey, value: AnyHashable?) { + func addEvent(key: ObservableKey, value: T?) { addEvent(ObservedEvent(key: key, value: value)) } - func addEvent(key: Setting.BoolKey, value: AnyHashable?) { + func addEvent(key: Setting.BoolKey, value: T?) { addEvent(ObservedEvent(key: .setting(key), value: value)) } - func addEvent(key: Setting.EnumKey, value: AnyHashable?) { + func addEvent(key: Setting.EnumKey, value: T?) { addEvent(ObservedEvent(key: .setting(key), value: value)) } func updateProfile(displayName: String) throws { try updateProfile( - displayName: displayName, - displayPictureUrl: nil, - displayPictureEncryptionKey: nil, + displayName: .set(to: displayName), + displayPictureUrl: .useExisting, + displayPictureEncryptionKey: .useExisting, isReuploadProfilePicture: false ) } @@ -1321,9 +1360,9 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { func set(_ key: Setting.BoolKey, _ value: Bool?) {} func set(_ key: Setting.EnumKey, _ value: T?) {} func updateProfile( - displayName: String, - displayPictureUrl: String?, - displayPictureEncryptionKey: Data?, + displayName: Update, + displayPictureUrl: Update, + displayPictureEncryptionKey: Update, isReuploadProfilePicture: Bool ) throws {} diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift index d3eccefb6f..3896985b74 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift @@ -140,7 +140,7 @@ public extension VisibleMessage { displayName: \(displayName ?? "null"), profileKey: \(profileKey?.description ?? "null"), profilePictureUrl: \(profilePictureUrl ?? "null"), - UpdateTimestampSeconds: \(updateTimestampSeconds ?? 0) + updateTimestampSeconds: \(updateTimestampSeconds ?? 0) ) """ } diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift index d37de1670a..d51cad72ee 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift @@ -8,16 +8,14 @@ public extension VisibleMessage { struct VMQuote: Codable { public let timestamp: UInt64? public let authorId: String? - public let text: String? public func isValid(isSending: Bool) -> Bool { timestamp != nil && authorId != nil } // MARK: - Initialization - internal init(timestamp: UInt64, authorId: String, text: String?) { + internal init(timestamp: UInt64, authorId: String) { self.timestamp = timestamp self.authorId = authorId - self.text = text } // MARK: - Proto Conversion @@ -25,8 +23,7 @@ public extension VisibleMessage { public static func fromProto(_ proto: SNProtoDataMessageQuote) -> VMQuote? { return VMQuote( timestamp: proto.id, - authorId: proto.author, - text: proto.text + authorId: proto.author ) } @@ -36,7 +33,6 @@ public extension VisibleMessage { return nil } let quoteProto = SNProtoDataMessageQuote.builder(id: timestamp, author: authorId) - if let text = text { quoteProto.setText(text) } do { return try quoteProto.build() } catch { @@ -51,8 +47,7 @@ public extension VisibleMessage { """ Quote( timestamp: \(timestamp?.description ?? "null"), - authorId: \(authorId ?? "null"), - text: \(text ?? "null") + authorId: \(authorId ?? "null") ) """ } @@ -65,8 +60,7 @@ public extension VisibleMessage.VMQuote { static func from(quote: Quote) -> VisibleMessage.VMQuote { return VisibleMessage.VMQuote( timestamp: UInt64(quote.timestampMs), - authorId: quote.authorId, - text: quote.body + authorId: quote.authorId ) } } diff --git a/SessionMessagingKit/Protos/SessionProtos.proto b/SessionMessagingKit/Protos/SessionProtos.proto index a25e477759..dd848b18e9 100644 --- a/SessionMessagingKit/Protos/SessionProtos.proto +++ b/SessionMessagingKit/Protos/SessionProtos.proto @@ -115,7 +115,6 @@ message DataExtractionNotification { message LokiProfile { optional string displayName = 1; optional string profilePicture = 2; - optional uint64 lastUpdateSeconds = 3; // Timestamp of the last profile update } diff --git a/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift b/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift deleted file mode 100644 index 8c85b12bd5..0000000000 --- a/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift +++ /dev/null @@ -1,232 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Combine -import GRDB -import SessionNetworkingKit -import SessionUtilitiesKit - -// MARK: - AttachmentUploader - -public final class AttachmentUploader { - private enum Destination { - case fileServer - case community(roomToken: String, server: String) - - 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(roomToken: auth.roomToken, server: auth.server) - - default: return .fileServer - } - }() - let uploadInfo: UploadInfo = try { - let endpoint: (any EndpointType) = { - switch destination { - case .fileServer: return Network.FileServer.Endpoint.file - case .community(let roomToken, _): return Network.SOGS.Endpoint.roomFile(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 = Network.FileServer.fileId(for: attachment.downloadUrl) { - return ( - attachment, - try Network.PreparedRequest.cached( - FileUploadResponse(id: fileId, expires: nil), - 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, - let fileId: String = Network.FileServer.fileId(for: attachment.downloadUrl), - ( - !destination.shouldEncrypt || ( - attachment.encryptionKey != nil && - attachment.digest != nil - ) - ) - { - return ( - attachment, - try Network.PreparedRequest.cached( - FileUploadResponse(id: fileId, expires: nil), - 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 roomToken, _): - return ( - attachment, - try Network.SOGS.preparedUpload( - data: finalData, - roomToken: roomToken, - authMethod: authMethod, - 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 roomToken, let server)): - return Network.SOGS.downloadUrlString( - for: response.id, - server: server, - roomToken: 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 deleted file mode 100644 index adbc379e5c..0000000000 --- a/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift +++ /dev/null @@ -1,963 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// -// stringlint:disable - -import UIKit -import Combine -import MobileCoreServices -import AVFoundation -import UniformTypeIdentifiers -import SessionUtilitiesKit - -public enum SignalAttachmentError: Error { - case missingData - case fileSizeTooLarge - case invalidData - case couldNotParseImage - case couldNotConvertToJpeg - case couldNotConvertToMpeg4 - case couldNotRemoveMetadata - case invalidFileFormat - case couldNotResizeImage -} - -extension String { - public var filenameWithoutExtension: String { - return (self as NSString).deletingPathExtension - } - - public var fileExtension: String? { - return (self as NSString).pathExtension - } - - public func appendingFileExtension(_ fileExtension: String) -> String { - guard let result = (self as NSString).appendingPathExtension(fileExtension) else { - return self - } - return result - } -} - -extension SignalAttachmentError: LocalizedError { - public var errorDescription: String? { - switch self { - case .fileSizeTooLarge: - return "attachmentsErrorSize".localized() - case .invalidData, .missingData, .invalidFileFormat: - return "attachmentsErrorNotSupported".localized() - case .couldNotConvertToJpeg, .couldNotParseImage, .couldNotConvertToMpeg4, .couldNotResizeImage: - return "attachmentsErrorOpen".localized() - case .couldNotRemoveMetadata: - return "attachmentsImageErrorMetadata".localized() - } - } -} - -@objc -public enum TSImageQualityTier: UInt { - case original - case high - case mediumHigh - case medium - case mediumLow - case low -} - -@objc -public enum TSImageQuality: UInt { - case original - case medium - case compact - - func imageQualityTier() -> TSImageQualityTier { - switch self { - case .original: - return .original - case .medium: - return .mediumHigh - case .compact: - return .medium - } - } -} - -// Represents a possible attachment to upload. -// The attachment may be invalid. -// -// Signal attachments are subject to validation and -// in some cases, file format conversion. -// -// This class gathers that logic. It offers factory methods -// for attachments that do the necessary work. -// -// The return value for the factory methods will be nil if the input is nil. -// -// [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 - - public let dataSource: (any DataSource) - public var captionText: String? - public var linkPreviewDraft: LinkPreviewDraft? - - public var data: Data { return dataSource.data } - public var dataLength: UInt { return UInt(dataSource.dataLength) } - public var dataUrl: URL? { return dataSource.dataUrl } - public var sourceFilename: String? { return dataSource.sourceFilename?.filteredFilename } - public var isValidImage: Bool { return dataSource.isValidImage } - public var isValidVideo: Bool { return dataSource.isValidVideo } - public var imageSize: CGSize? { return dataSource.imageSize } - - // This flag should be set for text attachments that can be sent as text messages. - public var isConvertibleToTextMessage = false - - // This flag should be set for attachments that can be sent as contact shares. - public var isConvertibleToContactShare = false - - // Attachment types are identified using UTType. - public let dataType: UTType - - public var error: SignalAttachmentError? { - didSet { - assert(oldValue == nil) - } - } - - // To avoid redundant work of repeatedly compressing/uncompressing - // images, we cache the UIImage associated with this attachment if - // possible. - private var cachedImage: UIImage? - private var cachedVideoPreview: UIImage? - - private(set) public var isVoiceMessage = false - - // MARK: - - public static let maxAttachmentsAllowed: Int = 32 - - // MARK: Constructor - - // This method should not be called directly; use the factory - // methods instead. - private init(dataSource: (any DataSource), dataType: UTType) { - self.dataSource = dataSource - self.dataType = dataType - } - - // MARK: Methods - - public var hasError: Bool { return error != nil } - - public var errorName: String? { - guard let error = error else { - // This method should only be called if there is an error. - return nil - } - - return "\(error)" - } - - public var localizedErrorDescription: String? { - guard let error = self.error else { - // This method should only be called if there is an error. - return nil - } - guard let errorDescription = error.errorDescription else { - return nil - } - - return "\(errorDescription)" - } - - public class var missingDataErrorMessage: String { - guard let errorDescription = SignalAttachmentError.missingData.errorDescription else { - return "" - } - - return errorDescription - } - - public func text() -> String? { - guard let text = String(data: dataSource.data, encoding: .utf8) else { - return nil - } - - return text - } - - public func duration(using dependencies: Dependencies) -> TimeInterval? { - switch (isAudio, isVideo) { - case (true, _): - let audioPlayer: AVAudioPlayer? = try? AVAudioPlayer(data: dataSource.data) - - return (audioPlayer?.duration).map { $0 > 0 ? $0 : nil } - - case (_, true): - 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 - } - } - - // Returns the MIME type for this attachment or nil if no MIME type - // can be identified. - public var mimeType: String { - guard - let fileExtension: String = sourceFilename.map({ URL(fileURLWithPath: $0) })?.pathExtension, - !fileExtension.isEmpty, - let fileExtensionMimeType: String = UTType(sessionFileExtension: fileExtension)?.preferredMIMEType - else { return (dataType.preferredMIMEType ?? UTType.mimeTypeDefault) } - - // UTI types are an imperfect means of representing file type; - // file extensions are also imperfect but far more reliable and - // comprehensive so we always prefer to try to deduce MIME type - // from the file extension. - return fileExtensionMimeType - } - - // Use the filename if known. If not, e.g. if the attachment was copy/pasted, we'll generate a filename - // like: "signal-2017-04-24-095918.zip" - public var filenameOrDefault: String { - if let filename = sourceFilename { - return filename.filteredFilename - } else { - let kDefaultAttachmentName = "signal" - - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "YYYY-MM-dd-HHmmss" - let dateString = dateFormatter.string(from: Date()) - - let withoutExtension = "\(kDefaultAttachmentName)-\(dateString)" - if let fileExtension = self.fileExtension { - return "\(withoutExtension).\(fileExtension)" - } - - return withoutExtension - } - } - - // Returns the file extension for this attachment or nil if no file extension - // can be identified. - public var fileExtension: String? { - guard - let fileExtension: String = sourceFilename.map({ URL(fileURLWithPath: $0) })?.pathExtension, - !fileExtension.isEmpty - else { return dataType.sessionFileExtension(sourceFilename: sourceFilename) } - - return fileExtension.filteredFilename - } - - public var isImage: Bool { dataType.isImage || dataType.isAnimated } - public var isAnimatedImage: Bool { dataType.isAnimated } - public var isVideo: Bool { dataType.isVideo } - public var isAudio: Bool { dataType.isAudio } - - public var isText: Bool { - isConvertibleToTextMessage && - dataType.conforms(to: .text) - } - - public var isUrl: Bool { - dataType.conforms(to: .url) - } - - public class func pasteboardHasPossibleAttachment() -> Bool { - return UIPasteboard.general.numberOfItems > 0 - } - - public class func pasteboardHasText() -> Bool { - guard - UIPasteboard.general.numberOfItems > 0, - let pasteboardUTIdentifiers: [[String]] = UIPasteboard.general.types(forItemSet: IndexSet(integer: 0)), - let pasteboardUTTypes: Set = pasteboardUTIdentifiers.first.map({ Set($0.compactMap { UTType($0) }) }) - else { return false } - - // The pasteboard can be populated with multiple UTI types - // with different payloads. iMessage for example will copy - // an animated GIF to the pasteboard with the following UTI - // types: - // - // * "public.url-name" - // * "public.utf8-plain-text" - // * "com.compuserve.gif" - // - // We want to paste the animated GIF itself, not it's name. - // - // In general, our rule is to prefer non-text pasteboard - // contents, so we return true IFF there is a text UTI type - // and there is no non-text UTI type. - guard !pasteboardUTTypes.contains(where: { !$0.conforms(to: .text) }) else { return false } - - return pasteboardUTTypes.contains(where: { $0.conforms(to: .text) || $0.conforms(to: .url) }) - } - - // Returns an attachment from the pasteboard, or nil if no attachment - // can be found. - // - // NOTE: The attachment returned by this method may not be valid. - // Check the attachment's error property. - public class func attachmentFromPasteboard(using dependencies: Dependencies) -> SignalAttachment? { - guard - UIPasteboard.general.numberOfItems > 0, - let pasteboardUTIdentifiers: [[String]] = UIPasteboard.general.types(forItemSet: IndexSet(integer: 0)), - let pasteboardUTTypes: Set = pasteboardUTIdentifiers.first.map({ Set($0.compactMap { UTType($0) }) }) - else { return nil } - - for type in UTType.supportedInputImageTypes { - if pasteboardUTTypes.contains(type) { - guard let data: Data = dataForFirstPasteboardItem(type: type) else { return nil } - - // Pasted images _SHOULD _NOT_ be resized, if possible. - let dataSource = DataSourceValue(data: data, dataType: type, using: dependencies) - return attachment(dataSource: dataSource, type: type, imageQuality: .original, using: dependencies) - } - } - for type in UTType.supportedVideoTypes { - if pasteboardUTTypes.contains(type) { - guard let data = dataForFirstPasteboardItem(type: type) else { return nil } - - let dataSource = DataSourceValue(data: data, dataType: type, using: dependencies) - return videoAttachment(dataSource: dataSource, type: type, using: dependencies) - } - } - for type in UTType.supportedAudioTypes { - if pasteboardUTTypes.contains(type) { - guard let data = dataForFirstPasteboardItem(type: type) else { return nil } - - let dataSource = DataSourceValue(data: data, dataType: type, using: dependencies) - return audioAttachment(dataSource: dataSource, type: type, using: dependencies) - } - } - - let type: UTType = pasteboardUTTypes[pasteboardUTTypes.startIndex] - guard let data = dataForFirstPasteboardItem(type: type) else { return nil } - - let dataSource = DataSourceValue(data: data, dataType: type, using: dependencies) - return genericAttachment(dataSource: dataSource, type: type, using: dependencies) - } - - // This method should only be called for dataUTIs that - // are appropriate for the first pasteboard item. - private class func dataForFirstPasteboardItem(type: UTType) -> Data? { - guard - UIPasteboard.general.numberOfItems > 0, - let dataValues: [Data] = UIPasteboard.general.data( - forPasteboardType: type.identifier, - inItemSet: IndexSet(integer: 0) - ), - !dataValues.isEmpty - else { return nil } - - return dataValues[0] - } - - // MARK: Image Attachments - - // Factory method for an image attachment. - // - // NOTE: The attachment returned by this method may not be valid. - // Check the attachment's error property. - private class func imageAttachment(dataSource: (any DataSource)?, type: UTType, imageQuality: TSImageQuality, using dependencies: Dependencies) -> SignalAttachment { - assert(dataSource != nil) - guard var dataSource = dataSource else { - let attachment = SignalAttachment(dataSource: DataSourceValue.empty(using: dependencies), dataType: type) - attachment.error = .missingData - return attachment - } - - let attachment = SignalAttachment(dataSource: dataSource, dataType: type) - - guard UTType.supportedInputImageTypes.contains(type) else { - attachment.error = .invalidFileFormat - return attachment - } - - guard dataSource.dataLength > 0 else { - attachment.error = .invalidData - return attachment - } - - if UTType.supportedAnimatedImageTypes.contains(type) { - guard dataSource.dataLength <= SNUtilitiesKit.maxFileSize else { - attachment.error = .fileSizeTooLarge - return attachment - } - - // Never re-encode animated images (i.e. GIFs) as JPEGs. - return attachment - } else { - guard let image = UIImage(data: dataSource.data) else { - attachment.error = .couldNotParseImage - return attachment - } - attachment.cachedImage = image - - let isValidOutput = isValidOutputImage(image: image, dataSource: dataSource, type: type, imageQuality: imageQuality) - - if let sourceFilename = dataSource.sourceFilename, - let sourceFileExtension = sourceFilename.fileExtension, - ["heic", "heif"].contains(sourceFileExtension.lowercased()) { - - // If a .heic file actually contains jpeg data, update the extension to match. - // - // Here's how that can happen: - // In iOS11, the Photos.app records photos with HEIC UTIType, with the .HEIC extension. - // Since HEIC isn't a valid output format for Signal, we'll detect that and convert to JPEG, - // updating the extension as well. No problem. - // However the problem comes in when you edit an HEIC image in Photos.app - the image is saved - // in the Photos.app as a JPEG, but retains the (now incongruous) HEIC extension in the filename. - assert(type == .jpeg || !isValidOutput) - - let baseFilename = sourceFilename.filenameWithoutExtension - dataSource.sourceFilename = baseFilename.appendingFileExtension("jpg") - } - - if isValidOutput { - return removeImageMetadata(attachment: attachment, using: dependencies) - } else { - return compressImageAsJPEG(image: image, attachment: attachment, filename: dataSource.sourceFilename, imageQuality: imageQuality, using: dependencies) - } - } - } - - // If the proposed attachment already conforms to the - // file size and content size limits, don't recompress it. - private class func isValidOutputImage(image: UIImage?, dataSource: (any DataSource)?, type: UTType, imageQuality: TSImageQuality) -> Bool { - guard - image != nil, - let dataSource = dataSource, - UTType.supportedOutputImageTypes.contains(type) - else { return false } - - return ( - doesImageHaveAcceptableFileSize(dataSource: dataSource, imageQuality: imageQuality) && - dataSource.dataLength <= SNUtilitiesKit.maxFileSize - ) - } - - // Factory method for an image attachment. - // - // NOTE: The attachment returned by this method may nil or not be valid. - // Check the attachment's error property. - public class func imageAttachment(image: UIImage?, type: UTType, filename: String?, imageQuality: TSImageQuality, using dependencies: Dependencies) -> SignalAttachment { - guard let image: UIImage = image else { - let dataSource = DataSourceValue.empty(using: dependencies) - dataSource.sourceFilename = filename - let attachment = SignalAttachment(dataSource: dataSource, dataType: type) - attachment.error = .missingData - return attachment - } - - // Make a placeholder attachment on which to hang errors if necessary. - let dataSource = DataSourceValue.empty(using: dependencies) - dataSource.sourceFilename = filename - let attachment = SignalAttachment(dataSource: dataSource, dataType: type) - attachment.cachedImage = image - - return compressImageAsJPEG(image: image, attachment: attachment, filename: filename, imageQuality: imageQuality, using: dependencies) - } - - private class func compressImageAsJPEG(image: UIImage, attachment: SignalAttachment, filename: String?, imageQuality: TSImageQuality, using dependencies: Dependencies) -> SignalAttachment { - assert(attachment.error == nil) - - if imageQuality == .original && - attachment.dataLength < SNUtilitiesKit.maxFileSize && - UTType.supportedOutputImageTypes.contains(attachment.dataType) { - // We should avoid resizing images attached "as documents" if possible. - return attachment - } - - var imageUploadQuality = imageQuality.imageQualityTier() - - while true { - let maxSize = maxSizeForImage(image: image, imageUploadQuality: imageUploadQuality) - var dstImage: UIImage! = image - if image.size.width > maxSize || - image.size.height > maxSize { - guard let resizedImage = imageScaled(image, toMaxSize: maxSize) else { - attachment.error = .couldNotResizeImage - return attachment - } - dstImage = resizedImage - } - guard let jpgImageData = dstImage.jpegData(compressionQuality: jpegCompressionQuality(imageUploadQuality: imageUploadQuality)) else { - attachment.error = .couldNotConvertToJpeg - return attachment - } - - let dataSource = DataSourceValue(data: jpgImageData, fileExtension: "jpg", using: dependencies) - let baseFilename = filename?.filenameWithoutExtension - let jpgFilename = baseFilename?.appendingFileExtension("jpg") - dataSource.sourceFilename = jpgFilename - - if doesImageHaveAcceptableFileSize(dataSource: dataSource, imageQuality: imageQuality) && - dataSource.dataLength <= SNUtilitiesKit.maxFileSize { - let recompressedAttachment = SignalAttachment(dataSource: dataSource, dataType: .jpeg) - recompressedAttachment.cachedImage = dstImage - return recompressedAttachment - } - - // If the JPEG output is larger than the file size limit, - // continue to try again by progressively reducing the - // image upload quality. - switch imageUploadQuality { - case .original: - imageUploadQuality = .high - case .high: - imageUploadQuality = .mediumHigh - case .mediumHigh: - imageUploadQuality = .medium - case .medium: - imageUploadQuality = .mediumLow - case .mediumLow: - imageUploadQuality = .low - case .low: - attachment.error = .fileSizeTooLarge - return attachment - } - } - } - - // NOTE: For unknown reasons, resizing images with UIGraphicsBeginImageContext() - // crashes reliably in the share extension after screen lock's auth UI has been presented. - // Resizing using a CGContext seems to work fine. - private class func imageScaled(_ uiImage: UIImage, toMaxSize maxSize: CGFloat) -> UIImage? { - guard let cgImage = uiImage.cgImage else { - return nil - } - - // It's essential that we work consistently in "CG" coordinates (which are - // pixels and don't reflect orientation), not "UI" coordinates (which - // are points and do reflect orientation). - let scrSize = CGSize(width: cgImage.width, height: cgImage.height) - var maxSizeRect = CGRect.zero - maxSizeRect.size = CGSize(width: maxSize, height: maxSize) - let newSize = AVMakeRect(aspectRatio: scrSize, insideRect: maxSizeRect).size - - let colorSpace = CGColorSpaceCreateDeviceRGB() - let bitmapInfo: CGBitmapInfo = [ - CGBitmapInfo(rawValue: CGImageByteOrderInfo.orderDefault.rawValue), - CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)] - guard let context = CGContext.init(data: nil, - width: Int(newSize.width), - height: Int(newSize.height), - bitsPerComponent: 8, - bytesPerRow: 0, - space: colorSpace, - bitmapInfo: bitmapInfo.rawValue) else { - return nil - } - context.interpolationQuality = .high - - var drawRect = CGRect.zero - drawRect.size = newSize - context.draw(cgImage, in: drawRect) - - guard let newCGImage = context.makeImage() else { - return nil - } - return UIImage(cgImage: newCGImage, - scale: uiImage.scale, - orientation: uiImage.imageOrientation) - } - - private class func doesImageHaveAcceptableFileSize(dataSource: (any DataSource), imageQuality: TSImageQuality) -> Bool { - switch imageQuality { - case .original: - return true - case .medium: - return dataSource.dataLength < UInt(1024 * 1024) - case .compact: - return dataSource.dataLength < UInt(400 * 1024) - } - } - - private class func maxSizeForImage(image: UIImage, imageUploadQuality: TSImageQualityTier) -> CGFloat { - switch imageUploadQuality { - case .original: - return max(image.size.width, image.size.height) - case .high: - return 2048 - case .mediumHigh: - return 1536 - case .medium: - return 1024 - case .mediumLow: - return 768 - case .low: - return 512 - } - } - - private class func jpegCompressionQuality(imageUploadQuality: TSImageQualityTier) -> CGFloat { - switch imageUploadQuality { - case .original: - return 1 - case .high: - return 0.9 - case .mediumHigh: - return 0.8 - case .medium: - return 0.7 - case .mediumLow: - return 0.6 - case .low: - return 0.5 - } - } - - private class func removeImageMetadata(attachment: SignalAttachment, using dependencies: Dependencies) -> SignalAttachment { - guard let source = CGImageSourceCreateWithData(attachment.data as CFData, nil) else { - let attachment = SignalAttachment(dataSource: DataSourceValue.empty(using: dependencies), dataType: attachment.dataType) - attachment.error = .missingData - return attachment - } - - guard let type = CGImageSourceGetType(source) else { - let attachment = SignalAttachment(dataSource: DataSourceValue.empty(using: dependencies), dataType: attachment.dataType) - attachment.error = .invalidFileFormat - return attachment - } - - let count = CGImageSourceGetCount(source) - let mutableData = NSMutableData() - guard let destination = CGImageDestinationCreateWithData(mutableData as CFMutableData, type, count, nil) else { - attachment.error = .couldNotRemoveMetadata - return attachment - } - - let removeMetadataProperties: [String: AnyObject] = - [ - kCGImagePropertyExifDictionary as String: kCFNull, - kCGImagePropertyExifAuxDictionary as String: kCFNull, - kCGImagePropertyGPSDictionary as String: kCFNull, - kCGImagePropertyTIFFDictionary as String: kCFNull, - kCGImagePropertyJFIFDictionary as String: kCFNull, - kCGImagePropertyPNGDictionary as String: kCFNull, - kCGImagePropertyIPTCDictionary as String: kCFNull, - kCGImagePropertyMakerAppleDictionary as String: kCFNull - ] - - for index in 0...count-1 { - CGImageDestinationAddImageFromSource(destination, source, index, removeMetadataProperties as CFDictionary) - } - - if CGImageDestinationFinalize(destination) { - guard let dataSource = DataSourceValue(data: mutableData as Data, dataType: attachment.dataType, using: dependencies) else { - attachment.error = .couldNotRemoveMetadata - return attachment - } - - let strippedAttachment = SignalAttachment(dataSource: dataSource, dataType: attachment.dataType) - return strippedAttachment - - } else { - attachment.error = .couldNotRemoveMetadata - return attachment - } - } - - // MARK: Video Attachments - - // Factory method for video attachments. - // - // NOTE: The attachment returned by this method may not be valid. - // Check the attachment's error property. - private class func videoAttachment(dataSource: (any DataSource)?, type: UTType, using dependencies: Dependencies) -> SignalAttachment { - guard let dataSource = dataSource else { - let dataSource = DataSourceValue.empty(using: dependencies) - let attachment = SignalAttachment(dataSource: dataSource, dataType: type) - attachment.error = .missingData - return attachment - } - - return newAttachment( - dataSource: dataSource, - type: type, - validTypes: UTType.supportedVideoTypes, - maxFileSize: SNUtilitiesKit.maxFileSize, - using: dependencies - ) - } - - public class func copyToVideoTempDir(url fromUrl: URL, using dependencies: Dependencies) throws -> URL { - let baseDir = SignalAttachment.videoTempPath(using: dependencies) - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: baseDir.path) - let toUrl = baseDir.appendingPathComponent(fromUrl.lastPathComponent) - - try dependencies[singleton: .fileManager].copyItem(at: fromUrl, to: toUrl) - - return toUrl - } - - private class func videoTempPath(using dependencies: Dependencies) -> URL { - let videoDir = URL(fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectory) - .appendingPathComponent("video") - try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: videoDir.path) - return videoDir - } - - public class func compressVideoAsMp4( - dataSource: (any DataSource), - type: UTType, - using dependencies: Dependencies - ) -> (AnyPublisher, AVAssetExportSession?) { - guard let url = dataSource.dataUrl else { - let attachment = SignalAttachment(dataSource: DataSourceValue.empty(using: dependencies), dataType: type) - attachment.error = .missingData - return ( - Just(attachment) - .setFailureType(to: Error.self) - .eraseToAnyPublisher(), - nil - ) - } - - let asset = AVAsset(url: url) - - guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else { - let attachment = SignalAttachment(dataSource: DataSourceValue.empty(using: dependencies), dataType: type) - attachment.error = .couldNotConvertToMpeg4 - return ( - Just(attachment) - .setFailureType(to: Error.self) - .eraseToAnyPublisher(), - nil - ) - } - - exportSession.shouldOptimizeForNetworkUse = true - exportSession.outputFileType = AVFileType.mp4 - exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing() - - let exportURL = videoTempPath(using: dependencies) - .appendingPathComponent(UUID().uuidString).appendingPathExtension("mp4") - exportSession.outputURL = exportURL - - let publisher = Deferred { - Future { resolver in - exportSession.exportAsynchronously { - let baseFilename = dataSource.sourceFilename - let mp4Filename = baseFilename?.filenameWithoutExtension.appendingFileExtension("mp4") - - guard let dataSource = DataSourcePath(fileUrl: exportURL, sourceFilename: baseFilename, shouldDeleteOnDeinit: true, using: dependencies) else { - let attachment = SignalAttachment(dataSource: DataSourceValue.empty(using: dependencies), dataType: type) - attachment.error = .couldNotConvertToMpeg4 - resolver(Result.success(attachment)) - return - } - - dataSource.sourceFilename = mp4Filename - - let attachment = SignalAttachment(dataSource: dataSource, dataType: .mpeg4Movie) - resolver(Result.success(attachment)) - } - } - } - .eraseToAnyPublisher() - - return (publisher, exportSession) - } - - public struct VideoCompressionResult { - public let attachmentPublisher: AnyPublisher - public let exportSession: AVAssetExportSession? - - fileprivate init(attachmentPublisher: AnyPublisher, exportSession: AVAssetExportSession?) { - self.attachmentPublisher = attachmentPublisher - self.exportSession = exportSession - } - } - - public class func compressVideoAsMp4(dataSource: (any DataSource), type: UTType, using dependencies: Dependencies) -> VideoCompressionResult { - let (attachmentPublisher, exportSession) = compressVideoAsMp4(dataSource: dataSource, type: type, using: dependencies) - return VideoCompressionResult(attachmentPublisher: attachmentPublisher, exportSession: exportSession) - } - - public class func isInvalidVideo(dataSource: (any DataSource), type: UTType) -> Bool { - guard UTType.supportedVideoTypes.contains(type) else { - // not a video - return false - } - - guard isValidOutputVideo(dataSource: dataSource, type: type) else { - // found a video which needs to be converted - return true - } - - // It is a video, but it's not invalid - return false - } - - private class func isValidOutputVideo(dataSource: (any DataSource)?, type: UTType) -> Bool { - guard - let dataSource = dataSource, - UTType.supportedOutputVideoTypes.contains(type), - dataSource.dataLength <= SNUtilitiesKit.maxFileSize - else { return false } - - return false - } - - // MARK: Audio Attachments - - // Factory method for audio attachments. - // - // NOTE: The attachment returned by this method may not be valid. - // Check the attachment's error property. - private class func audioAttachment(dataSource: (any DataSource)?, type: UTType, using dependencies: Dependencies) -> SignalAttachment { - return newAttachment( - dataSource: dataSource, - type: type, - validTypes: UTType.supportedAudioTypes, - maxFileSize: SNUtilitiesKit.maxFileSize, - using: dependencies - ) - } - - // MARK: Generic Attachments - - // Factory method for generic attachments. - // - // NOTE: The attachment returned by this method may not be valid. - // Check the attachment's error property. - private class func genericAttachment(dataSource: (any DataSource)?, type: UTType, using dependencies: Dependencies) -> SignalAttachment { - return newAttachment( - dataSource: dataSource, - type: type, - validTypes: nil, - maxFileSize: SNUtilitiesKit.maxFileSize, - using: dependencies - ) - } - - // MARK: Voice Messages - - public class func voiceMessageAttachment(dataSource: (any DataSource)?, type: UTType, using dependencies: Dependencies) -> SignalAttachment { - let attachment = audioAttachment(dataSource: dataSource, type: type, using: dependencies) - attachment.isVoiceMessage = true - return attachment - } - - // MARK: Attachments - - // Factory method for non-image Attachments. - // - // NOTE: The attachment returned by this method may not be valid. - // Check the attachment's error property. - public class func attachment(dataSource: (any DataSource)?, type: UTType, using dependencies: Dependencies) -> SignalAttachment { - return attachment(dataSource: dataSource, type: type, imageQuality: .original, using: dependencies) - } - - // Factory method for attachments of any kind. - // - // NOTE: The attachment returned by this method may not be valid. - // Check the attachment's error property. - public class func attachment(dataSource: (any DataSource)?, type: UTType, imageQuality: TSImageQuality, using dependencies: Dependencies) -> SignalAttachment { - if UTType.supportedInputImageTypes.contains(type) { - return imageAttachment(dataSource: dataSource, type: type, imageQuality: imageQuality, using: dependencies) - } else if UTType.supportedVideoTypes.contains(type) { - return videoAttachment(dataSource: dataSource, type: type, using: dependencies) - } else if UTType.supportedAudioTypes.contains(type) { - return audioAttachment(dataSource: dataSource, type: type, using: dependencies) - } - - return genericAttachment(dataSource: dataSource, type: type, using: dependencies) - } - - public class func empty(using dependencies: Dependencies) -> SignalAttachment { - return SignalAttachment.attachment( - dataSource: DataSourceValue.empty(using: dependencies), - type: .content, - imageQuality: .original, - using: dependencies - ) - } - - // MARK: Helper Methods - - private class func newAttachment( - dataSource: (any DataSource)?, - type: UTType, - validTypes: Set?, - maxFileSize: UInt, - using dependencies: Dependencies - ) -> SignalAttachment { - guard let dataSource = dataSource else { - let attachment = SignalAttachment(dataSource: DataSourceValue.empty(using: dependencies), dataType: type) - attachment.error = .missingData - return attachment - } - - let attachment = SignalAttachment(dataSource: dataSource, dataType: type) - - if let validTypes: Set = validTypes { - guard validTypes.contains(type) else { - attachment.error = .invalidFileFormat - return attachment - } - } - - guard dataSource.dataLength > 0 else { - assert(dataSource.dataLength > 0) - attachment.error = .invalidData - return attachment - } - - guard dataSource.dataLength <= maxFileSize else { - attachment.error = .fileSizeTooLarge - return attachment - } - - // Attachment is valid - return attachment - } - - // MARK: - Equatable - - public static func == (lhs: SignalAttachment, rhs: SignalAttachment) -> Bool { - switch (lhs.dataSource, rhs.dataSource) { - case (let lhsDataSource as DataSourcePath, let rhsDataSource as DataSourcePath): - guard lhsDataSource == rhsDataSource else { return false } - break - - case (let lhsDataSource as DataSourceValue, let rhsDataSource as DataSourceValue): - guard lhsDataSource == rhsDataSource else { return false } - break - - default: return false - } - - return ( - lhs.dataType == rhs.dataType && - lhs.captionText == rhs.captionText && - lhs.linkPreviewDraft == rhs.linkPreviewDraft && - lhs.isConvertibleToTextMessage == rhs.isConvertibleToTextMessage && - lhs.isConvertibleToContactShare == rhs.isConvertibleToContactShare && - lhs.cachedImage == rhs.cachedImage && - lhs.cachedVideoPreview == rhs.cachedVideoPreview && - lhs.isVoiceMessage == rhs.isVoiceMessage - ) - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift b/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift index 0a84310cfd..4f777b3ccd 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift @@ -3,23 +3,80 @@ // stringlint:disable import Foundation +import SessionUIKit -public enum AttachmentError: LocalizedError { +public enum AttachmentError: Error, CustomStringConvertible { case invalidStartState case noAttachment case notUploaded - case invalidData case encryptionFailed + case legacyEncryptionFailed + case legacyDecryptionFailed + case notEncrypted case uploadIsStillPendingDownload - - public var errorDescription: String? { + case uploadFailed + case downloadFailed + + case missingData + case fileSizeTooLarge + case invalidData + case couldNotParseImage + case couldNotConvert + case couldNotConvertToJpeg + case couldNotConvertToMpeg4 + case couldNotConvertToWebP + case couldNotRemoveMetadata + case invalidFileFormat + case couldNotResizeImage + case invalidAttachmentSource + case invalidPath + case writeFailed + case alreadyDownloaded(String?) + case downloadNoLongerValid + case databaseChangesFailed + case conversionTimeout + case conversionResultedInLargerFile + + case invalidMediaSource + case invalidDimensions + case invalidDuration + case invalidImageData + + public var description: String { switch self { case .invalidStartState: return "Cannot upload an attachment in this state." case .noAttachment: return "No such attachment." case .notUploaded: return "Attachment not uploaded." - case .invalidData: return "Invalid attachment data." case .encryptionFailed: return "Couldn't encrypt file." + case .legacyEncryptionFailed: return "Couldn't encrypt file (legacy)." + case .legacyDecryptionFailed: return "Couldn't decrypt file (legacy)." + case .notEncrypted: return "File not encrypted." case .uploadIsStillPendingDownload: return "Upload is still pending download." + case .uploadFailed: return "Upload failed." + case .downloadFailed: return "Download failed." + case .invalidAttachmentSource: return "Invalid attachment source." + case .invalidPath: return "Failed to generate a valid path." + case .writeFailed: return "Failed to write to disk." + case .alreadyDownloaded: return "File already downloaded." + case .downloadNoLongerValid: return "Download is no longer valid." + case .databaseChangesFailed: return "Database changes failed." + case .conversionTimeout: return "Conversion timed out." + case .conversionResultedInLargerFile: return "Conversion resulted in a larger file." + + case .invalidMediaSource: return "Invalid media source." + case .invalidDimensions: return "Invalid dimensions." + case .invalidDuration: return "Invalid duration." + + case .fileSizeTooLarge: return "attachmentsErrorSize".localized() + case .invalidData, .missingData, .invalidFileFormat, .invalidImageData: + return "attachmentsErrorNotSupported".localized() + + case .couldNotConvert, .couldNotConvertToJpeg, .couldNotParseImage, .couldNotConvertToMpeg4, + .couldNotConvertToWebP, .couldNotResizeImage: + return "attachmentsErrorOpen".localized() + + case .couldNotRemoveMetadata: + return "attachmentsImageErrorMetadata".localized() } } } diff --git a/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewDraft.swift b/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewDraft.swift index 92b540875e..7d28a3df98 100644 --- a/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewDraft.swift +++ b/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewDraft.swift @@ -1,26 +1,22 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionUIKit public struct LinkPreviewDraft: Equatable, Hashable { public var urlString: String public var title: String? - public var jpegImageData: Data? + public var imageSource: ImageDataManager.DataSource? - public init(urlString: String, title: String?, jpegImageData: Data? = nil) { + public init(urlString: String, title: String?, imageSource: ImageDataManager.DataSource? = nil) { self.urlString = urlString self.title = title - self.jpegImageData = jpegImageData + self.imageSource = imageSource } public func isValid() -> Bool { - var hasTitle = false - - if let titleValue = title { - hasTitle = titleValue.count > 0 - } - - let hasImage = jpegImageData != nil + let hasTitle = (title == nil || title?.isEmpty == false) + let hasImage: Bool = (imageSource != nil) return (hasTitle || hasImage) } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift index 4de5e3bcb9..7e7fda9a59 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift @@ -145,7 +145,7 @@ extension MessageReceiver { publicKey: sender, displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), - blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, + blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) @@ -249,7 +249,7 @@ extension MessageReceiver { publicKey: sender, displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), - blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, + blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) @@ -610,7 +610,7 @@ extension MessageReceiver { publicKey: sender, displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), - blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, + blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) @@ -628,7 +628,7 @@ extension MessageReceiver { name: $0, displayPictureUrl: profile.profilePictureUrl, displayPictureEncryptionKey: profile.profileKey, - profileLastUpdated: (Double(sentTimestampMs) / 1000) + profileLastUpdated: profile.updateTimestampSeconds ) } }, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift index c10460d2f4..ec2aa46540 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift @@ -61,7 +61,7 @@ extension MessageReceiver { /// If it's the `Note to Self` conversation then we want to just delete the interaction if userSessionId.hexString == interactionInfo.threadId { - try Interaction.deleteOne(db, id: interactionInfo.id) + try Interaction.deleteWhere(db, .filter(Interaction.Columns.id == interactionInfo.id)) } /// Can't delete from the legacy group swarm so only bother for contact conversations diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 430b38e325..ea756faa8d 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -43,7 +43,7 @@ extension MessageReceiver { publicKey: sender, displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), - blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, + blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift index a0129669ac..401bbe1647 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift @@ -3,249 +3,232 @@ import Foundation import Combine import GRDB +import SessionUIKit import SessionUtilitiesKit import SessionNetworkingKit extension MessageSender { private typealias PreparedGroupData = ( groupSessionId: SessionId, + identityKeyPair: KeyPair, groupState: [ConfigDump.Variant: LibSession.Config], thread: SessionThread, group: ClosedGroup, - members: [GroupMember], - preparedNotificationsSubscription: Network.PreparedRequest? + members: [GroupMember] ) public static func createGroup( name: String, description: String?, - displayPictureData: Data?, + displayPicture: ImageDataManager.DataSource?, + displayPictureCropRect: CGRect?, members: [(String, Profile?)], using dependencies: Dependencies - ) -> AnyPublisher { + ) async throws -> SessionThread { let userSessionId: SessionId = dependencies[cache: .general].sessionId let sortedOtherMembers: [(String, Profile?)] = members .filter { id, _ in id != userSessionId.hexString } .sortedById(userSessionId: userSessionId) + var displayPictureInfo: DisplayPictureManager.UploadResult? - return Just(()) - .setFailureType(to: Error.self) - .flatMap { _ -> AnyPublisher in - guard let displayPictureData: Data = displayPictureData else { - return Just(nil) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - return dependencies[singleton: .displayPictureManager] - .prepareAndUploadDisplayPicture(imageData: displayPictureData, compression: true) - .mapError { error -> Error in error } - .map { Optional($0) } - .eraseToAnyPublisher() - } - .flatMap { (displayPictureInfo: DisplayPictureManager.UploadResult?) -> AnyPublisher in - dependencies[singleton: .storage].writePublisher { db -> PreparedGroupData in - /// Create and cache the libSession entries - let createdInfo: LibSession.CreatedGroupInfo = try LibSession.createGroup( - db, - name: name, - description: description, - displayPictureUrl: displayPictureInfo?.downloadUrl, - displayPictureEncryptionKey: displayPictureInfo?.encryptionKey, - members: members, - using: dependencies + if let source: ImageDataManager.DataSource = displayPicture { + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .media(source), + using: dependencies + ) + let preparedAttachment: PreparedAttachment = try await dependencies[singleton: .displayPictureManager] + .prepareDisplayPicture(attachment: pendingAttachment, cropRect: displayPictureCropRect) + displayPictureInfo = try await dependencies[singleton: .displayPictureManager] + .uploadDisplayPicture(preparedAttachment: preparedAttachment) + } + + let preparedGroupData: PreparedGroupData = try await dependencies[singleton: .storage].writeAsync { db in + /// Create and cache the libSession entries + let createdInfo: LibSession.CreatedGroupInfo = try LibSession.createGroup( + db, + name: name, + description: description, + displayPictureUrl: displayPictureInfo?.downloadUrl, + displayPictureEncryptionKey: displayPictureInfo?.encryptionKey, + members: members, + using: dependencies + ) + + /// Save the relevant objects to the database + let thread: SessionThread = try SessionThread.upsert( + db, + id: createdInfo.group.id, + variant: .group, + values: SessionThread.TargetValues( + creationDateTimestamp: .setTo(createdInfo.group.formationTimestamp), + shouldBeVisible: .setTo(true) + ), + using: dependencies + ) + try createdInfo.group.insert(db) + try createdInfo.members.forEach { try $0.insert(db) } + + /// Add a record of the initial invites going out (default to being read as we don't want the creator of the group + /// to see the "Unread Messages" banner above this control message) + _ = try? Interaction( + threadId: createdInfo.group.id, + threadVariant: .group, + authorId: userSessionId.hexString, + variant: .infoGroupMembersUpdated, + body: ClosedGroup.MessageInfo + .addedUsers( + hasCurrentUser: false, + names: sortedOtherMembers.map { id, profile in + profile?.displayName(for: .group) ?? + id.truncated() + }, + historyShared: false ) - - /// Save the relevant objects to the database - let thread: SessionThread = try SessionThread.upsert( - db, - id: createdInfo.group.id, - variant: .group, - values: SessionThread.TargetValues( - creationDateTimestamp: .setTo(createdInfo.group.formationTimestamp), - shouldBeVisible: .setTo(true) + .infoString(using: dependencies), + timestampMs: Int64(createdInfo.group.formationTimestamp * 1000), + wasRead: true, + using: dependencies + ).inserted(db) + + /// Schedule the "members added" control message to be sent after the config sync completes + try dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .messageSend, + behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, + threadId: createdInfo.group.id, + details: MessageSendJob.Details( + destination: .closedGroup(groupPublicKey: createdInfo.group.id), + message: GroupUpdateMemberChangeMessage( + changeType: .added, + memberSessionIds: sortedOtherMembers.map { id, _ in id }, + historyShared: false, + sentTimestampMs: UInt64(createdInfo.group.formationTimestamp * 1000), + authMethod: Authentication.groupAdmin( + groupSessionId: createdInfo.groupSessionId, + ed25519SecretKey: createdInfo.identityKeyPair.secretKey + ), + using: dependencies ), - using: dependencies + requiredConfigSyncVariant: .groupMembers ) - try createdInfo.group.insert(db) - try createdInfo.members.forEach { try $0.insert(db) } - - /// Add a record of the initial invites going out (default to being read as we don't want the creator of the group - /// to see the "Unread Messages" banner above this control message) - _ = try? Interaction( - threadId: createdInfo.group.id, - threadVariant: .group, - authorId: userSessionId.hexString, - variant: .infoGroupMembersUpdated, - body: ClosedGroup.MessageInfo - .addedUsers( - hasCurrentUser: false, - names: sortedOtherMembers.map { id, profile in - profile?.displayName(for: .group) ?? - id.truncated() - }, - historyShared: false + ), + canStartJob: false + ) + + return ( + createdInfo.groupSessionId, + createdInfo.identityKeyPair, + createdInfo.groupState, + thread, + createdInfo.group, + createdInfo.members + ) + } + + do { + // TODO: Refactor to async/await when supported + try await ConfigurationSyncJob.run( + swarmPublicKey: preparedGroupData.groupSessionId.hexString, + requireAllRequestsSucceed: true, + using: dependencies + ).values.first { _ in true } + } + catch { + /// Remove the config and database states + try await dependencies[singleton: .storage].writeAsync { db in + LibSession.removeGroupStateIfNeeded( + db, + groupSessionId: preparedGroupData.groupSessionId, + using: dependencies + ) + + _ = try? preparedGroupData.thread.delete(db) + _ = try? preparedGroupData.group.delete(db) + try? preparedGroupData.members.forEach { try $0.delete(db) } + _ = try? Job + .filter(Job.Columns.threadId == preparedGroupData.group.id) + .deleteAll(db) + } + throw error + } + + /// Save the successfully created group and add to the user config/ + try await dependencies[singleton: .storage].writeAsync { db in + try LibSession.saveCreatedGroup( + db, + group: preparedGroupData.group, + groupState: preparedGroupData.groupState, + using: dependencies + ) + } + + /// Start polling + dependencies + .mutate(cache: .groupPollers) { $0.getOrCreatePoller(for: preparedGroupData.thread.id) } + .startIfNeeded() + + /// Subscribe for push notifications (if PNs are enabled) + if let token: String = dependencies[defaults: .standard, key: .deviceToken] { + let request = try? Network.PushNotification + .preparedSubscribe( + token: Data(hex: token), + swarms: [( + preparedGroupData.groupSessionId, + Authentication.groupAdmin( + groupSessionId: preparedGroupData.groupSessionId, + ed25519SecretKey: preparedGroupData.identityKeyPair.secretKey + ) + )], + using: dependencies + ) + request + .send(using: dependencies) + .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) + .sinkUntilComplete() + } + + /// Save jobs for sending group member invitations + try? await dependencies[singleton: .storage].writeAsync { db in + preparedGroupData.members + .filter { $0.profileId != userSessionId.hexString } + .compactMap { member -> (GroupMember, GroupInviteMemberJob.Details)? in + /// Generate authData for the removed member + guard + let memberAuthInfo: Authentication.Info = try? dependencies.mutate(cache: .libSession, { cache in + try dependencies[singleton: .crypto].tryGenerate( + .memberAuthData( + config: cache.config( + for: .groupKeys, + sessionId: preparedGroupData.groupSessionId + ), + groupSessionId: preparedGroupData.groupSessionId, + memberId: member.profileId + ) ) - .infoString(using: dependencies), - timestampMs: Int64(createdInfo.group.formationTimestamp * 1000), - wasRead: true, - using: dependencies - ).inserted(db) + }), + let jobDetails: GroupInviteMemberJob.Details = try? GroupInviteMemberJob.Details( + memberSessionIdHexString: member.profileId, + authInfo: memberAuthInfo + ) + else { return nil } - /// Schedule the "members added" control message to be sent after the config sync completes - try dependencies[singleton: .jobRunner].add( + return (member, jobDetails) + } + .forEach { member, jobDetails in + dependencies[singleton: .jobRunner].add( db, job: Job( - variant: .messageSend, - behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, - threadId: createdInfo.group.id, - details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: createdInfo.group.id), - message: GroupUpdateMemberChangeMessage( - changeType: .added, - memberSessionIds: sortedOtherMembers.map { id, _ in id }, - historyShared: false, - sentTimestampMs: UInt64(createdInfo.group.formationTimestamp * 1000), - authMethod: Authentication.groupAdmin( - groupSessionId: createdInfo.groupSessionId, - ed25519SecretKey: createdInfo.identityKeyPair.secretKey - ), - using: dependencies - ), - requiredConfigSyncVariant: .groupMembers - ) + variant: .groupInviteMember, + threadId: preparedGroupData.thread.id, + details: jobDetails ), - canStartJob: false - ) - - // Prepare the notification subscription - var preparedNotificationSubscription: Network.PreparedRequest? - - if let token: String = dependencies[defaults: .standard, key: .deviceToken] { - preparedNotificationSubscription = try? Network.PushNotification - .preparedSubscribe( - token: Data(hex: token), - swarms: [( - createdInfo.groupSessionId, - Authentication.groupAdmin( - groupSessionId: createdInfo.groupSessionId, - ed25519SecretKey: createdInfo.identityKeyPair.secretKey - ) - )], - using: dependencies - ) - } - - return ( - createdInfo.groupSessionId, - createdInfo.groupState, - thread, - createdInfo.group, - createdInfo.members, - preparedNotificationSubscription + canStartJob: true ) } - } - .flatMap { preparedGroupData -> AnyPublisher in - ConfigurationSyncJob - .run( - swarmPublicKey: preparedGroupData.groupSessionId.hexString, - requireAllRequestsSucceed: true, - using: dependencies - ) - .flatMap { _ in - dependencies[singleton: .storage].writePublisher { db in - // Save the successfully created group and add to the user config - try LibSession.saveCreatedGroup( - db, - group: preparedGroupData.group, - groupState: preparedGroupData.groupState, - using: dependencies - ) - - return preparedGroupData - } - } - .handleEvents( - receiveCompletion: { result in - switch result { - case .finished: break - case .failure: - // Remove the config and database states - dependencies[singleton: .storage].writeAsync { db in - LibSession.removeGroupStateIfNeeded( - db, - groupSessionId: preparedGroupData.groupSessionId, - using: dependencies - ) - - _ = try? preparedGroupData.thread.delete(db) - _ = try? preparedGroupData.group.delete(db) - try? preparedGroupData.members.forEach { try $0.delete(db) } - _ = try? Job - .filter(Job.Columns.threadId == preparedGroupData.group.id) - .deleteAll(db) - } - } - } - ) - .eraseToAnyPublisher() - } - .handleEvents( - receiveOutput: { groupSessionId, _, thread, group, groupMembers, preparedNotificationSubscription in - let userSessionId: SessionId = dependencies[cache: .general].sessionId - - // Start polling - dependencies - .mutate(cache: .groupPollers) { $0.getOrCreatePoller(for: thread.id) } - .startIfNeeded() - - // Subscribe for push notifications (if PNs are enabled) - preparedNotificationSubscription? - .send(using: dependencies) - .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) - .sinkUntilComplete() - - dependencies[singleton: .storage].writeAsync { db in - // Save jobs for sending group member invitations - groupMembers - .filter { $0.profileId != userSessionId.hexString } - .compactMap { member -> (GroupMember, GroupInviteMemberJob.Details)? in - // Generate authData for the removed member - guard - let memberAuthInfo: Authentication.Info = try? dependencies.mutate(cache: .libSession, { cache in - try dependencies[singleton: .crypto].tryGenerate( - .memberAuthData( - config: cache.config(for: .groupKeys, sessionId: groupSessionId), - groupSessionId: groupSessionId, - memberId: member.profileId - ) - ) - }), - let jobDetails: GroupInviteMemberJob.Details = try? GroupInviteMemberJob.Details( - memberSessionIdHexString: member.profileId, - authInfo: memberAuthInfo - ) - else { return nil } - - return (member, jobDetails) - } - .forEach { member, jobDetails in - dependencies[singleton: .jobRunner].add( - db, - job: Job( - variant: .groupInviteMember, - threadId: thread.id, - details: jobDetails - ), - canStartJob: true - ) - } - } - } - ) - .map { _, _, thread, _, _, _ in thread } - .eraseToAnyPublisher() + } + + return preparedGroupData.thread } public static func updateGroup( @@ -357,102 +340,100 @@ extension MessageSender { groupSessionId: String, displayPictureUpdate: DisplayPictureManager.Update, using dependencies: Dependencies - ) -> AnyPublisher { + ) async throws { guard let sessionId: SessionId = try? SessionId(from: groupSessionId), sessionId.prefix == .group else { - return Fail(error: MessageSenderError.invalidClosedGroupUpdate).eraseToAnyPublisher() + throw MessageSenderError.invalidClosedGroupUpdate } - return dependencies[singleton: .storage] - .writePublisher { db in - guard - let groupIdentityPrivateKey: Data = try? ClosedGroup - .filter(id: sessionId.hexString) - .select(.groupIdentityPrivateKey) - .asRequest(of: Data.self) - .fetchOne(db) - else { throw MessageSenderError.invalidClosedGroupUpdate } - - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let changeTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - - /// Perform the config changes without triggering a config sync (we will trigger one manually as part of the process) - try dependencies.mutate(cache: .libSession) { cache in - try cache.withCustomBehaviour(.skipAutomaticConfigSync, for: sessionId) { - switch displayPictureUpdate { - case .groupRemove: - try ClosedGroup - .filter(id: groupSessionId) - .updateAllAndConfig( - db, - ClosedGroup.Columns.displayPictureUrl.set(to: nil), - ClosedGroup.Columns.displayPictureEncryptionKey.set(to: nil), - using: dependencies - ) - - case .groupUpdateTo(let url, let key, let fileName): - try ClosedGroup - .filter(id: groupSessionId) - .updateAllAndConfig( - db, - ClosedGroup.Columns.displayPictureUrl.set(to: url), - ClosedGroup.Columns.displayPictureEncryptionKey.set(to: key), - using: dependencies - ) - - default: throw MessageSenderError.invalidClosedGroupUpdate - } + try await dependencies[singleton: .storage].writeAsync { db in + guard + let groupIdentityPrivateKey: Data = try? ClosedGroup + .filter(id: sessionId.hexString) + .select(.groupIdentityPrivateKey) + .asRequest(of: Data.self) + .fetchOne(db) + else { throw MessageSenderError.invalidClosedGroupUpdate } + + let userSessionId: SessionId = dependencies[cache: .general].sessionId + let changeTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + + /// Perform the config changes without triggering a config sync (we will trigger one manually as part of the process) + try dependencies.mutate(cache: .libSession) { cache in + try cache.withCustomBehaviour(.skipAutomaticConfigSync, for: sessionId) { + switch displayPictureUpdate { + case .groupRemove: + try ClosedGroup + .filter(id: groupSessionId) + .updateAllAndConfig( + db, + ClosedGroup.Columns.displayPictureUrl.set(to: nil), + ClosedGroup.Columns.displayPictureEncryptionKey.set(to: nil), + using: dependencies + ) + + case .groupUpdateTo(let url, let key): + try ClosedGroup + .filter(id: groupSessionId) + .updateAllAndConfig( + db, + ClosedGroup.Columns.displayPictureUrl.set(to: url), + ClosedGroup.Columns.displayPictureEncryptionKey.set(to: key), + using: dependencies + ) + + default: throw MessageSenderError.invalidClosedGroupUpdate } } - - let disappearingConfig: DisappearingMessagesConfiguration? = try? DisappearingMessagesConfiguration.fetchOne(db, id: sessionId.hexString) - - /// Add a record of the change to the conversation - _ = try Interaction( - threadId: groupSessionId, - threadVariant: .group, - authorId: userSessionId.hexString, - variant: .infoGroupInfoUpdated, - body: ClosedGroup.MessageInfo - .updatedDisplayPicture - .infoString(using: dependencies), - timestampMs: changeTimestampMs, - expiresInSeconds: disappearingConfig?.expiresInSeconds(), - expiresStartedAtMs: disappearingConfig?.initialExpiresStartedAtMs( - sentTimestampMs: Double(changeTimestampMs) - ), - using: dependencies - ).inserted(db) - - /// Schedule the control message to be sent to the group after the config sync completes - try dependencies[singleton: .jobRunner].add( - db, - job: Job( - variant: .messageSend, - behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, - threadId: sessionId.hexString, - details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: sessionId.hexString), - message: GroupUpdateInfoChangeMessage( - changeType: .avatar, - sentTimestampMs: UInt64(changeTimestampMs), - authMethod: Authentication.groupAdmin( - groupSessionId: sessionId, - ed25519SecretKey: Array(groupIdentityPrivateKey) - ), - using: dependencies - ).with(disappearingConfig), - requiredConfigSyncVariant: .groupInfo - ) - ), - canStartJob: false - ) } - .flatMap { _ -> AnyPublisher in - ConfigurationSyncJob - .run(swarmPublicKey: groupSessionId, using: dependencies) - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() + + let disappearingConfig: DisappearingMessagesConfiguration? = try? DisappearingMessagesConfiguration.fetchOne(db, id: sessionId.hexString) + + /// Add a record of the change to the conversation + _ = try Interaction( + threadId: groupSessionId, + threadVariant: .group, + authorId: userSessionId.hexString, + variant: .infoGroupInfoUpdated, + body: ClosedGroup.MessageInfo + .updatedDisplayPicture + .infoString(using: dependencies), + timestampMs: changeTimestampMs, + expiresInSeconds: disappearingConfig?.expiresInSeconds(), + expiresStartedAtMs: disappearingConfig?.initialExpiresStartedAtMs( + sentTimestampMs: Double(changeTimestampMs) + ), + using: dependencies + ).inserted(db) + + /// Schedule the control message to be sent to the group after the config sync completes + try dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .messageSend, + behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, + threadId: sessionId.hexString, + details: MessageSendJob.Details( + destination: .closedGroup(groupPublicKey: sessionId.hexString), + message: GroupUpdateInfoChangeMessage( + changeType: .avatar, + sentTimestampMs: UInt64(changeTimestampMs), + authMethod: Authentication.groupAdmin( + groupSessionId: sessionId, + ed25519SecretKey: Array(groupIdentityPrivateKey) + ), + using: dependencies + ).with(disappearingConfig), + requiredConfigSyncVariant: .groupInfo + ) + ), + canStartJob: false + ) + } + + _ = try await ConfigurationSyncJob + .run(swarmPublicKey: groupSessionId, using: dependencies) + .values + .first { _ in true } } public static func updateGroup( diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index f10e1dca4c..5dc79f6026 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -26,7 +26,7 @@ extension MessageSender { message: VisibleMessage.from( db, interaction: interaction, - proProof: dependencies.mutate(cache: .libSession, { $0.getProProof() }) + proProof: dependencies.mutate(cache: .libSession, { $0.getCurrentUserProProof() }) ), threadId: threadId, interactionId: interactionId, @@ -436,7 +436,9 @@ public extension VisibleMessage { text: interaction.body, attachmentIds: ((try? interaction.attachments.fetchAll(db)) ?? []) .map { $0.id }, - quote: (try? interaction.quote.fetchOne(db)) + quote: (try? Quote + .filter(Quote.Columns.interactionId == interaction.id) + .fetchOne(db)) .map { VMQuote.from(quote: $0) }, linkPreview: linkPreview .map { linkPreview in diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 8752d80003..6c114daedd 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -400,7 +400,7 @@ public final class MessageSender { .addingAttachmentsIfNeeded(message, attachments?.map { $0.attachment }) else { throw MessageSenderError.protoConversionFailed } - return try Result(proto.serializedData().paddedMessageBody()) + return try Result { try proto.serializedData().paddedMessageBody() } .mapError { MessageSenderError.other(nil, "Couldn't serialize proto", $0) } .successOrThrow() @@ -410,7 +410,7 @@ public final class MessageSender { .addingAttachmentsIfNeeded(message, attachments?.map { $0.attachment }) else { throw MessageSenderError.protoConversionFailed } - return try Result(proto.serializedData()) + return try Result { try proto.serializedData() } .map { serialisedData -> Data in switch destination { case .closedGroup(let groupId) where (try? SessionId.Prefix(from: groupId)) == .group: @@ -427,14 +427,14 @@ public final class MessageSender { 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( + let messageData: Data = try Result { + try MessageWrapper.wrap( type: .closedGroupMessage, timestampMs: sentTimestampMs, content: plaintext, wrapInWebSocketMessage: false ) - ) + } .mapError { MessageSenderError.other(nil, "Couldn't wrap message", $0) } .successOrThrow() @@ -459,7 +459,7 @@ public final class MessageSender { ) ) - return try Result( + return try Result { try MessageWrapper.wrap( type: try { switch destination { @@ -477,7 +477,7 @@ public final class MessageSender { }(), content: ciphertext ) - ) + } .mapError { MessageSenderError.other(nil, "Couldn't wrap message", $0) } .successOrThrow() diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift index b844d336dc..8e65f4550d 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift @@ -351,7 +351,7 @@ public extension NotificationsManagerType { threadId: threadId, threadVariant: threadVariant ) - + /// Ensure we should be showing a notification for the thread try ensureWeShouldShowNotification( message: message, @@ -383,6 +383,10 @@ public extension NotificationsManagerType { } }(), category: .incomingMessage, + groupingIdentifier: (isMessageRequest ? + .messageRequest : + .threadId(threadId) + ), title: try notificationTitle( cat: cat, message: message, diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI+SessionMessagingKit.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI+SessionMessagingKit.swift index dda9ad50dd..b868c35b3b 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI+SessionMessagingKit.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI+SessionMessagingKit.swift @@ -46,15 +46,21 @@ public extension Network.PushNotification { .filter(ClosedGroup.Columns.shouldPoll) .asRequest(of: String.self) .fetchSet(db) - .map { threadId in - ( - SessionId(.group, hex: threadId), - try Authentication.with( - db, - swarmPublicKey: threadId, - using: dependencies + .compactMap { threadId in + do { + return ( + SessionId(.group, hex: threadId), + try Authentication.with( + db, + swarmPublicKey: threadId, + using: dependencies + ) ) - ) + } + catch { + Log.warn(.pushNotificationAPI, "Unable to subscribe for push notifications to \(threadId) due to error: \(error).") + return nil + } } ), using: dependencies @@ -74,7 +80,7 @@ public extension Network.PushNotification { .eraseToAnyPublisher() } - public static func unsubscribeAll( + static func unsubscribeAll( token: Data, using dependencies: Dependencies ) -> AnyPublisher { @@ -100,15 +106,21 @@ public extension Network.PushNotification { .asRequest(of: String.self) .fetchSet(db)) .defaulting(to: []) - .map { threadId in - ( - SessionId(.group, hex: threadId), - try Authentication.with( - db, - swarmPublicKey: threadId, - using: dependencies + .compactMap { threadId in + do { + return ( + SessionId(.group, hex: threadId), + try Authentication.with( + db, + swarmPublicKey: threadId, + using: dependencies + ) ) - ) + } + catch { + Log.info(.pushNotificationAPI, "Unable to unsubscribe for push notifications to \(threadId) due to error: \(error).") + return nil + } }), using: dependencies ) diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationContent.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationContent.swift index 2557384fb4..33e11a2699 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationContent.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationContent.swift @@ -1,13 +1,32 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import UIKit import UserNotifications +// Add more later if any push notification needs to be customly grouped +// Currently default grouping is via `threadId` +public enum NotificationGroupingType: Equatable { + case messageRequest + case threadId(String) + case none + + var key: String? { + switch self { + case .messageRequest: "message-request-grouping-identifier" + case .threadId(let indentifier): indentifier + case .none: nil + } + } +} + public struct NotificationContent { public let threadId: String? public let threadVariant: SessionThread.Variant? public let identifier: String public let category: NotificationCategory + public let groupingIdentifier: NotificationGroupingType public let title: String? public let body: String? public let delay: TimeInterval? @@ -22,6 +41,7 @@ public struct NotificationContent { threadVariant: SessionThread.Variant?, identifier: String, category: NotificationCategory, + groupingIdentifier: NotificationGroupingType = .none, title: String? = nil, body: String? = nil, delay: TimeInterval? = nil, @@ -33,6 +53,7 @@ public struct NotificationContent { self.threadVariant = threadVariant self.identifier = identifier self.category = category + self.groupingIdentifier = groupingIdentifier self.title = title self.body = body self.delay = delay @@ -53,6 +74,7 @@ public struct NotificationContent { threadVariant: threadVariant, identifier: identifier, category: category, + groupingIdentifier: groupingIdentifier, title: (title ?? self.title), body: (body ?? self.body), delay: self.delay, @@ -67,7 +89,10 @@ public struct NotificationContent { content.categoryIdentifier = category.identifier content.userInfo = userInfo - if let threadId: String = threadId { content.threadIdentifier = threadId } + if let groupIdentifier = groupingIdentifier.key { + content.threadIdentifier = groupIdentifier + } + if let title: String = title { content.title = title } if let body: String = body { content.body = body } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift index 40477f59f3..e322563198 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift @@ -23,7 +23,7 @@ public protocol CommunityPollerType { typealias PollResponse = (info: ResponseInfoType, data: Network.BatchResponseMap) var isPolling: Bool { get } - var receivedPollResponse: AnyPublisher { get } + nonisolated var receivedPollResponse: AsyncStream { get } func startIfNeeded() func stop() @@ -54,9 +54,8 @@ public final class CommunityPoller: CommunityPollerType & PollerType { public let pollerName: String public let pollerDestination: PollerDestination public let logStartAndStopCalls: Bool - public var receivedPollResponse: AnyPublisher { - receivedPollResponseSubject.eraseToAnyPublisher() - } + nonisolated public var receivedPollResponse: AsyncStream { responseStream.stream } + nonisolated public var successfulPollCount: AsyncStream { pollCountStream.stream } public var isPolling: Bool = false public var pollCount: Int = 0 @@ -65,7 +64,8 @@ public final class CommunityPoller: CommunityPollerType & PollerType { public var cancellable: AnyCancellable? private let shouldStoreMessages: Bool - private let receivedPollResponseSubject: PassthroughSubject = PassthroughSubject() + nonisolated private let responseStream: CancellationAwareAsyncStream = CancellationAwareAsyncStream() + nonisolated private let pollCountStream: CurrentValueAsyncStream = CurrentValueAsyncStream(0) // MARK: - Initialization @@ -90,6 +90,13 @@ public final class CommunityPoller: CommunityPollerType & PollerType { self.logStartAndStopCalls = logStartAndStopCalls } + deinit { + // Send completion events to the observables + Task { [stream = responseStream] in + await stream.finishCurrentStreams() + } + } + // MARK: - Abstract Methods public func nextPollDelay() -> AnyPublisher { @@ -119,7 +126,7 @@ 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).") @@ -328,7 +335,12 @@ public final class CommunityPoller: CommunityPollerType & PollerType { } .handleEvents( receiveOutput: { [weak self, dependencies] _ in - self?.pollCount += 1 + let updatedPollCount: Int = ((self?.pollCount ?? 0) + 1) + self?.pollCount = updatedPollCount + + Task { [weak self] in + await self?.pollCountStream.send(updatedPollCount) + } dependencies.mutate(cache: .openGroupManager) { cache in cache.setLastSuccessfulCommunityPollTimestamp( diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift index 7e816d4a4f..6a47a74fbf 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift @@ -50,34 +50,37 @@ public final class GroupPoller: SwarmPoller { /// If the keys generation is greated than `0` then it means we have a valid config so shouldn't continue guard numKeys == 0 else { return } - dependencies[singleton: .storage] - .readPublisher { [pollerDestination] db in + Task.detached { [weak self, pollerDestination, dependencies] in + guard let self = self else { return } + + let isExpired: Bool? = try await dependencies[singleton: .storage].readAsync { [pollerDestination] db in try ClosedGroup .filter(id: pollerDestination.target) .select(.expired) .asRequest(of: Bool.self) .fetchOne(db) } - .filter { ($0 != true) } - .flatMap { [receivedPollResponse] _ in receivedPollResponse } - .first() - .map { $0.filter { $0.isConfigMessage } } - .filter { !$0.contains(where: { $0.namespace == Network.SnodeAPI.Namespace.configGroupKeys }) } - .sinkUntilComplete( - receiveValue: { [pollerDestination, pollerName, dependencies] configMessages in - Log.error(.poller, "\(pollerName) received no config messages in it's first poll, flagging as expired.") - - dependencies[singleton: .storage].writeAsync { db in - try ClosedGroup - .filter(id: pollerDestination.target) - .updateAllAndConfig( - db, - ClosedGroup.Columns.expired.set(to: true), - using: dependencies - ) - } - } - ) + + /// If we haven't set the `expired` value then we should check the first poll response to see if it's missing the + /// `GroupKeys` config message + guard + isExpired != true, + let response: PollResponse = await receivedPollResponse.first(), + !response.contains(where: { $0.namespace == .configGroupKeys }) + else { return } + + /// There isn't `GroupKeys` config so flag the group as `expired` + Log.error(.poller, "\(pollerName) received no config messages in it's first poll, flagging as expired.") + try await dependencies[singleton: .storage].writeAsync { db in + try ClosedGroup + .filter(id: pollerDestination.target) + .updateAllAndConfig( + db, + ClosedGroup.Columns.expired.set(to: true), + using: dependencies + ) + } + } } // MARK: - Abstract Methods diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/PollerType.swift b/SessionMessagingKit/Sending & Receiving/Pollers/PollerType.swift index c3e16e5fc5..6856571469 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/PollerType.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/PollerType.swift @@ -51,7 +51,8 @@ public protocol PollerType: AnyObject { var pollerName: String { get } var pollerDestination: PollerDestination { get } var logStartAndStopCalls: Bool { get } - var receivedPollResponse: AnyPublisher { get } + nonisolated var receivedPollResponse: AsyncStream { get } + nonisolated var successfulPollCount: AsyncStream { get } var isPolling: Bool { get set } var pollCount: Int { get set } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift index ab0851838b..306a12dc92 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift @@ -11,7 +11,7 @@ import SessionUtilitiesKit public protocol SwarmPollerType { typealias PollResponse = [ProcessedMessage] - var receivedPollResponse: AnyPublisher { get } + nonisolated var receivedPollResponse: AsyncStream { get } func startIfNeeded() func stop() @@ -31,9 +31,8 @@ public class SwarmPoller: SwarmPollerType & PollerType { public let pollerDestination: PollerDestination @ThreadSafeObject public var pollerDrainBehaviour: SwarmDrainBehaviour public let logStartAndStopCalls: Bool - public var receivedPollResponse: AnyPublisher { - receivedPollResponseSubject.eraseToAnyPublisher() - } + nonisolated public var receivedPollResponse: AsyncStream { responseStream.stream } + nonisolated public var successfulPollCount: AsyncStream { pollCountStream.stream } public var isPolling: Bool = false public var pollCount: Int = 0 @@ -44,7 +43,8 @@ public class SwarmPoller: SwarmPollerType & PollerType { private let namespaces: [Network.SnodeAPI.Namespace] private let customAuthMethod: AuthenticationMethod? private let shouldStoreMessages: Bool - private let receivedPollResponseSubject: PassthroughSubject = PassthroughSubject() + nonisolated private let responseStream: CancellationAwareAsyncStream = CancellationAwareAsyncStream() + nonisolated private let pollCountStream: CurrentValueAsyncStream = CurrentValueAsyncStream(0) // MARK: - Initialization @@ -72,6 +72,13 @@ public class SwarmPoller: SwarmPollerType & PollerType { self.logStartAndStopCalls = logStartAndStopCalls } + deinit { + // Send completion events to the observables + Task { [stream = responseStream] in + await stream.finishCurrentStreams() + } + } + // MARK: - Abstract Methods /// Calculate the delay which should occur before the next poll @@ -101,9 +108,14 @@ public class SwarmPoller: SwarmPollerType & PollerType { /// for cases where we need explicit/custom behaviours to occur (eg. Onboarding) public func poll(forceSynchronousProcessing: Bool) -> AnyPublisher { let pollerQueue: DispatchQueue = self.pollerQueue - let activeHashes: [String] = dependencies.mutate(cache: .libSession) { cache in - cache.activeHashes(for: pollerDestination.target) - } + let activeHashes: [String] = { + /// If we don't have an account then there won't be any active hashes so don't bother trying to get them + guard dependencies[cache: .general].userExists else { return [] } + + return dependencies.mutate(cache: .libSession) { cache in + cache.activeHashes(for: pollerDestination.target) + } + }() /// Fetch the messages return dependencies[singleton: .network] @@ -218,8 +230,14 @@ public class SwarmPoller: SwarmPollerType & PollerType { } .handleEvents( receiveOutput: { [weak self] (pollResult: PollResult) in + let updatedPollCount: Int = ((self?.pollCount ?? 0) + 1) + self?.pollCount = updatedPollCount + /// Notify any observers that we got a result - self?.receivedPollResponseSubject.send(pollResult.response) + Task { [weak self] in + await self?.responseStream.send(pollResult.response) + await self?.pollCountStream.send(updatedPollCount) + } } ) .eraseToAnyPublisher() @@ -387,8 +405,8 @@ public class SwarmPoller: SwarmPollerType & PollerType { } /// Make sure to add any synchronously processed messages to the `finalProcessedMessages` as otherwise - /// they wouldn't be emitted by the `receivedPollResponseSubject`, also need to add the count to - /// `messageCount` to ensure it's not incorrect + /// they wouldn't be emitted by `receivedPollResponse`, also need to add the count to `messageCount` to + /// ensure it's not incorrect finalProcessedMessages += processedMessages messageCount += processedMessages.count return nil diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index b6d6acbb89..71d534d69e 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -14,6 +14,22 @@ fileprivate typealias ReactionInfo = MessageViewModel.ReactionInfo fileprivate typealias TypingIndicatorInfo = MessageViewModel.TypingIndicatorInfo fileprivate typealias QuotedInfo = MessageViewModel.QuotedInfo +public struct QuoteViewModel: FetchableRecord, Decodable, Equatable, Hashable, Differentiable { + fileprivate static let numberOfColumns: Int = 4 + + public let interactionId: Int64 + public let authorId: String + public let timestampMs: Int64 + public let body: String? + + public init(interactionId: Int64, authorId: String, timestampMs: Int64, body: String?) { + self.interactionId = interactionId + self.authorId = authorId + self.timestampMs = timestampMs + self.body = body + } +} + // 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 @@ -42,6 +58,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, case rawBody case expiresStartedAtMs case expiresInSeconds + case isProMessage case state case hasBeenReadByRecipient @@ -49,8 +66,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, case isSenderModeratorOrAdmin case isTypingIndicator case profile - case quote - case quoteAttachment + case quotedInfo case linkPreview case linkPreviewAttachment @@ -62,6 +78,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, case reactionInfo case cellType case authorName + case authorNameSuppressedId case senderName case canHaveProfile case shouldShowProfile @@ -125,6 +142,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, public let rawBody: String? public let expiresStartedAtMs: Double? public let expiresInSeconds: TimeInterval? + public let isProMessage: Bool public let state: Interaction.State public let hasBeenReadByRecipient: Bool @@ -132,8 +150,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, public let isSenderModeratorOrAdmin: Bool public let isTypingIndicator: Bool? public let profile: Profile? - public let quote: Quote? - public let quoteAttachment: Attachment? + public let quotedInfo: QuotedInfo? public let linkPreview: LinkPreview? public let linkPreviewAttachment: Attachment? @@ -153,6 +170,9 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, /// This value includes the author name information public let authorName: String + + /// This value includes the author name information with the `id` suppressed (if it was present) + public let authorNameSuppressedId: String /// This value will be used to populate the author label, if it's null then the label will be hidden /// @@ -205,13 +225,12 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, // MARK: - Mutation public func with( - state: Interaction.State? = nil, // Optimistic outgoing messages - mostRecentFailureText: String? = nil, // Optimistic outgoing messages - profile: Profile? = nil, - quote: Quote? = nil, // Workaround for blinded current user - quoteAttachment: [Attachment]? = nil, // Pass an empty array to clear - attachments: [Attachment]? = nil, - reactionInfo: [ReactionInfo]? = nil + state: Update = .useExisting, // Optimistic outgoing messages + mostRecentFailureText: Update = .useExisting, // Optimistic outgoing messages + profile: Update = .useExisting, + quotedInfo: Update = .useExisting, // Workaround for blinded current user + attachments: Update<[Attachment]?> = .useExisting, + reactionInfo: Update<[ReactionInfo]?> = .useExisting ) -> MessageViewModel { return MessageViewModel( threadId: self.threadId, @@ -235,82 +254,22 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, rawBody: self.rawBody, expiresStartedAtMs: self.expiresStartedAtMs, expiresInSeconds: self.expiresInSeconds, - state: (state ?? self.state), + isProMessage: self.isProMessage, + state: state.or(self.state), hasBeenReadByRecipient: self.hasBeenReadByRecipient, - mostRecentFailureText: (mostRecentFailureText ?? self.mostRecentFailureText), + mostRecentFailureText: mostRecentFailureText.or(self.mostRecentFailureText), isSenderModeratorOrAdmin: self.isSenderModeratorOrAdmin, isTypingIndicator: self.isTypingIndicator, - profile: (profile ?? self.profile), - quote: (quote ?? self.quote), - quoteAttachment: (quoteAttachment ?? self.quoteAttachment.map { [$0] })?.first, // Only contains one + profile: profile.or(self.profile), + quotedInfo: quotedInfo.or(self.quotedInfo), linkPreview: self.linkPreview, linkPreviewAttachment: self.linkPreviewAttachment, currentUserSessionId: self.currentUserSessionId, - attachments: (attachments ?? self.attachments), - reactionInfo: (reactionInfo ?? self.reactionInfo), - cellType: self.cellType, - authorName: self.authorName, - senderName: self.senderName, - canHaveProfile: self.canHaveProfile, - shouldShowProfile: self.shouldShowProfile, - shouldShowDateHeader: self.shouldShowDateHeader, - containsOnlyEmoji: self.containsOnlyEmoji, - glyphCount: self.glyphCount, - previousVariant: self.previousVariant, - positionInCluster: self.positionInCluster, - isOnlyMessageInCluster: self.isOnlyMessageInCluster, - isLast: self.isLast, - isLastOutgoing: self.isLastOutgoing, - currentUserSessionIds: self.currentUserSessionIds, - optimisticMessageId: self.optimisticMessageId - ) - } - - public func removingQuoteAttachmentsIfNeeded( - validAttachments: [Attachment] - ) -> MessageViewModel { - guard - let quoteAttachment: Attachment = self.quoteAttachment, - !validAttachments.contains(quoteAttachment) - else { return self } - - return ViewModel( - threadId: self.threadId, - threadVariant: self.threadVariant, - threadIsTrusted: self.threadIsTrusted, - threadExpirationType: self.threadExpirationType, - threadExpirationTimer: self.threadExpirationTimer, - threadOpenGroupServer: self.threadOpenGroupServer, - threadOpenGroupPublicKey: self.threadOpenGroupPublicKey, - threadContactNameInternal: self.threadContactNameInternal, - rowId: self.rowId, - id: self.id, - serverHash: self.serverHash, - openGroupServerMessageId: self.openGroupServerMessageId, - variant: self.variant, - timestampMs: self.timestampMs, - receivedAtTimestampMs: self.receivedAtTimestampMs, - authorId: self.authorId, - authorNameInternal: self.authorNameInternal, - body: self.body, - rawBody: self.body, - expiresStartedAtMs: self.expiresStartedAtMs, - expiresInSeconds: self.expiresInSeconds, - state: self.state, - hasBeenReadByRecipient: self.hasBeenReadByRecipient, - mostRecentFailureText: self.mostRecentFailureText, - isSenderModeratorOrAdmin: self.isSenderModeratorOrAdmin, - isTypingIndicator: self.isTypingIndicator, - profile: self.profile, - quote: self.quote, - quoteAttachment: nil, - linkPreview: self.linkPreview, - linkPreviewAttachment: self.linkPreviewAttachment, - currentUserSessionId: self.currentUserSessionId, - attachments: self.attachments, - reactionInfo: self.reactionInfo, + attachments: attachments.or(self.attachments), + reactionInfo: reactionInfo.or(self.reactionInfo), cellType: self.cellType, authorName: self.authorName, + authorNameSuppressedId: self.authorNameSuppressedId, senderName: self.senderName, canHaveProfile: self.canHaveProfile, shouldShowProfile: self.shouldShowProfile, @@ -333,6 +292,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, isLast: Bool, isLastOutgoing: Bool, currentUserSessionIds: Set, + currentUserProfile: Profile, threadIsTrusted: Bool, using dependencies: Dependencies ) -> MessageViewModel { @@ -364,13 +324,41 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, return .genericAttachment }() - let authorDisplayName: String = Profile.displayName( - for: self.threadVariant, - id: self.authorId, - name: self.authorNameInternal, - nickname: nil, // Folded into 'authorName' within the Query - suppressId: false // Show the id next to the author name if desired - ) + // TODO: [Database Relocation] Clean up `currentUserProfile` logic (profile data should be sourced from a separate query for efficiency) + let authorDisplayName: String = { + guard authorId != currentUserProfile.id else { + return currentUserProfile.displayName( + for: self.threadVariant, + ignoringNickname: true, // Current user has no nickname + suppressId: false // Show the id next to the author name if desired + ) + } + + return Profile.displayName( + for: self.threadVariant, + id: self.authorId, + name: self.authorNameInternal, + nickname: nil, // Folded into 'authorName' within the Query + suppressId: false // Show the id next to the author name if desired + ) + }() + let authorDisplayNameSuppressedId: String = { + guard authorId != currentUserProfile.id else { + return currentUserProfile.displayName( + for: self.threadVariant, + ignoringNickname: true, // Current user has no nickname + suppressId: true // Exclude the id next to the author name + ) + } + + return Profile.displayName( + for: self.threadVariant, + id: self.authorId, + name: self.authorNameInternal, + nickname: nil, // Folded into 'authorName' within the Query + suppressId: true // Exclude the id next to the author name + ) + }() let shouldShowDateBeforeThisModel: Bool = { guard self.isTypingIndicator != true else { return false } guard self.variant != .infoCall else { return true } // Always show on calls @@ -449,7 +437,10 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, timestampMs: self.timestampMs, receivedAtTimestampMs: self.receivedAtTimestampMs, authorId: self.authorId, - authorNameInternal: self.authorNameInternal, + authorNameInternal: (self.threadId == currentUserProfile.id ? + "you".localized() : + self.authorNameInternal + ), body: (!self.variant.isInfoMessage ? self.body : // Info messages might not have a body so we should use the 'previewText' value instead @@ -480,14 +471,14 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, rawBody: self.body, expiresStartedAtMs: self.expiresStartedAtMs, expiresInSeconds: self.expiresInSeconds, + isProMessage: self.isProMessage, state: self.state, hasBeenReadByRecipient: self.hasBeenReadByRecipient, mostRecentFailureText: self.mostRecentFailureText, isSenderModeratorOrAdmin: self.isSenderModeratorOrAdmin, isTypingIndicator: self.isTypingIndicator, - profile: self.profile, - quote: self.quote, - quoteAttachment: self.quoteAttachment, + profile: (self.profile?.id == currentUserProfile.id ? currentUserProfile : self.profile), + quotedInfo: self.quotedInfo, linkPreview: self.linkPreview, linkPreviewAttachment: self.linkPreviewAttachment, currentUserSessionId: self.currentUserSessionId, @@ -495,6 +486,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, reactionInfo: self.reactionInfo, cellType: cellType, authorName: authorDisplayName, + authorNameSuppressedId: authorDisplayNameSuppressedId, senderName: { // Only show for group threads guard isGroupThread else { return nil } @@ -660,25 +652,61 @@ public extension MessageViewModel { // MARK: - QuotedInfo public extension MessageViewModel { - struct QuotedInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, ColumnExpressible { + struct QuotedInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Hashable, ColumnExpressible { public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { case rowId - case quote + case interactionId + case authorId + case timestampMs + case body case attachment case quotedInteractionId case quotedInteractionVariant } public let rowId: Int64 - public let quote: Quote + public let interactionId: Int64 + public let authorId: String + public let timestampMs: Int64 + public let body: String? public let attachment: Attachment? public let quotedInteractionId: Int64 public let quotedInteractionVariant: Interaction.Variant // MARK: - Identifiable - public var id: String { "quote-\(quote.interactionId)-attachment_\(attachment?.id ?? "None")" } + public var id: String { "quote-\(interactionId)-attachment_\(attachment?.id ?? "None")" } + + // MARK: - Initialization + + public init(previewBody: String) { + self.body = previewBody + + /// This is an preview version so none of these values matter + self.rowId = -1 + self.interactionId = -1 + self.authorId = "" + self.timestampMs = 0 + self.attachment = nil + self.quotedInteractionId = -1 + self.quotedInteractionVariant = .standardOutgoing + } + + public init?(replyModel: QuotedReplyModel?) { + guard let model: QuotedReplyModel = replyModel else { return nil } + + self.authorId = model.authorId + self.timestampMs = model.timestampMs + self.body = model.body + self.attachment = model.attachment + + /// This is an optimistic version so none of these values exist yet + self.rowId = -1 + self.interactionId = -1 + self.quotedInteractionId = -1 + self.quotedInteractionVariant = .standardOutgoing + } } } @@ -695,7 +723,7 @@ public extension MessageViewModel { timestampMs: Int64 = Int64.max, receivedAtTimestampMs: Int64 = Int64.max, body: String? = nil, - quote: Quote? = nil, + quotedInfo: QuotedInfo? = nil, cellType: CellType = .typingIndicator, isTypingIndicator: Bool? = nil, isLast: Bool = true, @@ -731,6 +759,7 @@ public extension MessageViewModel { self.rawBody = nil self.expiresStartedAtMs = nil self.expiresInSeconds = nil + self.isProMessage = false self.state = .sent self.hasBeenReadByRecipient = false @@ -738,8 +767,7 @@ public extension MessageViewModel { self.isSenderModeratorOrAdmin = false self.isTypingIndicator = isTypingIndicator self.profile = nil - self.quote = quote - self.quoteAttachment = nil + self.quotedInfo = quotedInfo self.linkPreview = nil self.linkPreviewAttachment = nil self.currentUserSessionId = "" @@ -750,6 +778,7 @@ public extension MessageViewModel { self.cellType = cellType self.authorName = "" + self.authorNameSuppressedId = "" self.senderName = nil self.canHaveProfile = false self.shouldShowProfile = false @@ -782,11 +811,11 @@ public extension MessageViewModel { body: String?, expiresStartedAtMs: Double?, expiresInSeconds: TimeInterval?, + isProMessage: Bool, state: Interaction.State = .sending, isSenderModeratorOrAdmin: Bool, currentUserProfile: Profile, - quote: Quote?, - quoteAttachment: Attachment?, + quotedInfo: QuotedInfo?, linkPreview: LinkPreview?, linkPreviewAttachment: Attachment?, attachments: [Attachment]? @@ -815,6 +844,7 @@ public extension MessageViewModel { self.rawBody = body self.expiresStartedAtMs = expiresStartedAtMs self.expiresInSeconds = expiresInSeconds + self.isProMessage = isProMessage self.state = state self.hasBeenReadByRecipient = false @@ -822,8 +852,7 @@ public extension MessageViewModel { self.isSenderModeratorOrAdmin = isSenderModeratorOrAdmin self.isTypingIndicator = false self.profile = currentUserProfile - self.quote = quote - self.quoteAttachment = quoteAttachment + self.quotedInfo = quotedInfo self.linkPreview = linkPreview self.linkPreviewAttachment = linkPreviewAttachment self.currentUserSessionId = currentUserProfile.id @@ -834,6 +863,7 @@ public extension MessageViewModel { self.cellType = .textOnlyMessage self.authorName = "" + self.authorNameSuppressedId = "" self.senderName = nil self.canHaveProfile = false self.shouldShowProfile = false @@ -926,17 +956,10 @@ public extension MessageViewModel { let disappearingMessagesConfig: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() let threadProfile: TypedTableAlias = TypedTableAlias(name: "threadProfile") - let quote: TypedTableAlias = TypedTableAlias() - let quoteInteraction: TypedTableAlias = TypedTableAlias(name: "quoteInteraction") - let quoteInteractionAttachment: TypedTableAlias = TypedTableAlias( - name: "quoteInteractionAttachment" - ) - let quoteLinkPreview: TypedTableAlias = TypedTableAlias(name: "quoteLinkPreview") - let quoteAttachment: TypedTableAlias = TypedTableAlias(name: ViewModel.CodingKeys.quoteAttachment.stringValue) let linkPreview: TypedTableAlias = TypedTableAlias() let linkPreviewAttachment: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .linkPreviewAttachment) - let numColumnsBeforeLinkedRecords: Int = 24 + let numColumnsBeforeLinkedRecords: Int = 25 let finalGroupSQL: SQL = (groupSQL ?? "") let request: SQLRequest = """ SELECT @@ -962,6 +985,7 @@ public extension MessageViewModel { \(interaction[.body]), \(interaction[.expiresStartedAtMs]), \(interaction[.expiresInSeconds]), + \(interaction[.isProMessage]), \(interaction[.state]), (\(interaction[.recipientReadTimestampMs]) IS NOT NULL) AS \(ViewModel.Columns.hasBeenReadByRecipient), \(interaction[.mostRecentFailureText]), @@ -977,12 +1001,6 @@ public extension MessageViewModel { ) AS \(ViewModel.Columns.isSenderModeratorOrAdmin), \(profile.allColumns), - \(quote[.interactionId]), - \(quote[.authorId]), - \(quote[.timestampMs]), - \(quoteInteraction[.body]), - \(quoteInteractionAttachment[.attachmentId]), - \(quoteAttachment.allColumns), \(linkPreview.allColumns), \(linkPreviewAttachment.allColumns), @@ -992,6 +1010,7 @@ public extension MessageViewModel { -- query from crashing when decoding we need to provide default values \(CellType.textOnlyMessage) AS \(ViewModel.Columns.cellType), '' AS \(ViewModel.Columns.authorName), + '' AS \(ViewModel.Columns.authorNameSuppressedId), false AS \(ViewModel.Columns.canHaveProfile), false AS \(ViewModel.Columns.shouldShowProfile), false AS \(ViewModel.Columns.shouldShowDateHeader), @@ -1007,32 +1026,6 @@ public extension MessageViewModel { LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfig[.threadId]) = \(interaction[.threadId]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) - LEFT JOIN \(Quote.self) ON \(quote[.interactionId]) = \(interaction[.id]) - LEFT JOIN \(quoteInteraction) ON ( - \(quoteInteraction[.timestampMs]) = \(quote[.timestampMs]) AND ( - \(quoteInteraction[.authorId]) = \(quote[.authorId]) OR ( - -- A users outgoing message is stored in some cases using their standard id - -- but the quote will use their blinded id so handle that case - \(quoteInteraction[.authorId]) = \(userSessionId.hexString) AND - \(quote[.authorId]) IN \(currentUserSessionIds) - ) - ) - ) - LEFT JOIN \(quoteInteractionAttachment) ON ( - \(quoteInteractionAttachment[.interactionId]) = \(quoteInteraction[.id]) AND - \(quoteInteractionAttachment[.albumIndex]) = 0 - ) - LEFT JOIN \(quoteLinkPreview) ON ( - \(quoteLinkPreview[.url]) = \(quoteInteraction[.linkPreviewUrl]) AND - \(Interaction.linkPreviewFilterLiteral( - interaction: quoteInteraction, - linkPreview: quoteLinkPreview - )) - ) - LEFT JOIN \(quoteAttachment) ON ( - \(quoteAttachment[.id]) = \(quoteInteractionAttachment[.attachmentId]) OR - \(quoteAttachment[.id]) = \(quoteLinkPreview[.attachmentId]) - ) LEFT JOIN \(LinkPreview.self) ON ( \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND @@ -1049,18 +1042,14 @@ public extension MessageViewModel { let adapters = try splittingRowAdapters(columnCounts: [ numColumnsBeforeLinkedRecords, Profile.numberOfSelectedColumns(db), - Quote.numberOfSelectedColumns(db), - Attachment.numberOfSelectedColumns(db), LinkPreview.numberOfSelectedColumns(db), Attachment.numberOfSelectedColumns(db) ]) return ScopeAdapter.with(ViewModel.self, [ .profile: adapters[1], - .quote: adapters[2], - .quoteAttachment: adapters[3], - .linkPreview: adapters[4], - .linkPreviewAttachment: adapters[5] + .linkPreview: adapters[2], + .linkPreviewAttachment: adapters[3] ]) } } @@ -1136,9 +1125,9 @@ public extension MessageViewModel.AttachmentInteractionInfo { updatedPagedDataCache = updatedPagedDataCache.upserting( dataToUpdate.with( - attachments: attachments + attachments: .set(to: attachments .sorted() - .map { $0.attachment } + .map { $0.attachment }) ) ) } @@ -1216,7 +1205,7 @@ public extension MessageViewModel.ReactionInfo { else { return } updatedPagedDataCache = updatedPagedDataCache.upserting( - dataToUpdate.with(reactionInfo: reactionInfo.sorted()) + dataToUpdate.with(reactionInfo: .set(to: reactionInfo.sorted())) ) pagedRowIdsWithNoReactions.remove(interactionRowId) } @@ -1226,7 +1215,7 @@ public extension MessageViewModel.ReactionInfo { items: pagedRowIdsWithNoReactions .compactMap { rowId -> ViewModel? in updatedPagedDataCache.data[rowId] } .filter { viewModel -> Bool in (viewModel.reactionInfo?.isEmpty == false) } - .map { viewModel -> ViewModel in viewModel.with(reactionInfo: []) } + .map { viewModel -> ViewModel in viewModel.with(reactionInfo: .set(to: nil)) } ) return updatedPagedDataCache @@ -1295,6 +1284,7 @@ public extension MessageViewModel.QuotedInfo { let quoteInteractionAttachment: TypedTableAlias = TypedTableAlias( name: "quoteInteractionAttachment" ) + let quoteLinkPreview: TypedTableAlias = TypedTableAlias(name: "quoteLinkPreview") let attachment: TypedTableAlias = TypedTableAlias() let finalFilterSQL: SQL = { @@ -1307,11 +1297,14 @@ public extension MessageViewModel.QuotedInfo { """ }() - let numColumnsBeforeLinkedRecords: Int = 1 + let numColumnsBeforeLinkedRecords: Int = 5 let request: SQLRequest = """ SELECT \(quote[.rowId]) AS \(QuotedInfo.Columns.rowId), - \(quote.allColumns), + \(quote[.interactionId]) AS \(QuotedInfo.Columns.interactionId), + \(quote[.authorId]) AS \(QuotedInfo.Columns.authorId), + \(quote[.timestampMs]) AS \(QuotedInfo.Columns.timestampMs), + \(quoteInteraction[.body]) AS \(QuotedInfo.Columns.body), \(attachment.allColumns), \(quoteInteraction[.id]) AS \(QuotedInfo.Columns.quotedInteractionId), \(quoteInteraction[.variant]) AS \(QuotedInfo.Columns.quotedInteractionVariant) @@ -1330,20 +1323,28 @@ public extension MessageViewModel.QuotedInfo { \(quoteInteractionAttachment[.interactionId]) = \(quoteInteraction[.id]) AND \(quoteInteractionAttachment[.albumIndex]) = 0 ) - LEFT JOIN \(Attachment.self) ON \(attachment[.id]) = \(quoteInteractionAttachment[.attachmentId]) + LEFT JOIN \(quoteLinkPreview) ON ( + \(quoteLinkPreview[.url]) = \(quoteInteraction[.linkPreviewUrl]) AND + \(Interaction.linkPreviewFilterLiteral( + interaction: quoteInteraction, + linkPreview: quoteLinkPreview + )) + ) + LEFT JOIN \(Attachment.self) ON ( + \(attachment[.id]) = \(quoteInteractionAttachment[.attachmentId]) OR + \(attachment[.id]) = \(quoteLinkPreview[.attachmentId]) + ) \(finalFilterSQL) """ return request.adapted { db in let adapters = try splittingRowAdapters(columnCounts: [ numColumnsBeforeLinkedRecords, - Quote.numberOfSelectedColumns(db), Attachment.numberOfSelectedColumns(db) ]) return ScopeAdapter.with(QuotedInfo.self, [ - .quote: adapters[1], - .attachment: adapters[2] + .attachment: adapters[1] ]) } } @@ -1363,7 +1364,7 @@ public extension MessageViewModel.QuotedInfo { dataCache.values.compactMap { quotedInfo in guard pagedRowIds.contains(quotedInfo.quotedInteractionId) || - pagedRowIds.contains(quotedInfo.quote.interactionId) + pagedRowIds.contains(quotedInfo.interactionId) else { return nil } return quotedInfo.rowId @@ -1378,7 +1379,7 @@ public extension MessageViewModel.QuotedInfo { // Update changed records dataCache.values.forEach { quoteInfo in guard - let interactionRowId: Int64 = updatedPagedDataCache.lookup[quoteInfo.quote.interactionId], + let interactionRowId: Int64 = updatedPagedDataCache.lookup[quoteInfo.interactionId], let dataToUpdate: ViewModel = updatedPagedDataCache.data[interactionRowId] else { return } @@ -1387,26 +1388,16 @@ public extension MessageViewModel.QuotedInfo { // then remove that content from the quote case false: updatedPagedDataCache = updatedPagedDataCache.upserting( - dataToUpdate.with( - quoteAttachment: quoteInfo.attachment.map { [$0] } - ) + dataToUpdate.with(quotedInfo: .set(to: quoteInfo)) ) // If the original message was deleted and the quote contains some of it's content // then remove that content from the quote case true: - guard - ( - dataToUpdate.quote?.body != nil || - dataToUpdate.quoteAttachment != nil - ) - else { return } + guard dataToUpdate.quotedInfo != nil else { return } updatedPagedDataCache = updatedPagedDataCache.upserting( - dataToUpdate.with( - quote: quoteInfo.quote.withOriginalMessageDeleted(), - quoteAttachment: [] - ) + dataToUpdate.with(quotedInfo: .set(to: nil)) ) } } diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index a85fc4b1d2..a1ed006575 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -93,6 +93,7 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D case recentReactionEmoji case wasKickedFromGroup case groupIsDestroyed + case isContactApproved } public struct MessageInputState: Equatable { @@ -157,7 +158,7 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D public let contactLastKnownClientVersion: FeatureVersion? public let threadDisplayPictureUrl: String? - internal let contactProfile: Profile? + public let contactProfile: Profile? internal let closedGroupProfileFront: Profile? internal let closedGroupProfileBack: Profile? internal let closedGroupProfileBackFallback: Profile? @@ -198,6 +199,9 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D public let wasKickedFromGroup: Bool? public let groupIsDestroyed: Bool? + /// Flag indicates that the contact's message request has been approved + public let isContactApproved: Bool? + // UI specific logic public var displayName: String { @@ -275,6 +279,13 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D ) } + if threadVariant == .community && threadCanWrite == false { + return MessageInputState( + allowedInputTypes: .none, + message: "permissionsWriteCommunity".localized() + ) + } + return MessageInputState( allowedInputTypes: (threadRequiresApproval == false && threadIsMessageRequest == false ? .all : @@ -334,6 +345,31 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D ) } + public func isSessionPro(using dependencies: Dependencies) -> Bool { + guard threadIsNoteToSelf == false && threadVariant != .community else { + return false + } + return dependencies.mutate(cache: .libSession) { [threadId] in $0.validateSessionProState(for: threadId)} + } + + public func getQRCodeString() -> String { + switch self.threadVariant { + case .contact, .legacyGroup, .group: + return self.threadId + + case .community: + guard + let urlString: String = LibSession.communityUrlFor( + server: self.openGroupServer, + roomToken: self.openGroupRoomToken, + publicKey: self.openGroupPublicKey + ) + else { return "" } + + return urlString + } + } + // MARK: - Marking as Read public enum ReadTarget { @@ -595,6 +631,7 @@ public extension SessionThreadViewModel { self.recentReactionEmoji = nil self.wasKickedFromGroup = false self.groupIsDestroyed = false + self.isContactApproved = false } } @@ -672,7 +709,8 @@ public extension SessionThreadViewModel { currentUserSessionIds: currentUserSessionIds, recentReactionEmoji: recentReactionEmoji, wasKickedFromGroup: wasKickedFromGroup, - groupIsDestroyed: groupIsDestroyed + groupIsDestroyed: groupIsDestroyed, + isContactApproved: isContactApproved ) } } @@ -1726,7 +1764,6 @@ public extension SessionThreadViewModel { GROUP_CONCAT(IFNULL(\(profile[.nickname]), \(profile[.name])), ', ') AS \(GroupMemberInfo.Columns.threadMemberNames) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) GROUP BY \(groupMember[.groupId]) ) AS \(groupMemberInfo) ON \(groupMemberInfo[.groupId]) = \(closedGroup[.threadId]) LEFT JOIN \(closedGroupProfileFront) ON ( @@ -2060,13 +2097,14 @@ public extension SessionThreadViewModel { /// **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 - let numColumnsBeforeProfiles: Int = 8 + let numColumnsBeforeProfiles: Int = 9 let request: SQLRequest = """ SELECT 100 AS \(Column.rank), \(contact[.rowId]) AS \(ViewModel.Columns.rowId), \(contact[.id]) AS \(ViewModel.Columns.threadId), + \(contact[.isApproved]) AS \(ViewModel.Columns.isContactApproved), \(SessionThread.Variant.contact) AS \(ViewModel.Columns.threadVariant), IFNULL(\(thread[.creationDateTimestamp]), \(currentTimestamp)) AS \(ViewModel.Columns.threadCreationDateTimestamp), '' AS \(ViewModel.Columns.threadMemberNames), diff --git a/SessionMessagingKit/Utilities/AsyncAccessible.swift b/SessionMessagingKit/Utilities/AsyncAccessible.swift deleted file mode 100644 index 18370d0e3f..0000000000 --- a/SessionMessagingKit/Utilities/AsyncAccessible.swift +++ /dev/null @@ -1,28 +0,0 @@ -// 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 index 394cb1fef0..60c6bf85cc 100644 --- a/SessionMessagingKit/Utilities/AttachmentManager.swift +++ b/SessionMessagingKit/Utilities/AttachmentManager.swift @@ -6,6 +6,8 @@ import AVFoundation import Combine import UniformTypeIdentifiers import GRDB +import SDWebImageWebPCoder +import SessionUtil import SessionUIKit import SessionNetworkingKit import SessionUtilitiesKit @@ -28,7 +30,12 @@ public extension Log.Category { // MARK: - AttachmentManager public final class AttachmentManager: Sendable, ThumbnailManager { + public static let maxAttachmentsAllowed: Int = 32 + private let dependencies: Dependencies + private let cache: StringCache = StringCache( + totalCostLimit: 5 * 1024 * 1024 /// Max 5MB of url to hash data (approx. 20,000 records) + ) // MARK: - Initalization @@ -36,7 +43,7 @@ public final class AttachmentManager: Sendable, ThumbnailManager { self.dependencies = dependencies } - // MARK: - General + // MARK: - File Paths public func sharedDataAttachmentsDirPath() -> String { let path: String = URL(fileURLWithPath: SessionFileManager.nonInjectedAppSharedDataDirectoryPath) @@ -47,7 +54,14 @@ public final class AttachmentManager: Sendable, ThumbnailManager { return path } - // MARK: - File Paths + private func placeholderUrlPath() -> String { + let path: String = URL(fileURLWithPath: sharedDataAttachmentsDirPath()) + .appendingPathComponent("uploadPlaceholderUrl") // stringlint:ignore + .path + try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: path) + + return path + } /// **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 @@ -55,81 +69,119 @@ public final class AttachmentManager: Sendable, ThumbnailManager { guard let urlString: String = urlString, !urlString.isEmpty - else { throw DisplayPictureError.invalidCall } + else { throw AttachmentError.invalidPath } - let urlHash = try dependencies[singleton: .crypto] - .tryGenerate(.hash(message: Array(urlString.utf8))) - .toHexString() + /// If the provided url is a placeholder url or located in the temporary directory then it _is_ a valid path, so we should just return + /// it directly instead of generating a hash + guard + !isPlaceholderUploadUrl(urlString) && + !dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(urlString) + else { return urlString } + + /// Otherwise we need to generate the deterministic file path based on the url provided + /// + /// **Note:** Now that download urls could contain fragments (or query params I guess) that could result in inconsistent paths + /// with old attachments so just to be safe we should strip them before generating the `urlHash` + let urlNoQueryOrFragment: String = urlString + .components(separatedBy: "?")[0] // stringlint:disable + .components(separatedBy: "#")[0] // stringlint:disable + let urlHash = try { + guard let cachedHash: String = cache.object(forKey: urlNoQueryOrFragment) else { + return try dependencies[singleton: .crypto] + .tryGenerate(.hash(message: Array(urlNoQueryOrFragment.utf8))) + .toHexString() + } + + return cachedHash + }() return URL(fileURLWithPath: sharedDataAttachmentsDirPath()) .appendingPathComponent(urlHash) .path } - private func placeholderUrlPath() -> String { - return URL(fileURLWithPath: sharedDataAttachmentsDirPath()) - .appendingPathComponent("uploadPlaceholderUrl") // stringlint:ignore + public func pendingUploadPath(for id: String) -> String { + return URL(fileURLWithPath: placeholderUrlPath()) + .appendingPathComponent(id) .path } - public func uploadPathAndUrl(for id: String) throws -> (url: String, path: String) { - let fakeLocalUrlPath: String = URL(fileURLWithPath: placeholderUrlPath()) - .appendingPathComponent(URL(fileURLWithPath: id).path) - .path + public func isPlaceholderUploadUrl(_ urlString: String?) -> Bool { + guard + let urlString: String = urlString, + let url: URL = URL(string: urlString) + else { return false } - return (fakeLocalUrlPath, try path(for: fakeLocalUrlPath)) - } - - public func isPlaceholderUploadUrl(_ url: String?) -> Bool { - return (url?.hasPrefix(placeholderUrlPath()) == true) + return url.path.hasPrefix(placeholderUrlPath()) } - public func temporaryPathForOpening(downloadUrl: String?, mimeType: String?, sourceFilename: String?) throws -> String { - guard let downloadUrl: String = downloadUrl else { throw AttachmentError.invalidData } - + public func temporaryPathForOpening( + originalPath: String, + mimeType: String?, + sourceFilename: String?, + allowInvalidType: Bool + ) throws -> String { /// 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 - } + do { + 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 } - return (sourceFilename.hasSuffix(".\(fileExtension)") ? // stringlint:ignore - String(sourceFilename.prefix(sourceFilename.count - (1 + fileExtension.count))) : - sourceFilename - ) - }() + 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 = { + guard let sourceFilename: String = sourceFilename else { + return URL(fileURLWithPath: originalPath).lastPathComponent + } + + return (sourceFilename.hasSuffix(".\(fileExtension)") ? // stringlint:ignore + String(sourceFilename.prefix(sourceFilename.count - (1 + fileExtension.count))) : + sourceFilename + ) + }() + } + } catch { + /// If an error was thrown it was because we couldn't get a valid file extension, in which case only continue if we want to + /// allow invalid types + guard allowInvalidType else { throw error } + + return URL(fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectory) + .appendingPathComponent( + URL(fileURLWithPath: originalPath) + .lastPathComponent + .replacingWhitespacesWithUnderscores + ) + .path } - + return URL(fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectory) - .appendingPathComponent(targetFilenameNoExtension) + .appendingPathComponent(targetFilenameNoExtension.replacingWhitespacesWithUnderscores) .appendingPathExtension(finalExtension) .path } - public func createTemporaryFileForOpening(downloadUrl: String?, mimeType: String?, sourceFilename: String?) throws -> String { + public func createTemporaryFileForOpening( + downloadUrl: String?, + mimeType: String?, + sourceFilename: String? + ) throws -> String { let path: String = try path(for: downloadUrl) /// Ensure the original file exists before generating a path for opening or trying to copy it @@ -138,9 +190,10 @@ public final class AttachmentManager: Sendable, ThumbnailManager { } let tmpPath: String = try temporaryPathForOpening( - downloadUrl: downloadUrl, + originalPath: path, mimeType: mimeType, - sourceFilename: sourceFilename + sourceFilename: sourceFilename, + allowInvalidType: false ) /// If the file already exists (since it's deterministically generated) then no need to copy it again @@ -151,6 +204,35 @@ public final class AttachmentManager: Sendable, ThumbnailManager { return tmpPath } + public func createTemporaryFileForOpening(filePath: String) throws -> String { + /// Ensure the original file exists before generating a path for opening or trying to copy it + guard dependencies[singleton: .fileManager].fileExists(atPath: filePath) else { + throw AttachmentError.invalidData + } + + let originalUrl: URL = URL(fileURLWithPath: filePath) + let fileName: String = originalUrl.deletingPathExtension().lastPathComponent + let fileExtension: String = originalUrl.pathExtension + + /// Removes white spaces on the filename and replaces it with _ + let filenameNoExtension = fileName + .replacingWhitespacesWithUnderscores + + let tmpPath: String = URL(fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectory) + .appendingPathComponent(filenameNoExtension) + .appendingPathExtension(fileExtension) + .path + + /// If the file already exists then we should remove it as it may not be the same file + if dependencies[singleton: .fileManager].fileExists(atPath: tmpPath) { + try dependencies[singleton: .fileManager].removeItem(atPath: tmpPath) + } + + try dependencies[singleton: .fileManager].copyItem(atPath: filePath, toPath: tmpPath) + + return tmpPath + } + public func resetStorage() { try? dependencies[singleton: .fileManager].removeItem( atPath: sharedDataAttachmentsDirPath() @@ -159,25 +241,77 @@ public final class AttachmentManager: Sendable, ThumbnailManager { // MARK: - ThumbnailManager - private func thumbnailUrl(for url: URL, size: ImageDataManager.ThumbnailSize) throws -> URL { - guard !url.lastPathComponent.isEmpty else { throw DisplayPictureError.invalidCall } + private func thumbnailPath(for name: String, size: ImageDataManager.ThumbnailSize) throws -> String { + guard !name.isEmpty else { throw AttachmentError.invalidPath } /// 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 + let thumbnailsUrl: URL = URL(fileURLWithPath: SessionFileManager.cachesDirectoryPath) + .appendingPathComponent(name) + try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: thumbnailsUrl.path) + + return thumbnailsUrl + .appendingPathComponent("thumbnail-\(size)") // stringlint:ignore + .path } - public func existingThumbnailImage(url: URL, size: ImageDataManager.ThumbnailSize) -> UIImage? { - guard let thumbnailUrl: URL = try? thumbnailUrl(for: url, size: size) else { return nil } + public func existingThumbnail(name: String, size: ImageDataManager.ThumbnailSize) -> ImageDataManager.DataSource? { + guard + let thumbnailPath: String = try? thumbnailPath(for: name, size: size), + dependencies[singleton: .fileManager].fileExists(atPath: thumbnailPath) + else { return nil } - return UIImage(contentsOfFile: thumbnailUrl.path) + return .url(URL(fileURLWithPath: thumbnailPath)) } - public func saveThumbnail(data: Data, size: ImageDataManager.ThumbnailSize, url: URL) { - guard let thumbnailUrl: URL = try? thumbnailUrl(for: url, size: size) else { return } + public func saveThumbnail( + name: String, + frames: [UIImage], + durations: [TimeInterval], + hasAlpha: Bool?, + size: ImageDataManager.ThumbnailSize + ) { + guard + let thumbnailPath: String = try? thumbnailPath(for: name, size: size), ( + frames.count == durations.count || + frames.count == 1 + ) + else { return } - try? data.write(to: thumbnailUrl) + let finalFrames: [CGImage] = frames.compactMap { $0.cgImage } + + /// Writing a `WebP` is much slower than writing a `GIF` (up to 3-4 times slower) but in many cases the resulting `WebP` + /// file would end up smaller (about 3 times smaller) - since we are generating a thumbnail the output _generally_ shouldn't be + /// that large (and the OS can purge files these thumbnails when it wants) so we default to `GIF` thumbnails here due to encoding + /// speed unless the source has alpha (in which case we need to use `WebP` as `GIF` doesn't have proper alpha support). By + /// spending less time encoding `GIF` would result in less battery drain that encoding to `WebP` would + /// + /// **Note:** The `WebP` encoding runs much slower on debug builds compared to release builds (can be 10 times slower) + if hasAlpha == true { + try? PendingAttachment.writeFramesAsWebPToFile( + frames: finalFrames, + metadata: MediaUtils.MediaMetadata( + pixelSize: (frames.first?.size ?? .zero), + frameDurations: (frames.count == 1 ? [0] : durations), + hasUnsafeMetadata: false + ), + encodeWebPLossless: false, + encodeCompressionQuality: PendingAttachment.ConversionFormat.defaultWebPCompressionQuality, + filePath: thumbnailPath, + using: dependencies + ) + } + else { + try? PendingAttachment.writeFramesAsGifToFile( + frames: finalFrames, + metadata: MediaUtils.MediaMetadata( + pixelSize: (frames.first?.size ?? .zero), + frameDurations: (frames.count == 1 ? [0] : durations), + hasUnsafeMetadata: false + ), + compressionQuality: PendingAttachment.ConversionFormat.defaultGifCompressionQuality, + filePath: thumbnailPath + ) + } } // MARK: - Validity @@ -189,64 +323,1382 @@ public final class AttachmentManager: Sendable, ThumbnailManager { ) -> (isValid: Bool, duration: TimeInterval?) { guard let path: String = try? path(for: downloadUrl) else { return (false, nil) } + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .file(URL(fileURLWithPath: path)), + utType: UTType(sessionMimeType: contentType), + sourceFilename: sourceFilename, + using: dependencies + ) + // Process audio attachments - if UTType.isAudio(contentType) { - do { - let audioPlayer: AVAudioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path)) + if pendingAttachment.utType.isAudio { + return (pendingAttachment.duration > 0, pendingAttachment.duration) + } + + // Process image attachments + if pendingAttachment.utType.isImage || pendingAttachment.utType.isAnimated { + return (pendingAttachment.isValidVisualMedia, nil) + } + + // Process video attachments + if pendingAttachment.utType.isVideo { + return (pendingAttachment.isValidVisualMedia, pendingAttachment.duration) + } + + // Any other attachment types are valid and have no duration + return (true, nil) + } +} + +// MARK: - PendingAttachment + +public struct PendingAttachment: Sendable, Equatable, Hashable { + public let source: DataSource + public let sourceFilename: String? + public let metadata: Metadata? + private let existingAttachmentId: String? + + public var utType: UTType { metadata?.utType ?? .invalid } + public var fileSize: UInt64 { metadata?.fileSize ?? 0 } + public var duration: TimeInterval { + switch metadata { + case .media(let mediaMetadata): return mediaMetadata.duration + case .file, .none: return 0 + } + } + + // MARK: Initialization + + public init( + source: DataSource, + utType: UTType? = nil, + sourceFilename: String? = nil, + using dependencies: Dependencies + ) { + self.metadata = PendingAttachment.metadata( + for: source, + utType: utType, + sourceFilename: sourceFilename, + using: dependencies + ) + self.sourceFilename = sourceFilename + self.existingAttachmentId = nil + + /// To avoid confusion (and reduce bugs related to checking the `source` type) if we are given a `file` source that is + /// actually media, then convert it to a `media` source + switch (source, metadata) { + case (.file(let url), .media(let mediaMetadata)): + if let utType: UTType = mediaMetadata.utType, utType.isVideo { + self.source = .media(.videoUrl(url, utType, sourceFilename, dependencies[singleton: .attachmentManager])) + } + else { + self.source = .media(.url(url)) + } + + default: self.source = source + } + } + + public init( + attachment: Attachment, + using dependencies: Dependencies + ) throws { + let filePath: String = try dependencies[singleton: .attachmentManager] + .path(for: attachment.downloadUrl) + let source: DataSource + + switch attachment.variant { + case .standard: source = .file(URL(fileURLWithPath: filePath)) + case .voiceMessage: source = .voiceMessage(URL(fileURLWithPath: filePath)) + } + + self.source = source + self.sourceFilename = attachment.sourceFilename + self.metadata = PendingAttachment.metadata( + for: source, + utType: UTType(sessionMimeType: attachment.contentType), + sourceFilename: attachment.sourceFilename, + using: dependencies + ) + self.existingAttachmentId = attachment.id + } + + // MARK: - Internal Functions + + private static func metadata( + for dataSource: DataSource, + utType: UTType?, + sourceFilename: String?, + using dependencies: Dependencies + ) -> Metadata? { + let maybeFileSize: UInt64? = dataSource.fileSize(using: dependencies) + + switch dataSource { + case .file(let url), .voiceMessage(let url): + guard + let utType: UTType = utType, + let fileSize: UInt64 = maybeFileSize + else { return nil } + + /// If the url is actually media then try to load `MediaMetadata`, falling back to the `FileMetadata` + guard + let metadata: MediaUtils.MediaMetadata = MediaUtils.MediaMetadata( + from: url.path, + utType: utType, + sourceFilename: sourceFilename, + using: dependencies + ) + else { return .file(FileMetadata(utType: utType, fileSize: fileSize)) } - return ((audioPlayer.duration > 0), audioPlayer.duration) + return .media(metadata) + + case .media(.image(_, .some(let image))): + guard let metadata: MediaUtils.MediaMetadata = MediaUtils.MediaMetadata(image: image) else { + return nil + } + + return .media(metadata) + + case .media(.videoUrl(let url, _, _, _)): + guard + let metadata: MediaUtils.MediaMetadata = MediaUtils.MediaMetadata( + from: url.path, + utType: utType, + sourceFilename: sourceFilename, + using: dependencies + ) + else { return nil } + + return .media(metadata) + + case .media(let mediaSource): + guard + let fileSize: UInt64 = maybeFileSize, + let source: CGImageSource = mediaSource.createImageSource(), + let metadata: MediaUtils.MediaMetadata = MediaUtils.MediaMetadata( + source: source, + fileSize: fileSize + ) + else { return nil } + + return .media(metadata) + + case .text: + guard + let utType: UTType = utType, + let fileSize: UInt64 = maybeFileSize + else { return nil } + + return .file(FileMetadata(utType: utType, fileSize: fileSize)) + } + } +} + +// MARK: - PendingAttachment.DataSource + +public extension PendingAttachment { + enum DataSource: Sendable, Equatable, Hashable { + case media(ImageDataManager.DataSource) + case file(URL) + case voiceMessage(URL) + case text(String) + + // MARK: - Convenience + + public static func media(_ url: URL) -> DataSource { + return .media(.url(url)) + } + + fileprivate var visualMediaSource: ImageDataManager.DataSource? { + switch self { + case .media(let source): return source + case .file, .voiceMessage, .text: return nil } - catch { - switch (error as NSError).code { - case Int(kAudioFileInvalidFileError), Int(kAudioFileStreamError_InvalidFile): - // Ignore "invalid audio file" errors - return (false, nil) + } + + fileprivate var url: URL? { + switch (self, visualMediaSource) { + case (.file(let url), _), (.voiceMessage(let url), _), (_, .url(let url)), + (_, .videoUrl(let url, _, _, _)), (_, .urlThumbnail(let url, _, _)): + return url + + case (_, .none), (_, .data), (_, .image), (_, .placeholderIcon), (_, .asyncSource), (.media, _), (.text, _): + return nil + } + } + + fileprivate func fileSize(using dependencies: Dependencies) -> UInt64? { + switch (self, visualMediaSource) { + case (.file(let url), _), (.voiceMessage(let url), _), (_, .url(let url)), + (_, .videoUrl(let url, _, _, _)): + return dependencies[singleton: .fileManager].fileSize(of: url.path) + + case (_, .data(_, let data)): return UInt64(data.count) + case (.text(let content), _): + return (content.data(using: .ascii)?.count).map { UInt64($0) } + + default: return nil + } + } + } +} + +// MARK: - PendingAttachment.Metadata + +public extension PendingAttachment { + enum Metadata: Sendable, Equatable, Hashable { + case media(MediaUtils.MediaMetadata) + case file(FileMetadata) + + var utType: UTType { + switch self { + case .media(let metadata): return (metadata.utType ?? .invalid) + case .file(let metadata): return metadata.utType + } + } + + public var fileSize: UInt64 { + switch self { + case .media(let metadata): return metadata.fileSize + case .file(let metadata): return metadata.fileSize + } + } + + public var pixelSize: CGSize? { + switch self { + case .media(let metadata): return metadata.pixelSize + case .file: return nil + } + } + } + + struct FileMetadata: Sendable, Equatable, Hashable { + public let utType: UTType + public let fileSize: UInt64 + + init(utType: UTType, fileSize: UInt64) { + self.utType = utType + self.fileSize = fileSize + } + } +} + +// MARK: - PreparedAttachment + +public struct PreparedAttachment: Sendable, Equatable, Hashable { + public let attachment: Attachment + public let filePath: String + + public init( + attachment: Attachment, + filePath: String + ) { + self.attachment = attachment + self.filePath = filePath + } +} + +// MARK: - Operation + +public extension PendingAttachment { + enum Operation: Sendable, Equatable, Hashable { + case convert(to: ConversionFormat) + case stripImageMetadata + case encrypt(domain: Crypto.AttachmentDomain) + + fileprivate enum Erased: Equatable { + case convert + case stripImageMetadata + case encrypt + } + + fileprivate var erased: Erased { + switch self { + case .convert: return .convert + case .stripImageMetadata: return .stripImageMetadata + case .encrypt: return .encrypt + } + } + } + + enum ConversionFormat: Sendable, Equatable, Hashable { + fileprivate static let defaultWebPCompressionQuality: CGFloat = 0.8 + fileprivate static let defaultWebPCompressionEffort: CGFloat = 0.25 + fileprivate static let defaultGifCompressionQuality: CGFloat = 0.8 + fileprivate static let defaultResizeMode: UIImage.ResizeMode = .fit + + case current + case mp4 + case png(maxDimension: CGFloat?, cropRect: CGRect?, resizeMode: UIImage.ResizeMode) + + /// A `compressionQuality` value of `0` gives the smallest size and `1` the largest + case webPLossy(maxDimension: CGFloat?, cropRect: CGRect?, resizeMode: UIImage.ResizeMode, compressionQuality: CGFloat) + + /// A `compressionEffort` value of `0` is the fastest (but gives larger files) and a value of `1` is the slowest but compresses the most + case webPLossless(maxDimension: CGFloat?, cropRect: CGRect?, resizeMode: UIImage.ResizeMode, compressionEffort: CGFloat) + + case gif(maxDimension: CGFloat?, cropRect: CGRect?, resizeMode: UIImage.ResizeMode, compressionQuality: CGFloat) + + public static var png: ConversionFormat { + .png( + maxDimension: nil, + cropRect: nil, + resizeMode: defaultResizeMode + ) + } + public static func png( + maxDimension: CGFloat? = nil, + cropRect: CGRect? = nil, + resizeMode: UIImage.ResizeMode? = nil + ) -> ConversionFormat { + return .png( + maxDimension: maxDimension, + cropRect: cropRect, + resizeMode: (resizeMode ?? defaultResizeMode) + ) + } + + public static var webPLossy: ConversionFormat { + .webPLossy( + maxDimension: nil, + cropRect: nil, + resizeMode: defaultResizeMode, + compressionQuality: defaultWebPCompressionQuality + ) + } + public static func webPLossy( + maxDimension: CGFloat? = nil, + cropRect: CGRect? = nil, + resizeMode: UIImage.ResizeMode? = nil + ) -> ConversionFormat { + return .webPLossy( + maxDimension: maxDimension, + cropRect: cropRect, + resizeMode: (resizeMode ?? defaultResizeMode), + compressionQuality: defaultWebPCompressionQuality + ) + } + + public static var webPLossless: ConversionFormat { + .webPLossless( + maxDimension: nil, + cropRect: nil, + resizeMode: defaultResizeMode, + compressionEffort: defaultWebPCompressionEffort + ) + } + public static func webPLossless( + maxDimension: CGFloat? = nil, + cropRect: CGRect? = nil, + resizeMode: UIImage.ResizeMode? = nil + ) -> ConversionFormat { + return .webPLossless( + maxDimension: maxDimension, + cropRect: cropRect, + resizeMode: (resizeMode ?? defaultResizeMode), + compressionEffort: defaultWebPCompressionEffort + ) + } + + public static var gif: ConversionFormat { + .gif( + maxDimension: nil, + cropRect: nil, + resizeMode: defaultResizeMode, + compressionQuality: defaultGifCompressionQuality + ) + } + public static func gif( + maxDimension: CGFloat? = nil, + cropRect: CGRect? = nil, + resizeMode: UIImage.ResizeMode? = nil + ) -> ConversionFormat { + return .gif( + maxDimension: maxDimension, + cropRect: cropRect, + resizeMode: (resizeMode ?? defaultResizeMode), + compressionQuality: defaultGifCompressionQuality + ) + } + + var webPIsLossless: Bool { + switch self { + case .webPLossless: return true + default: return false + } + } + + func utType(metadata: MediaUtils.MediaMetadata) -> UTType { + switch self { + case .current: return (metadata.utType ?? .invalid) + case .mp4: return .mpeg4Movie + case .png: return .png + case .webPLossy, .webPLossless: return .webP + case .gif: return .gif + } + } + } + + // MARK: - Encryption and Preparation + + /// Checks whether the attachment would need preparation based on the provided `operations` + /// + /// **Note:** Any `convert` checks behave as an `OR` + func needsPreparation(operations: Set) -> Bool { + switch (source, metadata) { + case (_, .media(let mediaMetadata)): + return mediaNeedsPreparation(operations, metadata: mediaMetadata) + + case (.file, _): return fileNeedsPreparation(operations) + case (.text, _): return true /// Need to write to a file in order to upload as an attachment + + /// These cases are invalid so if they are called then just return `true` so the `prepare` function gets called (which + /// will then throw when going down an invalid path) + case (.voiceMessage, _), (.media, _): return true + } + } + + private func fileNeedsPreparation(_ operations: Set) -> Bool { + /// Check the type of `metadata` we have (as if the `file` was actually media then the `metadata` will be `media` + /// and as such we want to go down the `mediaNeedsPreparation` path) + switch self.metadata { + case .file, .none: break + case .media(let mediaMetadata): + return mediaNeedsPreparation(operations, metadata: mediaMetadata) + } + + for operation in operations { + switch operation { + case .encrypt: return true + case .convert, .stripImageMetadata: continue /// None of these are supported for general files + } + } + + /// None of the requested `operations` were needed so the file doesn't need preparation + return false + } + + private func mediaNeedsPreparation( + _ operations: Set, + metadata: MediaUtils.MediaMetadata + ) -> Bool { + /// If the media does not have a valid pixel size then just return `true`, this will result in one of the `prepare` functions being + /// called which will throw due to the invalid size + guard metadata.hasValidPixelSize else { return true } + + let erasedOperations: Set = Set(operations.map { $0.erased }) + + /// Encryption always needs to happen + guard !erasedOperations.contains(.encrypt) else { return true } + + /// Check if we have unsafe metadata to strip (we don't currently strip metadata from animated images) + if + erasedOperations.contains(.stripImageMetadata) && + metadata.frameCount == 1 && + metadata.hasUnsafeMetadata + { + return true + } + + /// Otherwise we need to check the `convert` operations provided (these should behave as an `OR` to allow us to support + /// multiple possible "allowed" formats + typealias FormatRequirements = (formats: Set, maxDimension: CGFloat?, cropRect: CGRect?) + let fullRect: CGRect = CGRect(x: 0, y: 0, width: 1, height: 1) + let formatRequirements: FormatRequirements = operations + .filter { $0.erased == .convert } + .reduce(FormatRequirements([], nil, nil)) { result, next in + guard case .convert(let format) = next else { return result } + + switch format { + case .current, .mp4: + return ( + result.formats.inserting(format.utType(metadata: metadata)), + result.maxDimension, + result.cropRect + ) + + case .png(let maxDimension, let cropRect, _), + .webPLossy(let maxDimension, let cropRect, _, _), + .webPLossless(let maxDimension, let cropRect, _, _), + .gif(let maxDimension, let cropRect, _, _): + let finalMax: CGFloat? + let finalCrop: CGRect? + let validCurrentCrop: CGRect? = (result.cropRect != nil && result.cropRect != fullRect ? + result.cropRect : + nil + ) + let validNextCrop: CGRect? = (cropRect != nil && cropRect != fullRect ? + cropRect : + nil + ) - default: return (false, nil) + switch (result.maxDimension, maxDimension) { + case (.some(let current), .some(let nextMax)): finalMax = min(current, nextMax) + case (.some(let current), .none): finalMax = current + case (.none, .some(let nextMax)): finalMax = nextMax + case (.none, .none): finalMax = nil + } + + switch (validCurrentCrop, validNextCrop) { + case (.some(let current), .some(let nextCrop)): + /// Smallest area wins + let currentArea: CGFloat = (current.width * current.height) + let nextArea: CGFloat = (nextCrop.width * nextCrop.height) + finalCrop = (currentArea < nextArea ? current : nextCrop) + + case (.some(let current), .none): finalCrop = current + case (.none, .some(let nextCrop)): finalCrop = nextCrop + case (.none, .none): finalCrop = nil + } + + return ( + result.formats.inserting(format.utType(metadata: metadata)), + finalMax, + finalCrop + ) } } + + /// If the format doesn't match one of the desired formats then convert + guard formatRequirements.formats.contains(metadata.utType ?? .invalid) else { return true } + + /// If the source is too large then we need to scale + let maxImageDimension: CGFloat = max( + metadata.pixelSize.width, + metadata.pixelSize.height + ) + + if let maxDimension: CGFloat = formatRequirements.maxDimension, maxImageDimension > maxDimension { + return true } - // Process image attachments - if UTType.isImage(contentType) || UTType.isAnimated(contentType) { - return ( - MediaUtils.isValidImage(at: path, type: UTType(sessionMimeType: contentType), using: dependencies), - nil + /// If we want to crop + if let cropRect: CGRect = formatRequirements.cropRect, cropRect != fullRect { + return true + } + + /// None of the requested `operations` were needed so the file doesn't need preparation + return false + } + + func ensureExpectedEncryptedSize( + domain: Crypto.AttachmentDomain, + maxFileSize: UInt, + using dependencies: Dependencies + ) throws { + let encryptedSize: Int + + if dependencies[feature: .deterministicAttachmentEncryption] { + encryptedSize = try dependencies[singleton: .crypto].tryGenerate( + .expectedEncryptedAttachmentSize(plaintextSize: Int(fileSize)) ) } + else { + switch domain { + case .attachment: + encryptedSize = try dependencies[singleton: .crypto].tryGenerate( + .legacyExpectedEncryptedAttachmentSize(plaintextSize: Int(fileSize)) + ) + + case .profilePicture: + encryptedSize = try dependencies[singleton: .crypto].tryGenerate( + .legacyEncryptedDisplayPictureSize(plaintextSize: Int(fileSize)) + ) + } + } - // Process video attachments - if UTType.isVideo(contentType) { - let assetInfo: (asset: AVURLAsset, cleanup: () -> Void)? = AVURLAsset.asset( - for: path, - mimeType: contentType, - sourceFilename: sourceFilename, + /// May as well throw here if we know the attachment is too large to send + guard UInt(encryptedSize) <= maxFileSize else { + throw AttachmentError.fileSizeTooLarge + } + } + + func prepare( + operations: Set, + storeAtPendingAttachmentUploadPath: Bool = false, + using dependencies: Dependencies + ) async throws -> PreparedAttachment { + /// Generate the temporary path to use for the attachment data + /// + /// **Note:** If `storeAtPendingAttachmentUploadPath` is `true` then the file is stored alongside other attachments + /// rather than in the temporary directory because the `AttachmentUploadJob` can exist between launches, but the temporary + /// directory gets cleared on every launch) + let attachmentId: String = (existingAttachmentId ?? UUID().uuidString) + let filePath: String = (storeAtPendingAttachmentUploadPath ? + dependencies[singleton: .attachmentManager].pendingUploadPath(for: attachmentId) : + dependencies[singleton: .fileManager].temporaryFilePath() + ) + + /// Perform any source-specific operations and load the attachment data into memory + switch source { + case .media where (utType.isImage || utType.isAnimated): + try await prepareImage(operations, filePath: filePath, using: dependencies) + + case .media where utType.isVideo: + try await prepareVideo(operations, filePath: filePath, using: dependencies) + + case .media where utType.isAudio: + try await prepareAudio(operations, filePath: filePath, using: dependencies) + + case .voiceMessage: + try await prepareAudio(operations, filePath: filePath, using: dependencies) + + case .text: + try await prepareText(operations, filePath: filePath, using: dependencies) + + case .file, .media: + try await prepareGeneral(operations, filePath: filePath, using: dependencies) + } + + /// Get the size of the prepared data + let preparedFileSize: UInt64? = dependencies[singleton: .fileManager].fileSize(of: filePath) + + /// If we don't have the `encrypt` transform then we can just return the `preparedData` (which is unencrypted but should + /// have all other `Operation` changes applied + // FIXME: We should store attachments encrypted and decrypt them when we want to render/open them + guard case .encrypt(let encryptionDomain) = operations.first(where: { $0.erased == .encrypt }) else { + return PreparedAttachment( + attachment: try prepareAttachment( + id: attachmentId, + downloadUrl: filePath, + byteCount: UInt(preparedFileSize ?? 0), + encryptionKey: nil, + digest: nil, + using: dependencies + ), + filePath: filePath + ) + } + + /// May as well throw here if we know the attachment is too large to send + try ensureExpectedEncryptedSize( + domain: encryptionDomain, + maxFileSize: Network.maxFileSize, + using: dependencies + ) + + /// Encrypt the data using either the legacy or updated encryption + typealias EncryptionData = (ciphertext: Data, encryptionKey: Data, digest: Data) + let (encryptedData, finalByteCount): (EncryptionData, UInt) = try autoreleasepool { + do { + let result: EncryptionData + let finalByteCount: UInt + let plaintext: Data = try dependencies[singleton: .fileManager] + .contents(atPath: filePath) ?? { throw AttachmentError.invalidData }() + + if dependencies[feature: .deterministicAttachmentEncryption] { + let encryptionResult = try dependencies[singleton: .crypto].tryGenerate( + .encryptAttachment(plaintext: plaintext, domain: encryptionDomain) + ) + + /// Ideally we would set this to the `ciphertext` size so that the "download file" UI is accurate but then we'd + /// need to update it after the download to be the `plaintext` so the "message info" UI was accurate - this + /// also (currently) causes issues on Desktop so for the time being just stick with the `plaintext` size + finalByteCount = UInt(preparedFileSize ?? 0) + result = (encryptionResult.ciphertext, encryptionResult.encryptionKey, Data()) + } + else { + switch encryptionDomain { + case .attachment: + result = try dependencies[singleton: .crypto].tryGenerate( + .legacyEncryptedAttachment(plaintext: plaintext) + ) + + /// For legacy attachments we need to set `byteCount` to the size of the data prior to encryption in + /// order to be able to strip the padding correctly + finalByteCount = UInt(preparedFileSize ?? 0) + + case .profilePicture: + let encryptionKey: Data = try dependencies[singleton: .crypto] + .tryGenerate(.randomBytes(DisplayPictureManager.encryptionKeySize)) + let ciphertext: Data = try dependencies[singleton: .crypto].tryGenerate( + .legacyEncryptedDisplayPicture(data: plaintext, key: encryptionKey) + ) + + /// Ideally we would set this to the `ciphertext` size so that the "download file" UI is accurate but then we'd + /// need to update it after the download to be the `plaintext` so the "message info" UI was accurate - this + /// also (currently) causes issues on Desktop so for the time being just stick with the `plaintext` size + finalByteCount = UInt(preparedFileSize ?? 0) + result = (ciphertext, encryptionKey, Data()) + } + + /// Since the legacy encryption is a little more questionable we should double check the ciphertext size + guard result.ciphertext.count <= Network.maxFileSize else { + throw AttachmentError.fileSizeTooLarge + } + } + + /// Since we successfully encrypted the data we can remove the file with the unencrypted content and replace it with + /// the encrypted content + try dependencies[singleton: .fileManager].removeItem(atPath: filePath) + try dependencies[singleton: .fileManager].write( + data: result.ciphertext, + toPath: filePath + ) + + return (result, finalByteCount) + } + catch { + /// If we failed to encrypt the data then we need to remove the temporary file that we created (as it won't be used) + try? dependencies[singleton: .fileManager].removeItem(atPath: filePath) + throw error + } + } + + return PreparedAttachment( + attachment: try prepareAttachment( + id: attachmentId, + downloadUrl: filePath, + byteCount: finalByteCount, + encryptionKey: encryptedData.encryptionKey, + digest: encryptedData.digest, + using: dependencies + ), + filePath: filePath + ) + } + + private func prepareImage( + _ operations: Set, + filePath: String, + using dependencies: Dependencies + ) async throws { + guard + let targetSource: ImageDataManager.DataSource = visualMediaSource, + case .media(let mediaMatadata) = self.metadata + else { throw AttachmentError.invalidMediaSource } + + guard mediaMatadata.hasValidPixelSize else { + Log.error(.attachmentManager, "Source has invalid image dimensions.") + throw AttachmentError.invalidDimensions + } + + /// If we want to convert to a certain format then that's all we need to do + if case .convert(let format) = operations.first(where: { $0.erased == .convert }) { + return try await createImage( + source: targetSource, + metadata: mediaMatadata, + format: format, + filePath: filePath, using: dependencies ) + } + + /// Otherwise if all we want to do is strip the metadata then we should do that + /// + /// **Note:** We don't currently support stripping metadata from animated images without conversion (as we need to do + /// every frame which would have a negative impact on sending things like GIF attachments since it's fairly slow) + if operations.contains(.stripImageMetadata) && !utType.isAnimated { + let outputData: NSMutableData = NSMutableData() + let options: CFDictionary? = dependencies[singleton: .mediaDecoder].defaultImageOptions 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 source: CGImageSource = targetSource.createImageSource(), + let sourceType: String = CGImageSourceGetType(source) as? String, + let cgImage: CGImage = CGImageSourceCreateImageAtIndex(source, 0, options), + let destination = CGImageDestinationCreateWithData(outputData as CFMutableData, sourceType as CFString, 1, nil) + else { throw AttachmentError.invalidData } + + /// Preserve orientation metadata + let properties: [String: Any]? = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] + let orientation: Any? = properties?[kCGImagePropertyOrientation as String] + let imageProperties: [CFString: Any] = ( + orientation.map { [kCGImagePropertyOrientation: $0] } ?? + [:] + ) + + CGImageDestinationAddImage(destination, cgImage, imageProperties as CFDictionary) + + guard CGImageDestinationFinalize(destination) else { + throw AttachmentError.couldNotResizeImage } - let durationSeconds: TimeInterval = ( - // According to the CMTime docs "value/timescale = seconds" - TimeInterval(asset.duration.value) / TimeInterval(asset.duration.timescale) + return try dependencies[singleton: .fileManager].write( + data: outputData as Data, + toPath: filePath ) - assetInfo?.cleanup() + } + + /// If we got here then we don't want to modify the source so we just need to ensure the file exists on disk + return try await createImage( + source: targetSource, + metadata: mediaMatadata, + format: .current, + filePath: filePath, + using: dependencies + ) + } + + private func prepareVideo( + _ operations: Set, + filePath: String, + using dependencies: Dependencies + ) async throws { + guard + let targetSource: ImageDataManager.DataSource = visualMediaSource, + case .media(let mediaMatadata) = self.metadata + else { throw AttachmentError.invalidMediaSource } + + guard mediaMatadata.hasValidPixelSize else { + Log.error(.attachmentManager, "Source has invalid image dimensions.") + throw AttachmentError.invalidDimensions + } + guard mediaMatadata.hasValidDuration else { + Log.error(.attachmentManager, "Source has invalid duration.") + throw AttachmentError.invalidDuration + } + + /// If we want to convert to a certain format then that's all we need to do + if case .convert(let format) = operations.first(where: { $0.erased == .convert }) { + return try await createVideo( + source: targetSource, + metadata: mediaMatadata, + format: format, + filePath: filePath, + using: dependencies + ) + } + + /// If we got here then we don't want to modify the source so we just need to ensure the file exists on disk + try await createVideo( + source: targetSource, + metadata: mediaMatadata, + format: .current, + filePath: filePath, + using: dependencies + ) + } + + private func prepareAudio( + _ operations: Set, + filePath: String, + using dependencies: Dependencies + ) async throws { + guard case .media(let mediaMatadata) = self.metadata else { + throw AttachmentError.invalidMediaSource + } + + guard mediaMatadata.hasValidDuration else { + Log.error(.attachmentManager, "Source has invalid duration.") + throw AttachmentError.invalidDuration + } + + switch source { + case .voiceMessage(let url): + try dependencies[singleton: .fileManager].copyItem(atPath: url.path, toPath: filePath) - return (true, durationSeconds) + case .media(let mediaSource) where utType.isAudio: + switch mediaSource { + case .url(let url): + try dependencies[singleton: .fileManager].copyItem( + atPath: url.path, + toPath: filePath + ) + + case .data(_, let data): + try dependencies[singleton: .fileManager].write(data: data, toPath: filePath) + + default: throw AttachmentError.invalidMediaSource + } + + default: throw AttachmentError.invalidMediaSource } + } + + private func prepareText( + _ operations: Set, + filePath: String, + using dependencies: Dependencies + ) async throws { + guard + case .text(let text) = source, + let data: Data = text.data(using: .ascii) + else { throw AttachmentError.invalidData } - // Any other attachment types are valid and have no duration - return (true, nil) + try dependencies[singleton: .fileManager].write(data: data, toPath: filePath) + } + + private func prepareGeneral( + _ operations: Set, + filePath: String, + using dependencies: Dependencies + ) async throws { + switch source { + case .media where (utType.isImage || utType.isAnimated): + try await prepareImage(operations, filePath: filePath, using: dependencies) + + case .media where utType.isVideo: + try await prepareVideo(operations, filePath: filePath, using: dependencies) + + case .media where utType.isAudio: + try await prepareAudio(operations, filePath: filePath, using: dependencies) + + case .voiceMessage: + try await prepareAudio(operations, filePath: filePath, using: dependencies) + + case .text: + try await prepareText(operations, filePath: filePath, using: dependencies) + + case .file(let url), .media(.url(let url)): + try dependencies[singleton: .fileManager].copyItem( + atPath: url.path, + toPath: filePath + ) + + case .media(.data(_, let data)): + try dependencies[singleton: .fileManager].write(data: data, toPath: filePath) + + default: throw AttachmentError.invalidData + } + } + + private func prepareAttachment( + id: String, + downloadUrl: String, + byteCount: UInt, + encryptionKey: Data?, + digest: Data?, + using dependencies: Dependencies + ) throws -> Attachment { + let contentType: String = { + guard + let fileExtension: String = sourceFilename.map({ URL(fileURLWithPath: $0) })?.pathExtension, + !fileExtension.isEmpty, + let fileExtensionMimeType: String = UTType(sessionFileExtension: fileExtension)?.preferredMIMEType + else { return (utType.preferredMIMEType ?? UTType.mimeTypeDefault) } + + /// UTTypes are an imperfect means of representing file type; file extensions are also imperfect but far more + /// reliable and comprehensive so we always prefer to try to deduce MIME type from the file extension + return fileExtensionMimeType + }() + let imageSize: CGSize? = { + switch metadata { + case .media(let mediaMetadata): return mediaMetadata.displaySize + case .file, .none: return nil + } + }() + + return Attachment( + id: id, + serverId: nil, + variant: { + switch source { + case .voiceMessage: return .voiceMessage + default: return .standard + } + }(), + state: .uploading, + contentType: contentType, + byteCount: byteCount, + creationTimestamp: nil, + sourceFilename: sourceFilename, + downloadUrl: downloadUrl, + width: imageSize.map { UInt(floor($0.width)) }, + height: imageSize.map { UInt(floor($0.height)) }, + duration: duration, + isVisualMedia: utType.isVisualMedia, + isValid: isValidVisualMedia, + encryptionKey: encryptionKey, + digest: digest + ) + } +} + +// MARK: - Convenience + +public extension PendingAttachment { + var visualMediaSource: ImageDataManager.DataSource? { source.visualMediaSource } + + /// Returns the file extension for this attachment or nil if no file extension can be identified + var fileExtension: String? { + guard + let fileExtension: String = sourceFilename.map({ URL(fileURLWithPath: $0) })?.pathExtension, + !fileExtension.isEmpty + else { return utType.sessionFileExtension(sourceFilename: sourceFilename) } + + return fileExtension.filteredFilename + } + + var isValidVisualMedia: Bool { + guard utType.isImage || utType.isAnimated || utType.isVideo else { return false } + guard case .media(let mediaMetadata) = metadata else { return false } + + return ( + mediaMetadata.hasValidPixelSize && + mediaMetadata.hasValidDuration + ) + } +} + +// MARK: - Type Conversions + +public extension PendingAttachment { + func toText() -> String? { + /// Just to be safe ensure the file size isn't crazy large - since we have a character limit of 2,000 - 10,000 characters + /// (which is ~40Kb) a 100Kb limit should be sufficiend + guard (metadata?.fileSize ?? 0) < (1024 * 100) else { return nil } + + switch (source, source.visualMediaSource) { + case (.text(let text), _): return text + case (.file(let fileUrl), _): return try? String(contentsOf: fileUrl, encoding: .utf8) + case (_, .data(_, let data)): return String(data: data, encoding: .utf8) + case (.media, _), (.voiceMessage, _): return nil + } + } + + private func createVideo( + source: ImageDataManager.DataSource, + metadata: MediaUtils.MediaMetadata, + format: ConversionFormat, + filePath: String, + using dependencies: Dependencies + ) async throws { + let url: URL + + switch source { + case .url(let targetUrl), .videoUrl(let targetUrl, _, _, _): url = targetUrl + case .data, .icon, .image, .urlThumbnail, .placeholderIcon, .asyncSource: + throw AttachmentError.invalidData + } + + /// Ensure the target format is an image format we support + switch format { + case .mp4, .current: break + case .png, .webPLossy, .webPLossless, .gif: throw AttachmentError.couldNotConvert + } + + /// Ensure we _actually_ need to make changes first + guard mediaNeedsPreparation([.convert(to: format)], metadata: metadata) else { + try dependencies[singleton: .fileManager].copyItem(atPath: url.path, toPath: filePath) + return + } + + return try await PendingAttachment.convertToMpeg4( + asset: AVAsset(url: url), + presetName: AVAssetExportPresetMediumQuality, + filePath: filePath + ) + } + + private func createImage( + source: ImageDataManager.DataSource, + metadata: MediaUtils.MediaMetadata, + format: ConversionFormat, + filePath: String, + using dependencies: Dependencies + ) async throws { + /// Ensure the target format is an image format we support + let targetMaxDimension: CGFloat? + let targetCropRect: CGRect? + let targetResizeMode: UIImage.ResizeMode + + switch format { + case .png(let maxDimension, let cropRect, let resizeMode), + .gif(let maxDimension, let cropRect, let resizeMode, _), + .webPLossy(let maxDimension, let cropRect, let resizeMode, _), + .webPLossless(let maxDimension, let cropRect, let resizeMode, _): + targetMaxDimension = maxDimension + targetCropRect = cropRect + targetResizeMode = resizeMode + break + + case .current: + targetMaxDimension = nil + targetCropRect = nil + targetResizeMode = ConversionFormat.defaultResizeMode + break + + case .mp4: throw AttachmentError.couldNotConvert + } + + /// Ensure we _actually_ need to make changes first + guard mediaNeedsPreparation([.convert(to: format)], metadata: metadata) else { + switch source { + case .url(let url): + try dependencies[singleton: .fileManager].copyItem(atPath: url.path, toPath: filePath) + + case .image(_, let directImage): + /// For direct image, convert to data first + guard + let image: UIImage = directImage, + let data: Data = image.pngData() + else { throw AttachmentError.invalidData } + + try dependencies[singleton: .fileManager].write(data: data, toPath: filePath) + + case .data(_, let data): + try dependencies[singleton: .fileManager].write(data: data, toPath: filePath) + + default: throw AttachmentError.invalidMediaSource + } + return + } + + /// Create a task to process the image asyncronously + let task: Task = Task.detached(priority: .userInitiated) { + /// Extract the source + let imageSource: CGImageSource + let targetSize: CGSize = ( + targetMaxDimension.map { CGSize(width: $0, height: $0) } ?? + metadata.pixelSize + ) + let isGif: Bool = { + switch format { + case .gif: return true + default: return false + } + }() + let isOpaque: Bool = ( + metadata.hasAlpha != true || + isGif /// GIF doesn't support alpha (single transparent color only) + ) + + switch source { + case .image(_, let directImage): + /// For direct image, convert to data first + guard + let image = directImage, + let data = image.pngData() + else { throw AttachmentError.invalidData } + + imageSource = try dependencies[singleton: .mediaDecoder].source(for: data) ?? { + throw AttachmentError.invalidData + }() + + case .url(let url): + imageSource = try dependencies[singleton: .mediaDecoder].source(for: url) ?? { + throw AttachmentError.invalidData + }() + + case .data(_, let data): + imageSource = try dependencies[singleton: .mediaDecoder].source(for: data) ?? { + throw AttachmentError.invalidData + }() + + default: throw AttachmentError.invalidMediaSource + } + + /// Process frames in parallel (in batches) to balance performance and memory usage + let options: CFDictionary? = dependencies[singleton: .mediaDecoder].defaultImageOptions + let estimatedFrameMemory: CGFloat = (targetSize.width * targetSize.height * 4) + let batchSize: Int = max(2, min(8, Int(50_000_000 / estimatedFrameMemory))) + var frames: [CGImage] = [] + frames.reserveCapacity(metadata.frameCount) + + try Task.checkCancellation() + + for batchStart in stride(from: 0, to: metadata.frameCount, by: batchSize) { + typealias FrameResult = (index: Int, frame: CGImage) + + try Task.checkCancellation() + + let batchEnd: Int = min(batchStart + batchSize, metadata.frameCount) + let batchFrames: [CGImage] = try await withThrowingTaskGroup(of: FrameResult.self) { group in + for i in batchStart.. Update { return from(profile.profilePictureUrl, key: profile.profileKey, contactProProof: profile.sessionProProof, fallback: fallback, using: dependencies) @@ -52,35 +53,26 @@ public class DisplayPictureManager { static func from(_ url: String?, key: Data?, contactProProof: String?, 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) + let key: Data = key else { return fallback } - return .contactUpdateTo(url: url, key: key, filePath: filePath, contactProProof: contactProProof) + return .contactUpdateTo(url: url, key: key, contactProProof: contactProProof) } } public static let maxBytes: UInt = (5 * 1000 * 1000) - public static let maxDiameter: CGFloat = 640 - public static let aes256KeyByteLength: Int = 32 + public static let maxDimension: CGFloat = 600 + public static var encryptionKeySize: Int { LibSession.attachmentEncryptionKeySize } internal static let nonceLength: Int = 12 internal static let tagLength: Int = 16 private let dependencies: Dependencies + private let cache: StringCache = StringCache( + totalCostLimit: 5 * 1024 * 1024 /// Max 5MB of url to hash data (approx. 20,000 records) + ) 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) { @@ -101,7 +93,7 @@ public class DisplayPictureManager { public func sharedDataDisplayPictureDirPath() -> String { let path: String = URL(fileURLWithPath: dependencies[singleton: .fileManager].appSharedDataDirectoryPath) - .appendingPathComponent("DisplayPictures") // stringlint:ignore + .appendingPathComponent("DisplayPictures") .path try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: path) @@ -116,12 +108,25 @@ public class DisplayPictureManager { guard let urlString: String = urlString, !urlString.isEmpty - else { throw DisplayPictureError.invalidCall } + else { throw AttachmentError.invalidPath } + + /// If the provided url is located in the temporary directory then it _is_ a valid path, so we should just return it directly instead + /// of generating a hash + guard !dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(urlString) else { + return urlString + } + /// Otherwise we need to generate the deterministic file path based on the url provided + /// + /// **Note:** Now that download urls could contain fragments (or query params I guess) that could result in inconsistent paths + /// with old attachments so just to be safe we should strip them before generating the `urlHash` + let urlNoQueryOrFragment: String = urlString + .components(separatedBy: "?")[0] + .components(separatedBy: "#")[0] let urlHash = try { - guard let cachedHash: String = cache.object(forKey: urlString as NSString) as? String else { + guard let cachedHash: String = cache.object(forKey: urlNoQueryOrFragment) else { return try dependencies[singleton: .crypto] - .tryGenerate(.hash(message: Array(urlString.utf8))) + .tryGenerate(.hash(message: Array(urlNoQueryOrFragment.utf8))) .toHexString() } @@ -182,151 +187,237 @@ public class DisplayPictureManager { // MARK: - Uploading - public func prepareAndUploadDisplayPicture(imageData: Data, compression: Bool) -> AnyPublisher { - return Just(()) - .setFailureType(to: DisplayPictureError.self) - .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 - let finalImageData: Data - let fileExtension: String - let guessedFormat: ImageFormat = MediaUtils.guessedImageFormat(data: imageData) + private static func standardOperations(cropRect: CGRect?) -> Set { + return [ + .convert(to: .webPLossy( + maxDimension: DisplayPictureManager.maxDimension, + cropRect: cropRect, + resizeMode: .fill + )), + .stripImageMetadata + ] + } + + public func reuploadNeedsPreparation(attachment: PendingAttachment) -> Bool { + /// When re-uploading we only want to check if the file needs to be resized or converted to `WebP`/`GIF` to avoid a situation + /// where different clients end up "ping-ponging" changes to the display picture + /// + /// **Note:** The `UTType` check behaves as an `OR` + return attachment.needsPreparation( + operations: [ + .convert(to: .webPLossy(maxDimension: DisplayPictureManager.maxDimension, resizeMode: .fill)), + .convert(to: .gif(maxDimension: DisplayPictureManager.maxDimension, resizeMode: .fill)) + ] + ) + } + + public func prepareDisplayPicture( + attachment: PendingAttachment, + fallbackIfConversionTakesTooLong: Bool = false, + cropRect: CGRect? = nil + ) async throws -> PreparedAttachment { + /// If we don't want the fallbacks then just run the standard operations + guard fallbackIfConversionTakesTooLong else { + return try await attachment.prepare( + operations: DisplayPictureManager.standardOperations(cropRect: cropRect), + using: dependencies + ) + } + + actor TaskRacer { + private let allTasks: [Task] + private var continuation: CheckedContinuation? + private var hasFinished = false + + public static func race(_ tasks: Task...) async throws -> Success { + guard !tasks.isEmpty else { throw AttachmentError.invalidData } - finalImageData = try { - switch guessedFormat { - case .gif, .webp: - // Animated images can't be resized so if the data is too large we should error - guard imageData.count <= DisplayPictureManager.maxBytes else { - // Our avatar dimensions are so small that it's incredibly unlikely we wouldn't - // be able to fit our profile photo (eg. generating pure noise at our resolution - // compresses to ~200k) - Log.error(.displayPictureManager, "Updating service with profile failed: \(DisplayPictureError.uploadMaxFileSizeExceeded).") - throw DisplayPictureError.uploadMaxFileSizeExceeded - } - - return imageData + let racer: TaskRacer = TaskRacer(tasks: tasks) + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + Task { + await racer.setContinuation(continuation) - default: break - } - - // Process the image to ensure it meets our standards for size and compress it to - // standardise the formwat and remove any metadata - guard var image: UIImage = UIImage(data: imageData) else { - throw DisplayPictureError.invalidCall - } - - if image.size.width != DisplayPictureManager.maxDiameter || image.size.height != DisplayPictureManager.maxDiameter { - // To help ensure the user is being shown the same cropping of their avatar as - // everyone else will see, we want to be sure that the image was resized before this point. - Log.verbose(.displayPictureManager, "Avatar image should have been resized before trying to upload.") - image = image.resized(toFillPixelSize: CGSize(width: DisplayPictureManager.maxDiameter, height: DisplayPictureManager.maxDiameter)) - } - - guard let data: Data = image.jpegData(compressionQuality: (compression ? 0.95 : 1.0)) else { - Log.error(.displayPictureManager, "Updating service with profile failed.") - throw DisplayPictureError.writeFailed - } - - guard data.count <= DisplayPictureManager.maxBytes else { - // Our avatar dimensions are so small that it's incredibly unlikely we wouldn't - // be able to fit our profile photo (eg. generating pure noise at our resolution - // compresses to ~200k) - Log.verbose(.displayPictureManager, "Suprised to find profile avatar was too large. Was it scaled properly? image: \(image)") - Log.error(.displayPictureManager, "Updating service with profile failed.") - throw DisplayPictureError.uploadMaxFileSizeExceeded + for task in tasks { + Task { + let result = await task.result + await racer.tryToFinish(with: result) + } + } + } } - - return data - }() - - newEncryptionKey = try dependencies[singleton: .crypto] - .tryGenerate(.randomBytes(DisplayPictureManager.aes256KeyByteLength)) - fileExtension = { - switch guessedFormat { - case .gif: return "gif" // stringlint:ignore - case .webp: return "webp" // stringlint:ignore - default: return "jpg" // stringlint:ignore + } onCancel: { + for task in tasks { + task.cancel() } - }() - - // If we have a new avatar image, we must first: - // - // * Write it to disk. - // * Encrypt it - // * Upload it to asset service - // * Send asset service info to Signal Service - Log.verbose(.displayPictureManager, "Updating local profile on service with new avatar.") + } + } + + init(tasks: [Task]) { + self.allTasks = tasks + } + + func setContinuation(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func tryToFinish(with result: Result) { + guard !hasFinished else { return } - let temporaryFilePath: String = dependencies[singleton: .fileManager].temporaryFilePath(fileExtension: fileExtension) + hasFinished = true - // Write the avatar to disk - do { try finalImageData.write(to: URL(fileURLWithPath: temporaryFilePath), options: [.atomic]) } - catch { - Log.error(.displayPictureManager, "Updating service with profile failed.") - throw DisplayPictureError.writeFailed - } + continuation?.resume(with: result) + continuation = nil - // Encrypt the avatar for upload - guard - let encryptedData: Data = dependencies[singleton: .crypto].generate( - .encryptedDataDisplayPicture(data: finalImageData, key: newEncryptionKey) - ) - else { - Log.error(.displayPictureManager, "Updating service with profile failed.") - throw DisplayPictureError.encryptionFailed + for task in allTasks { + task.cancel() } - - // Upload the avatar to the FileServer - guard - let preparedUpload: Network.PreparedRequest = try? Network.preparedUpload( - data: encryptedData, - requestAndPathBuildTimeout: Network.fileUploadTimeout, + } + } + + /// The desired output for a profile picture is a `WebP` at the specified size (and `cropRect`) that is generated in under `5s` + do { + let result: PreparedAttachment = try await TaskRacer.race( + Task { + return try await attachment.prepare( + operations: DisplayPictureManager.standardOperations(cropRect: cropRect), using: dependencies ) - else { - Log.error(.displayPictureManager, "Updating service with profile failed.") - throw DisplayPictureError.uploadFailed + }, + Task { + try await Task.sleep(for: .seconds(5)) + throw AttachmentError.conversionTimeout } - - return (preparedUpload, temporaryFilePath, newEncryptionKey) + ) + + let preparedSize: UInt64? = dependencies[singleton: .fileManager].fileSize(of: result.filePath) + + guard (preparedSize ?? UInt64.max) < attachment.fileSize else { + throw AttachmentError.conversionResultedInLargerFile } - .flatMap { [dependencies] preparedUpload, temporaryFilePath, newEncryptionKey -> AnyPublisher<(FileUploadResponse, String, Data), Error> in - preparedUpload.send(using: dependencies) - .map { _, response -> (FileUploadResponse, String, Data) in - (response, temporaryFilePath, newEncryptionKey) + + return result + } + catch AttachmentError.conversionTimeout {} /// Expected case + catch AttachmentError.conversionResultedInLargerFile {} /// Expected case + catch { throw error } + + /// If the original file was a `GIF` then we should see if we can just resize/crop that instead, but since we've already waited + /// for `5s` we only want to give `2s` for this conversion + /// + /// **Note:** In this case we want to ignore any error and just fallback to the original file (with metadata stripped) + if attachment.utType == .gif { + do { + let result: PreparedAttachment = try await TaskRacer.race( + Task { + return try await attachment.prepare( + operations: [ + .convert(to: .gif( + maxDimension: DisplayPictureManager.maxDimension, + cropRect: cropRect, + resizeMode: .fill + )), + .stripImageMetadata + ], + using: dependencies + ) + }, + Task { + try await Task.sleep(for: .seconds(2)) + throw AttachmentError.conversionTimeout } - .eraseToAnyPublisher() - } - .tryMap { [dependencies] fileUploadResponse, temporaryFilePath, newEncryptionKey -> (String, Date?, String, Data) in - let downloadUrl: String = Network.FileServer.downloadUrlString(for: fileUploadResponse.id) - let expries: Date? = fileUploadResponse.expires.map { Date(timeIntervalSince1970: $0)} - let finalFilePath: String = try dependencies[singleton: .displayPictureManager].path(for: downloadUrl) - try dependencies[singleton: .fileManager].moveItem(atPath: temporaryFilePath, toPath: finalFilePath) + ) - return (downloadUrl, expries, finalFilePath, newEncryptionKey) - } - .mapError { error in - Log.error(.displayPictureManager, "Updating service with profile failed with error: \(error).") + /// Only return the resized GIF if it's smaller than the original (the current GIF encoding we use is just the built-in iOS + /// encoding which isn't very advanced, as such some GIFs can end up quite large, even if they are cropped versions + /// of other GIFs - this is likely due to the lack of "frame differencing" support) + let preparedSize: UInt64? = dependencies[singleton: .fileManager].fileSize(of: result.filePath) - switch error { - case NetworkError.maxFileSizeExceeded: return DisplayPictureError.uploadMaxFileSizeExceeded - case let displayPictureError as DisplayPictureError: return displayPictureError - default: return DisplayPictureError.uploadFailed - } - } - .map { [dependencies] downloadUrl, expires, finalFilePath, newEncryptionKey -> UploadResult in - /// Load the data into the `imageDataManager` (assuming we will use it elsewhere in the UI) - Task(priority: .userInitiated) { - await dependencies[singleton: .imageDataManager].load( - .url(URL(fileURLWithPath: finalFilePath)) - ) + guard (preparedSize ?? UInt64.max) < attachment.fileSize else { + throw AttachmentError.conversionResultedInLargerFile } - Log.verbose(.displayPictureManager, "Successfully uploaded avatar image.") - return (downloadUrl, finalFilePath, newEncryptionKey, expires) + return result } - .eraseToAnyPublisher() + catch AttachmentError.conversionTimeout {} /// Expected case + catch AttachmentError.conversionResultedInLargerFile {} /// Expected case + catch { throw error } + } + + /// If we weren't able to generate the `WebP` (or resized `GIF` if the source was a `GIF`) then just use the original source + /// with metadata stripped + return try await attachment.prepare( + operations: [.stripImageMetadata], + using: dependencies + ) + } + + public func uploadDisplayPicture(preparedAttachment: PreparedAttachment) async throws -> UploadResult { + let uploadResponse: FileUploadResponse + let pendingAttachment: PendingAttachment = try PendingAttachment( + attachment: preparedAttachment.attachment, + using: dependencies + ) + let attachment: PreparedAttachment = try await pendingAttachment.prepare( + operations: [ + .encrypt(domain: .profilePicture) + ], + using: dependencies + ) + + /// Clean up the file after the upload completes + defer { try? dependencies[singleton: .fileManager].removeItem(atPath: attachment.filePath) } + + try Task.checkCancellation() + + /// Ensure we have an encryption key for the `PreparedAttachment` we want to use as a display picture + guard let encryptionKey: Data = attachment.attachment.encryptionKey else { + throw AttachmentError.notEncrypted + } + + do { + /// Upload the data + let data: Data = try dependencies[singleton: .fileManager] + .contents(atPath: attachment.filePath) ?? { throw AttachmentError.invalidData }() + let request: Network.PreparedRequest = try Network.FileServer.preparedUpload( + data: data, + requestAndPathBuildTimeout: Network.fileUploadTimeout, + using: dependencies + ) + + // TODO: Refactor to use async/await when the networking refactor is merged + uploadResponse = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw AttachmentError.uploadFailed }() + } + catch NetworkError.maxFileSizeExceeded { throw AttachmentError.fileSizeTooLarge } + catch { throw AttachmentError.uploadFailed } + + try Task.checkCancellation() + + /// Generate the `downloadUrl` and move the temporary file to it's expected destination + /// + /// **Note:** Display pictures are currently stored unencrypted so we need to move the original `preparedAttachment` + /// file to the `finalFilePath` rather than the encrypted one + // FIXME: Should probably store display pictures encrypted and decrypt on load + let downloadUrl: String = Network.FileServer.downloadUrlString( + for: uploadResponse.id, + using: dependencies + ) + let finalFilePath: String = try dependencies[singleton: .displayPictureManager].path(for: downloadUrl) + try dependencies[singleton: .fileManager].moveItem( + atPath: preparedAttachment.filePath, + toPath: finalFilePath + ) + + /// Load the data into the `imageDataManager` (assuming we will use it elsewhere in the UI) + Task.detached(priority: .userInitiated) { [dependencies] in + await dependencies[singleton: .imageDataManager].load(.url(URL(fileURLWithPath: finalFilePath))) + } + + return (downloadUrl, finalFilePath, encryptionKey) } } diff --git a/SessionMessagingKit/Utilities/ExtensionHelper.swift b/SessionMessagingKit/Utilities/ExtensionHelper.swift index 0075b91d78..4693cffae8 100644 --- a/SessionMessagingKit/Utilities/ExtensionHelper.swift +++ b/SessionMessagingKit/Utilities/ExtensionHelper.swift @@ -105,26 +105,30 @@ public class ExtensionHelper: ExtensionHelperType { private func read(from path: String) throws -> 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 } + guard var encKey: [UInt8] = (try? dependencies[singleton: .keychain].getOrGenerateEncryptionKey( + forKey: .extensionEncryptionKey, + length: encryptionKeyLength, + cat: .cat + )).map({ Array($0) }) else { + Log.error(.cat, "Failed to retrieve encryption key") + 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) + do { + return try JSONDecoder(using: dependencies) + .decode(UserMetadata.self, from: plaintext) + } + catch { + Log.error(.cat, "Failed to parse UserMetadata") + return nil + } } // MARK: - Deduping diff --git a/SessionMessagingKit/Utilities/Profile+Updating.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift index 8baf719cdd..a762fe8b86 100644 --- a/SessionMessagingKit/Utilities/Profile+Updating.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -20,6 +20,63 @@ public extension Profile { case currentUserUpdate(String?) } + indirect enum CacheSource { + case value(Profile?, fallback: CacheSource) + case libSession(fallback: CacheSource) + case database + + func resolve(_ db: ObservingDatabase, publicKey: String, using dependencies: Dependencies) -> Profile { + switch self { + case .value(.some(let profile), _): return profile + case .value(.none, let fallback): + return fallback.resolve(db, publicKey: publicKey, using: dependencies) + + case .libSession(let fallback): + if let profile: Profile = dependencies.mutate(cache: .libSession, { $0.profile(contactId: publicKey) }) { + return profile + } + + return fallback.resolve(db, publicKey: publicKey, using: dependencies) + + case .database: return Profile.fetchOrCreate(db, id: publicKey) + } + } + } + + enum UpdateStatus { + case shouldUpdate + case matchesCurrent + case stale + + /// To try to maintain backwards compatibility with profile changes we want to continue to accept profile changes from old clients if + /// we haven't received a profile update from a new client yet otherwise, if we have, then we should only accept profile changes if + /// they are newer that our cached version of the profile data + init(updateTimestamp: TimeInterval?, cachedProfile: Profile) { + let finalProfileUpdateTimestamp: TimeInterval = (updateTimestamp ?? 0) + let finalCachedProfileUpdateTimestamp: TimeInterval = (cachedProfile.profileLastUpdated ?? 0) + + /// If neither the profile update or the cached profile have a timestamp then we should just always accept the update + /// + /// **Note:** We check if they are equal to `0` here because the default value from `libSession` will be `0` + /// rather than `null` + guard finalProfileUpdateTimestamp != 0 || finalCachedProfileUpdateTimestamp != 0 else { + self = .shouldUpdate + return + } + + /// Otherwise we compare the values to determine the current state + switch finalProfileUpdateTimestamp { + case finalCachedProfileUpdateTimestamp...: + self = (finalProfileUpdateTimestamp == finalCachedProfileUpdateTimestamp ? + .matchesCurrent : + .shouldUpdate + ) + + default: self = .stale + } + } + } + static func isTooLong(profileName: String) -> Bool { /// String.utf8CString will include the null terminator (Int8)0 as the end of string buffer. /// When the string is exactly 100 bytes String.utf8CString.count will be 101. @@ -34,222 +91,259 @@ public extension Profile { displayNameUpdate: DisplayNameUpdate = .none, displayPictureUpdate: DisplayPictureManager.Update = .none, using dependencies: Dependencies - ) -> AnyPublisher { - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let isRemovingAvatar: Bool = { - switch displayPictureUpdate { - case .currentUserRemove: return true - default: return false - } - }() - + ) async throws { + /// Perform any non-database related changes for the update switch displayPictureUpdate { - case .contactRemove, .contactUpdateTo, .groupRemove, .groupUpdateTo, .groupUploadImageData: - return Fail(error: DisplayPictureError.invalidCall) - .eraseToAnyPublisher() + case .contactRemove, .contactUpdateTo, .groupRemove, .groupUpdateTo, .groupUploadImage: + throw AttachmentError.invalidStartState - case .none, .currentUserRemove, .currentUserUpdateTo: - return dependencies[singleton: .storage] - .writePublisher { db in - if isRemovingAvatar { - let existingProfileUrl: String? = try Profile - .filter(id: userSessionId.hexString) - .select(.displayPictureUrl) - .asRequest(of: String.self) - .fetchOne(db) - - /// 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: filePath - ) - try? dependencies[singleton: .fileManager].removeItem(atPath: filePath) - } - } - - switch existingProfileUrl { - case .some: Log.verbose(.profile, "Updating local profile on service with cleared avatar.") - case .none: Log.verbose(.profile, "Updating local profile on service with no avatar.") - } - } - - let profileUpdateTimestampMs: Double = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - try Profile.updateIfNeeded( - db, - publicKey: userSessionId.hexString, - displayNameUpdate: displayNameUpdate, - displayPictureUpdate: displayPictureUpdate, - profileUpdateTimestamp: TimeInterval(profileUpdateTimestampMs / 1000), - using: dependencies + case .none, .currentUserUpdateTo: break + case .currentUserRemove: + /// Remove any cached avatar image data + if + let existingProfileUrl: String = dependencies + .mutate(cache: .libSession, { $0.profile }) + .displayPictureUrl, + let filePath: String = try? dependencies[singleton: .displayPictureManager] + .path(for: existingProfileUrl) + { + Log.verbose(.profile, "Updating local profile on service with cleared avatar.") + Task(priority: .low) { + await dependencies[singleton: .imageDataManager].removeImage( + identifier: filePath ) - Log.info(.profile, "Successfully updated user profile.") + try? dependencies[singleton: .fileManager].removeItem(atPath: filePath) } - .mapError { _ in DisplayPictureError.databaseChangesFailed } - .eraseToAnyPublisher() - - case .currentUserUploadImageData(let data, let isReupload): - return dependencies[singleton: .displayPictureManager] - .prepareAndUploadDisplayPicture(imageData: data, compression: !isReupload) - .mapError { $0 as Error } - .flatMapStorageWritePublisher(using: dependencies, updates: { db, result in - let profileUpdateTimestamp: TimeInterval = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000 - try Profile.updateIfNeeded( - db, - publicKey: userSessionId.hexString, - displayNameUpdate: displayNameUpdate, - displayPictureUpdate: .currentUserUpdateTo( - url: result.downloadUrl, - key: result.encryptionKey, - filePath: result.filePath, - sessionProProof: dependencies.mutate(cache: .libSession) { $0.getProProof() } - ), - profileUpdateTimestamp: profileUpdateTimestamp, - isReuploadCurrentUserProfilePicture: isReupload, - using: dependencies - ) - - dependencies[defaults: .standard, key: .profilePictureExpiresDate] = result.expries - dependencies[defaults: .standard, key: .lastProfilePictureUpload] = dependencies.dateNow - Log.info(.profile, "Successfully updated user profile.") - }) - .mapError { error in - switch error { - case let displayPictureError as DisplayPictureError: return displayPictureError - default: return DisplayPictureError.databaseChangesFailed - } - } - .eraseToAnyPublisher() + } + else { + Log.verbose(.profile, "Updating local profile on service with no avatar.") + } } - } - - /// To try to maintain backwards compatibility with profile changes we want to continue to accept profile changes from old clients if - /// we haven't received a profile update from a new client yet otherwise, if we have, then we should only accept profile changes if - /// they are newer that our cached version of the profile data - static func shouldUpdateProfile( - _ profileUpdateTimestamp: TimeInterval?, - profile: Profile, - using dependencies: Dependencies - ) -> Bool { - /// We should consider `libSession` the source-of-truth for profile data for contacts so try to retrieve the profile data from - /// there before falling back to the one fetched from the database - let targetProfile: Profile = ( - dependencies.mutate(cache: .libSession) { $0.profile(contactId: profile.id) } ?? - profile - ) - let finalProfileUpdateTimestamp: TimeInterval = (profileUpdateTimestamp ?? 0) - let finalCachedProfileUpdateTimestamp: TimeInterval = (targetProfile.profileLastUpdated ?? 0) - /// If neither the profile update or the cached profile have a timestamp then we should just always accept the update - /// - /// **Note:** We check if they are equal to `0` here because the default value from `libSession` will be `0` - /// rather than `null` - guard finalProfileUpdateTimestamp != 0 || finalCachedProfileUpdateTimestamp != 0 else { - return true + /// Finally, update the `Profile` data in the database + do { + let userSessionId: SessionId = dependencies[cache: .general].sessionId + let profileUpdateTimestamp: TimeInterval = (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + + try await dependencies[singleton: .storage].writeAsync { db in + try Profile.updateIfNeeded( + db, + publicKey: userSessionId.hexString, + displayNameUpdate: displayNameUpdate, + displayPictureUpdate: displayPictureUpdate, + profileUpdateTimestamp: profileUpdateTimestamp, + using: dependencies + ) + } } - - /// Otherwise we should only accept the update if it's newer than our cached value - return (finalProfileUpdateTimestamp > finalCachedProfileUpdateTimestamp) + catch { throw AttachmentError.databaseChangesFailed } } static func updateIfNeeded( _ db: ObservingDatabase, publicKey: String, displayNameUpdate: DisplayNameUpdate = .none, - displayPictureUpdate: DisplayPictureManager.Update, - blocksCommunityMessageRequests: Bool? = nil, + displayPictureUpdate: DisplayPictureManager.Update = .none, + nicknameUpdate: Update = .useExisting, + blocksCommunityMessageRequests: Update = .useExisting, profileUpdateTimestamp: TimeInterval?, - isReuploadCurrentUserProfilePicture: Bool = false, + cacheSource: CacheSource = .libSession(fallback: .database), + suppressUserProfileConfigUpdate: Bool = false, using dependencies: Dependencies ) throws { - let isCurrentUser = (publicKey == dependencies[cache: .general].sessionId.hexString) - let profile: Profile = Profile.fetchOrCreate(db, id: publicKey) + let userSessionId: SessionId = dependencies[cache: .general].sessionId + let isCurrentUser = (publicKey == userSessionId.hexString) + let profile: Profile = cacheSource.resolve(db, publicKey: publicKey, using: dependencies) + let updateStatus: UpdateStatus = UpdateStatus( + updateTimestamp: profileUpdateTimestamp, + cachedProfile: profile + ) + var updatedProfile: Profile = profile var profileChanges: [ConfigColumnAssignment] = [] - guard shouldUpdateProfile(profileUpdateTimestamp, profile: profile, using: dependencies) else { - return + /// We should only update profile info controled by other users if `updateStatus` is `shouldUpdate` + if updateStatus == .shouldUpdate { + /// Name + switch (displayNameUpdate, isCurrentUser) { + case (.none, _): break + case (.currentUserUpdate(let name), true), (.contactUpdate(let name), false): + guard let name: String = name, !name.isEmpty, name != profile.name else { break } + + if profile.name != name { + updatedProfile = updatedProfile.with(name: name) + profileChanges.append(Profile.Columns.name.set(to: name)) + 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 + } + + /// Blocks community message requests flag + switch blocksCommunityMessageRequests { + case .useExisting: break + case .set(let value): + guard value != profile.blocksCommunityMessageRequests else { break } + + updatedProfile = updatedProfile.with(blocksCommunityMessageRequests: .set(to: value)) + profileChanges.append(Profile.Columns.blocksCommunityMessageRequests.set(to: value)) + } + + /// Profile picture & profile key + switch (displayPictureUpdate, isCurrentUser) { + case (.none, _): break + case (.groupRemove, _), (.groupUpdateTo, _): throw AttachmentError.invalidStartState + case (.contactRemove, false), (.currentUserRemove, true): + if profile.displayPictureEncryptionKey != nil { + updatedProfile = updatedProfile.with(displayPictureEncryptionKey: .set(to: nil)) + profileChanges.append(Profile.Columns.displayPictureEncryptionKey.set(to: nil)) + } + + if profile.displayPictureUrl != nil { + updatedProfile = updatedProfile.with(displayPictureUrl: .set(to: nil)) + profileChanges.append(Profile.Columns.displayPictureUrl.set(to: nil)) + db.addProfileEvent(id: publicKey, change: .displayPictureUrl(nil)) + } + + case (.contactUpdateTo(let url, let key, let proProof), false), + (.currentUserUpdateTo(let url, let key, let proProof, _), true): + /// If we have already downloaded the image then we can just directly update the stored profile data (it normally + /// wouldn't be updated until after the download completes) + let fileExists: Bool = ((try? dependencies[singleton: .displayPictureManager] + .path(for: url)) + .map { dependencies[singleton: .fileManager].fileExists(atPath: $0) } ?? false) + + if fileExists { + if url != profile.displayPictureUrl { + /// Remove the old display picture (since we are replacing it) + if + let existingProfileUrl: String = updatedProfile.displayPictureUrl, + let existingFilePath: String = try? dependencies[singleton: .displayPictureManager] + .path(for: existingProfileUrl) + { + Task.detached(priority: .low) { + await dependencies[singleton: .imageDataManager].removeImage( + identifier: existingFilePath + ) + try? dependencies[singleton: .fileManager].removeItem(atPath: existingFilePath) + } + } + + updatedProfile = updatedProfile.with(displayPictureUrl: .set(to: url)) + profileChanges.append(Profile.Columns.displayPictureUrl.set(to: url)) + db.addProfileEvent(id: publicKey, change: .displayPictureUrl(url)) + } + + if key != profile.displayPictureEncryptionKey && key.count == DisplayPictureManager.encryptionKeySize { + updatedProfile = updatedProfile.with(displayPictureEncryptionKey: .set(to: key)) + profileChanges.append(Profile.Columns.displayPictureEncryptionKey.set(to: key)) + } + } + + // TODO: Handle Pro Proof update + + /// Don't want profiles in messages to modify the current users profile info so ignore those cases + default: break + } } - // Name - switch (displayNameUpdate, isCurrentUser) { - case (.none, _): break - case (.currentUserUpdate(let name), true), (.contactUpdate(let name), false): - guard let name: String = name, !name.isEmpty, name != profile.name else { break } + /// Nickname - this is controlled by the current user so should always be used + switch (nicknameUpdate, isCurrentUser) { + case (.useExisting, _): break + case (.set(let nickname), false): + let finalNickname: String? = (nickname?.isEmpty == false ? nickname : nil) - if profile.name != name { - profileChanges.append(Profile.Columns.name.set(to: name)) - db.addProfileEvent(id: publicKey, change: .name(name)) + if profile.nickname != finalNickname { + updatedProfile = updatedProfile.with(nickname: .set(to: finalNickname)) + profileChanges.append(Profile.Columns.nickname.set(to: finalNickname)) + db.addProfileEvent(id: publicKey, change: .nickname(finalNickname)) } - - // Don't want profiles in messages to modify the current users profile info so ignore those cases + default: break } - // Blocks community message requests flag - if let blocksCommunityMessageRequests: Bool = blocksCommunityMessageRequests { - profileChanges.append(Profile.Columns.blocksCommunityMessageRequests.set(to: blocksCommunityMessageRequests)) + /// Add a conversation event if the display name for a conversation changed + let effectiveDisplayName: String? = { + if isCurrentUser { + guard case .currentUserUpdate(let name) = displayNameUpdate else { return nil } + + return name + } + + if case .set(let nickname) = nicknameUpdate, let nickname, !nickname.isEmpty { + return nickname + } + + if case .contactUpdate(let name) = displayNameUpdate, let name, !name.isEmpty { + return name + } + + return nil + }() + + if + let newDisplayName: String = effectiveDisplayName, + newDisplayName != (isCurrentUser ? profile.name : (profile.nickname ?? profile.name)) + { + db.addConversationEvent(id: publicKey, type: .updated(.displayName(newDisplayName))) } - // Profile picture & profile key - switch (displayPictureUpdate, isCurrentUser) { - case (.none, _): break - case (.currentUserUploadImageData, _), (.groupRemove, _), (.groupUpdateTo, _): - preconditionFailure("Invalid options for this function") - - case (.contactRemove, false), (.currentUserRemove, true): - if profile.displayPictureEncryptionKey != nil { - profileChanges.append(Profile.Columns.displayPictureEncryptionKey.set(to: nil)) - } - - if profile.displayPictureUrl != nil { - profileChanges.append(Profile.Columns.displayPictureUrl.set(to: nil)) - db.addProfileEvent(id: publicKey, change: .displayPictureUrl(nil)) - } + /// If the profile was either updated or matches the current (latest) state then we should check if we have the display picture on + /// disk and, if not, we should schedule a download (a display picture may not be present after linking devices, restoration, etc.) + if updateStatus == .shouldUpdate || updateStatus == .matchesCurrent { + var targetUrl: String? = profile.displayPictureUrl + var targetKey: Data? = profile.displayPictureEncryptionKey - case (.contactUpdateTo(let url, let key, let filePath, let proProof), false), - (.currentUserUpdateTo(let url, let key, let filePath, let proProof), 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( - variant: .displayPictureDownload, - shouldBeUnique: true, - details: DisplayPictureDownloadJob.Details( - target: .profile(id: profile.id, url: url, encryptionKey: key), - timestamp: profileUpdateTimestamp - ) - ), - canStartJob: dependencies[singleton: .appContext].isMainApp - ) - } - else { - if url != profile.displayPictureUrl { - profileChanges.append(Profile.Columns.displayPictureUrl.set(to: url)) - db.addProfileEvent(id: publicKey, change: .displayPictureUrl(url)) - } + switch displayPictureUpdate { + case .contactUpdateTo(let url, let key, _), .currentUserUpdateTo(let url, let key, _, _): + targetUrl = url + targetKey = key - if key != profile.displayPictureEncryptionKey && key.count == DisplayPictureManager.aes256KeyByteLength { - profileChanges.append(Profile.Columns.displayPictureEncryptionKey.set(to: key)) - } - } - - // TODO: Handle Pro Proof update + default: break + } - /// Don't want profiles in messages to modify the current users profile info so ignore those cases - default: break + if + let url: String = targetUrl, + let key: Data = targetKey, + !key.isEmpty, + let path: String = try? dependencies[singleton: .displayPictureManager].path(for: url), + !dependencies[singleton: .fileManager].fileExists(atPath: path) + { + dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .displayPictureDownload, + shouldBeUnique: true, + details: DisplayPictureDownloadJob.Details( + target: .profile(id: profile.id, url: url, encryptionKey: key), + timestamp: profileUpdateTimestamp + ) + ), + canStartJob: dependencies[singleton: .appContext].isMainApp + ) + } } /// Persist any changes if !profileChanges.isEmpty { + let changeString: String = db.currentEvents() + .filter { $0.key.generic == .profile } + .compactMap { + switch ($0.value as? ProfileEvent)?.change { + case .none: return nil + case .name: return "name updated" // stringlint:ignore + case .displayPictureUrl(let url): + return (url != nil ? "displayPictureUrl updated" : "displayPictureUrl removed") // stringlint:ignore + + case .nickname(let nickname): + return (nickname != nil ? "nickname updated" : "nickname removed") // stringlint:ignore + } + } + .joined(separator: ", ") + updatedProfile = updatedProfile.with(profileLastUpdated: .set(to: profileUpdateTimestamp)) profileChanges.append(Profile.Columns.profileLastUpdated.set(to: profileUpdateTimestamp)) - try profile.upsert(db) + try updatedProfile.upsert(db) try Profile .filter(id: publicKey) @@ -258,21 +352,28 @@ public extension Profile { profileChanges, using: dependencies ) - + /// We don't automatically update the current users profile data when changed in the database so need to manually /// trigger the update - if isCurrentUser, let updatedProfile = try? Profile.fetchOne(db, id: publicKey) { + if !suppressUserProfileConfigUpdate, isCurrentUser { try dependencies.mutate(cache: .libSession) { cache in - try cache.performAndPushChange(db, for: .userProfile, sessionId: dependencies[cache: .general].sessionId) { _ in + try cache.performAndPushChange(db, for: .userProfile, sessionId: userSessionId) { _ in try cache.updateProfile( - displayName: updatedProfile.name, - displayPictureUrl: updatedProfile.displayPictureUrl, - displayPictureEncryptionKey: updatedProfile.displayPictureEncryptionKey, - isReuploadProfilePicture: isReuploadCurrentUserProfilePicture + displayName: .set(to: updatedProfile.name), + displayPictureUrl: .set(to: updatedProfile.displayPictureUrl), + displayPictureEncryptionKey: .set(to: updatedProfile.displayPictureEncryptionKey), + isReuploadProfilePicture: { + switch displayPictureUpdate { + case .currentUserUpdateTo(_, _, _, let isReupload): return isReupload + default: return false + } + }() ) } } } + + Log.custom(isCurrentUser ? .info : .debug, [.profile], "Successfully updated \(isCurrentUser ? "user profile" : "profile for \(publicKey)")) (\(changeString)).") } } } diff --git a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift index 6a0ddff983..66517115fb 100644 --- a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift +++ b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift @@ -42,15 +42,16 @@ public extension ProfilePictureView { additionalProfile: Profile? = nil, additionalProfileIcon: ProfileIcon = .none, using dependencies: Dependencies - ) -> (Info?, Info?) { + ) -> (info: Info?, additionalInfo: Info?) { let explicitPath: String? = try? dependencies[singleton: .displayPictureManager].path( for: displayPictureUrl ) + let explicitPathFileExists: Bool = (explicitPath.map { dependencies[singleton: .fileManager].fileExists(atPath: $0) } ?? false) - switch (explicitPath, publicKey.isEmpty, threadVariant) { + switch (explicitPath, explicitPathFileExists, publicKey.isEmpty, threadVariant) { // TODO: Deal with this case later when implement group related Pro features - case (.some(let path), _, .legacyGroup), (.some(let path), _, .group): fallthrough - case (.some(let path), _, .community): + case (.some(let path), true, _, .legacyGroup), (.some(let path), true, _, .group): fallthrough + case (.some(let path), true, _, .community): /// If we are given an explicit `displayPictureUrl` then only use that return (Info( source: .url(URL(fileURLWithPath: path)), @@ -58,7 +59,7 @@ public extension ProfilePictureView { icon: profileIcon ), nil) - case (.some(let path), _, _): + case (.some(let path), true, _, _): /// If we are given an explicit `displayPictureUrl` then only use that return ( Info( @@ -69,14 +70,14 @@ public extension ProfilePictureView { nil ) - case (_, _, .community): + case (_, _, _, .community): return ( Info( source: { switch size { case .navigation, .message: return .image("SessionWhite16", #imageLiteral(resourceName: "SessionWhite16")) case .list: return .image("SessionWhite24", #imageLiteral(resourceName: "SessionWhite24")) - case .hero, .modal: return .image("SessionWhite40", #imageLiteral(resourceName: "SessionWhite40")) + case .hero, .modal, .expanded: return .image("SessionWhite40", #imageLiteral(resourceName: "SessionWhite40")) } }(), animationBehaviour: .generic(true), @@ -92,9 +93,9 @@ public extension ProfilePictureView { nil ) - case (_, true, _): return (nil, nil) + case (_, _, true, _): return (nil, nil) - case (_, _, .legacyGroup), (_, _, .group): + case (_, _, _, .legacyGroup), (_, _, _, .group): let source: ImageDataManager.DataSource = { guard let path: String = try? dependencies[singleton: .displayPictureManager] @@ -162,7 +163,7 @@ public extension ProfilePictureView { ) ) - case (_, _, .contact): + case (_, _, _, .contact): let source: ImageDataManager.DataSource = { guard let path: String = try? dependencies[singleton: .displayPictureManager] diff --git a/SessionMessagingKit/Utilities/SessionProState.swift b/SessionMessagingKit/Utilities/SessionProState.swift index 17e0ea1b7e..e90ce744f4 100644 --- a/SessionMessagingKit/Utilities/SessionProState.swift +++ b/SessionMessagingKit/Utilities/SessionProState.swift @@ -20,7 +20,7 @@ public class SessionProState: SessionProManagerType { public var isSessionProSubject: CurrentValueSubject public var isSessionProPublisher: AnyPublisher { isSessionProSubject - .filter { $0 } + .compactMap { $0 } .eraseToAnyPublisher() } @@ -42,7 +42,13 @@ public class SessionProState: SessionProManagerType { afterClosed: (() -> Void)?, presenting: ((UIViewController) -> Void)? ) -> Bool { - guard dependencies[feature: .sessionProEnabled] && (!isSessionProSubject.value) else { + let shouldShowProCTA: Bool = { + guard dependencies[feature: .sessionProEnabled] else { return false } + if case .groupLimit = variant { return true } + return !dependencies[feature: .mockCurrentUserSessionPro] + }() + + guard shouldShowProCTA else { return false } beforePresented?() diff --git a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift index 1389d05588..705ce13d6d 100644 --- a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift @@ -10,7 +10,7 @@ import Nimble @testable import SessionMessagingKit @testable import SessionUtilitiesKit -class DisplayPictureDownloadJobSpec: QuickSpec { +class DisplayPictureDownloadJobSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -40,11 +40,6 @@ class DisplayPictureDownloadJobSpec: QuickSpec { try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) } ) - @TestState var imageData: Data! = Data( - hex: "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c" + - "489000000017352474200aece1ce90000000d49444154185763f8cfc0f01f0005000" + - "1ffa65c9b5d0000000049454e44ae426082" - ) @TestState var encryptionKey: Data! = Data(hex: "c8e52eb1016702a663ac9a1ab5522daa128ab40762a514de271eddf598e3b8d4") @TestState var encryptedData: Data! = Data( hex: "778921bdd0e432227b53ee49c23421aeb796b7e5663468ff79daffb1af08cd1" + @@ -55,7 +50,15 @@ class DisplayPictureDownloadJobSpec: QuickSpec { @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( initialSetup: { network in network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.response(data: encryptedData)) } ) @@ -66,8 +69,8 @@ class DisplayPictureDownloadJobSpec: QuickSpec { initialSetup: { crypto in crypto.when { $0.generate(.uuid()) }.thenReturn(UUID(uuidString: "00000000-0000-0000-0000-000000001234")) crypto - .when { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } - .thenReturn(imageData) + .when { $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) } + .thenReturn(TestConstants.validImageData) crypto.when { $0.generate(.hash(message: .any, length: .any)) }.thenReturn("TestHash".bytes) crypto .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } @@ -83,14 +86,19 @@ class DisplayPictureDownloadJobSpec: QuickSpec { crypto .when { $0.generate(.signatureBlind15(message: .any, serverPublicKey: .any, ed25519SecretKey: .any)) } .thenReturn("TestSogsSignature".bytes) + crypto + .when { $0.generate(.x25519(ed25519Pubkey: .any)) } + .thenReturn(Array(Data(hex: TestConstants.serverPublicKey))) } ) - @TestState(singleton: .imageDataManager, in: dependencies) var mockImageDataManager: MockImageDataManager! = MockImageDataManager( initialSetup: { imageDataManager in imageDataManager .when { await $0.load(.any) } .thenReturn(nil) + imageDataManager + .when { await $0.removeImage(identifier: .any) } + .thenReturn(()) } ) @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( @@ -124,7 +132,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { using: dependencies ) - expect(error).to(matchError(JobRunnerError.missingRequiredDetails)) + await expect(error).toEventually(matchError(JobRunnerError.missingRequiredDetails)) expect(permanentFailure).to(beTrue()) } @@ -481,37 +489,50 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: -- generates a FileServer download request correctly it("generates a FileServer download request correctly") { + profile = Profile( + id: "1234", + name: "test", + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil + ) + mockStorage.write { db in try profile.insert(db) } job = Job( variant: .displayPictureDownload, shouldBeUnique: true, details: DisplayPictureDownloadJob.Details( target: .profile( - id: "", + id: "1234", url: "http://filev2.getsession.org/file/1234", encryptionKey: encryptionKey ), timestamp: 0 ) ) - let expectedRequest: Network.PreparedRequest = try Network.preparedDownload( + let expectedRequest: Network.PreparedRequest = try Network.FileServer.preparedDownload( url: URL(string: "http://filev2.getsession.org/file/1234")!, using: dependencies ) + var receivedResult: Bool = false DisplayPictureDownloadJob.run( job, scheduler: DispatchQueue.main, - success: { _, _ in }, - failure: { _, _, _ in }, - deferred: { _ in }, + success: { _, _ in receivedResult = true }, + failure: { _, _, _ in receivedResult = true }, + deferred: { _ in receivedResult = true }, using: dependencies ) - expect(mockNetwork) - .to(call(.exactly(times: 1), matchingParameters: .all) { network in + await expect(receivedResult).toEventually(beTrue()) + await expect(mockNetwork) + .toEventually(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.FileServer.Endpoint.directUrl( + URL(string: "http://filev2.getsession.org/file/1234")! + ), + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) @@ -527,6 +548,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { publicKey: TestConstants.serverPublicKey, isActive: false, name: "test", + imageId: "12", userCount: 0, infoUpdates: 0 ).insert(db) @@ -561,20 +583,23 @@ class DisplayPictureDownloadJobSpec: QuickSpec { ) }! + var receivedResult: Bool = false DisplayPictureDownloadJob.run( job, scheduler: DispatchQueue.main, - success: { _, _ in }, - failure: { _, _, _ in }, - deferred: { _ in }, + success: { _, _ in receivedResult = true }, + failure: { _, _, _ in receivedResult = true }, + deferred: { _ in receivedResult = true }, using: dependencies ) - expect(mockNetwork) - .to(call(.exactly(times: 1), matchingParameters: .all) { network in + await expect(receivedResult).toEventually(beTrue()) + await expect(mockNetwork) + .toEventually(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.SOGS.Endpoint.roomFileIndividual("testRoom", "12"), + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) @@ -583,7 +608,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: -- checking if a downloaded display picture is valid context("checking if a downloaded display picture is valid") { - @TestState var jobResult: JobRunner.JobResult! = .notFound + @TestState var jobResult: JobRunner.JobResult? beforeEach { profile = Profile( @@ -617,13 +642,15 @@ class DisplayPictureDownloadJobSpec: QuickSpec { deferred: { _ in jobResult = .deferred }, using: dependencies ) + + await expect(jobResult).toEventuallyNot(beNil()) } // MARK: ---- when it fails to decrypt the data context("when it fails to decrypt the data") { beforeEach { mockCrypto - .when { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } + .when { $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) } .thenReturn(nil) } @@ -631,7 +658,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try Profile.fetchOne(db) }).to(equal(profile)) @@ -642,15 +669,15 @@ class DisplayPictureDownloadJobSpec: QuickSpec { context("when it decrypts invalid image data") { beforeEach { mockCrypto - .when { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } - .thenReturn(Data([1, 2, 3])) + .when { $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) } + .thenReturn(TestConstants.invalidImageData) } // MARK: ------ does not save the picture it("does not save the picture") { expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try Profile.fetchOne(db) }).to(equal(profile)) @@ -667,7 +694,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: ------ does not save the picture it("does not save the picture") { - expect(mockImageDataManager).toNotEventually(call { + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try Profile.fetchOne(db) }).to(equal(profile)) @@ -680,7 +707,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { .to(call(.exactly(times: 1), matchingParameters: .all) { mockFileManager in mockFileManager.createFile( atPath: "/test/DisplayPictures/5465737448617368", - contents: imageData, + contents: TestConstants.validImageData, attributes: nil ) }) @@ -688,7 +715,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: ---- adds the image data to the displayPicture cache it("adds the image data to the displayPicture cache") { - expect(mockImageDataManager) + await expect(mockImageDataManager) .toEventually(call(.exactly(times: 1), matchingParameters: .all) { await $0.load( .url(URL(fileURLWithPath: "/test/DisplayPictures/5465737448617368")) @@ -737,13 +764,15 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: -------- does not save the picture it("does not save the picture") { + /// Succeeds as the download has been superseded + await expect(jobResult).toEventually(equal(.succeeded)) expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) + $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try Profile.fetchOne(db) }).to(beNil()) @@ -767,11 +796,11 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) + $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try Profile.fetchOne(db) }) @@ -804,11 +833,11 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) + $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try Profile.fetchOne(db) }) @@ -840,17 +869,17 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("saves the picture") { expect(mockCrypto) .to(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) + $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { $0.createFile( atPath: "/test/DisplayPictures/5465737448617368", - contents: imageData, + contents: TestConstants.validImageData, attributes: nil ) }) - expect(mockImageDataManager) + await expect(mockImageDataManager) .toEventually(call(.exactly(times: 1), matchingParameters: .all) { await $0.load( .url(URL(fileURLWithPath: "/test/DisplayPictures/5465737448617368")) @@ -937,11 +966,11 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) + $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try ClosedGroup.fetchOne(db) }).to(beNil()) @@ -964,11 +993,11 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) + $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try ClosedGroup.fetchOne(db) }) @@ -1005,11 +1034,11 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) + $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try ClosedGroup.fetchOne(db) }) @@ -1093,8 +1122,16 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // SOGS doesn't encrypt it's images so replace the encrypted mock response mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn(MockNetwork.response(data: imageData)) + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } + .thenReturn(MockNetwork.response(data: TestConstants.validImageData)) } // MARK: ------ that does not exist @@ -1107,7 +1144,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { await $0.load(.any) }) + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try OpenGroup.fetchOne(db) }).to(beNil()) } } @@ -1128,7 +1165,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { await $0.load(.any) }) + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try OpenGroup.fetchOne(db) }) .toNot(equal( OpenGroup( @@ -1162,11 +1199,11 @@ class DisplayPictureDownloadJobSpec: QuickSpec { expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { $0.createFile( atPath: "/test/DisplayPictures/5465737448617368", - contents: imageData, + contents: TestConstants.validImageData, attributes: nil ) }) - expect(mockImageDataManager) + await expect(mockImageDataManager) .toEventually(call(.exactly(times: 1), matchingParameters: .all) { await $0.load( .url(URL(fileURLWithPath: "/test/DisplayPictures/5465737448617368")) diff --git a/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift b/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift index e549e2c221..693a81bc63 100644 --- a/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift @@ -13,7 +13,7 @@ extension Job: @retroactive MutableIdentifiable { public mutating func setId(_ id: Int64?) { self.id = id } } -class MessageSendJobSpec: QuickSpec { +class MessageSendJobSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -24,7 +24,8 @@ class MessageSendJobSpec: QuickSpec { variant: .standard, state: .failedDownload, contentType: "text/plain", - byteCount: 200 + byteCount: 200, + downloadUrl: "http://localhost" ) @TestState var interactionAttachment: InteractionAttachment! @TestState var dependencies: TestDependencies! = TestDependencies { dependencies in @@ -357,10 +358,6 @@ class MessageSendJobSpec: QuickSpec { it("it defers when trying to send with an attachment which is still pending upload") { var didDefer: Bool = false - mockStorage.write { db in - try attachment.with(state: .uploading, using: dependencies).upsert(db) - } - MessageSendJob.run( job, scheduler: DispatchQueue.main, @@ -370,7 +367,7 @@ class MessageSendJobSpec: QuickSpec { using: dependencies ) - expect(didDefer).to(beTrue()) + await expect(didDefer).toEventually(beTrue()) } // MARK: -------- it defers when trying to send with an uploaded attachment that has an invalid downloadUrl @@ -378,13 +375,24 @@ class MessageSendJobSpec: QuickSpec { var didDefer: Bool = false mockStorage.write { db in - try attachment - .with( - state: .uploaded, - downloadUrl: nil, - using: dependencies - ) - .upsert(db) + try Attachment( + id: attachment.id, + serverId: attachment.serverId, + variant: attachment.variant, + state: .uploaded, + contentType: attachment.contentType, + byteCount: attachment.byteCount, + creationTimestamp: attachment.creationTimestamp, + sourceFilename: attachment.sourceFilename, + downloadUrl: nil, + width: attachment.width, + height: attachment.height, + duration: attachment.duration, + isVisualMedia: attachment.isVisualMedia, + isValid: attachment.isValid, + encryptionKey: attachment.encryptionKey, + digest: attachment.digest + ).upsert(db) } MessageSendJob.run( @@ -420,8 +428,8 @@ class MessageSendJobSpec: QuickSpec { using: dependencies ) - expect(mockJobRunner) - .to(call(.exactly(times: 1), matchingParameters: .all) { + await expect(mockJobRunner) + .toEventually(call(.exactly(times: 1), matchingParameters: .all) { $0.insert( .any, job: Job( @@ -452,8 +460,8 @@ class MessageSendJobSpec: QuickSpec { using: dependencies ) - expect(mockStorage.read { db in try JobDependencies.fetchOne(db) }) - .to(equal(JobDependencies(jobId: 54321, dependantId: 1000))) + await expect(mockStorage.read { db in try JobDependencies.fetchOne(db) }) + .toEventually(equal(JobDependencies(jobId: 54321, dependantId: 1000))) } } } diff --git a/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift index 42ff8e59cc..24b8185e74 100644 --- a/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift @@ -37,7 +37,15 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( initialSetup: { network in network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn( MockNetwork.batchResponseData( with: [ @@ -182,7 +190,15 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { // MARK: -- creates an inactive entry in the database if one does not exist it("creates an inactive entry in the database if one does not exist") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.errorResponse()) RetrieveDefaultOpenGroupRoomsJob.run( @@ -206,7 +222,15 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { // MARK: -- does not create a new entry if one already exists it("does not create a new entry if one already exists") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.errorResponse()) mockStorage.write { db in @@ -281,8 +305,9 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { expect(mockNetwork) .to(call { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.SOGS.Endpoint.sequence, + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) @@ -294,7 +319,15 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { // MARK: -- will retry 8 times before it fails it("will retry 8 times before it fails") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.nullResponse()) RetrieveDefaultOpenGroupRoomsJob.run( @@ -312,7 +345,13 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { expect(error).to(matchError(NetworkError.parsingFailed)) expect(mockNetwork) // First attempt + 8 retries .to(call(.exactly(times: 9)) { network in - network.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) + network.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) }) } @@ -376,7 +415,15 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { .insert(db) } mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn( MockNetwork.batchResponseData( with: [ @@ -506,7 +553,15 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { // MARK: -- does not schedule a display picture download if there is no imageId it("does not schedule a display picture download if there is no imageId") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn( MockNetwork.batchResponseData( with: [ diff --git a/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift index 209274b6fd..180d585389 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift @@ -40,7 +40,15 @@ class LibSessionGroupInfoSpec: QuickSpec { @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( initialSetup: { network in network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.response(data: Data([1, 2, 3]))) } ) @@ -280,7 +288,7 @@ class LibSessionGroupInfoSpec: QuickSpec { createGroupOutput.groupState[.groupInfo]?.conf.map { var displayPic: user_profile_pic = user_profile_pic() displayPic.set(\.url, to: "https://www.oxen.io/file/1234") - displayPic.set(\.key, to: Data(repeating: 1, count: DisplayPictureManager.aes256KeyByteLength)) + displayPic.set(\.key, to: Data(repeating: 1, count: DisplayPictureManager.encryptionKeySize)) groups_info_set_pic($0, displayPic) } @@ -309,7 +317,7 @@ class LibSessionGroupInfoSpec: QuickSpec { url: "https://www.oxen.io/file/1234", encryptionKey: Data( repeating: 1, - count: DisplayPictureManager.aes256KeyByteLength + count: DisplayPictureManager.encryptionKeySize ) ), timestamp: 1234567891 @@ -894,8 +902,9 @@ class LibSessionGroupInfoSpec: QuickSpec { expect(mockNetwork) .to(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.SnodeAPI.Endpoint.deleteMessages, + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) @@ -958,7 +967,13 @@ class LibSessionGroupInfoSpec: QuickSpec { expect(result?.map { $0.variant }).to(equal([.standardIncomingDeleted])) expect(mockNetwork) .toNot(call { network in - network.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) + network.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) }) } } diff --git a/SessionMessagingKitTests/LibSession/LibSessionGroupMembersSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionGroupMembersSpec.swift index 43f6c045de..8d5b88a6ba 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionGroupMembersSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionGroupMembersSpec.swift @@ -39,7 +39,15 @@ class LibSessionGroupMembersSpec: QuickSpec { @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( initialSetup: { network in network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.response(data: Data([1, 2, 3]))) } ) diff --git a/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift index 84c3252952..37f922543b 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift @@ -595,7 +595,7 @@ fileprivate extension LibSessionUtilSpec { case .profile_pic: contact.set(\.profile_pic.url, to: rand.nextBytes(count: LibSession.sizeMaxProfileUrlBytes).toHexString()) - contact.set(\.profile_pic.key, to: rand.nextBytes(count: DisplayPictureManager.aes256KeyByteLength)) + contact.set(\.profile_pic.key, to: rand.nextBytes(count: DisplayPictureManager.encryptionKeySize)) } } @@ -2695,7 +2695,7 @@ fileprivate extension LibSessionUtilSpec { case .profile_pic: member.set(\.profile_pic.url, to: rand.nextBytes(count: LibSession.sizeMaxProfileUrlBytes).toHexString()) - member.set(\.profile_pic.key, to: Data(rand.nextBytes(count: DisplayPictureManager.aes256KeyByteLength))) + member.set(\.profile_pic.key, to: Data(rand.nextBytes(count: DisplayPictureManager.encryptionKeySize))) } } diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 9d2d2d4cb3..5a0783b1e4 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -139,7 +139,15 @@ class OpenGroupManagerSpec: QuickSpec { @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( initialSetup: { network in network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.errorResponse()) } ) @@ -669,7 +677,15 @@ class OpenGroupManagerSpec: QuickSpec { } mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockCapabilitiesAndRoomResponse) mockUserDefaults @@ -807,7 +823,15 @@ class OpenGroupManagerSpec: QuickSpec { context("with an invalid response") { beforeEach { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.response(data: Data())) mockUserDefaults @@ -2559,8 +2583,9 @@ class OpenGroupManagerSpec: QuickSpec { expect(mockNetwork) .to(call { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.SOGS.Endpoint.sequence, + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) @@ -2574,7 +2599,15 @@ class OpenGroupManagerSpec: QuickSpec { cache.defaultRoomsPublisher.sinkUntilComplete() expect(mockNetwork) - .toNot(call { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) }) + .toNot(call { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + }) } } } diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift index 2926f10f11..25902e1a88 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift @@ -67,8 +67,16 @@ class MessageReceiverGroupsSpec: QuickSpec { @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( initialSetup: { network in network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn(MockNetwork.response(with: FileUploadResponse(id: "1", expires: nil))) + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } + .thenReturn(MockNetwork.response(with: FileUploadResponse(id: "1", uploaded: nil, expires: nil))) network .when { $0.getSwarm(for: .any) } .thenReturn([ @@ -209,7 +217,7 @@ class MessageReceiverGroupsSpec: QuickSpec { @TestState var mockSwarmPoller: MockSwarmPoller! = MockSwarmPoller( initialSetup: { cache in cache.when { $0.startIfNeeded() }.thenReturn(()) - cache.when { $0.receivedPollResponse }.thenReturn(Just([]).eraseToAnyPublisher()) + cache.when { $0.receivedPollResponse }.thenReturn(.singleValue(value: [])) } ) @TestState(cache: .groupPollers, in: dependencies) var mockGroupPollersCache: MockGroupPollerCache! = MockGroupPollerCache( @@ -402,7 +410,7 @@ class MessageReceiverGroupsSpec: QuickSpec { inviteMessage.profile = VisibleMessage.VMProfile( displayName: "TestName", - profileKey: Data((0.. = try Network + let expectedRequest: Network.PreparedRequest = try Network.FileServer .preparedUpload(data: TestConstants.validImageData, using: dependencies) expect(mockNetwork) .toNot(call { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.FileServer.Endpoint.file, + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) @@ -608,24 +660,36 @@ class MessageSenderGroupsSpec: QuickSpec { context("with an image") { // MARK: ------ uploads the image it("uploads the image") { + // Prevent the ConfigSyncJob network request by making the libSession cache appear empty + mockLibSessionCache.when { $0.isEmpty }.thenReturn(true) mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn(MockNetwork.response(with: FileUploadResponse(id: "1", expires: nil))) + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } + .thenReturn(MockNetwork.response(with: FileUploadResponse(id: "1", uploaded: nil, expires: nil))) + mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(TestConstants.validImageData) - MessageSender - .createGroup( + let result = await Result { + try await MessageSender.createGroup( name: "TestGroupName", description: nil, - displayPictureData: TestConstants.validImageData, + displayPicture: .data("Test", TestConstants.validImageData), + displayPictureCropRect: nil, members: [ ("051111111111111111111111111111111111111111111111111111111111111111", nil) ], using: dependencies ) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) + } + expect { try result.get() }.toNot(throwError()) - let expectedRequest: Network.PreparedRequest = try Network + let expectedRequest: Network.PreparedRequest = try Network.FileServer .preparedUpload( data: TestConstants.validImageData, requestAndPathBuildTimeout: Network.fileUploadTimeout, @@ -635,8 +699,9 @@ class MessageSenderGroupsSpec: QuickSpec { expect(mockNetwork) .to(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.FileServer.Endpoint.file, + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) @@ -648,65 +713,80 @@ class MessageSenderGroupsSpec: QuickSpec { // Prevent the ConfigSyncJob network request by making the libSession cache appear empty mockLibSessionCache.when { $0.isEmpty }.thenReturn(true) mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn(MockNetwork.response(with: FileUploadResponse(id: "1", expires: nil))) + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } + .thenReturn(MockNetwork.response(with: FileUploadResponse(id: "1", uploaded: nil, expires: nil))) - MessageSender - .createGroup( + let result = await Result { + try await MessageSender.createGroup( name: "TestGroupName", description: nil, - displayPictureData: TestConstants.validImageData, + displayPicture: .data("Test", TestConstants.validImageData), + displayPictureCropRect: nil, members: [ ("051111111111111111111111111111111111111111111111111111111111111111", nil) ], using: dependencies ) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) + } + expect { try result.get() }.toNot(throwError()) 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?.displayPictureEncryptionKey) - .to(equal(Data((0.., CommunityPollerType { var isPolling: Bool { mock() } - var receivedPollResponse: AnyPublisher { mock() } + nonisolated var receivedPollResponse: AsyncStream { mock() } func startIfNeeded() { mockNoReturn() } func stop() { mockNoReturn() } diff --git a/SessionMessagingKitTests/_TestUtilities/MockImageDataManager.swift b/SessionMessagingKitTests/_TestUtilities/MockImageDataManager.swift index 78693da700..3e177fcd41 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockImageDataManager.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockImageDataManager.swift @@ -8,14 +8,14 @@ import SessionUIKit class MockImageDataManager: Mock, ImageDataManagerType { @discardableResult func load( _ source: ImageDataManager.DataSource - ) async -> ImageDataManager.ProcessedImageData? { + ) async -> ImageDataManager.FrameBuffer? { return mock(args: [source]) } @MainActor func load( _ source: ImageDataManager.DataSource, - onComplete: @MainActor @escaping (ImageDataManager.ProcessedImageData?) -> Void + onComplete: @MainActor @escaping (ImageDataManager.FrameBuffer?) -> Void ) { mockNoReturn(args: [source], untrackedArgs: [onComplete]) } @@ -24,7 +24,7 @@ class MockImageDataManager: Mock, ImageDataManagerType { mockNoReturn(args: [image, identifier]) } - func cachedImage(identifier: String) async -> ImageDataManager.ProcessedImageData? { + func cachedImage(identifier: String) async -> ImageDataManager.FrameBuffer? { return mock(args: [identifier]) } diff --git a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift index 24f5fa056a..8fc93a25a2 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift @@ -184,7 +184,12 @@ class MockLibSessionCache: Mock, LibSessionCacheType { mockNoReturn(generics: [T.self], args: [key, value]) } - func updateProfile(displayName: String, displayPictureUrl: String?, displayPictureEncryptionKey: Data?, isReuploadProfilePicture: Bool) throws { + func updateProfile( + displayName: Update, + displayPictureUrl: Update, + displayPictureEncryptionKey: Update, + isReuploadProfilePicture: Bool + ) throws { try mockThrowingNoReturn(args: [displayName, displayPictureUrl, displayPictureEncryptionKey, isReuploadProfilePicture]) } diff --git a/SessionMessagingKitTests/_TestUtilities/MockPoller.swift b/SessionMessagingKitTests/_TestUtilities/MockPoller.swift index cfc5968bd2..a20133bb86 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockPoller.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockPoller.swift @@ -16,7 +16,8 @@ class MockPoller: Mock, PollerType { var pollerName: String { mock() } var pollerDestination: PollerDestination { mock() } var logStartAndStopCalls: Bool { mock() } - var receivedPollResponse: AnyPublisher { mock() } + nonisolated var receivedPollResponse: AsyncStream { mock() } + nonisolated var successfulPollCount: AsyncStream { mock() } var isPolling: Bool { get { mock() } set { mockNoReturn(args: [newValue]) } diff --git a/SessionMessagingKitTests/_TestUtilities/MockSwarmPoller.swift b/SessionMessagingKitTests/_TestUtilities/MockSwarmPoller.swift index 05af305032..9e952bed30 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockSwarmPoller.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockSwarmPoller.swift @@ -12,7 +12,8 @@ class MockSwarmPoller: Mock, SwarmPollerType & Pol var pollerName: String { mock() } var pollerDestination: PollerDestination { mock() } var logStartAndStopCalls: Bool { mock() } - var receivedPollResponse: AnyPublisher { mock() } + nonisolated var receivedPollResponse: AsyncStream { mock() } + nonisolated var successfulPollCount: AsyncStream { mock() } var isPolling: Bool { get { mock() } set { mockNoReturn(args: [newValue]) } diff --git a/SessionNetworkingKit/FileServer/FileServer.swift b/SessionNetworkingKit/FileServer/FileServer.swift index 90fdec9e08..ff8cd75875 100644 --- a/SessionNetworkingKit/FileServer/FileServer.swift +++ b/SessionNetworkingKit/FileServer/FileServer.swift @@ -7,34 +7,74 @@ import SessionUtilitiesKit public extension Network { enum FileServer { - internal static let fileServer = "http://filev2.getsession.org" - internal static let fileServerPublicKey = "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59" - internal static let legacyFileServer = "http://88.99.175.227" - internal static let legacyFileServerPublicKey = "7cb31905b55cd5580c686911debf672577b3fb0bff81df4ce2d5c4cb3a7aaa69" - - static func fileServerPubkey(url: String? = nil) -> String { - switch url?.contains(legacyFileServer) { - case true: return legacyFileServerPublicKey - default: return fileServerPublicKey + public static let defaultServer = "http://filev2.getsession.org" + internal static let defaultEdPublicKey = "b8eef9821445ae16e2e97ef8aa6fe782fd11ad5253cd6723b281341dba22e371" + + public static func server(using dependencies: Dependencies) -> String { + guard dependencies[feature: .customFileServer].isValid else { + return defaultServer } + + return dependencies[feature: .customFileServer].url } - static func isFileServerUrl(url: URL) -> Bool { - return ( - url.absoluteString.starts(with: fileServer) || - url.absoluteString.starts(with: legacyFileServer) + internal static func edPublicKey(using dependencies: Dependencies) -> String { + let customPubkey: String = dependencies[feature: .customFileServer].pubkey + + guard + dependencies[feature: .customFileServer].isValid, + !customPubkey.isEmpty /// An empty `pubkey` will be considered value (as we just fallback to the default) + else { return defaultEdPublicKey } + + return dependencies[feature: .customFileServer].pubkey + } + + internal static func x25519PublicKey(using dependencies: Dependencies) throws -> String { + let edPublicKey: String = edPublicKey(using: dependencies) + let x25519Pubkey: [UInt8] = try dependencies[singleton: .crypto].tryGenerate( + .x25519(ed25519Pubkey: Array(Data(hex: edPublicKey))) ) + + return x25519Pubkey.toHexString() } - public static func downloadUrlString(for url: String, fileId: String) -> String { - switch url.contains(legacyFileServer) { - case true: return "\(fileServer)/\(Endpoint.fileIndividual(fileId).path)" - default: return downloadUrlString(for: fileId) + internal static func x25519PublicKey(for url: URL, using dependencies: Dependencies) throws -> String { + let edPublicKey: String = (url.fragmentParameters[.publicKey] ?? defaultEdPublicKey) + + guard Hex.isValid(edPublicKey) && edPublicKey.count == 64 else { + throw CryptoError.invalidPublicKey } + + let x25519Pubkey: [UInt8] = try dependencies[singleton: .crypto].tryGenerate( + .x25519(ed25519Pubkey: Array(Data(hex: edPublicKey))) + ) + + return x25519Pubkey.toHexString() } - public static func downloadUrlString(for fileId: String) -> String { - return "\(fileServer)/\(Endpoint.fileIndividual(fileId).path)" + public static func downloadUrlString( + for fileId: String, + using dependencies: Dependencies + ) -> String { + var fragments: [HTTPFragmentParam: String] = [:] + let edPublicKey: String = edPublicKey(using: dependencies) + + if dependencies[feature: .deterministicAttachmentEncryption] { + fragments[.deterministicEncryption] = "" /// No value needed + } + + if edPublicKey != defaultEdPublicKey { + fragments[.publicKey] = edPublicKey + } + + let baseUrl: String = [ + server(using: dependencies), + Endpoint.fileIndividual(fileId).path + ].joined(separator: "/") + + return [baseUrl, HTTPFragmentParam.string(for: fragments)] + .filter { !$0.isEmpty } + .joined(separator: "#") } public static func fileId(for downloadUrl: String?) -> String? { @@ -46,46 +86,97 @@ public extension Network { .map { String($0) } } } + + public static func usesDeterministicEncryption(_ downloadUrl: String?) -> Bool { + return (downloadUrl + .map { URL(string: $0) }? + .fragmentParameters[.deterministicEncryption] != nil) + } } - - static func preparedUpload( - data: Data, - requestAndPathBuildTimeout: TimeInterval? = nil, - using dependencies: Dependencies - ) throws -> PreparedRequest { - return try PreparedRequest( - request: Request( - endpoint: FileServer.Endpoint.file, - destination: .serverUpload( - server: FileServer.fileServer, - x25519PublicKey: FileServer.fileServerPublicKey, - fileName: nil - ), - body: data - ), - responseType: FileUploadResponse.self, - requestTimeout: Network.fileUploadTimeout, - requestAndPathBuildTimeout: requestAndPathBuildTimeout, - using: dependencies +} + +// MARK: - Dev Settings + +public extension FeatureStorage { + static let customFileServer: FeatureConfig = Dependencies.create( + identifier: "customFileServer" + ) +} + +public extension Network.FileServer { + struct Custom: Sendable, Equatable, Codable, FeatureOption { + public typealias RawValue = String + + private struct Values: Equatable, Codable { + public let url: String + public let pubkey: String + } + + public static let defaultOption: Custom = Custom( + url: "", + pubkey: "" ) - } - - static func preparedDownload( - url: URL, - using dependencies: Dependencies - ) throws -> PreparedRequest { - return try PreparedRequest( - request: Request( - endpoint: FileServer.Endpoint.directUrl(url), - destination: .serverDownload( - url: url, - x25519PublicKey: FileServer.fileServerPublicKey, - fileName: nil + + public let title: String = "Custom File Server" + public let subtitle: String? = nil + private let values: Values + + public var url: String { values.url } + public var pubkey: String { values.pubkey } + public var isEmpty: Bool { + values.url.isEmpty && + values.pubkey.isEmpty + } + public var isValid: Bool { + let pubkeyValid: Bool = ( + Hex.isValid(values.pubkey) && + values.pubkey.count == 64 + ) + + return ( + URL(string: url) != nil && ( + values.pubkey.isEmpty || /// Default pubkey would be used if empty + pubkeyValid ) - ), - responseType: Data.self, - requestTimeout: Network.fileUploadTimeout, - using: dependencies - ) + ) + } + + /// This is needed to conform to `FeatureOption` so it can be saved to `UserDefaults` + public var rawValue: String { + (try? JSONEncoder().encode(values)).map { String(data: $0, encoding: .utf8) } ?? "" + } + + // MARK: - Initialization + + public init(url: String, pubkey: String) { + self.values = Values(url: url, pubkey: pubkey) + } + + public init?(rawValue: String) { + guard + let data: Data = rawValue.data(using: .utf8), + let decodedValues: Values = try? JSONDecoder().decode(Values.self, from: data) + else { return nil } + + self.values = decodedValues + } + + // MARK: - Functions + + public func with( + url: String? = nil, + pubkey: String? = nil + ) -> Custom { + return Custom( + url: (url ?? self.values.url), + pubkey: (pubkey ?? self.values.pubkey) + ) + } + + // MARK: - Equality + + public static func == (lhs: Custom, rhs: Custom) -> Bool { + return (lhs.values == rhs.values) + } } } diff --git a/SessionNetworkingKit/FileServer/FileServerAPI.swift b/SessionNetworkingKit/FileServer/FileServerAPI.swift index 9e8e46a5b3..58915d03a5 100644 --- a/SessionNetworkingKit/FileServer/FileServerAPI.swift +++ b/SessionNetworkingKit/FileServer/FileServerAPI.swift @@ -1,4 +1,6 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import Foundation import SessionUtilitiesKit @@ -15,16 +17,16 @@ public extension Network.FileServer { var headers: [HTTPHeader: String] = [:] if dependencies[feature: .shortenFileTTL] { - headers = [.fileCustomTTL : "60"] + headers = [.fileCustomTTL: "60"] } return try Network.PreparedRequest( request: Request( endpoint: .file, destination: .serverUpload( - server: FileServer.fileServer, + server: FileServer.server(using: dependencies), headers: headers, - x25519PublicKey: FileServer.fileServerPublicKey, + x25519PublicKey: FileServer.x25519PublicKey(using: dependencies), fileName: nil ), body: data @@ -40,17 +42,47 @@ public extension Network.FileServer { url: URL, using dependencies: Dependencies ) throws -> Network.PreparedRequest { + let strippedUrl: URL = try url.strippingQueryAndFragment ?? { throw NetworkError.invalidURL }() + return try Network.PreparedRequest( request: Request( - endpoint: .directUrl(url), + endpoint: .directUrl(strippedUrl), destination: .serverDownload( - url: url, - x25519PublicKey: FileServer.fileServerPublicKey, + url: strippedUrl, + x25519PublicKey: FileServer.x25519PublicKey(for: url, using: dependencies), fileName: nil ) ), responseType: Data.self, - requestTimeout: Network.fileUploadTimeout, + requestTimeout: Network.fileDownloadTimeout, + using: dependencies + ) + } + + static func preparedExtend( + url: URL, + customTtl: TimeInterval?, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + let strippedUrl: URL = try url.strippingQueryAndFragment ?? { throw NetworkError.invalidURL }() + + var headers: [HTTPHeader: String] = [:] + + if let ttl: TimeInterval = customTtl { + headers = [.fileCustomTTL: "\(Int(floor(ttl)))"] + } + + return try Network.PreparedRequest( + request: Request( + endpoint: .extendUrl(strippedUrl), + destination: .server( + method: .post, + url: strippedUrl, + headers: headers, + x25519PublicKey: FileServer.x25519PublicKey(for: url, using: dependencies) + ) + ), + responseType: ExtendExpirationResponse.self, using: dependencies ) } diff --git a/SessionNetworkingKit/FileServer/FileServerEndpoint.swift b/SessionNetworkingKit/FileServer/FileServerEndpoint.swift index 5f23b85624..dd9cc2f7e7 100644 --- a/SessionNetworkingKit/FileServer/FileServerEndpoint.swift +++ b/SessionNetworkingKit/FileServer/FileServerEndpoint.swift @@ -1,4 +1,6 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import Foundation @@ -8,6 +10,8 @@ public extension Network.FileServer { case fileIndividual(String) case directUrl(URL) case sessionVersion + case extend(String) + case extendUrl(URL) public static var name: String { "FileServer.Endpoint" } @@ -17,6 +21,8 @@ public extension Network.FileServer { case .fileIndividual(let fileId): return "file/\(fileId)" case .directUrl(let url): return url.path.removingPrefix("/") case .sessionVersion: return "session_version" + case .extend(let fileId): return "/file/\(fileId)/extend" + case .extendUrl(let url): return "\(url.path.removingPrefix("/"))/extend" } } } diff --git a/SessionNetworkingKit/FileServer/Models/ExtendExpirationResponse.swift b/SessionNetworkingKit/FileServer/Models/ExtendExpirationResponse.swift new file mode 100644 index 0000000000..76f11cd75f --- /dev/null +++ b/SessionNetworkingKit/FileServer/Models/ExtendExpirationResponse.swift @@ -0,0 +1,17 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension Network.FileServer { + struct ExtendExpirationResponse: Codable { + public let size: Int + public let uploaded: TimeInterval + public let expires: TimeInterval + + public init(size: Int, uploaded: TimeInterval, expires: TimeInterval) { + self.size = size + self.uploaded = uploaded + self.expires = expires + } + } +} diff --git a/SessionNetworkingKit/FileServer/Types/HTTPFragmentParam+FileServer.swift b/SessionNetworkingKit/FileServer/Types/HTTPFragmentParam+FileServer.swift new file mode 100644 index 0000000000..2c35f4348d --- /dev/null +++ b/SessionNetworkingKit/FileServer/Types/HTTPFragmentParam+FileServer.swift @@ -0,0 +1,8 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension HTTPFragmentParam { + static let publicKey: HTTPFragmentParam = "p" + static let deterministicEncryption: HTTPFragmentParam = "d" +} diff --git a/SessionNetworkingKit/LibSession/LibSession+Networking.swift b/SessionNetworkingKit/LibSession/LibSession+Networking.swift index 53fabef8b7..7c43114a6d 100644 --- a/SessionNetworkingKit/LibSession/LibSession+Networking.swift +++ b/SessionNetworkingKit/LibSession/LibSession+Networking.swift @@ -109,16 +109,18 @@ class LibSessionNetwork: NetworkType { .eraseToAnyPublisher() } - func send( - _ body: Data?, - to destination: Network.Destination, + func send( + endpoint: E, + destination: Network.Destination, + body: Data?, requestTimeout: TimeInterval, requestAndPathBuildTimeout: TimeInterval? ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { switch destination { case .server, .serverUpload, .serverDownload, .cached: return sendRequest( - to: destination, + endpoint: endpoint, + destination: destination, body: body, requestTimeout: requestTimeout, requestAndPathBuildTimeout: requestAndPathBuildTimeout @@ -128,7 +130,8 @@ class LibSessionNetwork: NetworkType { guard body != nil else { return Fail(error: NetworkError.invalidPreparedRequest).eraseToAnyPublisher() } return sendRequest( - to: destination, + endpoint: endpoint, + destination: destination, body: body, requestTimeout: requestTimeout, requestAndPathBuildTimeout: requestAndPathBuildTimeout @@ -143,7 +146,8 @@ class LibSessionNetwork: NetworkType { return getSwarm(for: swarmPublicKey) .tryFlatMapWithRandomSnode(retry: retryCount, using: dependencies) { [weak self] snode in try self.validOrThrow().sendRequest( - to: .snode(snode, swarmPublicKey: swarmPublicKey), + endpoint: endpoint, + destination: .snode(snode, swarmPublicKey: swarmPublicKey), body: body, requestTimeout: requestTimeout, requestAndPathBuildTimeout: requestAndPathBuildTimeout @@ -168,7 +172,8 @@ class LibSessionNetwork: NetworkType { else { throw NetworkError.invalidPreparedRequest } return try self.validOrThrow().sendRequest( - to: .snode(snode, swarmPublicKey: swarmPublicKey), + endpoint: endpoint, + destination: .snode(snode, swarmPublicKey: swarmPublicKey), body: updatedBody, requestTimeout: requestTimeout, requestAndPathBuildTimeout: requestAndPathBuildTimeout @@ -227,9 +232,10 @@ class LibSessionNetwork: NetworkType { } // MARK: - Internal Functions - + private func sendRequest( - to destination: Network.Destination, + endpoint: (any EndpointType), + destination: Network.Destination, body: T?, requestTimeout: TimeInterval, requestAndPathBuildTimeout: TimeInterval? @@ -286,8 +292,8 @@ class LibSessionNetwork: NetworkType { ctx ) - case .server: - try destination.withUnsafePointer { cServerDestination in + case .server(let info): + try info.withUnsafePointer(endpoint: endpoint) { cServerDestination in network_send_onion_request_to_server_destination( network, cServerDestination, @@ -305,10 +311,10 @@ class LibSessionNetwork: NetworkType { ) } - case .serverUpload(_, let fileName): + case .serverUpload(let info, let fileName): guard !cPayloadBytes.isEmpty else { throw NetworkError.invalidPreparedRequest } - try destination.withUnsafePointer { cServerDestination in + try info.withUnsafePointer(endpoint: endpoint) { cServerDestination in network_upload_to_server( network, cServerDestination, @@ -327,8 +333,8 @@ class LibSessionNetwork: NetworkType { ) } - case .serverDownload: - try destination.withUnsafePointer { cServerDestination in + case .serverDownload(let info): + try info.withUnsafePointer(endpoint: endpoint) { cServerDestination in network_download_from_server( network, cServerDestination, @@ -566,41 +572,31 @@ extension network_service_node: @retroactive CAccessible, @retroactive CMutable // MARK: - Convenience -private extension Network.Destination { - func withUnsafePointer(_ body: (network_server_destination) throws -> Result) throws -> Result { - let method: HTTPMethod - let url: URL - let headers: [HTTPHeader: String]? - let x25519PublicKey: String - - switch self { - case .snode, .randomSnode, .randomSnodeLatestNetworkTimeTarget, .cached: throw NetworkError.invalidPreparedRequest - case .server(let info), .serverUpload(let info, _), .serverDownload(let info): - method = info.method - url = try info.url - headers = info.headers - x25519PublicKey = String(info.x25519PublicKey - .suffix(64)) // Quick way to drop '05' prefix if present - } +private extension Network.Destination.ServerInfo { + func withUnsafePointer(endpoint: (any EndpointType), _ body: (network_server_destination) throws -> Result) throws -> Result { + let x25519PublicKey: String = String(x25519PublicKey.suffix(64)) // Quick way to drop '05' prefix if present - guard let host: String = url.host else { throw NetworkError.invalidURL } + guard let host: String = self.host else { throw NetworkError.invalidURL } guard x25519PublicKey.count == 64 || x25519PublicKey.count == 66 else { throw LibSessionError.invalidCConversion } - let targetScheme: String = (url.scheme ?? "https") - let endpoint: String = url.path - .appending(url.query.map { value in "?\(value)" } ?? "") - let port: UInt16 = UInt16(url.port ?? (targetScheme == "https" ? 443 : 80)) - let headerKeys: [String] = (headers?.map { $0.key } ?? []) - let headerValues: [String] = (headers?.map { $0.value } ?? []) + let targetScheme: String = (self.scheme ?? "https") + let pathWithParams: String = Network.Destination.generatePathWithParamsAndFragments( + endpoint: endpoint, + queryParameters: queryParameters, + fragmentParameters: fragmentParameters + ) + let port: UInt16 = UInt16(self.port ?? (targetScheme == "https" ? 443 : 80)) + let headerKeys: [String] = headers.map { $0.key } + let headerValues: [String] = headers.map { $0.value } let headersSize = headerKeys.count // Use scoped closure to avoid manual memory management (crazy nesting but it ends up safer) return try method.rawValue.withCString { cMethodPtr in try targetScheme.withCString { cTargetSchemePtr in try host.withCString { cHostPtr in - try endpoint.withCString { cEndpointPtr in + try pathWithParams.withCString { cPathWithParamsPtr in try x25519PublicKey.withCString { cX25519PubkeyPtr in try headerKeys.withUnsafeCStrArray { headerKeysPtr in try headerValues.withUnsafeCStrArray { headerValuesPtr in @@ -608,7 +604,7 @@ private extension Network.Destination { method: cMethodPtr, protocol: cTargetSchemePtr, host: cHostPtr, - endpoint: cEndpointPtr, + endpoint: cPathWithParamsPtr, port: port, x25519_pubkey: cX25519PubkeyPtr, headers: headerKeysPtr.baseAddress, diff --git a/SessionNetworkingKit/Models/FileUploadResponse.swift b/SessionNetworkingKit/Models/FileUploadResponse.swift index 9d28838d92..08c101a2ae 100644 --- a/SessionNetworkingKit/Models/FileUploadResponse.swift +++ b/SessionNetworkingKit/Models/FileUploadResponse.swift @@ -4,10 +4,12 @@ import Foundation public struct FileUploadResponse: Codable { public let id: String + public let uploaded: TimeInterval? public let expires: TimeInterval? - public init(id: String, expires: TimeInterval?) { + public init(id: String, uploaded: TimeInterval?, expires: TimeInterval?) { self.id = id + self.uploaded = uploaded self.expires = expires } } @@ -24,14 +26,16 @@ extension FileUploadResponse { if let intValue: Int64 = try? container.decode(Int64.self, forKey: .id) { self = FileUploadResponse( id: "\(intValue)", - expires: try? container.decode(TimeInterval?.self, forKey: .expires) + uploaded: try container.decodeIfPresent(TimeInterval.self, forKey: .uploaded), + expires: try container.decodeIfPresent(TimeInterval.self, forKey: .expires) ) return } self = FileUploadResponse( id: try container.decode(String.self, forKey: .id), - expires: try? container.decode(TimeInterval?.self, forKey: .expires) + uploaded: try container.decodeIfPresent(TimeInterval.self, forKey: .uploaded), + expires: try container.decodeIfPresent(TimeInterval.self, forKey: .expires) ) } } diff --git a/SessionNetworkingKit/PushNotification/Types/Request+PushNotificationAPI.swift b/SessionNetworkingKit/PushNotification/Types/Request+PushNotificationAPI.swift index 480c90bf65..ad4abeabc2 100644 --- a/SessionNetworkingKit/PushNotification/Types/Request+PushNotificationAPI.swift +++ b/SessionNetworkingKit/PushNotification/Types/Request+PushNotificationAPI.swift @@ -8,6 +8,7 @@ public extension Request where Endpoint == Network.PushNotification.Endpoint { method: HTTPMethod, endpoint: Endpoint, queryParameters: [HTTPQueryParam: String] = [:], + fragmentParameters: [HTTPFragmentParam: String] = [:], headers: [HTTPHeader: String] = [:], body: T? = nil ) throws { @@ -17,6 +18,7 @@ public extension Request where Endpoint == Network.PushNotification.Endpoint { method: method, server: Network.PushNotification.server, queryParameters: queryParameters, + fragmentParameters: fragmentParameters, headers: headers, x25519PublicKey: Network.PushNotification.serverPublicKey ), diff --git a/SessionNetworkingKit/SOGS/SOGS.swift b/SessionNetworkingKit/SOGS/SOGS.swift index 0c5e5ce75e..80299ad743 100644 --- a/SessionNetworkingKit/SOGS/SOGS.swift +++ b/SessionNetworkingKit/SOGS/SOGS.swift @@ -1,4 +1,6 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import Foundation diff --git a/SessionNetworkingKit/SOGS/SOGSAPI.swift b/SessionNetworkingKit/SOGS/SOGSAPI.swift index 981058ae32..a4a3b34b7e 100644 --- a/SessionNetworkingKit/SOGS/SOGSAPI.swift +++ b/SessionNetworkingKit/SOGS/SOGSAPI.swift @@ -621,16 +621,10 @@ public extension Network.SOGS { authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { - /// URL(String:) won't convert raw emojis, so need to do a little encoding here. - /// The raw emoji will come back when calling url.path - guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - throw SOGSError.invalidEmoji - } - return try Network.PreparedRequest( request: Request( method: .get, - endpoint: .reactors(roomToken, id: id, emoji: encodedEmoji), + endpoint: .reactors(roomToken, id: id, emoji: emoji), authMethod: authMethod ), responseType: NoResponse.self, @@ -651,16 +645,10 @@ public extension Network.SOGS { authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { - /// URL(String:) won't convert raw emojis, so need to do a little encoding here. - /// The raw emoji will come back when calling url.path - guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - throw SOGSError.invalidEmoji - } - return try Network.PreparedRequest( request: Request( method: .put, - endpoint: .reaction(roomToken, id: id, emoji: encodedEmoji), + endpoint: .reaction(roomToken, id: id, emoji: emoji), authMethod: authMethod ), responseType: ReactionAddResponse.self, @@ -679,16 +667,10 @@ public extension Network.SOGS { authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { - /// URL(String:) won't convert raw emojis, so need to do a little encoding here. - /// The raw emoji will come back when calling url.path - guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - throw SOGSError.invalidEmoji - } - return try Network.PreparedRequest( request: Request( method: .delete, - endpoint: .reaction(roomToken, id: id, emoji: encodedEmoji), + endpoint: .reaction(roomToken, id: id, emoji: emoji), authMethod: authMethod ), responseType: ReactionRemoveResponse.self, @@ -708,16 +690,10 @@ public extension Network.SOGS { authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { - /// URL(String:) won't convert raw emojis, so need to do a little encoding here. - /// The raw emoji will come back when calling url.path - guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - throw SOGSError.invalidEmoji - } - return try Network.PreparedRequest( request: Request( method: .delete, - endpoint: .reactionDelete(roomToken, id: id, emoji: encodedEmoji), + endpoint: .reactionDelete(roomToken, id: id, emoji: emoji), authMethod: authMethod ), responseType: ReactionRemoveAllResponse.self, @@ -1226,14 +1202,12 @@ public extension Network.SOGS { // MARK: - Authentication fileprivate static func signatureHeaders( - url: URL, method: HTTPMethod, + pathAndParamsString: String, body: Data?, authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> [HTTPHeader: String] { - let path: String = url.path - .appending(url.query.map { value in "?\(value)" }) let method: String = method.rawValue let timestamp: Int = Int(floor(dependencies.dateNow.timeIntervalSince1970)) @@ -1263,7 +1237,7 @@ public extension Network.SOGS { .appending(contentsOf: nonce) .appending(contentsOf: timestampBytes) .appending(contentsOf: method.bytes) - .appending(contentsOf: path.bytes) + .appending(contentsOf: pathAndParamsString.bytes) .appending(contentsOf: bodyHash ?? []) /// Sign the above message @@ -1369,12 +1343,65 @@ public extension Network.SOGS { preparedRequest: Network.PreparedRequest, using dependencies: Dependencies ) throws -> Network.Destination { + /// Handle the cached and invalid cases first (no need to sign them) + switch preparedRequest.destination { + case .cached: return preparedRequest.destination + case .snode, .randomSnode: throw NetworkError.unauthorised + default: break + } + guard let signingData: AdditionalSigningData = preparedRequest.additionalSignatureData as? AdditionalSigningData else { throw SOGSError.signingFailed } - return try preparedRequest.destination - .signed(data: signingData, body: preparedRequest.body, using: dependencies) + let signatureHeaders: [HTTPHeader: String] = try Network.SOGS.signatureHeaders( + method: preparedRequest.method, + pathAndParamsString: preparedRequest.path, + body: preparedRequest.body, + authMethod: signingData.authMethod, + using: dependencies + ) + + switch preparedRequest.destination { + case .server(let info): + return .server( + info: Network.Destination.ServerInfo( + method: info.method, + server: info.server, + queryParameters: info.queryParameters, + fragmentParameters: info.fragmentParameters, + headers: info.headers.updated(with: signatureHeaders), + x25519PublicKey: info.x25519PublicKey + ) + ) + + case .serverUpload(let info, let fileName): + return .serverUpload( + info: Network.Destination.ServerInfo( + method: info.method, + server: info.server, + queryParameters: info.queryParameters, + fragmentParameters: info.fragmentParameters, + headers: info.headers.updated(with: signatureHeaders), + x25519PublicKey: info.x25519PublicKey + ), + fileName: fileName + ) + + case .serverDownload(let info): + return .serverDownload( + info: Network.Destination.ServerInfo( + method: info.method, + server: info.server, + queryParameters: info.queryParameters, + fragmentParameters: info.fragmentParameters, + headers: info.headers.updated(with: signatureHeaders), + x25519PublicKey: info.x25519PublicKey + ) + ) + + case .snode, .randomSnode, .randomSnodeLatestNetworkTimeTarget, .cached: throw SOGSError.signingFailed + } } } @@ -1387,30 +1414,3 @@ private extension Network.SOGS { } } } - -private extension Network.Destination { - func signed(data: Network.SOGS.AdditionalSigningData, body: Data?, using dependencies: Dependencies) throws -> Network.Destination { - switch self { - case .snode, .randomSnode, .randomSnodeLatestNetworkTimeTarget: throw NetworkError.unauthorised - case .cached: return self - case .server(let info): return .server(info: try info.signed(data, body, using: dependencies)) - case .serverUpload(let info, let fileName): - return .serverUpload(info: try info.signed(data, body, using: dependencies), fileName: fileName) - - case .serverDownload(let info): - return .serverDownload(info: try info.signed(data, body, using: dependencies)) - } - } -} - -private extension Network.Destination.ServerInfo { - func signed(_ data: Network.SOGS.AdditionalSigningData, _ body: Data?, using dependencies: Dependencies) throws -> Network.Destination.ServerInfo { - return updated(with: try Network.SOGS.signatureHeaders( - url: url, - method: method, - body: body, - authMethod: data.authMethod, - using: dependencies - )) - } -} diff --git a/SessionNetworkingKit/SOGS/Types/Request+SOGS.swift b/SessionNetworkingKit/SOGS/Types/Request+SOGS.swift index 5373174584..607c494570 100644 --- a/SessionNetworkingKit/SOGS/Types/Request+SOGS.swift +++ b/SessionNetworkingKit/SOGS/Types/Request+SOGS.swift @@ -8,6 +8,7 @@ public extension Request where Endpoint == Network.SOGS.Endpoint { method: HTTPMethod = .get, endpoint: Endpoint, queryParameters: [HTTPQueryParam: String] = [:], + fragmentParameters: [HTTPFragmentParam: String] = [:], headers: [HTTPHeader: String] = [:], body: T? = nil, authMethod: AuthenticationMethod @@ -22,6 +23,7 @@ public extension Request where Endpoint == Network.SOGS.Endpoint { method: method, server: server, queryParameters: queryParameters, + fragmentParameters: fragmentParameters, headers: headers, x25519PublicKey: publicKey ), diff --git a/SessionNetworkingKit/SessionNetwork/SessionNetworkAPI.swift b/SessionNetworkingKit/SessionNetwork/SessionNetworkAPI.swift index 1d11aa7888..a0fe1260a5 100644 --- a/SessionNetworkingKit/SessionNetwork/SessionNetworkAPI.swift +++ b/SessionNetworkingKit/SessionNetwork/SessionNetworkAPI.swift @@ -23,6 +23,7 @@ public extension Network.SessionNetwork { method: .get, server: Network.SessionNetwork.networkAPIServer, queryParameters: [:], + fragmentParameters: [:], x25519PublicKey: Network.SessionNetwork.networkAPIServerPublicKey ) ), @@ -36,19 +37,16 @@ public extension Network.SessionNetwork { // MARK: - Authentication fileprivate static func signatureHeaders( - url: URL, method: HTTPMethod, + pathAndParamsString: String, body: Data?, using dependencies: Dependencies ) throws -> [HTTPHeader: String] { let timestamp: UInt64 = UInt64(floor(dependencies.dateNow.timeIntervalSince1970)) - let path: String = url.path - .appending(url.query.map { value in "?\(value)" }) - let signResult: (publicKey: String, signature: [UInt8]) = try sign( timestamp: timestamp, method: method.rawValue, - path: path, + pathAndParamsString: pathAndParamsString, body: body, using: dependencies ) @@ -63,7 +61,7 @@ public extension Network.SessionNetwork { private static func sign( timestamp: UInt64, method: String, - path: String, + pathAndParamsString: String, body: Data?, using dependencies: Dependencies ) throws -> (publicKey: String, signature: [UInt8]) { @@ -83,7 +81,7 @@ public extension Network.SessionNetwork { .signatureVersionBlind07( timestamp: timestamp, method: method, - path: path, + path: pathAndParamsString, body: bodyString, ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey ) @@ -100,19 +98,25 @@ public extension Network.SessionNetwork { preparedRequest: Network.PreparedRequest, using dependencies: Dependencies ) throws -> Network.Destination { - guard - let url: URL = preparedRequest.destination.url, - case let .server(info) = preparedRequest.destination - else { throw NetworkError.invalidPreparedRequest } + guard case let .server(info) = preparedRequest.destination else { + throw NetworkError.invalidPreparedRequest + } return .server( - info: info.updated( - with: try signatureHeaders( - url: url, - method: preparedRequest.method, - body: preparedRequest.body, - using: dependencies - ) + info: Network.Destination.ServerInfo( + method: info.method, + server: info.server, + queryParameters: info.queryParameters, + fragmentParameters: info.fragmentParameters, + headers: info.headers.updated( + with: try signatureHeaders( + method: preparedRequest.method, + pathAndParamsString: preparedRequest.path, + body: preparedRequest.body, + using: dependencies + ) + ), + x25519PublicKey: info.x25519PublicKey ) ) } diff --git a/SessionNetworkingKit/Types/Destination.swift b/SessionNetworkingKit/Types/Destination.swift index 9e48a355e4..b8e0c473a3 100644 --- a/SessionNetworkingKit/Types/Destination.swift +++ b/SessionNetworkingKit/Types/Destination.swift @@ -8,46 +8,33 @@ import SessionUtilitiesKit public extension Network { enum Destination: Equatable { public struct ServerInfo: Equatable { - private static let invalidServer: String = "INVALID_SERVER" - private static let invalidUrl: URL = URL(fileURLWithPath: "INVALID_URL") - - private let server: String - private let queryParameters: [HTTPQueryParam: String] - private let _url: URL - private let _pathAndParamsString: String - public let method: HTTPMethod + public let server: String + public let queryParameters: [HTTPQueryParam: String] + public let fragmentParameters: [HTTPFragmentParam: String] public let headers: [HTTPHeader: String] public let x25519PublicKey: String - public var url: URL { - get throws { - guard _url != ServerInfo.invalidUrl else { throw NetworkError.invalidURL } - - return _url - } - } - public var pathAndParamsString: String { - get throws { - guard _url != ServerInfo.invalidUrl else { throw NetworkError.invalidPreparedRequest } - - return _pathAndParamsString - } - } + // Use iOS URL processing to extract the values from `server` + + public var host: String? { URLComponents(string: server)?.host } + public var scheme: String? { URLComponents(string: server)?.scheme } + public var port: Int? { URLComponents(string: server)?.port } + + // MARK: - Initialization public init( method: HTTPMethod, server: String, queryParameters: [HTTPQueryParam: String], + fragmentParameters: [HTTPFragmentParam: String], headers: [HTTPHeader: String], x25519PublicKey: String ) { - self._url = ServerInfo.invalidUrl - self._pathAndParamsString = "" - self.method = method self.server = server self.queryParameters = queryParameters + self.fragmentParameters = fragmentParameters self.headers = headers self.x25519PublicKey = x25519PublicKey } @@ -56,53 +43,25 @@ public extension Network { method: HTTPMethod, url: URL, server: String?, - pathAndParamsString: String?, - queryParameters: [HTTPQueryParam: String] = [:], + queryParameters: [HTTPQueryParam: String], + fragmentParameters: [HTTPFragmentParam: String], headers: [HTTPHeader: String], x25519PublicKey: String - ) { - self._url = url - self._pathAndParamsString = (pathAndParamsString ?? url.path) - + ) throws { self.method = method - self.server = { + self.server = try { if let explicitServer: String = server { return explicitServer } if let urlHost: String = url.host { return "\(url.scheme.map { "\($0)://" } ?? "")\(urlHost)" } - return ServerInfo.invalidServer + throw NetworkError.invalidURL }() self.queryParameters = queryParameters + self.fragmentParameters = fragmentParameters self.headers = headers self.x25519PublicKey = x25519PublicKey } - - fileprivate func updated(for endpoint: E) throws -> ServerInfo { - let pathAndParamsString: String = generatePathsAndParams(endpoint: endpoint, queryParameters: queryParameters) - - return ServerInfo( - method: method, - url: try (URL(string: "\(server)\(pathAndParamsString)") ?? { throw NetworkError.invalidURL }()), - server: server, - pathAndParamsString: pathAndParamsString, - queryParameters: queryParameters, - headers: headers, - x25519PublicKey: x25519PublicKey - ) - } - - public func updated(with headers: [HTTPHeader: String]) -> ServerInfo { - return ServerInfo( - method: method, - url: _url, - server: server, - pathAndParamsString: _pathAndParamsString, - queryParameters: queryParameters, - headers: self.headers.updated(with: headers), - x25519PublicKey: x25519PublicKey - ) - } } case snode(LibSession.Snode, swarmPublicKey: String?) @@ -126,11 +85,10 @@ public extension Network { } } - public var url: URL? { + public var server: String? { switch self { - case .server(let info), .serverUpload(let info, _), .serverDownload(let info): return try? info.url - case .snode, .randomSnode, .randomSnodeLatestNetworkTimeTarget: return nil - case .cached: return nil + case .server(let info), .serverUpload(let info, _), .serverDownload(let info): return info.server + default: return nil } } @@ -144,11 +102,21 @@ public extension Network { } } - public var urlPathAndParamsString: String { + public var queryParameters: [HTTPQueryParam: String] { switch self { case .server(let info), .serverUpload(let info, _), .serverDownload(let info): - return ((try? info.pathAndParamsString) ?? "") - default: return "" + return info.queryParameters + + default: return [:] + } + } + + public var fragmentParameters: [HTTPFragmentParam: String] { + switch self { + case .server(let info), .serverUpload(let info, _), .serverDownload(let info): + return info.fragmentParameters + + default: return [:] } } @@ -156,6 +124,7 @@ public extension Network { method: HTTPMethod = .get, server: String, queryParameters: [HTTPQueryParam: String] = [:], + fragmentParameters: [HTTPFragmentParam: String] = [:], headers: [HTTPHeader: String] = [:], x25519PublicKey: String ) throws -> Destination { @@ -163,6 +132,26 @@ public extension Network { method: method, server: server, queryParameters: queryParameters, + fragmentParameters: fragmentParameters, + headers: headers, + x25519PublicKey: x25519PublicKey + )) + } + + public static func server( + method: HTTPMethod = .get, + url: URL, + queryParameters: [HTTPQueryParam: String] = [:], + fragmentParameters: [HTTPFragmentParam: String] = [:], + headers: [HTTPHeader: String] = [:], + x25519PublicKey: String + ) throws -> Destination { + return .server(info: try ServerInfo( + method: method, + url: url, + server: nil, + queryParameters: queryParameters, + fragmentParameters: fragmentParameters, headers: headers, x25519PublicKey: x25519PublicKey )) @@ -171,6 +160,7 @@ public extension Network { public static func serverUpload( server: String, queryParameters: [HTTPQueryParam: String] = [:], + fragmentParameters: [HTTPFragmentParam: String] = [:], headers: [HTTPHeader: String] = [:], x25519PublicKey: String, fileName: String? @@ -180,6 +170,7 @@ public extension Network { method: .post, server: server, queryParameters: queryParameters, + fragmentParameters: fragmentParameters, headers: headers, x25519PublicKey: x25519PublicKey ), @@ -190,15 +181,17 @@ public extension Network { public static func serverDownload( url: URL, queryParameters: [HTTPQueryParam: String] = [:], + fragmentParameters: [HTTPFragmentParam: String] = [:], headers: [HTTPHeader: String] = [:], x25519PublicKey: String, fileName: String? ) throws -> Destination { - return .serverDownload(info: ServerInfo( + return .serverDownload(info: try ServerInfo( method: .get, url: url, server: nil, - pathAndParamsString: nil, + queryParameters: queryParameters, + fragmentParameters: fragmentParameters, headers: headers, x25519PublicKey: x25519PublicKey )) @@ -225,27 +218,25 @@ public extension Network { // MARK: - Convenience - internal static func generatePathsAndParams(endpoint: E, queryParameters: [HTTPQueryParam: String]) -> String { - return [ + internal static func generatePathWithParamsAndFragments( + endpoint: E, + queryParameters: [HTTPQueryParam: String], + fragmentParameters: [HTTPFragmentParam: String] + ) -> String { + let pathWithParams: String = [ "/\(endpoint.path)", - queryParameters - .map { key, value in "\(key)=\(value)" } - .joined(separator: "&") + HTTPQueryParam.string(for: queryParameters) ] - .compactMap { $0 } .filter { !$0.isEmpty } .joined(separator: "?") - } - - internal func withGeneratedUrl(for endpoint: E) throws -> Destination { - switch self { - case .server(let info): return .server(info: try info.updated(for: endpoint)) - case .serverUpload(let info, let fileName): - return .serverUpload(info: try info.updated(for: endpoint), fileName: fileName) - case .serverDownload(let info): return .serverDownload(info: try info.updated(for: endpoint)) - - default: return self - } + + + return [ + pathWithParams, + HTTPFragmentParam.string(for: fragmentParameters) + ] + .filter { !$0.isEmpty } + .joined(separator: "#") } // MARK: - Equatable diff --git a/SessionNetworkingKit/Types/HTTPFragmentParam.swift b/SessionNetworkingKit/Types/HTTPFragmentParam.swift new file mode 100644 index 0000000000..50821c5496 --- /dev/null +++ b/SessionNetworkingKit/Types/HTTPFragmentParam.swift @@ -0,0 +1,22 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public struct HTTPFragmentParam: RawRepresentable, Codable, ExpressibleByStringLiteral, Hashable { + public let rawValue: String + + public init(_ rawValue: String) { self.rawValue = rawValue } + public init?(rawValue: String) { self.init(rawValue) } + public init(stringLiteral value: String) { self.init(value) } + public init(unicodeScalarLiteral value: String) { self.init(value) } + public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } +} + +public extension HTTPFragmentParam { + static func string(for fragments: [HTTPFragmentParam: String]) -> String { + /// The clients are set up to handle keys with no values so exclude them since they would just waste characters + return fragments + .map { key, value in "\(key.rawValue)\(!value.isEmpty ? "=\(value)" : "")" } + .joined(separator: "&") + } +} diff --git a/SessionNetworkingKit/Types/HTTPQueryParam.swift b/SessionNetworkingKit/Types/HTTPQueryParam.swift index a766bf1edc..a350963bdb 100644 --- a/SessionNetworkingKit/Types/HTTPQueryParam.swift +++ b/SessionNetworkingKit/Types/HTTPQueryParam.swift @@ -2,4 +2,20 @@ import Foundation -public typealias HTTPQueryParam = String +public struct HTTPQueryParam: RawRepresentable, Codable, ExpressibleByStringLiteral, Hashable { + public let rawValue: String + + public init(_ rawValue: String) { self.rawValue = rawValue } + public init?(rawValue: String) { self.init(rawValue) } + public init(stringLiteral value: String) { self.init(value) } + public init(unicodeScalarLiteral value: String) { self.init(value) } + public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } +} + +public extension HTTPQueryParam { + static func string(for parameters: [HTTPQueryParam: String]) -> String { + return parameters + .map { key, value in "\(key.rawValue)=\(value)" } + .joined(separator: "&") + } +} diff --git a/SessionNetworkingKit/Types/Network.swift b/SessionNetworkingKit/Types/Network.swift index a11afeb54c..f7dd1251ec 100644 --- a/SessionNetworkingKit/Types/Network.swift +++ b/SessionNetworkingKit/Types/Network.swift @@ -42,9 +42,10 @@ public protocol NetworkType { func getSwarm(for swarmPublicKey: String) -> AnyPublisher, Error> func getRandomNodes(count: Int) -> AnyPublisher, Error> - func send( - _ body: Data?, - to destination: Network.Destination, + func send( + endpoint: E, + destination: Network.Destination, + body: Data?, requestTimeout: TimeInterval, requestAndPathBuildTimeout: TimeInterval? ) -> AnyPublisher<(ResponseInfoType, Data?), Error> diff --git a/SessionNetworkingKit/Types/PreparedRequest+Sending.swift b/SessionNetworkingKit/Types/PreparedRequest+Sending.swift index a49bae9cd0..d71a25c8dd 100644 --- a/SessionNetworkingKit/Types/PreparedRequest+Sending.swift +++ b/SessionNetworkingKit/Types/PreparedRequest+Sending.swift @@ -7,7 +7,13 @@ import SessionUtilitiesKit public extension Network.PreparedRequest { func send(using dependencies: Dependencies) -> AnyPublisher<(ResponseInfoType, R), Error> { return dependencies[singleton: .network] - .send(body, to: destination, requestTimeout: requestTimeout, requestAndPathBuildTimeout: requestAndPathBuildTimeout) + .send( + endpoint: endpoint, + destination: destination, + body: body, + requestTimeout: requestTimeout, + requestAndPathBuildTimeout: requestAndPathBuildTimeout + ) .decoded(with: self, using: dependencies) .retry(retryCount, using: dependencies) .handleEvents( diff --git a/SessionNetworkingKit/Types/PreparedRequest.swift b/SessionNetworkingKit/Types/PreparedRequest.swift index 67c1697324..66f1899245 100644 --- a/SessionNetworkingKit/Types/PreparedRequest.swift +++ b/SessionNetworkingKit/Types/PreparedRequest.swift @@ -230,7 +230,11 @@ public extension Network { self.method = request.destination.method self.endpoint = request.endpoint self.endpointName = E.name - self.path = request.destination.urlPathAndParamsString + self.path = Destination.generatePathWithParamsAndFragments( + endpoint: endpoint, + queryParameters: request.destination.queryParameters, + fragmentParameters: request.destination.fragmentParameters + ) self.headers = request.destination.headers self.batchEndpoints = batchEndpoints diff --git a/SessionNetworkingKit/Types/ProxiedContentDownloader.swift b/SessionNetworkingKit/Types/ProxiedContentDownloader.swift index 6bff42eac9..0939623ca2 100644 --- a/SessionNetworkingKit/Types/ProxiedContentDownloader.swift +++ b/SessionNetworkingKit/Types/ProxiedContentDownloader.swift @@ -12,6 +12,12 @@ public enum ProxiedContentRequestPriority: Equatable { case low, high } +// MARK: - Log.Category + +private extension Log.Category { + static let cat: Log.Category = .create("ProxiedContentDownloader", defaultLevel: .off) +} + // MARK: - Singleton public extension Singleton { @@ -154,8 +160,8 @@ public class ProxiedContentAssetRequest: Equatable { // Exactly one of success or failure should be called once, // on the main thread _unless_ this request is cancelled before // the request succeeds or fails. - private var success: ((ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void)? - private var failure: ((ProxiedContentAssetRequest) -> Void)? + private var success: (@MainActor (ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void)? + private var failure: (@MainActor (ProxiedContentAssetRequest) -> Void)? var shouldIgnoreSignalProxy = false var wasCancelled = false @@ -176,8 +182,8 @@ public class ProxiedContentAssetRequest: Equatable { init( assetDescription: ProxiedContentAssetDescription, priority: ProxiedContentRequestPriority, - success: @escaping ((ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void), - failure: @escaping ((ProxiedContentAssetRequest) -> Void), + success: @escaping (@MainActor (ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void), + failure: @escaping (@MainActor (ProxiedContentAssetRequest) -> Void), using dependencies: Dependencies ) { self.dependencies = dependencies @@ -340,17 +346,21 @@ public class ProxiedContentAssetRequest: Equatable { } public func requestDidSucceed(asset: ProxiedContentAsset) { - success?(self, asset) - + let callback: (@MainActor (ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void)? = success + // Only one of the callbacks should be called, and only once. clearCallbacks() + + Task { @MainActor in callback?(self, asset) } } public func requestDidFail() { - failure?(self) - + let callback: (@MainActor (ProxiedContentAssetRequest) -> Void)? = failure + // Only one of the callbacks should be called, and only once. clearCallbacks() + + Task { @MainActor in callback?(self) } } public static func == (lhs: ProxiedContentAssetRequest, rhs: ProxiedContentAssetRequest) -> Bool { @@ -482,12 +492,12 @@ open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessio // which case the ProxiedContentAssetRequest parameter will be nil. public func requestAsset(assetDescription: ProxiedContentAssetDescription, priority: ProxiedContentRequestPriority, - success:@escaping ((ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void), - failure:@escaping ((ProxiedContentAssetRequest) -> Void), + success:@escaping (@MainActor (ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void), + failure:@escaping (@MainActor (ProxiedContentAssetRequest) -> Void), shouldIgnoreSignalProxy: Bool = false) -> ProxiedContentAssetRequest? { if let asset = assetMap.get(key: assetDescription.url) { // Synchronous cache hit. - success(nil, asset) + Task { @MainActor in success(nil, asset) } return nil } @@ -636,9 +646,8 @@ open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessio // // stringlint:ignore_contents @MainActor private func processRequestQueueSync() { - guard let assetRequest = popNextAssetRequest() else { - return - } + guard let assetRequest = popNextAssetRequest() else { return } + guard !assetRequest.wasCancelled else { // Discard the cancelled asset request and try again. removeAssetRequestFromQueue(assetRequest: assetRequest) @@ -698,7 +707,7 @@ open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessio // Start a download task. guard let assetSegment = assetRequest.firstWaitingSegment() else { - print("queued asset request does not have a waiting segment.") + Log.verbose(.cat, "queued asset request does not have a waiting segment.") return } assetSegment.state = .downloading @@ -739,13 +748,13 @@ open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessio } guard let data = data, data.count > 0 else { - print("Asset size response missing data.") + Log.debug(.cat, "Asset size response missing data.") assetRequest.state = .failed self.assetRequestDidFail(assetRequest: assetRequest) return } guard let httpResponse = response as? HTTPURLResponse else { - print("Asset size response is invalid.") + Log.debug(.cat, "Asset size response is invalid.") assetRequest.state = .failed self.assetRequestDidFail(assetRequest: assetRequest) return @@ -753,7 +762,7 @@ open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessio var firstContentRangeString: String? for header in httpResponse.allHeaderFields.keys { guard let headerString = header as? String else { - print("Invalid header: \(header)") + Log.debug(.cat, "Invalid header: \(header)") continue } if headerString.lowercased() == "content-range" { // stringlint:ignore @@ -761,7 +770,7 @@ open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessio } } guard let contentRangeString = firstContentRangeString else { - print("Asset size response is missing content range.") + Log.debug(.cat, "Asset size response is missing content range.") assetRequest.state = .failed self.assetRequestDidFail(assetRequest: assetRequest) return @@ -778,13 +787,13 @@ open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessio } guard contentLengthString.count > 0, let contentLength = Int(contentLengthString) else { - print("Asset size response has unparsable content length.") + Log.debug(.cat, "Asset size response has unparsable content length.") assetRequest.state = .failed self.assetRequestDidFail(assetRequest: assetRequest) return } guard contentLength > 0 else { - print("Asset size response has invalid content length.") + Log.debug(.cat, "Asset size response has invalid content length.") assetRequest.state = .failed self.assetRequestDidFail(assetRequest: assetRequest) return @@ -942,3 +951,47 @@ open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessio } } } + +extension ProxiedContentDownloader { + /// Async/await version of requestAsset + public func requestAsset( + assetDescription: ProxiedContentAssetDescription, + priority: ProxiedContentRequestPriority, + shouldIgnoreSignalProxy: Bool = false + ) async throws -> ProxiedContentAsset { + if let asset: ProxiedContentAsset = assetMap.get(key: assetDescription.url) { + return asset + } + + return try await withCheckedThrowingContinuation { continuation in + var hasResumed: Bool = false + let lock: NSLock = NSLock() + let safeResume: (Result) -> Void = { result in + lock.lock() + defer { lock.unlock() } + + guard !hasResumed else { return } + hasResumed = true + + switch result { + case .success(let asset): continuation.resume(returning: asset) + case .failure(let error): continuation.resume(throwing: error) + } + } + + let request: ProxiedContentAssetRequest? = requestAsset( + assetDescription: assetDescription, + priority: priority, + success: { _, asset in safeResume(.success(asset)) }, + failure: { _ in safeResume(.failure(NetworkError.invalidResponse)) }, + shouldIgnoreSignalProxy: shouldIgnoreSignalProxy + ) + + // If the task is already cancelled, cancel the request immediately + if Task.isCancelled { + request?.cancel() + safeResume(.failure(CancellationError())) + } + } + } +} diff --git a/SessionNetworkingKit/Types/Request.swift b/SessionNetworkingKit/Types/Request.swift index f25efccaa9..69d5ee5231 100644 --- a/SessionNetworkingKit/Types/Request.swift +++ b/SessionNetworkingKit/Types/Request.swift @@ -48,7 +48,7 @@ public struct Request { body: T? = nil ) throws { self.endpoint = endpoint - self.destination = try destination.withGeneratedUrl(for: endpoint) + self.destination = destination self.body = body } diff --git a/SessionNetworkingKit/Utilities/URL+Utilities.swift b/SessionNetworkingKit/Utilities/URL+Utilities.swift new file mode 100644 index 0000000000..2baca71ef4 --- /dev/null +++ b/SessionNetworkingKit/Utilities/URL+Utilities.swift @@ -0,0 +1,40 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension URL { + var strippingQueryAndFragment: URL? { + guard var components: URLComponents = URLComponents(url: self, resolvingAgainstBaseURL: false) else { + return nil + } + components.queryItems = nil + components.fragment = nil + + return components.url + } + + var queryParameters: [HTTPQueryParam: String] { + guard + let components: URLComponents = URLComponents(url: self, resolvingAgainstBaseURL: false), + let queryItems: [URLQueryItem] = components.queryItems + else { return [:] } + + return queryItems.reduce(into: [:]) { result, next in + result[HTTPQueryParam(next.name)] = (next.value ?? "") + } + } + + var fragmentParameters: [HTTPFragmentParam: String] { + guard let fragment = self.fragment else { return [:] } + + // Parse fragment as if it were a query string + var components: URLComponents = URLComponents() + components.query = fragment + + guard let queryItems: [URLQueryItem] = components.queryItems else { return [:] } + + return queryItems.reduce(into: [:]) { result, next in + result[HTTPFragmentParam(next.name)] = (next.value ?? "") + } + } +} diff --git a/SessionNetworkingKitTests/SOGS/SOGSAPISpec.swift b/SessionNetworkingKitTests/SOGS/SOGSAPISpec.swift index 342be00b57..80ae78bb07 100644 --- a/SessionNetworkingKitTests/SOGS/SOGSAPISpec.swift +++ b/SessionNetworkingKitTests/SOGS/SOGSAPISpec.swift @@ -621,7 +621,15 @@ class SOGSAPISpec: QuickSpec { // MARK: ---- processes a valid response correctly it("processes a valid response correctly") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockCapabilitiesAndRoomResponse) var response: (info: ResponseInfoType, data: Network.SOGS.CapabilitiesAndRoomResponse)? @@ -656,7 +664,15 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ errors when not given a room response it("errors when not given a room response") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockCapabilitiesAndBanResponse) var response: (info: ResponseInfoType, data: Network.SOGS.CapabilitiesAndRoomResponse)? @@ -689,7 +705,15 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ errors when not given a capabilities response it("errors when not given a capabilities response") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockBanAndRoomResponse) var response: (info: ResponseInfoType, data: Network.SOGS.CapabilitiesAndRoomResponse)? @@ -793,7 +817,15 @@ class SOGSAPISpec: QuickSpec { // MARK: ---- processes a valid response correctly it("processes a valid response correctly") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockCapabilitiesAndRoomsResponse) var response: (info: ResponseInfoType, data: Network.SOGS.CapabilitiesAndRoomsResponse)? @@ -827,7 +859,15 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ errors when not given a room response it("errors when not given a room response") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn( MockNetwork.batchResponseData(with: [ (Network.SOGS.Endpoint.capabilities, Network.SOGS.CapabilitiesResponse.mockBatchSubResponse()), @@ -867,7 +907,15 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ errors when not given a capabilities response it("errors when not given a capabilities response") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockBanAndRoomsResponse) var response: (info: ResponseInfoType, data: Network.SOGS.CapabilitiesAndRoomsResponse)? @@ -2237,7 +2285,15 @@ class SOGSAPISpec: QuickSpec { beforeEach { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.response(type: [Network.SOGS.Room].self)) } diff --git a/SessionNetworkingKitTests/Types/BatchRequestSpec.swift b/SessionNetworkingKitTests/Types/BatchRequestSpec.swift index 05554a2be1..e7cd9a9c75 100644 --- a/SessionNetworkingKitTests/Types/BatchRequestSpec.swift +++ b/SessionNetworkingKitTests/Types/BatchRequestSpec.swift @@ -27,6 +27,7 @@ class BatchRequestSpec: QuickSpec { destination: try! .server( server: "testServer", queryParameters: [:], + fragmentParameters: [:], headers: [ "TestCustomHeader": "TestCustom", HTTPHeader.testHeader: "Test" @@ -63,6 +64,7 @@ class BatchRequestSpec: QuickSpec { destination: try! .server( server: "testServer", queryParameters: [:], + fragmentParameters: [:], headers: [ "TestCustomHeader": "TestCustom", HTTPHeader.testHeader: "Test" @@ -102,6 +104,7 @@ class BatchRequestSpec: QuickSpec { destination: try! .server( server: "testServer", queryParameters: [:], + fragmentParameters: [:], headers: [:], x25519PublicKey: "05\(TestConstants.publicKey)" ), @@ -132,6 +135,7 @@ class BatchRequestSpec: QuickSpec { destination: try! .server( server: "testServer", queryParameters: [:], + fragmentParameters: [:], headers: [:], x25519PublicKey: "05\(TestConstants.publicKey)" ), @@ -162,6 +166,7 @@ class BatchRequestSpec: QuickSpec { destination: try! .server( server: "testServer", queryParameters: [:], + fragmentParameters: [:], headers: [:], x25519PublicKey: "05\(TestConstants.publicKey)" ), @@ -196,6 +201,7 @@ class BatchRequestSpec: QuickSpec { destination: try! .server( server: "testServer", queryParameters: [:], + fragmentParameters: [:], headers: [:], x25519PublicKey: "05\(TestConstants.publicKey)" ), @@ -227,6 +233,7 @@ class BatchRequestSpec: QuickSpec { destination: try! .server( server: "testServer", queryParameters: [:], + fragmentParameters: [:], headers: [:], x25519PublicKey: "05\(TestConstants.publicKey)" ), @@ -258,6 +265,7 @@ class BatchRequestSpec: QuickSpec { destination: try! .server( server: "testServer", queryParameters: [:], + fragmentParameters: [:], headers: [:], x25519PublicKey: "05\(TestConstants.publicKey)" ), diff --git a/SessionNetworkingKitTests/Types/DestinationSpec.swift b/SessionNetworkingKitTests/Types/DestinationSpec.swift index e226a25f21..f50243fd77 100644 --- a/SessionNetworkingKitTests/Types/DestinationSpec.swift +++ b/SessionNetworkingKitTests/Types/DestinationSpec.swift @@ -9,25 +9,36 @@ import Nimble class DestinationSpec: QuickSpec { override class func spec() { + // MARK: Configuration + + @TestState var dependencies: TestDependencies! = TestDependencies() + + @TestState var urlRequest: URLRequest? + @TestState var preparedRequest: Network.PreparedRequest! + @TestState var request: Request! + @TestState var responseInfo: ResponseInfoType! = Network.ResponseInfo(code: 200, headers: [:]) + // MARK: - a Destination describe("a Destination") { // MARK: -- when generating a path context("when generating a path") { // MARK: ---- adds a leading forward slash to the endpoint path it("adds a leading forward slash to the endpoint path") { - let result: String = Network.Destination.generatePathsAndParams( + let result: String = Network.Destination.generatePathWithParamsAndFragments( endpoint: TestEndpoint.test1, - queryParameters: [:] + queryParameters: [:], + fragmentParameters: [:] ) expect(result).to(equal("/test1")) } - // MARK: ---- creates a valid URL with no query parameters - it("creates a valid URL with no query parameters") { - let result: String = Network.Destination.generatePathsAndParams( + // MARK: ---- creates a valid URL with no query parameters or fragments + it("creates a valid URL with no query parameters or fragments") { + let result: String = Network.Destination.generatePathWithParamsAndFragments( endpoint: TestEndpoint.test1, - queryParameters: [:] + queryParameters: [:], + fragmentParameters: [:] ) expect(result).to(equal("/test1")) @@ -35,28 +46,43 @@ class DestinationSpec: QuickSpec { // MARK: ---- creates a valid URL when query parameters are provided it("creates a valid URL when query parameters are provided") { - let result: String = Network.Destination.generatePathsAndParams( + let result: String = Network.Destination.generatePathWithParamsAndFragments( endpoint: TestEndpoint.test1, queryParameters: [ .testParam: "123" - ] + ], + fragmentParameters: [:] ) expect(result).to(equal("/test1?testParam=123")) } - } - - // MARK: -- for a server - context("for a server") { - // MARK: ---- throws an error if the generated URL is invalid - it("throws an error if the generated URL is invalid") { - expect { - _ = try Network.Destination.server( - server: "ftp:// test Server", - x25519PublicKey: "" - ).withGeneratedUrl(for: TestEndpoint.testParams("test", 123)) - } - .to(throwError(NetworkError.invalidURL)) + + // MARK: ---- creates a valid URL when fragment parameters are provided + it("creates a valid URL when fragment parameters are provided") { + let result: String = Network.Destination.generatePathWithParamsAndFragments( + endpoint: TestEndpoint.test1, + queryParameters: [:], + fragmentParameters: [ + .testFrag: "456" + ] + ) + + expect(result).to(equal("/test1#testFrag=456")) + } + + // MARK: ---- creates a valid URL when both query and fragment parameters are provided + it("creates a valid URL when both query and fragment parameters are provided") { + let result: String = Network.Destination.generatePathWithParamsAndFragments( + endpoint: TestEndpoint.test1, + queryParameters: [ + .testParam: "123" + ], + fragmentParameters: [ + .testFrag: "456" + ] + ) + + expect(result).to(equal("/test1?testParam=123#testFrag=456")) } } } @@ -65,10 +91,18 @@ class DestinationSpec: QuickSpec { // MARK: - Test Types +fileprivate extension HTTPHeader { + static let testHeader: HTTPHeader = "TestHeader" +} + fileprivate extension HTTPQueryParam { static let testParam: HTTPQueryParam = "testParam" } +fileprivate extension HTTPFragmentParam { + static let testFrag: HTTPFragmentParam = "testFrag" +} + fileprivate enum TestEndpoint: EndpointType { case test1 case testParams(String, Int) diff --git a/SessionNetworkingKitTests/Types/PreparedRequestSendingSpec.swift b/SessionNetworkingKitTests/Types/PreparedRequestSendingSpec.swift index a1ad6f438b..c3ce6176f7 100644 --- a/SessionNetworkingKitTests/Types/PreparedRequestSendingSpec.swift +++ b/SessionNetworkingKitTests/Types/PreparedRequestSendingSpec.swift @@ -45,7 +45,15 @@ class PreparedRequestSendingSpec: QuickSpec { context("when sending") { beforeEach { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.response(with: 1)) } @@ -352,7 +360,15 @@ class PreparedRequestSendingSpec: QuickSpec { beforeEach { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn( MockNetwork.batchResponseData(with: [ (endpoint: TestEndpoint.endpoint1, data: TestType.mockBatchSubResponse()), diff --git a/SessionNetworkingKitTests/Types/RequestSpec.swift b/SessionNetworkingKitTests/Types/RequestSpec.swift index 0b951cf1d3..4f920637f9 100644 --- a/SessionNetworkingKitTests/Types/RequestSpec.swift +++ b/SessionNetworkingKitTests/Types/RequestSpec.swift @@ -48,9 +48,10 @@ class RequestSpec: QuickSpec { ) ) - expect(request.destination.url?.absoluteString).to(equal("testServer/test1")) + expect(request.endpoint).to(equal(.test1)) + expect(request.destination.server).to(equal("testServer")) + expect(request.destination.queryParameters).to(equal([:])) expect(request.destination.method.rawValue).to(equal("DELETE")) - expect(request.destination.urlPathAndParamsString).to(equal("/test1")) expect(request.destination.headers).to(equal(["TestHeader": "test"])) expect(request.body).to(beNil()) } diff --git a/SessionNetworkingKitTests/_TestUtilities/CommonSSKMockExtensions.swift b/SessionNetworkingKitTests/_TestUtilities/CommonSSKMockExtensions.swift index bbd0834908..6ffb4bc3e4 100644 --- a/SessionNetworkingKitTests/_TestUtilities/CommonSSKMockExtensions.swift +++ b/SessionNetworkingKitTests/_TestUtilities/CommonSSKMockExtensions.swift @@ -37,7 +37,7 @@ extension Network.Destination: Mocked { server: "testServer", headers: [:], x25519PublicKey: "" - ).withGeneratedUrl(for: MockEndpoint.mock) + ) } extension Network.SOGS.CapabilitiesResponse: Mocked { diff --git a/SessionNetworkingKitTests/_TestUtilities/MockNetwork.swift b/SessionNetworkingKitTests/_TestUtilities/MockNetwork.swift index eff21161ac..a496460a77 100644 --- a/SessionNetworkingKitTests/_TestUtilities/MockNetwork.swift +++ b/SessionNetworkingKitTests/_TestUtilities/MockNetwork.swift @@ -19,27 +19,19 @@ class MockNetwork: Mock, NetworkType { return mock(args: [count]) } - func send( - _ body: Data?, - to destination: Network.Destination, + func send( + endpoint: E, + destination: Network.Destination, + body: Data?, requestTimeout: TimeInterval, requestAndPathBuildTimeout: TimeInterval? ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { requestData = RequestData( - body: body, method: destination.method, - pathAndParamsString: destination.urlPathAndParamsString, headers: destination.headers, - x25519PublicKey: { - switch destination { - case .server(let info), .serverUpload(let info, _), .serverDownload(let info): return info.x25519PublicKey - case .snode(_, let swarmPublicKey): return swarmPublicKey - case .randomSnode(let swarmPublicKey, _), .randomSnodeLatestNetworkTimeTarget(let swarmPublicKey, _, _): - return swarmPublicKey - - case .cached: return nil - } - }(), + path: endpoint.path, + queryParameters: destination.queryParameters, + body: body, requestTimeout: requestTimeout, requestAndPathBuildTimeout: requestAndPathBuildTimeout ) @@ -116,20 +108,20 @@ struct MockResponseInfo: ResponseInfoType, Mocked { struct RequestData: Codable, Mocked { static let mock: RequestData = RequestData( - body: nil, method: .get, - pathAndParamsString: "", headers: [:], - x25519PublicKey: nil, + path: "/mock", + queryParameters: [:], + body: nil, requestTimeout: 0, requestAndPathBuildTimeout: nil ) - let body: Data? let method: HTTPMethod - let pathAndParamsString: String let headers: [HTTPHeader: String] - let x25519PublicKey: String? + let path: String + let queryParameters: [HTTPQueryParam: String] + let body: Data? let requestTimeout: TimeInterval let requestAndPathBuildTimeout: TimeInterval? } diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 54fc5cd9a9..0e3e03e4b9 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -49,7 +49,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension Log.info(.cat, "didReceive called with requestId: \(request.identifier).") /// Create the context if we don't have it (needed before _any_ interaction with the database) - if !dependencies[singleton: .appContext].isValid { + if !dependencies.has(singleton: .appContext) || !dependencies[singleton: .appContext].isValid { dependencies.set(singleton: .appContext, to: NotificationServiceExtensionContext(using: dependencies)) Dependencies.setIsRTLRetriever(requiresMainThread: false) { NotificationServiceExtensionContext.determineDeviceRTL() diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index b1f420316f..f1dae8efd7 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -12,10 +12,11 @@ import SessionUtilitiesKit import SessionMessagingKit final class ShareNavController: UINavigationController { - public static var attachmentPrepPublisher: AnyPublisher<[SignalAttachment], Error>? + @MainActor public static var pendingAttachments: CurrentValueAsyncStream<[PendingAttachment]?> = CurrentValueAsyncStream(nil) /// The `ShareNavController` is initialized from a storyboard so we need to manually initialize this private let dependencies: Dependencies = Dependencies.createEmpty() + private var processPendingAttachmentsTask: Task? // MARK: - Error @@ -35,7 +36,7 @@ final class ShareNavController: UINavigationController { /// This should be the first thing we do (Note: If you leave the share context and return to it the context will already exist, trying /// to override it results in the share context crashing so ensure it doesn't exist first) - if !dependencies[singleton: .appContext].isValid { + if !dependencies.has(singleton: .appContext) { dependencies.set(singleton: .appContext, to: ShareAppExtensionContext(rootViewController: self, using: dependencies)) Dependencies.setIsRTLRetriever(requiresMainThread: false) { ShareAppExtensionContext.determineDeviceRTL() } } @@ -171,6 +172,7 @@ final class ShareNavController: UINavigationController { } deinit { + processPendingAttachmentsTask?.cancel() NotificationCenter.default.removeObserver(self) Log.flush() @@ -212,20 +214,34 @@ final class ShareNavController: UINavigationController { setViewControllers([ threadPickerVC ], animated: false) - let publisher = buildAttachments() - ModalActivityIndicatorViewController - .present( - fromViewController: self, - canCancel: false - ) { activityIndicator in - publisher - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .receive(on: DispatchQueue.main) - .sinkUntilComplete( - receiveCompletion: { _ in activityIndicator.dismiss { } } + let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController() + present(indicator, animated: false) + + processPendingAttachmentsTask?.cancel() + processPendingAttachmentsTask = Task.detached(priority: .userInitiated) { [weak self, indicator] in + guard let self = self else { return } + + do { + let attachments: [PendingAttachment] = try await buildAttachments() + + /// Validate the expected attachment sizes before proceeding + try attachments.forEach { attachment in + try attachment.ensureExpectedEncryptedSize( + domain: .attachment, + maxFileSize: Network.maxFileSize, + using: self.dependencies ) + } + + await ShareNavController.pendingAttachments.send(attachments) + await indicator.dismiss() + } + catch { + await indicator.dismiss { [weak self] in + self?.shareViewFailed(error: error) + } } - ShareNavController.attachmentPrepPublisher = publisher + } } func shareViewWasCompleted(threadId: String?, interactionId: Int64?) { @@ -249,7 +265,8 @@ final class ShareNavController: UINavigationController { Log.error("Failed to share due to error: \(error)") let errorTitle: String = { switch error { - case NetworkError.maxFileSizeExceeded: return "attachmentsErrorSending".localized() + case NetworkError.maxFileSizeExceeded, AttachmentError.fileSizeTooLarge: + return "attachmentsErrorSending".localized() case AttachmentError.noAttachment, AttachmentError.encryptionFailed: return Constants.app_name @@ -260,7 +277,9 @@ final class ShareNavController: UINavigationController { }() let errorText: String = { switch error { - case NetworkError.maxFileSizeExceeded: return "attachmentsErrorSize".localized() + case NetworkError.maxFileSizeExceeded, AttachmentError.fileSizeTooLarge: + return "attachmentsErrorSize".localized() + case AttachmentError.noAttachment, AttachmentError.encryptionFailed: return "attachmentsErrorSending".localized() @@ -281,26 +300,6 @@ final class ShareNavController: UINavigationController { } // MARK: Attachment Prep - - private class func createDataSource(type: UTType, url: URL, customFileName: String?, using dependencies: Dependencies) -> (any DataSource)? { - switch (type, type.conforms(to: .text)) { - // Share URLs as text messages whose text content is the URL - case (.url, _): return DataSourceValue(text: url.absoluteString, using: dependencies) - - // Share text as oversize text messages. - // - // NOTE: SharingThreadPickerViewController will try to unpack them - // and send them as normal text messages if possible. - case (_, true): return DataSourcePath(fileUrl: url, sourceFilename: customFileName, shouldDeleteOnDeinit: false, using: dependencies) - - default: - guard let dataSource = DataSourcePath(fileUrl: url, sourceFilename: customFileName, shouldDeleteOnDeinit: false, using: dependencies) else { - return nil - } - - return dataSource - } - } private func extractItemProviders() throws -> [NSItemProvider]? { guard let inputItems = self.extensionContext?.inputItems else { @@ -369,19 +368,6 @@ final class ShareNavController: UINavigationController { return [] } - - private func selectItemProviders() -> AnyPublisher<[NSItemProvider], Error> { - do { - let result: [NSItemProvider] = try extractItemProviders() ?? { - throw ShareViewControllerError.assertionError(description: "no input item") - }() - - return Just(result) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - catch { return Fail(error: error).eraseToAnyPublisher() } - } // MARK: - LoadedItem @@ -390,26 +376,18 @@ final class ShareNavController: UINavigationController { let itemUrl: URL let type: UTType - var customFileName: String? - var isConvertibleToTextMessage = false - var isConvertibleToContactShare = false - - init(itemProvider: NSItemProvider, - itemUrl: URL, - type: UTType, - customFileName: String? = nil, - isConvertibleToTextMessage: Bool = false, - isConvertibleToContactShare: Bool = false) { + init( + itemProvider: NSItemProvider, + itemUrl: URL, + type: UTType + ) { self.itemProvider = itemProvider self.itemUrl = itemUrl self.type = type - self.customFileName = customFileName - self.isConvertibleToTextMessage = isConvertibleToTextMessage - self.isConvertibleToContactShare = isConvertibleToContactShare } } - private func loadItemProvider(itemProvider: NSItemProvider) -> AnyPublisher { + private func pendingAttachment(itemProvider: NSItemProvider) async throws -> PendingAttachment { Log.info("utiTypes for attachment: \(itemProvider.registeredTypeIdentifiers)") // We need to be very careful about which UTI type we use. @@ -422,264 +400,202 @@ final class ShareNavController: UINavigationController { // so in the case of file attachments we try to refine the attachment type // using the file extension. guard let srcType: UTType = itemProvider.type else { - let error = ShareViewControllerError.unsupportedMedia - return Fail(error: error) - .eraseToAnyPublisher() + throw ShareViewControllerError.unsupportedMedia } Log.debug("matched UTType: \(srcType.identifier)") - - return Deferred { [weak self, dependencies] in - Future { resolver in - let loadCompletion: NSItemProvider.CompletionHandler = { value, error in - guard self != nil else { return } - if let error: Error = error { - resolver(Result.failure(error)) - return - } - - guard let value = value else { - resolver( - Result.failure(ShareViewControllerError.assertionError(description: "missing item provider")) + + let pendingAttachment: PendingAttachment = try await withCheckedThrowingContinuation { [itemProvider, dependencies] continuation in + itemProvider.loadItem(forTypeIdentifier: srcType.identifier, options: nil) { value, error in + if let error: Error = error { + return continuation.resume(throwing: error) + } + + switch value { + case .none: + return continuation.resume( + throwing: ShareViewControllerError.assertionError( + description: "missing item provider" + ) ) - return - } - - Log.debug("value type: \(type(of: value))") - - switch value { - case let data as Data: - let customFileName = "Contact.vcf" // stringlint:ignore - let customFileExtension: String? = srcType.sessionFileExtension(sourceFilename: nil) - - guard let tempFilePath = try? dependencies[singleton: .fileManager].write(data: data, toTemporaryFileWithExtension: customFileExtension) else { - resolver( - Result.failure(ShareViewControllerError.assertionError(description: "Error writing item data: \(String(describing: error))")) - ) - return - } - let fileUrl = URL(fileURLWithPath: tempFilePath) - - resolver( - Result.success( - LoadedItem( - itemProvider: itemProvider, - itemUrl: fileUrl, - type: srcType, - customFileName: customFileName, - isConvertibleToContactShare: false - ) + + case let data as Data: + guard let tempFilePath = try? dependencies[singleton: .fileManager].write(dataToTemporaryFile: data) else { + return continuation.resume( + throwing: ShareViewControllerError.assertionError( + description: "Error writing item data" ) ) - - case let string as String: - Log.debug("string provider: \(string)") - guard let data = string.filteredForDisplay.data(using: String.Encoding.utf8) else { - resolver( - Result.failure(ShareViewControllerError.assertionError(description: "Error writing item data: \(String(describing: error))")) - ) - return - } - guard let tempFilePath: String = try? dependencies[singleton: .fileManager].write(data: data, toTemporaryFileWithExtension: "txt") else { // stringlint:ignore - resolver( - Result.failure(ShareViewControllerError.assertionError(description: "Error writing item data: \(String(describing: error))")) - ) - return - } - - let fileUrl = URL(fileURLWithPath: tempFilePath) - - let isConvertibleToTextMessage = !itemProvider.registeredTypeIdentifiers.contains(UTType.fileURL.identifier) - - if srcType.conforms(to: .text) { - resolver( - Result.success( - LoadedItem( - itemProvider: itemProvider, - itemUrl: fileUrl, - type: srcType, - isConvertibleToTextMessage: isConvertibleToTextMessage - ) - ) - ) - } - else { - resolver( - Result.success( - LoadedItem( - itemProvider: itemProvider, - itemUrl: fileUrl, - type: .text, - isConvertibleToTextMessage: isConvertibleToTextMessage - ) - ) + } + + return continuation.resume( + returning: PendingAttachment( + source: .file(URL(fileURLWithPath: tempFilePath)), + utType: srcType, + using: dependencies + ) + ) + + case let string as String: + Log.debug("string provider: \(string)") + return continuation.resume( + returning: PendingAttachment( + source: .text(string.filteredForDisplay), + utType: srcType, + using: dependencies + ) + ) + + case let url as URL: + /// If it's not a file URL then the user is sharing a website so we should handle it as text + guard url.isFileURL else { + return continuation.resume( + returning: PendingAttachment( + source: .text(url.absoluteString), + utType: srcType, + using: dependencies ) - } - - case let url as URL: - // If the share itself is a URL (e.g. a link from Safari), try to send this as a text message. - let isConvertibleToTextMessage = ( - itemProvider.registeredTypeIdentifiers.contains(UTType.url.identifier) && - !itemProvider.registeredTypeIdentifiers.contains(UTType.fileURL.identifier) ) + } + + /// Otherwise we should copy the content into a temporary directory so we don't need to worry about + /// weird system file security issues when trying to eventually share it + let tmpPath: String = dependencies[singleton: .fileManager] + .temporaryFilePath(fileExtension: url.pathExtension) + + do { + try dependencies[singleton: .fileManager].copyItem(at: url, to: URL(fileURLWithPath: tmpPath)) - if isConvertibleToTextMessage { - resolver( - Result.success( - LoadedItem( - itemProvider: itemProvider, - itemUrl: url, - type: .url, - isConvertibleToTextMessage: isConvertibleToTextMessage - ) - ) - ) - } - else { - resolver( - Result.success( - LoadedItem( - itemProvider: itemProvider, - itemUrl: url, - type: srcType, - isConvertibleToTextMessage: isConvertibleToTextMessage - ) - ) + return continuation.resume( + returning: PendingAttachment( + source: .file(URL(fileURLWithPath: tmpPath)), + utType: (UTType(sessionFileExtension: url.pathExtension) ?? .url), + sourceFilename: url.lastPathComponent, + using: dependencies ) - } - - case let image as UIImage: - if let data = image.pngData() { - let tempFilePath: String = dependencies[singleton: .fileManager].temporaryFilePath(fileExtension: "png") // stringlint:ignore - do { - let url = NSURL.fileURL(withPath: tempFilePath) - try data.write(to: url) - - resolver( - Result.success( - LoadedItem( - itemProvider: itemProvider, - itemUrl: url, - type: srcType - ) - ) - ) - } - catch { - resolver( - Result.failure(ShareViewControllerError.assertionError(description: "couldn't write UIImage: \(String(describing: error))")) - ) - } - } - else { - resolver( - Result.failure(ShareViewControllerError.assertionError(description: "couldn't convert UIImage to PNG: \(String(describing: error))")) + ) + } + catch { + return continuation.resume( + throwing: ShareViewControllerError.assertionError( + description: "Failed to copy temporary file: \(error)" ) - } - - default: - // It's unavoidable that we may sometimes receives data types that we - // don't know how to handle. - resolver( - Result.failure(ShareViewControllerError.assertionError(description: "unexpected value: \(String(describing: value))")) ) - } + } + + case let image as UIImage: + return continuation.resume( + returning: PendingAttachment( + source: .media(.image(UUID().uuidString, image)), + utType: srcType, + using: dependencies + ) + ) + + default: + // It's unavoidable that we may sometimes receives data types that we + // don't know how to handle. + return continuation.resume( + throwing: ShareViewControllerError.assertionError( + description: "Unexpected value: \(String(describing: value))" + ) + ) } - - itemProvider.loadItem(forTypeIdentifier: srcType.identifier, options: nil, completionHandler: loadCompletion) } } - .eraseToAnyPublisher() - } - - private func buildAttachment(forLoadedItem loadedItem: LoadedItem) -> AnyPublisher { - let itemProvider = loadedItem.itemProvider - let itemUrl = loadedItem.itemUrl - - var url = itemUrl - do { - if isVideoNeedingRelocation(itemProvider: itemProvider, itemUrl: itemUrl) { - url = try SignalAttachment.copyToVideoTempDir(url: itemUrl, using: dependencies) + + /// Apple likes to use special formats for media so in order to maintain compatibility with other clients we want to + /// convert videos to `MPEG4` and images to `WebP` if it's not one of the supported output types + let utType: UTType = pendingAttachment.utType + let frameCount: Int = { + switch pendingAttachment.metadata { + case .media(let metadata): return metadata.frameCount + default: return 1 } - } catch { - let error = ShareViewControllerError.assertionError(description: "Could not copy video") - return Fail(error: error) - .eraseToAnyPublisher() - } - - Log.debug("building DataSource with url: \(url), UTType: \(loadedItem.type)") - - guard let dataSource = ShareNavController.createDataSource(type: loadedItem.type, url: url, customFileName: loadedItem.customFileName, using: dependencies) else { - let error = ShareViewControllerError.assertionError(description: "Unable to read attachment data") - return Fail(error: error) - .eraseToAnyPublisher() - } + }() - // start with base utiType, but it might be something generic like "image" - var specificType: UTType = loadedItem.type - if loadedItem.type == .url { - // Use kUTTypeURL for URLs. - } else if loadedItem.type.conforms(to: .text) { - // Use kUTTypeText for text. - } else if url.pathExtension.count > 0 { - // Determine a more specific utiType based on file extension - if let fileExtensionType: UTType = UTType(sessionFileExtension: url.pathExtension) { - Log.debug("UTType based on extension: \(fileExtensionType.identifier)") - specificType = fileExtensionType + if utType.isVideo && !UTType.supportedOutputVideoTypes.contains(utType) { + /// Since we need to convert the file we should clean up the temporary one we created earlier (the conversion will create + /// a new one) + defer { + switch pendingAttachment.source { + case .file(let url), .media(.url(let url)), .media(.videoUrl(let url, _, _, _)): + if dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(url.path) { + try? dependencies[singleton: .fileManager].removeItem(atPath: url.path) + } + default: break + } } + + let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare( + operations: [.convert(to: .mp4)], + using: dependencies + ) + + return PendingAttachment( + source: .media( + .videoUrl( + URL(fileURLWithPath: preparedAttachment.filePath), + .mpeg4Movie, + pendingAttachment.sourceFilename, + dependencies[singleton: .attachmentManager] + ) + ), + utType: .mpeg4Movie, + sourceFilename: pendingAttachment.sourceFilename, + using: dependencies + ) } - - guard !SignalAttachment.isInvalidVideo(dataSource: dataSource, type: specificType) else { - // This can happen, e.g. when sharing a quicktime-video from iCloud drive. - let (publisher, _) = SignalAttachment.compressVideoAsMp4(dataSource: dataSource, type: specificType, using: dependencies) - return publisher - } - - let attachment = SignalAttachment.attachment(dataSource: dataSource, type: specificType, imageQuality: .medium, using: dependencies) - if loadedItem.isConvertibleToContactShare { - Log.debug("isConvertibleToContactShare") - attachment.isConvertibleToContactShare = true - } else if loadedItem.isConvertibleToTextMessage { - Log.debug("isConvertibleToTextMessage") - attachment.isConvertibleToTextMessage = true + + if utType.isImage && frameCount == 1 && !UTType.supportedOutputImageTypes.contains(utType) { + /// Since we need to convert the file we should clean up the temporary one we created earlier (the conversion will create + /// a new one) + defer { + switch pendingAttachment.source { + case .file(let url), .media(.url(let url)), .media(.videoUrl(let url, _, _, _)): + if dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(url.path) { + try? dependencies[singleton: .fileManager].removeItem(atPath: url.path) + } + default: break + } + } + + let targetFormat: PendingAttachment.ConversionFormat = (dependencies[feature: .usePngInsteadOfWebPForFallbackImageType] ? + .png : .webPLossy + ) + let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare( + operations: [.convert(to: targetFormat)], + using: dependencies + ) + + return PendingAttachment( + source: .media(.url(URL(fileURLWithPath: preparedAttachment.filePath))), + utType: .webP, + sourceFilename: pendingAttachment.sourceFilename, + using: dependencies + ) } - return Just(attachment) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + + return pendingAttachment } - private func buildAttachments() -> AnyPublisher<[SignalAttachment], Error> { - return selectItemProviders() - .tryFlatMap { [weak self] itemProviders -> AnyPublisher<[SignalAttachment], Error> in - guard let strongSelf = self else { - throw ShareViewControllerError.assertionError(description: "expired") - } - - var loadPublishers = [AnyPublisher]() + private func buildAttachments() async throws -> [PendingAttachment] { + let itemProviders: [NSItemProvider] = try extractItemProviders() ?? { + throw ShareViewControllerError.assertionError(description: "no input item") + }() + + var result: [PendingAttachment] = [] - for itemProvider in itemProviders.prefix(SignalAttachment.maxAttachmentsAllowed) { - let loadPublisher = strongSelf.loadItemProvider(itemProvider: itemProvider) - .flatMap { loadedItem -> AnyPublisher in - return strongSelf.buildAttachment(forLoadedItem: loadedItem) - } - .eraseToAnyPublisher() + for itemProvider in itemProviders.prefix(AttachmentManager.maxAttachmentsAllowed) { + let attachment: PendingAttachment = try await pendingAttachment(itemProvider: itemProvider) - loadPublishers.append(loadPublisher) - } - - return Publishers - .MergeMany(loadPublishers) - .collect() - .eraseToAnyPublisher() - } - .tryMap { signalAttachments -> [SignalAttachment] in - guard signalAttachments.count > 0 else { - throw ShareViewControllerError.assertionError(description: "no valid attachments") - } - - return signalAttachments - } - .shareReplay(1) - .eraseToAnyPublisher() + result.append(attachment) + } + + guard !result.isEmpty else { + throw ShareViewControllerError.assertionError(description: "no valid attachments") + } + + return result } // Some host apps (e.g. iOS Photos.app) sometimes auto-converts some video formats (e.g. com.apple.quicktime-movie) @@ -808,12 +724,32 @@ private struct SAESNUIKitConfig: SNUIKit.ConfigType { 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 - ) + func assetInfo(for path: String, utType: UTType, sourceFilename: String?) -> (asset: AVURLAsset, isValidVideo: Bool, cleanup: () -> Void)? { + guard + let result: (asset: AVURLAsset, utType: UTType, cleanup: () -> Void) = AVURLAsset.asset( + for: path, + utType: utType, + sourceFilename: sourceFilename, + using: dependencies + ) + else { return nil } + + return (result.asset, MediaUtils.isValidVideo(asset: result.asset, utType: result.utType), result.cleanup) + } + + func mediaDecoderDefaultImageOptions() -> CFDictionary { + return dependencies[singleton: .mediaDecoder].defaultImageOptions + } + + func mediaDecoderDefaultThumbnailOptions(maxDimension: CGFloat) -> CFDictionary { + return dependencies[singleton: .mediaDecoder].defaultThumbnailOptions(maxDimension: maxDimension) + } + + func mediaDecoderSource(for url: URL) -> CGImageSource? { + return dependencies[singleton: .mediaDecoder].source(for: url) + } + + func mediaDecoderSource(for data: Data) -> CGImageSource? { + return dependencies[singleton: .mediaDecoder].source(for: data) } } diff --git a/SessionShareExtension/SimplifiedConversationCell.swift b/SessionShareExtension/SimplifiedConversationCell.swift index d4ced9d635..7d3c4d4047 100644 --- a/SessionShareExtension/SimplifiedConversationCell.swift +++ b/SessionShareExtension/SimplifiedConversationCell.swift @@ -49,11 +49,15 @@ final class SimplifiedConversationCell: UITableViewCell { return view }() - private lazy var displayNameLabel: UILabel = { - let result = UILabel() + private lazy var displayNameLabel: SessionLabelWithProBadge = { + let result = SessionLabelWithProBadge( + proBadgeSize: .mini, + withStretchingSpacer: false + ) result.font = .boldSystemFont(ofSize: Values.mediumFontSize) result.themeTextColor = .textPrimary result.lineBreakMode = .byTruncatingTail + result.isProBadgeHidden = true return result }() @@ -100,6 +104,7 @@ final class SimplifiedConversationCell: UITableViewCell { using: dependencies ) displayNameLabel.text = cellViewModel.displayName + displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) self.isAccessibilityElement = true self.accessibilityIdentifier = "Contact" diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 60295606b9..5c55187216 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -207,51 +207,69 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - ShareNavController.attachmentPrepPublisher? - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .receive(on: DispatchQueue.main) - .sinkUntilComplete( - receiveValue: { [weak self, dependencies = self.viewModel.dependencies] attachments in - guard - let strongSelf = self, - let approvalVC: UINavigationController = AttachmentApprovalViewController.wrappedInNavController( - threadId: strongSelf.viewModel.viewData[indexPath.row].threadId, - threadVariant: strongSelf.viewModel.viewData[indexPath.row].threadVariant, - attachments: attachments, - approvalDelegate: strongSelf, - disableLinkPreviewImageDownload: ( - strongSelf.viewModel.viewData[indexPath.row].threadCanUpload != true - ), - using: dependencies - ) - else { return } - - self?.navigationController?.present(approvalVC, animated: true, completion: nil) - } - ) + Task(priority: .userInitiated) { [weak self] in + let attachments: [PendingAttachment] = await ShareNavController.pendingAttachments.stream + .compactMap { $0 } + .first(where: { _ in true }) + .defaulting(to: []) + + guard + !attachments.isEmpty, + let self = self, + let approvalVC: UINavigationController = AttachmentApprovalViewController.wrappedInNavController( + threadId: self.viewModel.viewData[indexPath.row].threadId, + threadVariant: self.viewModel.viewData[indexPath.row].threadVariant, + attachments: attachments, + approvalDelegate: self, + disableLinkPreviewImageDownload: ( + self.viewModel.viewData[indexPath.row].threadCanUpload != true + ), + didLoadLinkPreview: { [weak self] linkPreview in + self?.viewModel.didLoadLinkPreview(linkPreview: linkPreview) + }, + using: self.viewModel.dependencies + ) + else { + self?.shareNavController?.shareViewFailed(error: AttachmentError.invalidData) + return + } + + navigationController?.present(approvalVC, animated: true, completion: nil) + } } func attachmentApproval( _ attachmentApproval: AttachmentApprovalViewController, - didApproveAttachments attachments: [SignalAttachment], + didApproveAttachments attachments: [PendingAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, messageText: String? ) { // Sharing a URL or plain text will populate the 'messageText' field so in those // cases we should ignore the attachments - let isSharingUrl: Bool = (attachments.count == 1 && attachments[0].isUrl) - let isSharingText: Bool = (attachments.count == 1 && attachments[0].isText) - let finalAttachments: [SignalAttachment] = (isSharingUrl || isSharingText ? [] : attachments) - let body: String? = ( - isSharingUrl && (messageText?.isEmpty == true || attachments[0].linkPreviewDraft == nil) ? - ( - (messageText?.isEmpty == true || (attachments[0].text() == messageText) ? - attachments[0].text() : - "\(attachments[0].text() ?? "")\n\n\(messageText ?? "")" // stringlint:ignore - ) - ) : - messageText + let isSharingUrl: Bool = (attachments.count == 1 && attachments[0].utType.conforms(to: .url)) + let isSharingText: Bool = { + guard attachments.count == 1 else { return false } + + switch attachments[0].source { + case .text: return true + default: return false + } + }() + let finalPendingAttachments: [PendingAttachment] = (isSharingUrl || isSharingText ? [] : attachments) + let body: String? = { + guard isSharingUrl else { return messageText } + + let attachmentText: String? = attachments[0].toText() + + return (messageText?.isEmpty == true || attachmentText == messageText ? + attachmentText : + "\(attachmentText ?? "")\n\n\(messageText ?? "")" // stringlint:ignore + ) + }() + let linkPreviewDraft: LinkPreviewDraft? = (isSharingUrl ? + viewModel.linkPreviewDrafts.first(where: { $0.urlString == body }) : + nil ) let userSessionId: SessionId = viewModel.dependencies[cache: .general].sessionId let swarmPublicKey: String = { @@ -263,29 +281,64 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView shareNavController?.dismiss(animated: true, completion: nil) - ModalActivityIndicatorViewController.present(fromViewController: shareNavController!, canCancel: false, message: "sending".localized()) { [dependencies = viewModel.dependencies] activityIndicator in + let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController( + canCancel: false, + message: "sending".localized() + ) + shareNavController?.present(indicator, animated: false) + + Task(priority: .userInitiated) { [weak self, indicator, dependencies = viewModel.dependencies] in dependencies[singleton: .storage].resumeDatabaseAccess() dependencies.mutate(cache: .libSessionNetwork) { $0.resumeNetworkAccess() } - /// When we prepare the message we set the timestamp to be the `dependencies[cache: .snodeAPI].currentOffsetTimestampMs()` - /// but won't actually have a value because the share extension won't have talked to a service node yet which can cause - /// issues with Disappearing Messages, as a result we need to explicitly `getNetworkTime` in order to ensure it's accurate - /// before we create the interaction var sharedInteractionId: Int64? - dependencies[singleton: .network] - .getSwarm(for: swarmPublicKey) - .tryFlatMapWithRandomSnode(using: dependencies) { snode in - try Network.SnodeAPI - .preparedGetNetworkTime(from: snode, using: dependencies) - .send(using: dependencies) + + do { + /// When we prepare the message we set the timestamp to be the `dependencies[cache: .snodeAPI].currentOffsetTimestampMs()` + /// but won't actually have a value because the share extension won't have talked to a service node yet which can cause + /// issues with Disappearing Messages, as a result we need to explicitly `getNetworkTime` in order to ensure it's accurate + /// before we create the interaction + // FIXME: Make this async/await when the refactored networking is merged + var swarm: Set = try await dependencies[singleton: .network] + .getSwarm(for: swarmPublicKey) + .values + .first(where: { _ in true }) ?? { throw AttachmentError.uploadFailed }() + let snode: LibSession.Snode = try dependencies.popRandomElement(&swarm) ?? { + throw SnodeAPIError.ranOutOfRandomSnodes(nil) + }() + try Task.checkCancellation() + + /// If there is a `LinkPreviewDraft` then we may need to add it, so generate it's attachment if possible + var linkPreviewPreparedAttachment: PreparedAttachment? + + if let linkPreviewDraft: LinkPreviewDraft = linkPreviewDraft { + linkPreviewPreparedAttachment = try? await LinkPreview.prepareAttachmentIfPossible( + urlString: linkPreviewDraft.urlString, + imageSource: linkPreviewDraft.imageSource, + using: dependencies + ) } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .flatMapStorageWritePublisher(using: dependencies) { db, _ -> (Message, Message.Destination, Int64?, AuthenticationMethod, [Network.PreparedRequest<(attachment: Attachment, fileId: String)>]) in + + /// Prepare any attachment to be sent + var finalAttachments: [Attachment] = try await AttachmentUploadJob.preparePriorToUpload( + attachments: finalPendingAttachments, + using: dependencies + ) + + typealias ShareDatabaseData = ( + message: Message, + destination: Message.Destination, + interactionId: Int64?, + authMethod: AuthenticationMethod, + attachmentsNeedingUpload: [Attachment] + ) + + let shareData: ShareDatabaseData = try await dependencies[singleton: .storage].writeAsync { db in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { throw MessageSenderError.noThread } - // Update the thread to be visible (if it isn't already) + /// Update the thread to be visible (if it isn't already) if !thread.shouldBeVisible || thread.pinnedPriority == LibSession.hiddenPriority { try SessionThread.updateVisibility( db, @@ -296,7 +349,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView ) } - // Create the interaction + /// Create the interaction let sentTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() let destinationDisappearingMessagesConfiguration: DisappearingMessagesConfiguration? = try? DisappearingMessagesConfiguration .filter(id: threadId) @@ -314,7 +367,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView expiresStartedAtMs: destinationDisappearingMessagesConfiguration?.initialExpiresStartedAtMs( sentTimestampMs: Double(sentTimestampMs) ), - linkPreviewUrl: (isSharingUrl ? attachments.first?.linkPreviewDraft?.urlString : nil), + linkPreviewUrl: linkPreviewDraft?.urlString, using: dependencies ).inserted(db) sharedInteractionId = interaction.id @@ -327,35 +380,28 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView // one then add it now if isSharingUrl, - let linkPreviewDraft: LinkPreviewDraft = attachments.first?.linkPreviewDraft, + let linkPreviewDraft: LinkPreviewDraft = linkPreviewDraft, (try? interaction.linkPreview.isEmpty(db)) == true { try LinkPreview( url: linkPreviewDraft.urlString, title: linkPreviewDraft.title, - attachmentId: LinkPreview - .generateAttachmentIfPossible( - imageData: linkPreviewDraft.jpegImageData, - type: .jpeg, - using: dependencies - )? + attachmentId: linkPreviewPreparedAttachment? + .attachment .inserted(db) .id, using: dependencies ).insert(db) } - // Process any attachments - try AttachmentUploader.process( + // Link any attachments to their interaction + try AttachmentUploadJob.link( db, - attachments: AttachmentUploader.prepare( - attachments: finalAttachments, - using: dependencies - ), - for: interactionId + attachments: finalAttachments, + toInteractionWithId: interactionId ) - // Using the same logic as the `MessageSendJob` retrieve + // Using the same logic as the `MessageSendJob` retrieve let authMethod: AuthenticationMethod = try Authentication.with( db, threadId: threadId, @@ -363,18 +409,10 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView using: dependencies ) let attachmentState: MessageSendJob.AttachmentState = try MessageSendJob - .fetchAttachmentState(db, interactionId: interactionId) - let preparedUploads: [Network.PreparedRequest<(attachment: Attachment, fileId: String)>] = try Attachment + .fetchAttachmentState(db, interactionId: interactionId, using: dependencies) + let attachmentsNeedingUpload: [Attachment] = try Attachment .filter(ids: attachmentState.allAttachmentIds) .fetchAll(db) - .map { attachment in - try AttachmentUploader.preparedUpload( - attachment: attachment, - logCategory: nil, - authMethod: authMethod, - using: dependencies - ) - } let visibleMessage: VisibleMessage = VisibleMessage.from(db, interaction: interaction) let destination: Message.Destination = try Message.Destination.from( db, @@ -382,68 +420,80 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView threadVariant: threadVariant ) - return (visibleMessage, destination, interaction.id, authMethod, preparedUploads) - } - .flatMap { (message: Message, destination: Message.Destination, interactionId: Int64?, authMethod: AuthenticationMethod, preparedUploads: [Network.PreparedRequest<(attachment: Attachment, fileId: String)>]) -> AnyPublisher<(Message, Message.Destination, Int64?, AuthenticationMethod, [(Attachment, String)]), Error> in - guard !preparedUploads.isEmpty else { - return Just((message, destination, interactionId, authMethod, [])) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - return Publishers - .MergeMany(preparedUploads.map { $0.send(using: dependencies) }) - .collect() - .map { results in (message, destination, interactionId, authMethod, results.map { _, value in value }) } - .eraseToAnyPublisher() - } - .tryFlatMap { message, destination, interactionId, authMethod, attachments -> AnyPublisher<(Message, [Attachment]), Error> in - try MessageSender - .preparedSend( - message: message, - to: destination, - namespace: destination.defaultNamespace, - interactionId: interactionId, - attachments: attachments, - authMethod: authMethod, - onEvent: MessageSender.standardEventHandling(using: dependencies), - using: dependencies - ) - .send(using: dependencies) - .map { _, message in - (message, attachments.map { attachment, _ in attachment }) - } - .eraseToAnyPublisher() + return (visibleMessage, destination, interaction.id, authMethod, attachmentsNeedingUpload) } - .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) + try Task.checkCancellation() + + /// Perform any uploads that are needed + let uploadedAttachments: [(attachment: Attachment, fileId: String)] = (shareData.attachmentsNeedingUpload.isEmpty ? + [] : + try await withThrowingTaskGroup(of: (attachment: Attachment, response: FileUploadResponse).self) { group in + shareData.attachmentsNeedingUpload.forEach { attachment in + group.addTask { + try await AttachmentUploadJob.upload( + attachment: attachment, + threadId: threadId, + interactionId: shareData.interactionId, + messageSendJobId: nil, + authMethod: shareData.authMethod, + onEvent: AttachmentUploadJob.standardEventHandling(using: dependencies), + using: dependencies + ) } } + + return try await group.reduce(into: []) { result, next in + result.append((next.attachment, next.response.id)) } + }) + + let request: Network.PreparedRequest = try MessageSender.preparedSend( + message: shareData.message, + to: shareData.destination, + namespace: shareData.destination.defaultNamespace, + interactionId: shareData.interactionId, + attachments: uploadedAttachments, + authMethod: shareData.authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), + using: dependencies ) - .receive(on: DispatchQueue.main) - .sinkUntilComplete( - receiveCompletion: { [weak self] result in - dependencies.mutate(cache: .libSessionNetwork) { $0.suspendNetworkAccess() } - dependencies[singleton: .storage].suspendDatabaseAccess() - Log.flush() - activityIndicator.dismiss { } - - switch result { - case .finished: self?.shareNavController?.shareViewWasCompleted( - threadId: threadId, - interactionId: sharedInteractionId - ) - case .failure(let error): self?.shareNavController?.shareViewFailed(error: error) + + // FIXME: Make this async/await when the refactored networking is merged + let response: Message = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw AttachmentError.uploadFailed }() + try Task.checkCancellation() + + /// Need to actually save the uploaded attachments now that we are done + if !uploadedAttachments.isEmpty { + try? await dependencies[singleton: .storage].writeAsync { db in + uploadedAttachments.forEach { attachment, _ in + try? attachment.upsert(db) } } - ) + } + + dependencies.mutate(cache: .libSessionNetwork) { $0.suspendNetworkAccess() } + dependencies[singleton: .storage].suspendDatabaseAccess() + Log.flush() + + await MainActor.run { [weak self] in + indicator.dismiss() + + self?.shareNavController?.shareViewWasCompleted( + threadId: threadId, + interactionId: sharedInteractionId + ) + } + } + catch { + dependencies.mutate(cache: .libSessionNetwork) { $0.suspendNetworkAccess() } + dependencies[singleton: .storage].suspendDatabaseAccess() + Log.flush() + indicator.dismiss() + self?.shareNavController?.shareViewFailed(error: error) + } } } @@ -454,7 +504,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeMessageText newMessageText: String?) { } - func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment) { + func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: PendingAttachment) { } func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) { diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index ffc6799dd6..206ca22f7b 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -14,6 +14,8 @@ public class ThreadPickerViewModel { public let dependencies: Dependencies public let userMetadata: ExtensionHelper.UserMetadata? public let hasNonTextAttachment: Bool + // FIXME: Clean up to follow proper MVVM + @MainActor public private(set) var linkPreviewDrafts: [LinkPreviewDraft] = [] init( userMetadata: ExtensionHelper.UserMetadata?, @@ -60,18 +62,17 @@ public class ThreadPickerViewModel { .shareQuery(userSessionId: userSessionId) .fetchAll(db) .map { threadViewModel in - let wasKickedFromGroup: Bool = ( - threadViewModel.threadVariant == .group && - dependencies.mutate(cache: .libSession) { cache in - cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadViewModel.threadId)) + let (wasKickedFromGroup, groupIsDestroyed): (Bool, Bool) = { + guard threadViewModel.threadVariant == .group else { return (false, false) } + + let sessionId: SessionId = SessionId(.group, hex: threadViewModel.threadId) + return dependencies.mutate(cache: .libSession) { cache in + ( + cache.wasKickedFromGroup(groupSessionId: sessionId), + cache.groupIsDestroyed(groupSessionId: sessionId) + ) } - ) - let groupIsDestroyed: Bool = ( - threadViewModel.threadVariant == .group && - dependencies.mutate(cache: .libSession) { cache in - cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadViewModel.threadId)) - } - ) + }() return threadViewModel.populatingPostQueryData( recentReactionEmoji: nil, @@ -97,6 +98,10 @@ public class ThreadPickerViewModel { // MARK: - Functions + @MainActor public func didLoadLinkPreview(linkPreview: LinkPreviewDraft) { + linkPreviewDrafts.append(linkPreview) + } + public func updateData(_ updatedData: [SessionThreadViewModel]) { self.viewData = updatedData } diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index 77514dedf8..b1c9946e0b 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -241,20 +241,12 @@ class ThreadSettingsViewModelSpec: AsyncSpec { expect(item?.title?.text).to(equal("TestUser")) } - // MARK: ---- has an edit icon - it("has an edit icon") { - let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) - .toEventuallyNot(beNil()) - .retrieveValue() - expect(item?.trailingAccessory).toNot(beNil()) - } - // MARK: ---- presents a confirmation modal when tapped it("presents a confirmation modal when tapped") { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - await item?.onTap?() + await item?.onTapView?(UIView()) await expect(screenTransitions.first?.destination) .toEventually(beAKindOf(ConfirmationModal.self)) expect(screenTransitions.first?.transition).to(equal(TransitionType.present)) @@ -270,7 +262,7 @@ class ThreadSettingsViewModelSpec: AsyncSpec { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - await item?.onTap?() + await item?.onTapView?(UIView()) await expect(screenTransitions.first?.destination) .toEventually(beAKindOf(ConfirmationModal.self)) @@ -455,21 +447,12 @@ class ThreadSettingsViewModelSpec: AsyncSpec { setupTestSubscriptions() } - // MARK: ------ has an edit icon - it("has an edit icon") { - let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) - .toEventuallyNot(beNil()) - .retrieveValue() - expect(item?.trailingAccessory).toNot(beNil()) - } - // MARK: ------ presents a confirmation modal when tapped it("presents a confirmation modal when tapped") { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - expect(item?.trailingAccessory).toNot(beNil()) - await item?.onTap?() + await item?.onTapView?(UIView()) await expect(screenTransitions.first?.destination) .toEventually(beAKindOf(ConfirmationModal.self)) expect(screenTransitions.first?.transition).to(equal(TransitionType.present)) @@ -584,21 +567,12 @@ class ThreadSettingsViewModelSpec: AsyncSpec { setupTestSubscriptions() } - // MARK: ------ has an edit icon - it("has an edit icon") { - let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) - .toEventuallyNot(beNil()) - .retrieveValue() - expect(item?.trailingAccessory).toNot(beNil()) - } - // MARK: ------ presents a confirmation modal when tapped it("presents a confirmation modal when tapped") { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - expect(item?.trailingAccessory).toNot(beNil()) - await item?.onTap?() + await item?.onTapView?(UIView()) await expect(screenTransitions.first?.destination) .toEventually(beAKindOf(ConfirmationModal.self)) expect(screenTransitions.first?.transition).to(equal(TransitionType.present)) @@ -626,7 +600,7 @@ class ThreadSettingsViewModelSpec: AsyncSpec { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - await item?.onTap?() + await item?.onTapView?(UIView()) await expect(screenTransitions.first?.destination) .toEventually(beAKindOf(ConfirmationModal.self)) diff --git a/SessionTests/Database/DatabaseSpec.swift b/SessionTests/Database/DatabaseSpec.swift index 662bb23e3b..9dede2231b 100644 --- a/SessionTests/Database/DatabaseSpec.swift +++ b/SessionTests/Database/DatabaseSpec.swift @@ -35,6 +35,7 @@ class DatabaseSpec: QuickSpec { userSessionId: SessionId(.standard, hex: TestConstants.publicKey), using: dependencies ) + @TestState(singleton: .mediaDecoder, in: dependencies) var mockMediaDecoder: MockMediaDecoder! = MockMediaDecoder(initialSetup: { $0.defaultInitialSetup() }) @TestState var initialResult: Result! = nil @TestState var finalResult: Result! = nil @@ -237,7 +238,8 @@ class DatabaseSpec: QuickSpec { "messagingKit.MoveSettingsToLibSession", "messagingKit.RenameAttachments", "messagingKit.AddProMessageFlag", - "LastProfileUpdateTimestamp" + "LastProfileUpdateTimestamp", + "RemoveQuoteUnusedColumnsAndForeignKeys" ])) } diff --git a/SessionTests/Onboarding/OnboardingSpec.swift b/SessionTests/Onboarding/OnboardingSpec.swift index d60698f19d..86e2af1abc 100644 --- a/SessionTests/Onboarding/OnboardingSpec.swift +++ b/SessionTests/Onboarding/OnboardingSpec.swift @@ -57,6 +57,9 @@ class OnboardingSpec: AsyncSpec { crypto .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } .thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes)) + crypto + .when { $0.generate(.hash(message: .any)) } + .thenReturn([1, 2, 3]) } ) @TestState(cache: .libSession, in: dependencies) var mockLibSession: MockLibSessionCache! = MockLibSessionCache( @@ -117,7 +120,15 @@ class OnboardingSpec: AsyncSpec { ) network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.batchResponseData( with: [ ( @@ -165,6 +176,19 @@ class OnboardingSpec: AsyncSpec { @TestState(cache: .snodeAPI, in: dependencies) var mockSnodeAPICache: MockSnodeAPICache! = MockSnodeAPICache( initialSetup: { $0.defaultInitialSetup() } ) + @TestState(singleton: .jobRunner, in: dependencies) var mockJobRunner: MockJobRunner! = MockJobRunner( + initialSetup: { jobRunner in + jobRunner + .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } + .thenReturn(nil) + jobRunner + .when { $0.upsert(.any, job: .any, canStartJob: .any) } + .thenReturn(nil) + jobRunner + .when { $0.jobInfoFor(jobs: .any, state: .any, variant: .any) } + .thenReturn([:]) + } + ) @TestState var disposables: [AnyCancellable]! = [] @TestState var cache: Onboarding.Cache! @@ -484,8 +508,8 @@ class OnboardingSpec: AsyncSpec { await expect(mockNetwork) .toEventually(call(.exactly(times: 1), matchingParameters: .atLeast(3)) { $0.send( - Data(base64Encoded: base64EncodedDataString), - to: Network.Destination.snode( + endpoint: Network.SnodeAPI.Endpoint.batch, + destination: Network.Destination.snode( LibSession.Snode( ip: "1.2.3.4", quicPort: 1234, @@ -493,6 +517,7 @@ class OnboardingSpec: AsyncSpec { ), swarmPublicKey: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b" ), + body: Data(base64Encoded: base64EncodedDataString), requestTimeout: 10, requestAndPathBuildTimeout: nil ) @@ -545,6 +570,10 @@ class OnboardingSpec: AsyncSpec { // MARK: - an Onboarding Cache - Complete Registration describe("an Onboarding Cache when completing registration") { justBeforeEach { + /// The `profile_updated` timestamp in `libSession` is set to now so we need to set the value to some + /// distant future value to force the update logic to trigger + dependencies.dateNow = Date(timeIntervalSince1970: 12345678900) + cache = Onboarding.Cache( flow: .register, using: dependencies @@ -612,7 +641,7 @@ class OnboardingSpec: AsyncSpec { nickname: nil, displayPictureUrl: nil, displayPictureEncryptionKey: nil, - profileLastUpdated: 1234567890, + profileLastUpdated: 12345678900, blocksCommunityMessageRequests: nil ) ])) @@ -643,17 +672,19 @@ class OnboardingSpec: AsyncSpec { // MARK: -- has the correct profile in libSession it("has the correct profile in libSession") { - expect(dependencies.mutate(cache: .libSession) { $0.profile }).to(equal( + let profile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } + expect(profile).to(equal( Profile( id: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b", name: "TestCompleteName", nickname: nil, displayPictureUrl: nil, displayPictureEncryptionKey: nil, - profileLastUpdated: nil, + profileLastUpdated: profile.profileLastUpdated, blocksCommunityMessageRequests: nil ) )) + expect(profile.profileLastUpdated).toNot(beNil()) } // MARK: -- saves a config dump to the database @@ -661,36 +692,59 @@ class OnboardingSpec: AsyncSpec { let result: [ConfigDump]? = mockStorage.read { db in try ConfigDump.fetchAll(db) } - try require(result).to(haveCount(1)) - /// Since the `UserProfile` data is not deterministic then the best we can do is compare the `ConfigDump` - /// without it's data to ensure everything else is correct, then check that the dump data contains expected values - let resultData: Data = result![0].data - let resultWithoutData: ConfigDump = ConfigDump( - variant: result![0].variant, - sessionId: result![0].sessionId.hexString, - data: Data(), - timestampMs: result![0].timestampMs - ) - var resultDataString: String = "" + try require(result).to(haveCount(2)) + try require(Set((result?.map { $0.variant })!)).to(equal([.userProfile, .local])) + expect(result![0].variant).to(equal(.userProfile)) + let userProfileDump: ConfigDump = (result?.first(where: { $0.variant == .userProfile }))! + expect(userProfileDump.variant).to(equal(.userProfile)) + expect(userProfileDump.sessionId).to(equal(SessionId(.standard, hex: TestConstants.publicKey))) + expect(userProfileDump.timestampMs).to(equal(1234567890000)) - for i in (0.. = resultData.range(of: prefixData), + let infixRange: Range = resultData + .range(of: infixData, in: prefixRange.upperBound.. = resultData + .range(of: suffixData, in: infixRange.upperBound.. = prefixRange.upperBound.. CGFloat { - var allMentionRadii: [CGFloat?] = [] - let path: CGMutablePath = CGMutablePath() - path.addRect(CGRect( - x: 0, - y: 0, - width: CGFloat.greatestFiniteMagnitude, - height: CGFloat.greatestFiniteMagnitude - )) - - let framesetter = CTFramesetterCreateWithAttributedString(attributedText as CFAttributedString) - let frame: CTFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attributedText.length), path, nil) - let lines: [CTLine] = frame.lines - - lines.forEach { line in - let runs: [CTRun] = line.ctruns - - runs.forEach { run in - let attributes: NSDictionary = CTRunGetAttributes(run) - allMentionRadii.append( - attributes - .value(forKey: NSAttributedString.Key.currentUserMentionBackgroundPadding.rawValue) as? CGFloat - ) - } - } - - let maxRadii: CGFloat? = allMentionRadii - .compactMap { $0 } - .max() - - return (maxRadii ?? 0) - } - - // MARK: - Drawing - - override public func draw(_ rect: CGRect) { - guard - let targetLabel: UILabel = self.targetLabel, - let attributedText: NSAttributedString = targetLabel.attributedText, - let context = UIGraphicsGetCurrentContext() - else { return } - - // Need to invery the Y axis because iOS likes to render from the bottom left instead of the top left - context.textMatrix = .identity - context.translateBy(x: 0, y: bounds.size.height) - context.scaleBy(x: 1.0, y: -1.0) - - // Note: Calculations MUST happen based on the 'targetLabel' size as this class has extra padding - // which can result in calculations being off - let path = CGMutablePath() - let size = targetLabel.sizeThatFits(CGSize(width: targetLabel.bounds.width, height: .greatestFiniteMagnitude)) - path.addRect(CGRect(x: 0, y: 0, width: size.width, height: size.height), transform: .identity) - - let framesetter = CTFramesetterCreateWithAttributedString(attributedText as CFAttributedString) - let frame: CTFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attributedText.length), path, nil) - let lines: [CTLine] = frame.lines - - var origins = [CGPoint](repeating: .zero, count: lines.count) - CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins) - - var currentMentionBounds: CGRect? = nil // Store mention bounding box - var lastMentionBackgroundColor: UIColor = .clear - var lastMentionBackgroundCornerRadius: CGFloat = 0 - - for lineIndex in 0.. Void)?, diff --git a/SessionUIKit/Components/Modals & Toast/Modal.swift b/SessionUIKit/Components/Modals & Toast/Modal.swift index 9f8738dfbb..af3eeb0423 100644 --- a/SessionUIKit/Components/Modals & Toast/Modal.swift +++ b/SessionUIKit/Components/Modals & Toast/Modal.swift @@ -151,11 +151,19 @@ open class Modal: UIViewController, UIGestureRecognizerDelegate { public static func createButton(title: String, titleColor: ThemeValue) -> UIButton { let result: UIButton = UIButton() result.titleLabel?.font = .boldSystemFont(ofSize: Values.mediumFontSize) + result.titleLabel?.numberOfLines = 0 + result.titleLabel?.textAlignment = .center result.setTitle(title, for: .normal) result.setThemeTitleColor(titleColor, for: .normal) result.setThemeBackgroundColor(.alert_buttonBackground, for: .normal) result.setThemeBackgroundColor(.highlighted(.alert_buttonBackground), for: .highlighted) result.set(.height, to: Values.alertButtonHeight) + result.contentEdgeInsets = UIEdgeInsets( + top: 0, + left: Values.mediumSpacing, + bottom: 0, + right: Values.mediumSpacing + ) return result } diff --git a/SessionUIKit/Components/ProfilePictureView.swift b/SessionUIKit/Components/ProfilePictureView.swift index 8310756f6c..9d48ab664f 100644 --- a/SessionUIKit/Components/ProfilePictureView.swift +++ b/SessionUIKit/Components/ProfilePictureView.swift @@ -12,12 +12,13 @@ public final class ProfilePictureView: UIView { case currentUser(SessionProManagerType) } - let source: ImageDataManager.DataSource? + public let source: ImageDataManager.DataSource? let animationBehaviour: AnimationBehaviour let renderingMode: UIImage.RenderingMode? let themeTintColor: ThemeValue? let inset: UIEdgeInsets let icon: ProfileIcon + let cropRect: CGRect? let backgroundColor: ThemeValue? let forcedBackgroundColor: ForcedThemeValue? @@ -28,6 +29,7 @@ public final class ProfilePictureView: UIView { themeTintColor: ThemeValue? = nil, inset: UIEdgeInsets = .zero, icon: ProfileIcon = .none, + cropRect: CGRect? = nil, backgroundColor: ThemeValue? = nil, forcedBackgroundColor: ForcedThemeValue? = nil ) { @@ -37,6 +39,7 @@ public final class ProfilePictureView: UIView { self.themeTintColor = themeTintColor self.inset = inset self.icon = icon + self.cropRect = cropRect self.backgroundColor = backgroundColor self.forcedBackgroundColor = forcedBackgroundColor } @@ -48,6 +51,7 @@ public final class ProfilePictureView: UIView { case list case hero case modal + case expanded public var viewSize: CGFloat { switch self { @@ -55,6 +59,7 @@ public final class ProfilePictureView: UIView { case .list: return 46 case .hero: return 110 case .modal: return 90 + case .expanded: return 190 } } @@ -62,17 +67,18 @@ public final class ProfilePictureView: UIView { switch self { case .navigation, .message: return 26 case .list: return 46 - case .hero: return 80 + case .hero: return 90 case .modal: return 90 + case .expanded: return 190 } } public var multiImageSize: CGFloat { switch self { - case .navigation, .message: return 18 // Shouldn't be used + case .navigation, .message, .modal: return 18 // Shouldn't be used case .list: return 32 case .hero: return 80 - case .modal: return 90 + case .expanded: return 140 } } @@ -82,6 +88,7 @@ public final class ProfilePictureView: UIView { case .list: return 16 case .hero: return 24 case .modal: return 24 // Shouldn't be used + case .expanded: return 33 } } } @@ -92,10 +99,11 @@ public final class ProfilePictureView: UIView { case rightPlus case letter(Character, Bool) case pencil + case qrCode func iconVerticalInset(for size: Size) -> CGFloat { switch (self, size) { - case (.crown, .navigation), (.crown, .message): return 1 + case (.crown, .navigation), (.crown, .message): return 2 case (.crown, .list): return 3 case (.crown, .hero): return 5 @@ -106,8 +114,8 @@ public final class ProfilePictureView: UIView { var isLeadingAligned: Bool { switch self { - case .none, .crown, .letter: return true - case .rightPlus, .pencil: return false + case .none, .letter: return true + case .rightPlus, .pencil, .crown, .qrCode: return false } } } @@ -134,18 +142,7 @@ public final class ProfilePictureView: UIView { self.widthConstraint.constant = (customWidth ?? self.size.viewSize) } } - override public var clipsToBounds: Bool { - didSet { - imageContainerView.clipsToBounds = clipsToBounds - additionalImageContainerView.clipsToBounds = clipsToBounds - - imageContainerView.layer.cornerRadius = (clipsToBounds ? - (additionalImageContainerView.isHidden ? (size.imageSize / 2) : (size.multiImageSize / 2)) : - 0 - ) - imageContainerView.layer.cornerRadius = (clipsToBounds ? (size.multiImageSize / 2) : 0) - } - } + public override var isHidden: Bool { didSet { widthConstraint.constant = (isHidden ? 0 : size.viewSize) @@ -170,6 +167,8 @@ public final class ProfilePictureView: UIView { private var profileIconBottomConstraint: NSLayoutConstraint! private var profileIconBackgroundLeadingAlignConstraint: NSLayoutConstraint! private var profileIconBackgroundTrailingAlignConstraint: NSLayoutConstraint! + private var profileIconBackgroundTopAlignConstraint: NSLayoutConstraint! + private var profileIconBackgroundBottomAlignConstraint: NSLayoutConstraint! private var profileIconBackgroundWidthConstraint: NSLayoutConstraint! private var profileIconBackgroundHeightConstraint: NSLayoutConstraint! private var additionalProfileIconTopConstraint: NSLayoutConstraint! @@ -356,11 +355,14 @@ public final class ProfilePictureView: UIView { profileIconLabel.pin(to: profileIconBackgroundView) profileIconBackgroundLeadingAlignConstraint = profileIconBackgroundView.pin(.leading, to: .leading, of: imageContainerView) profileIconBackgroundTrailingAlignConstraint = profileIconBackgroundView.pin(.trailing, to: .trailing, of: imageContainerView) - profileIconBackgroundView.pin(.bottom, to: .bottom, of: imageContainerView) + profileIconBackgroundTopAlignConstraint = profileIconBackgroundView.pin(.top, to: .top, of: imageContainerView) + profileIconBackgroundBottomAlignConstraint = profileIconBackgroundView.pin(.bottom, to: .bottom, of: imageContainerView) profileIconBackgroundWidthConstraint = profileIconBackgroundView.set(.width, to: size.iconSize) profileIconBackgroundHeightConstraint = profileIconBackgroundView.set(.height, to: size.iconSize) profileIconBackgroundLeadingAlignConstraint.isActive = false profileIconBackgroundTrailingAlignConstraint.isActive = false + profileIconBackgroundTopAlignConstraint.isActive = false + profileIconBackgroundBottomAlignConstraint.isActive = false additionalProfileIconTopConstraint = additionalProfileIconImageView.pin( .top, @@ -419,7 +421,7 @@ public final class ProfilePictureView: UIView { label.isHidden = true case .crown: - imageView.image = UIImage(systemName: "crown.fill") + imageView.image = UIImage(named: "ic_crown")?.withRenderingMode(.alwaysTemplate) imageView.contentMode = .scaleAspectFit imageView.themeTintColor = .dynamicForPrimary( .green, @@ -429,6 +431,8 @@ public final class ProfilePictureView: UIView { backgroundView.themeBackgroundColor = .profileIcon_background imageView.isHidden = false label.isHidden = true + profileIconBackgroundTopAlignConstraint.isActive = false + profileIconBackgroundBottomAlignConstraint.isActive = true case .rightPlus: imageView.image = UIImage(systemName: "plus", withConfiguration: UIImage.SymbolConfiguration(weight: .semibold)) @@ -437,12 +441,16 @@ public final class ProfilePictureView: UIView { backgroundView.themeBackgroundColor = .primary imageView.isHidden = false label.isHidden = true + profileIconBackgroundTopAlignConstraint.isActive = false + profileIconBackgroundBottomAlignConstraint.isActive = true case .letter(let character, let dangerMode): label.themeTextColor = (dangerMode ? .textPrimary : .backgroundPrimary) backgroundView.themeBackgroundColor = (dangerMode ? .danger : .textPrimary) label.isHidden = false label.text = "\(character)" + profileIconBackgroundTopAlignConstraint.isActive = true + profileIconBackgroundBottomAlignConstraint.isActive = false case .pencil: imageView.image = Lucide.image(icon: .pencil, size: 14)?.withRenderingMode(.alwaysTemplate) @@ -451,7 +459,19 @@ public final class ProfilePictureView: UIView { backgroundView.themeBackgroundColor = .primary imageView.isHidden = false label.isHidden = true + profileIconBackgroundTopAlignConstraint.isActive = false + profileIconBackgroundBottomAlignConstraint.isActive = true + case .qrCode: + imageView.image = Lucide.image(icon: .qrCode, size: (size == .expanded ? 20 : 14))?.withRenderingMode(.alwaysTemplate) + imageView.contentMode = .center + imageView.themeTintColor = .black + backgroundView.themeBackgroundColor = .primary + imageView.isHidden = false + label.isHidden = true + profileIconBackgroundTopAlignConstraint.isActive = true + profileIconBackgroundBottomAlignConstraint.isActive = false + trailingAlignConstraint.constant = (size == .expanded ? -8 : 0) } } @@ -463,13 +483,10 @@ public final class ProfilePictureView: UIView { imageView.image = nil imageView.shouldAnimateImage = false - imageView.contentMode = .scaleAspectFill - imageContainerView.clipsToBounds = clipsToBounds imageContainerView.themeBackgroundColor = .backgroundSecondary additionalImageContainerView.isHidden = true additionalImageView.image = nil additionalImageView.shouldAnimateImage = false - additionalImageContainerView.clipsToBounds = clipsToBounds imageViewTopConstraint.isActive = false imageViewLeadingConstraint.isActive = false @@ -509,16 +526,15 @@ public final class ProfilePictureView: UIView { // Populate the main imageView switch (info.source, info.renderingMode) { - case (.some(let source), .some(let renderingMode)) where source.directImage != nil: - imageView.image = source.directImage?.withRenderingMode(renderingMode) - - case (.some(let source), _): - imageView.loadImage(source) + case (.image(_, let image), .some(let renderingMode)): + imageView.image = image?.withRenderingMode(renderingMode) + case (.some(let source), _): imageView.loadImage(source) default: imageView.image = nil } imageView.themeTintColor = info.themeTintColor + imageView.layer.contentsRect = contentsRect(for: info.source, cropRect: info.cropRect) imageContainerView.themeBackgroundColor = info.backgroundColor imageContainerView.themeBackgroundColorForced = info.forcedBackgroundColor profileIconBackgroundView.layer.cornerRadius = (size.iconSize / 2) @@ -532,13 +548,14 @@ public final class ProfilePictureView: UIView { } } + // Apply crop transform if needed startAnimationIfNeeded(for: info, with: imageView) // Check if there is a second image (if not then set the size and finish) guard let additionalInfo: Info = additionalInfo else { imageViewWidthConstraint.constant = size.imageSize imageViewHeightConstraint.constant = size.imageSize - imageContainerView.layer.cornerRadius = (imageContainerView.clipsToBounds ? (size.imageSize / 2) : 0) + imageContainerView.layer.cornerRadius = (size.imageSize / 2) return } @@ -556,8 +573,8 @@ public final class ProfilePictureView: UIView { // Set the additional image content and reposition the image views correctly switch (additionalInfo.source, additionalInfo.renderingMode) { - case (.some(let source), .some(let renderingMode)) where source.directImage != nil: - additionalImageView.image = source.directImage?.withRenderingMode(renderingMode) + case (.image(_, let image), .some(let renderingMode)): + additionalImageView.image = image?.withRenderingMode(renderingMode) additionalImageContainerView.isHidden = false case (.some(let source), _): @@ -570,6 +587,7 @@ public final class ProfilePictureView: UIView { } additionalImageView.themeTintColor = additionalInfo.themeTintColor + additionalImageView.layer.contentsRect = contentsRect(for: additionalInfo.source, cropRect: additionalInfo.cropRect) switch (info.backgroundColor, info.forcedBackgroundColor) { case (_, .some(let color)): additionalImageContainerView.themeBackgroundColorForced = color @@ -596,16 +614,82 @@ public final class ProfilePictureView: UIView { imageViewWidthConstraint.constant = size.multiImageSize imageViewHeightConstraint.constant = size.multiImageSize - imageContainerView.layer.cornerRadius = (imageContainerView.clipsToBounds ? (size.multiImageSize / 2) : 0) + imageContainerView.layer.cornerRadius = (size.multiImageSize / 2) additionalImageViewWidthConstraint.constant = size.multiImageSize additionalImageViewHeightConstraint.constant = size.multiImageSize - additionalImageContainerView.layer.cornerRadius = (additionalImageContainerView.clipsToBounds ? - (size.multiImageSize / 2) : - 0 - ) + additionalImageContainerView.layer.cornerRadius = (size.multiImageSize / 2) additionalProfileIconBackgroundView.layer.cornerRadius = (size.iconSize / 2) } + private func contentsRect(for source: ImageDataManager.DataSource?, cropRect: CGRect?) -> CGRect { + guard + let source: ImageDataManager.DataSource = source, + let cropRect: CGRect = cropRect + else { return CGRect(x: 0, y: 0, width: 1, height: 1) } + + switch source.orientationFromMetadata { + case .up: return cropRect + + case .upMirrored: + return CGRect( + x: (1 - cropRect.maxX), + y: cropRect.minY, + width: cropRect.width, + height: cropRect.height + ) + + case .down: + return CGRect( + x: (1 - cropRect.maxX), + y: (1 - cropRect.maxY), + width: cropRect.width, + height: cropRect.height + ) + + case .downMirrored: + return CGRect( + x: cropRect.minX, + y: (1 - cropRect.maxY), + width: cropRect.width, + height: cropRect.height + ) + + case .left: + return CGRect( + x: (1 - cropRect.maxY), + y: cropRect.minX, + width: cropRect.height, + height: cropRect.width + ) + + case .leftMirrored: + return CGRect( + x: cropRect.minY, + y: cropRect.minX, + width: cropRect.height, + height: cropRect.width + ) + + case .right: + return CGRect( + x: cropRect.minY, + y: (1 - cropRect.maxX), + width: cropRect.height, + height: cropRect.width + ) + + case .rightMirrored: + return CGRect( + x: (1 - cropRect.maxY), + y: (1 - cropRect.maxX), + width: cropRect.height, + height: cropRect.width + ) + + @unknown default: return cropRect + } + } + private func startAnimationIfNeeded(for info: Info, with targetImageView: SessionImageView) { switch info.animationBehaviour { case .generic(let enableAnimation), .contact(let enableAnimation): @@ -624,6 +708,13 @@ public final class ProfilePictureView: UIView { .store(in: &disposables) } } + + public func getTouchedView(from localPoint: CGPoint) -> UIView { + if profileIconBackgroundView.frame.contains(localPoint) { + return profileIconBackgroundView + } + return self + } } import SwiftUI diff --git a/Session/Shared/Views/SRCopyableLabel.swift b/SessionUIKit/Components/SRCopyableLabel.swift similarity index 84% rename from Session/Shared/Views/SRCopyableLabel.swift rename to SessionUIKit/Components/SRCopyableLabel.swift index 7c9476ccc2..1b908b944a 100644 --- a/Session/Shared/Views/SRCopyableLabel.swift +++ b/SessionUIKit/Components/SRCopyableLabel.swift @@ -7,7 +7,7 @@ import UIKit -@objc class SRCopyableLabel : UILabel { +@objc public class SRCopyableLabel : UILabel { override public var canBecomeFirstResponder: Bool { return true } @@ -29,7 +29,7 @@ import UIKit )) } - override func copy(_ sender: Any?) { + public override func copy(_ sender: Any?) { UIPasteboard.general.string = text UIMenuController.shared.hideMenu(from: self) } @@ -42,7 +42,7 @@ import UIKit } } - override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { return (action == #selector(copy(_:))) } } diff --git a/SessionUIKit/Components/Separator.swift b/SessionUIKit/Components/Separator.swift index a4ccd6296d..0b07f3c1ea 100644 --- a/SessionUIKit/Components/Separator.swift +++ b/SessionUIKit/Components/Separator.swift @@ -25,7 +25,7 @@ public final class Separator: UIView { private lazy var titleLabel: UILabel = { let result = UILabel() - result.font = .systemFont(ofSize: Values.smallFontSize) + result.font = Fonts.Body.baseRegular result.themeTextColor = .textSecondary result.textAlignment = .center @@ -69,8 +69,8 @@ public final class Separator: UIView { addSubview(titleLabel) titleLabel.pin(.top, to: .top, of: roundedLine, withInset: 6) - titleLabel.pin(.leading, to: .leading, of: roundedLine, withInset: 10) - titleLabel.pin(.trailing, to: .trailing, of: roundedLine, withInset: -10) + titleLabel.pin(.leading, to: .leading, of: roundedLine, withInset: 30) + titleLabel.pin(.trailing, to: .trailing, of: roundedLine, withInset: -30) titleLabel.pin(.bottom, to: .bottom, of: roundedLine, withInset: -6) roundedLine.pin(.top, to: .top, of: self) diff --git a/SessionUIKit/Components/SessionImageView.swift b/SessionUIKit/Components/SessionImageView.swift index a768725213..96ca56d73f 100644 --- a/SessionUIKit/Components/SessionImageView.swift +++ b/SessionUIKit/Components/SessionImageView.swift @@ -11,12 +11,11 @@ public class SessionImageView: UIImageView { private var streamConsumptionTask: Task? private var displayLink: CADisplayLink? - private var animationFrames: [UIImage?]? - private var animationFrameDurations: [TimeInterval]? + private var frameBuffer: ImageDataManager.FrameBuffer? public private(set) var currentFrameIndex: Int = 0 public private(set) var accumulatedTime: TimeInterval = 0 - public var imageSizeMetadata: CGSize? + public var imageDisplaySizeMetadata: CGSize? public override var image: UIImage? { didSet { @@ -25,11 +24,10 @@ public class SessionImageView: UIImageView { imageLoadTask?.cancel() stopAnimationLoop() currentLoadIdentifier = nil - animationFrames = nil - animationFrameDurations = nil + frameBuffer = nil currentFrameIndex = 0 accumulatedTime = 0 - imageSizeMetadata = nil + imageDisplaySizeMetadata = nil } } @@ -120,7 +118,7 @@ public class SessionImageView: UIImageView { case .none: pauseAnimationLoop() /// Pause when not visible case .some: /// Resume only if it has animation data and was meant to be animating - if let frames = animationFrames, frames.count > 1 { + if let frameBuffer: ImageDataManager.FrameBuffer = frameBuffer, frameBuffer.frameCount > 1 { resumeAnimationLoop() } } @@ -138,11 +136,11 @@ public class SessionImageView: UIImageView { } @MainActor - public func loadImage(_ source: ImageDataManager.DataSource, onComplete: (@MainActor (ImageDataManager.ProcessedImageData?) -> Void)? = nil) { + public func loadImage(_ source: ImageDataManager.DataSource, onComplete: (@MainActor (ImageDataManager.FrameBuffer?) -> 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, frames[0] != nil, !isAnimating() { + if let buffer: ImageDataManager.FrameBuffer = frameBuffer, !buffer.durations.isEmpty, !isAnimating() { startAnimationLoop() } return @@ -154,12 +152,9 @@ public class SessionImageView: UIImageView { /// No need to kick of an async task if we were given an image directly switch source { case .image(_, .some(let image)): - let processedData: ImageDataManager.ProcessedImageData = ImageDataManager.ProcessedImageData( - type: .staticImage(image) - ) - imageSizeMetadata = image.size - handleLoadedImageData(processedData) - onComplete?(processedData) + let buffer: ImageDataManager.FrameBuffer = ImageDataManager.FrameBuffer(image: image) + handleLoadedImageData(buffer) + onComplete?(buffer) return default: break @@ -167,7 +162,7 @@ public class SessionImageView: UIImageView { /// 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 + imageDisplaySizeMetadata = source.displaySizeFromMetadata guard let dataManager: ImageDataManagerType = self.dataManager else { #if DEBUG @@ -178,13 +173,13 @@ public class SessionImageView: UIImageView { } imageLoadTask = Task.detached(priority: .userInitiated) { [weak self, dataManager] in - let processedData: ImageDataManager.ProcessedImageData? = await dataManager.load(source) + let buffer: ImageDataManager.FrameBuffer? = await dataManager.load(source) await MainActor.run { [weak self] in guard !Task.isCancelled && self?.currentLoadIdentifier == source.identifier else { return } - self?.handleLoadedImageData(processedData) - onComplete?(processedData) + self?.handleLoadedImageData(buffer) + onComplete?(buffer) } } } @@ -193,10 +188,8 @@ public class SessionImageView: UIImageView { public func startAnimationLoop() { guard shouldAnimateImage, - let frames: [UIImage?] = animationFrames, - let durations: [TimeInterval] = animationFrameDurations, - !frames.isEmpty, - !durations.isEmpty + let buffer: ImageDataManager.FrameBuffer = frameBuffer, + !buffer.durations.isEmpty else { return stopAnimationLoop() } /// If it's already running (or paused) then no need to start the animation loop @@ -206,8 +199,8 @@ public class SessionImageView: UIImageView { } /// Just to be safe set the initial frame - if self.image == nil, !frames.isEmpty, frames[0] != nil { - self.image = frames[0] + if self.image == nil { + self.image = buffer.firstFrame } stopAnimationLoop() /// Make sure we don't unintentionally create extra `CADisplayLink` instances @@ -220,29 +213,49 @@ public class SessionImageView: UIImageView { @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() } + guard index >= 0, index < frameBuffer?.frameCount ?? 0 else { return } + // TODO: Won't this break the animation???? + Task { +// currentFrameIndex = index +// self.image = await frameBuffer?.getFrame(at: index) +// frameBuffer?. + /// Stop animating if we don't have a valid animation state + guard + let durations = frameBuffer?.durations, + index >= 0, + index < durations.count, + time > 0, + time < durations.reduce(0, +) + else { + image = frameBuffer?.getFrame(at: index) + currentFrameIndex = 0 + accumulatedTime = 0 + 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 = frameBuffer?.getFrame(at: index) + } + } + + @MainActor + public func copyAnimationPoint(from other: SessionImageView) { + self.handleLoadedImageData(other.frameBuffer) + self.image = other.image + self.currentFrameIndex = other.currentFrameIndex + self.accumulatedTime = other.accumulatedTime + self.imageDisplaySizeMetadata = other.imageDisplaySizeMetadata + self.shouldAnimateImage = other.shouldAnimateImage - /// Update the values - accumulatedTime = time - currentFrameIndex = index + if other.isAnimating { + self.startAnimationLoop() + } - /// 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 @@ -275,105 +288,89 @@ public class SessionImageView: UIImageView { self.image = nil currentLoadIdentifier = identifier - animationFrames = nil - animationFrameDurations = nil + frameBuffer = nil currentFrameIndex = 0 accumulatedTime = 0 - imageSizeMetadata = nil + imageDisplaySizeMetadata = nil } @MainActor - private func handleLoadedImageData(_ data: ImageDataManager.ProcessedImageData?) { - guard let data: ImageDataManager.ProcessedImageData = data else { + private func handleLoadedImageData(_ buffer: ImageDataManager.FrameBuffer?) { + guard let buffer: ImageDataManager.FrameBuffer = buffer else { self.image = nil stopAnimationLoop() return } - - switch data.type { - case .staticImage(let staticImg): - stopAnimationLoop() - self.image = staticImg - self.animationFrames = nil - self.animationFrameDurations = nil - - case .animatedImage(let frames, let durations): - self.image = frames.first - self.animationFrames = frames - self.animationFrameDurations = durations - self.currentFrameIndex = 0 - self.accumulatedTime = 0 - - guard self.shouldAnimateImage else { return } - - switch frames.count { - case 1...: startAnimationLoop() - default: stopAnimationLoop() /// Treat as a static image - } - - case .bufferedAnimatedImage(let firstFrame, let durations, let bufferedFrameStream): - self.image = firstFrame - self.animationFrameDurations = durations - self.animationFrames = Array(repeating: nil, count: durations.count) - self.animationFrames?[0] = firstFrame - - guard durations.count > 1 else { - stopAnimationLoop() - return + + /// **Note:** Setting `self.image` will reset the current state and clear any existing animation data so we need to call + /// it first and then store data afterwards (otherwise it'd just be cleared) + self.image = buffer.firstFrame + self.frameBuffer = buffer + self.imageDisplaySizeMetadata = buffer.firstFrame.size + + guard buffer.durations.count > 1 && self.shouldAnimateImage else { return } + + Task { + if buffer.isComplete { + return await MainActor.run { + if self.shouldAnimateImage { + self.startAnimationLoop() + } } - - streamConsumptionTask = Task { @MainActor in - for await event in bufferedFrameStream { - guard !Task.isCancelled else { break } - - switch event { - case .frame(let index, let frame): self.animationFrames?[index] = frame - case .readyToPlay: - guard self.shouldAnimateImage else { continue } - - startAnimationLoop() - } + } + + streamConsumptionTask = Task { @MainActor in + for await event in buffer.stream { + guard !Task.isCancelled else { break } + + switch event { + case .frameLoaded, .completed: break + case .readyToAnimate: + guard self.shouldAnimateImage else { continue } + + startAnimationLoop() } } + } } } @objc private func updateFrame(displayLink: CADisplayLink) { /// Stop animating if we don't have a valid animation state guard - let frames: [UIImage?] = animationFrames, - let durations = animationFrameDurations, - !frames.isEmpty, - !durations.isEmpty, - currentFrameIndex < durations.count + let buffer: ImageDataManager.FrameBuffer = frameBuffer, + !buffer.durations.isEmpty, + currentFrameIndex < buffer.durations.count else { return stopAnimationLoop() } accumulatedTime += displayLink.duration - var currentFrameDuration: TimeInterval = durations[currentFrameIndex] + var currentFrameDuration: TimeInterval = buffer.durations[currentFrameIndex] /// It's possible for a long `CADisplayLink` tick to take longeer than a single frame so try to handle those cases while accumulatedTime >= currentFrameDuration { accumulatedTime -= currentFrameDuration - - let nextFrameIndex: Int = ((currentFrameIndex + 1) % durations.count) + let nextFrameIndex: Int = ((currentFrameIndex + 1) % buffer.durations.count) /// If the next frame hasn't been decoded yet, pause on the current frame, we'll re-evaluate on the next display tick. - guard nextFrameIndex < frames.count, frames[nextFrameIndex] != nil else { break } + guard + nextFrameIndex < buffer.frameCount, + buffer.getFrame(at: nextFrameIndex) != nil + else { break } /// Prevent an infinite loop for all zero durations - guard durations[nextFrameIndex] > 0.001 else { break } + guard buffer.durations[nextFrameIndex] > 0.001 else { break } currentFrameIndex = nextFrameIndex - currentFrameDuration = durations[currentFrameIndex] + currentFrameDuration = buffer.durations[currentFrameIndex] } /// Make sure we don't cause an index-out-of-bounds somehow - guard currentFrameIndex < frames.count else { return stopAnimationLoop() } + guard currentFrameIndex < buffer.frameCount else { return stopAnimationLoop() } /// 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] + super.image = buffer.getFrame(at: currentFrameIndex) } } diff --git a/SessionUIKit/Components/SessionLabelWithProBadge.swift b/SessionUIKit/Components/SessionLabelWithProBadge.swift new file mode 100644 index 0000000000..d8565bdae6 --- /dev/null +++ b/SessionUIKit/Components/SessionLabelWithProBadge.swift @@ -0,0 +1,156 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +public class SessionLabelWithProBadge: UIView { + public var font: UIFont { + get { label.font } + set { + label.font = newValue + extraLabel.font = newValue + } + } + + public var text: String? { + get { label.text } + set { + guard label.text != newValue else { return } + label.text = newValue + } + } + + public var extraText: String? { + get { extraLabel.text } + set { + guard extraLabel.text != newValue else { return } + extraLabel.text = newValue + extraLabel.isHidden = !(newValue?.isEmpty == false) + } + } + + public var themeAttributedText: ThemedAttributedString? { + get { label.themeAttributedText } + set { + guard label.themeAttributedText != newValue else { return } + label.themeAttributedText = newValue + } + } + + public var extraThemeAttributedText: ThemedAttributedString? { + get { extraLabel.themeAttributedText } + set { + guard extraLabel.themeAttributedText != newValue else { return } + extraLabel.themeAttributedText = newValue + } + } + + public var themeTextColor: ThemeValue? { + get { label.themeTextColor } + set { + label.themeTextColor = newValue + extraLabel.themeTextColor = newValue + } + } + + public var textAlignment: NSTextAlignment { + get { label.textAlignment } + set { + label.textAlignment = newValue + extraLabel.textAlignment = newValue + } + } + + public var lineBreakMode: NSLineBreakMode { + get { label.lineBreakMode } + set { + label.lineBreakMode = newValue + extraLabel.lineBreakMode = newValue + } + } + + public var numberOfLines: Int { + get { label.numberOfLines } + set { + label.numberOfLines = newValue + extraLabel.numberOfLines = newValue + } + } + + public var isProBadgeHidden: Bool { + get { sessionProBadge.isHidden } + set { sessionProBadge.isHidden = newValue } + } + + public override var isUserInteractionEnabled: Bool { + get { super.isUserInteractionEnabled } + set { + super.isUserInteractionEnabled = newValue + label.isUserInteractionEnabled = newValue + extraLabel.isUserInteractionEnabled = newValue + } + } + + private let proBadgeSize: SessionProBadge.Size + private let proBadgeThemeBackgroundColor: ThemeValue + private let withStretchingSpacer: Bool + + // MARK: - UI Components + + private let label: SRCopyableLabel = SRCopyableLabel() + private let extraLabel: UILabel = UILabel() + + private lazy var sessionProBadge: SessionProBadge = { + let result: SessionProBadge = SessionProBadge(size: proBadgeSize) + result.themeBackgroundColor = proBadgeThemeBackgroundColor + result.isHidden = true + + return result + }() + + private lazy var stackView: UIStackView = { + let result: UIStackView = UIStackView( + arrangedSubviews: + [ + label, + sessionProBadge, + extraLabel, + withStretchingSpacer ? UIView.hStretchingSpacer() : nil + ] + .compactMap { $0 } + ) + result.axis = .horizontal + result.spacing = { + switch proBadgeSize { + case .mini: return 3 + default: return 4 + } + }() + result.alignment = .center + + return result + }() + + // MARK: - Initialization + + public init( + proBadgeSize: SessionProBadge.Size, + proBadgeThemeBackgroundColor: ThemeValue = .primary, + withStretchingSpacer: Bool = true + ) { + self.proBadgeSize = proBadgeSize + self.proBadgeThemeBackgroundColor = proBadgeThemeBackgroundColor + self.withStretchingSpacer = withStretchingSpacer + + super.init(frame: .zero) + self.addSubview(stackView) + stackView.pin(to: self) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func sizeThatFits(_ size: CGSize) -> CGSize { + return label.sizeThatFits(size) + } +} diff --git a/SessionUIKit/Components/SessionProBadge.swift b/SessionUIKit/Components/SessionProBadge.swift index c5cce36801..47bd9141d8 100644 --- a/SessionUIKit/Components/SessionProBadge.swift +++ b/SessionUIKit/Components/SessionProBadge.swift @@ -4,48 +4,66 @@ import UIKit public class SessionProBadge: UIView { public enum Size { - case small, large + case mini, small, medium, large - var width: CGFloat { + public var width: CGFloat { switch self { - case .small: return 40 + case .mini: return 24 + case .small: return 32 + case .medium: return 40 case .large: return 52 } } - var height: CGFloat { + public var height: CGFloat { switch self { - case .small: return 18 + case .mini: return 11 + case .small: return 14.5 + case .medium: return 18 case .large: return 26 } } - var cornerRadius: CGFloat { + public var cornerRadius: CGFloat { switch self { - case .small: return 4 + case .mini: return 2.5 + case .small: return 3.5 + case .medium: return 4 case .large: return 6 } } - var proFontHeight: CGFloat { + public var proFontHeight: CGFloat { switch self { - case .small: return 7 + case .mini: return 5 + case .small: return 6 + case .medium: return 7 case .large: return 11 } } - var proFontWidth: CGFloat { + public var proFontWidth: CGFloat { switch self { - case .small: return 28 + case .mini: return 17 + case .small: return 24 + case .medium: return 28 case .large: return 40 } } } - private let size: Size + public var size: Size { + didSet { + widthConstraint.constant = size.width + heightConstraint.constant = size.height + proImageWidthConstraint.constant = size.proFontWidth + proImageHeightConstraint.constant = size.proFontHeight + self.layer.cornerRadius = size.cornerRadius + } + } // MARK: - Initialization public init(size: Size) { self.size = size super.init(frame: CGRect(x: 0, y: 0, width: size.width, height: size.height)) - self.setupView() + setUpViewHierarchy() } public override init(frame: CGRect) { @@ -57,6 +75,7 @@ public class SessionProBadge: UIView { } // MARK: - UI + private lazy var proImageView: UIImageView = { let result: UIImageView = UIImageView(image: UIImage(named: "session_pro")) result.contentMode = .scaleAspectFit @@ -64,26 +83,28 @@ public class SessionProBadge: UIView { return result }() - private func setupView() { + private var widthConstraint: NSLayoutConstraint! + private var heightConstraint: NSLayoutConstraint! + private var proImageWidthConstraint: NSLayoutConstraint! + private var proImageHeightConstraint: NSLayoutConstraint! + + private func setUpViewHierarchy() { self.addSubview(proImageView) - proImageView.set(.height, to: self.size.proFontHeight) - proImageView.set(.width, to: self.size.proFontWidth) + proImageHeightConstraint = proImageView.set(.height, to: self.size.proFontHeight) + proImageWidthConstraint = proImageView.set(.width, to: self.size.proFontWidth) proImageView.center(in: self) self.themeBackgroundColor = .primary self.clipsToBounds = true self.layer.cornerRadius = self.size.cornerRadius - self.set(.width, to: self.size.width) - self.set(.height, to: self.size.height) - } - - public func toImage() -> UIImage { + widthConstraint = self.set(.width, to: self.size.width) + heightConstraint = self.set(.height, to: self.size.height) + self.proImageView.frame = CGRect( x: (size.width - size.proFontWidth) / 2, y: (size.height - size.proFontHeight) / 2, width: size.proFontWidth, height: size.proFontHeight ) - return self.toImage(isOpaque: self.isOpaque, scale: UIScreen.main.scale) } } diff --git a/SessionUIKit/Components/SwiftUI/AttributedLabel.swift b/SessionUIKit/Components/SwiftUI/AttributedLabel.swift new file mode 100644 index 0000000000..968af2033b --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/AttributedLabel.swift @@ -0,0 +1,32 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI + +public struct AttributedLabel: UIViewRepresentable { + public typealias UIViewType = UILabel + + let themedAttributedString: ThemedAttributedString? + let maxWidth: CGFloat? + + public init(_ themedAttributedString: ThemedAttributedString?, maxWidth: CGFloat? = nil) { + self.themedAttributedString = themedAttributedString + self.maxWidth = maxWidth + } + + public func makeUIView(context: Context) -> UILabel { + let label = UILabel() + label.numberOfLines = 0 + label.themeAttributedText = themedAttributedString + label.setContentHuggingPriority(.required, for: .horizontal) + label.setContentHuggingPriority(.required, for: .vertical) + label.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + return label + } + + public func updateUIView(_ label: UILabel, context: Context) { + label.themeAttributedText = themedAttributedString + if let maxWidth = maxWidth { + label.preferredMaxLayoutWidth = maxWidth + } + } +} diff --git a/SessionUIKit/Components/SwiftUI/LightBox.swift b/SessionUIKit/Components/SwiftUI/LightBox.swift index b9af52e625..fbcee7e8c5 100644 --- a/SessionUIKit/Components/SwiftUI/LightBox.swift +++ b/SessionUIKit/Components/SwiftUI/LightBox.swift @@ -9,6 +9,12 @@ public struct LightBox: View { public var itemsToShare: [UIImage] = [] public var content: () -> Content + public init(title: String? = nil, itemsToShare: [UIImage], content: @escaping () -> Content) { + self.title = title + self.itemsToShare = itemsToShare + self.content = content + } + public var body: some View { NavigationView { content() diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index 6b4ed4802c..0594460bec 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -10,7 +10,7 @@ public struct ProCTAModal: View { case longerMessages case animatedProfileImage(isSessionProActivated: Bool) case morePinnedConvos(isGrandfathered: Bool) - case groupLimit(isAdmin: Bool) + case groupLimit(isAdmin: Bool, isSessionProActivated: Bool, proBadgeImage: UIImage) // stringlint:ignore_contents public var backgroundImageName: String { @@ -23,8 +23,13 @@ public struct ProCTAModal: View { return "AnimatedProfileCTA.webp" case .morePinnedConvos: return "PinnedConversationsCTA.webp" - case .groupLimit(let isAdmin): - return isAdmin ? "" : "" + case .groupLimit(let isAdmin, let isSessionProActivated, _): + switch (isAdmin, isSessionProActivated) { + case (false, false): + return "GroupNonAdminCTA.webp" + default: + return "GroupAdminCTA.webp" + } } } // stringlint:ignore_contents @@ -40,10 +45,8 @@ public struct ProCTAModal: View { /// of the modal. public var animatedAvatarImagePadding: (leading: CGFloat, top: CGFloat) { switch self { - case .generic: - return (1313.5, 753) - case .animatedProfileImage: - return (690, 363) + case .generic: return (1293, 743) + case .animatedProfileImage: return (690, 363) default: return (0, 0) } } @@ -66,19 +69,29 @@ public struct ProCTAModal: View { .put(key: "app_pro", value: Constants.app_pro) .localized() case .morePinnedConvos(let isGrandfathered): - return isGrandfathered ? - "proCallToActionPinnedConversations" - .put(key: "app_pro", value: Constants.app_pro) - .localized() : - "proCallToActionPinnedConversationsMoreThan" + if isGrandfathered { + return "proCallToActionPinnedConversations" .put(key: "app_pro", value: Constants.app_pro) .localized() - case .groupLimit: - return "proUserProfileModalCallToAction" + } + return "proCallToActionPinnedConversationsMoreThan" .put(key: "app_pro", value: Constants.app_pro) - .put(key: "app_name", value: Constants.app_name) + .put(key: "limit", value: 5) // TODO: [PRO] Get from SessionProUIManager .localized() - } + + case .groupLimit(let isAdmin, let isSessionProActivated, _): + switch (isAdmin, isSessionProActivated) { + case (_, true): + return "proGroupActivatedDescription".localized() + case (true, false): + return "proUserProfileModalCallToAction" + .put(key: "app_pro", value: Constants.app_pro) + .put(key: "app_name", value: Constants.app_name) + .localized() + case (false, false): + return "Want to upgrade this group to Pro? Tell one of the group admins to upgrade to Pro" // TODO: Localised + } + } } public var benefits: [String] { @@ -107,13 +120,16 @@ public struct ProCTAModal: View { "proFeatureListLargerGroups".localized(), "proFeatureListLoadsMore".localized() ] - case .groupLimit(let isAdmin): - return !isAdmin ? [] : - [ - "proFeatureListLargerGroups".localized(), - "proFeatureListLongerMessages".localized(), - "proFeatureListLoadsMore".localized() - ] + case .groupLimit(let isAdmin, let isSessionProActivated, _): + switch (isAdmin, isSessionProActivated) { + case (true, false): + return [ + "proFeatureListLargerGroups".localized(), + "proFeatureListLongerMessages".localized(), + "proFeatureListLoadsMore".localized() + ] + default: return [] + } } } } @@ -156,7 +172,7 @@ public struct ProCTAModal: View { ZStack { if let animatedAvatarImageURL = variant.animatedAvatarImageURL { GeometryReader { geometry in - let size: CGFloat = geometry.size.width / 1522.0 * 187.0 + let size: CGFloat = geometry.size.width / 1522.0 * 135 let scale: CGFloat = geometry.size.width / 1522.0 SessionAsyncImage( source: .url(animatedAvatarImageURL), @@ -222,6 +238,14 @@ public struct ProCTAModal: View { .font(.Headings.H4) .foregroundColor(themeColor: .textPrimary) } + } else if case .groupLimit(_, let isSessionProActivated, _) = variant, isSessionProActivated { + HStack(spacing: Values.smallSpacing) { + SessionProBadge_SwiftUI(size: .large) + + Text("proGroupActivated".localized()) + .font(.Headings.H4) + .foregroundColor(themeColor: .textPrimary) + } } else { HStack(spacing: Values.smallSpacing) { Text("upgradeTo".localized()) @@ -240,15 +264,26 @@ public struct ProCTAModal: View { .font(.Body.largeRegular) .foregroundColor(themeColor: .textSecondary) - SessionProBadge_SwiftUI(size: .small) + SessionProBadge_SwiftUI(size: .medium) } } - Text(variant.subtitle) - .font(.Body.largeRegular) - .foregroundColor(themeColor: .textSecondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) + if + case .groupLimit(_, let isSessionProActivated, let proBadgeImage) = variant, + isSessionProActivated + { + (Text(variant.subtitle) + Text(" \(Image(uiImage: proBadgeImage))")) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } else { + Text(variant.subtitle) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } } // Benefits @@ -280,7 +315,7 @@ public struct ProCTAModal: View { // Buttons let onlyShowCloseButton: Bool = { - if case .groupLimit(let isAdmin) = variant, !isAdmin { return true } + if case .groupLimit(let isAdmin, let isSessionProActivated, _) = variant, (!isAdmin || isSessionProActivated) { return true } if case .animatedProfileImage(let isSessionProActivated) = variant, isSessionProActivated { return true } return false }() diff --git a/SessionUIKit/Components/SwiftUI/QRCodeView.swift b/SessionUIKit/Components/SwiftUI/QRCodeView.swift index 03c310b66b..a3422a53c0 100644 --- a/SessionUIKit/Components/SwiftUI/QRCodeView.swift +++ b/SessionUIKit/Components/SwiftUI/QRCodeView.swift @@ -23,7 +23,6 @@ public struct QRCodeView: View { } static private var cornerRadius: CGFloat = 10 - static private var logoSize: CGFloat = 66 public init( qrCodeImage: UIImage?, diff --git a/SessionUIKit/Components/SwiftUI/SessionAsyncImage.swift b/SessionUIKit/Components/SwiftUI/SessionAsyncImage.swift index 59ddb996e9..41121946e8 100644 --- a/SessionUIKit/Components/SwiftUI/SessionAsyncImage.swift +++ b/SessionUIKit/Components/SwiftUI/SessionAsyncImage.swift @@ -6,8 +6,7 @@ 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 frameBuffer: ImageDataManager.FrameBuffer? @State private var isAnimating: Bool = false @State private var currentFrameIndex: Int = 0 @@ -59,7 +58,7 @@ public struct SessionAsyncImage: View { await loadAndProcessData() } .onChange(of: shouldAnimateImage) { newValue in - if let frames = animationFrames, !frames.isEmpty { + if let buffer = frameBuffer, !buffer.durations.isEmpty { isAnimating = newValue } } @@ -71,71 +70,47 @@ public struct SessionAsyncImage: View { /// Reset the state before loading new data await MainActor.run { resetAnimationState() } - let processedData = await dataManager.load(source) + guard let buffer: ImageDataManager.FrameBuffer = await dataManager.load(source) else { + return await MainActor.run { + self.loadedImage = nil + self.frameBuffer = nil + } + } guard !Task.isCancelled else { return } - switch processedData?.type { - case .staticImage(let image): - await MainActor.run { - self.loadedImage = image - } + /// Set the first frame + await MainActor.run { + self.loadedImage = buffer.firstFrame + self.frameBuffer = buffer + self.currentFrameIndex = 0 + self.accumulatedTime = 0.0 + } + + guard buffer.durations.count > 1 && self.shouldAnimateImage else { + self.isAnimating = false /// Treat as a static image + return + } + + for await event in buffer.stream { + guard !Task.isCancelled else { break } - case .animatedImage(let frames, let durations) where frames.count > 1: - await MainActor.run { - self.animationFrames = frames - self.animationFrameDurations = durations - self.loadedImage = frames.first - - if self.shouldAnimateImage { - self.isAnimating = true /// Activate the `TimelineView` - } - } - - case .bufferedAnimatedImage(let firstFrame, let durations, let bufferedFrameStream) where durations.count > 1: - await MainActor.run { - self.loadedImage = firstFrame - self.animationFrameDurations = durations - self.animationFrames = Array(repeating: nil, count: durations.count) - self.animationFrames?[0] = firstFrame - } - - for await event in bufferedFrameStream { - guard !Task.isCancelled else { break } - - await MainActor.run { - switch event { - case .frame(let index, let frame): self.animationFrames?[index] = frame - case .readyToPlay: - guard self.shouldAnimateImage else { return } - - self.isAnimating = true - } - } - } - - case .animatedImage(let frames, _): - await MainActor.run { - self.loadedImage = frames.first - } - - case .bufferedAnimatedImage(let firstFrame, _, _): - await MainActor.run { - self.loadedImage = firstFrame - } - - default: - await MainActor.run { - self.loadedImage = nil + await MainActor.run { + switch event { + case .frameLoaded, .completed: break + case .readyToAnimate: + guard self.shouldAnimateImage else { return } + + self.isAnimating = true } + } } } @MainActor private func resetAnimationState() { self.loadedImage = nil - self.animationFrames = nil - self.animationFrameDurations = nil + self.frameBuffer = nil self.isAnimating = false self.currentFrameIndex = 0 self.accumulatedTime = 0.0 @@ -145,11 +120,9 @@ public struct SessionAsyncImage: View { private func updateAnimationFrame(at date: Date) { guard isAnimating, - let frames: [UIImage?] = animationFrames, - let durations = animationFrameDurations, - !frames.isEmpty, - !durations.isEmpty, - currentFrameIndex < durations.count, + let buffer: ImageDataManager.FrameBuffer = frameBuffer, + !buffer.durations.isEmpty, + currentFrameIndex < buffer.durations.count, let lastDate = lastFrameDate else { isAnimating = false @@ -161,34 +134,38 @@ public struct SessionAsyncImage: View { self.lastFrameDate = date accumulatedTime += elapsed - var currentFrameDuration: TimeInterval = durations[currentFrameIndex] + var currentFrameDuration: TimeInterval = buffer.durations[currentFrameIndex] // Advance frames if the accumulated time exceeds the current frame's duration while accumulatedTime >= currentFrameDuration { accumulatedTime -= currentFrameDuration - let nextFrameIndex: Int = ((currentFrameIndex + 1) % durations.count) + let nextFrameIndex: Int = ((currentFrameIndex + 1) % buffer.durations.count) /// If the next frame hasn't been decoded yet, pause on the current frame, we'll re-evaluate on the next display tick. - guard nextFrameIndex < frames.count, frames[nextFrameIndex] != nil else { break } - - + guard + nextFrameIndex < buffer.frameCount, + buffer.getFrame(at: nextFrameIndex) != nil + else { break } /// Prevent an infinite loop for all zero durations - guard durations[nextFrameIndex] > 0.001 else { break } + guard buffer.durations[nextFrameIndex] > 0.001 else { break } currentFrameIndex = nextFrameIndex - currentFrameDuration = durations[currentFrameIndex] + currentFrameDuration = buffer.durations[currentFrameIndex] } /// Make sure we don't cause an index-out-of-bounds somehow - guard currentFrameIndex < frames.count else { + guard currentFrameIndex < buffer.durations.count else { isAnimating = false return } /// Update the displayed image only if the frame has changed - if loadedImage !== frames[currentFrameIndex] { - loadedImage = frames[currentFrameIndex] + if + let nextFrame: UIImage = buffer.getFrame(at: currentFrameIndex), + loadedImage !== nextFrame + { + loadedImage = nextFrame } } } diff --git a/SessionUIKit/Components/SwiftUI/SessionProBadge+SwiftUI.swift b/SessionUIKit/Components/SwiftUI/SessionProBadge+SwiftUI.swift index 94b482f009..6d0e239115 100644 --- a/SessionUIKit/Components/SwiftUI/SessionProBadge+SwiftUI.swift +++ b/SessionUIKit/Components/SwiftUI/SessionProBadge+SwiftUI.swift @@ -4,15 +4,17 @@ import SwiftUI public struct SessionProBadge_SwiftUI: View { private let size: SessionProBadge.Size + private let themeBackgroundColor: ThemeValue - public init(size: SessionProBadge.Size) { + public init(size: SessionProBadge.Size, themeBackgroundColor: ThemeValue = .primary) { self.size = size + self.themeBackgroundColor = themeBackgroundColor } public var body: some View { ZStack { RoundedRectangle(cornerRadius: size.cornerRadius) - .fill(themeColor: .primary) + .fill(themeColor: themeBackgroundColor) Image("session_pro") .resizable() diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift index b54bd7a0df..1939c19c16 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift @@ -73,7 +73,7 @@ public struct UserProfileModal: View { ) .scaleEffect(scale, anchor: .topLeading) .onTapGesture { - withAnimation { + withAnimation(.easeInOut(duration: 0.1)) { self.isProfileImageExpanding.toggle() } } @@ -85,7 +85,7 @@ public struct UserProfileModal: View { ) if info.sessionId != nil { - let (buttonSize, iconSize): (CGFloat, CGFloat) = isProfileImageExpanding ? (33, 20) : (20, 12) + let (buttonSize, iconSize): (CGFloat, CGFloat) = isProfileImageExpanding ? (33, 20) : (24, 14) ZStack { Circle() .foregroundColor(themeColor: .primary) @@ -366,9 +366,8 @@ public struct UserProfileModal: View { let viewController = SessionHostingViewController( rootView: LightBox( itemsToShare: [ - QRCode.qrCodeImageWithTintAndBackground( + QRCode.qrCodeImageWithBackground( image: qrCodeImage, - themeStyle: ThemeManager.currentTheme.interfaceStyle, size: CGSize(width: 400, height: 400), insets: UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) ) diff --git a/SessionUIKit/Components/TappableLabel.swift b/SessionUIKit/Components/TappableLabel.swift index f750fca998..f79ef2e9e7 100644 --- a/SessionUIKit/Components/TappableLabel.swift +++ b/SessionUIKit/Components/TappableLabel.swift @@ -15,9 +15,8 @@ public protocol TappableLabelDelegate: AnyObject { public class TappableLabel: UILabel { public private(set) var links: [String: NSRange] = [:] - private lazy var highlightedMentionBackgroundView: HighlightMentionBackgroundView = HighlightMentionBackgroundView(targetLabel: self) private(set) var layoutManager = NSLayoutManager() - private(set) var textContainer = NSTextContainer(size: CGSize.zero) + public private(set) var textContainer = NSTextContainer(size: CGSize.zero) private(set) var textStorage = NSTextStorage() { didSet { textStorage.addLayoutManager(layoutManager) @@ -36,12 +35,6 @@ public class TappableLabel: UILabel { textStorage = NSTextStorage(attributedString: attributedText) findLinksAndRange(attributeString: attributedText) - highlightedMentionBackgroundView.maxPadding = highlightedMentionBackgroundView - .calculateMaxPadding(for: attributedText) - highlightedMentionBackgroundView.frame = self.bounds.insetBy( - dx: -highlightedMentionBackgroundView.maxPadding, - dy: -highlightedMentionBackgroundView.maxPadding - ) } } @@ -84,27 +77,36 @@ public class TappableLabel: UILabel { // MARK: - Layout - public override func didMoveToSuperview() { - super.didMoveToSuperview() - - // Note: Because we want the 'highlight' content to appear behind the label we need - // to add the 'highlightedMentionBackgroundView' below it in the view hierarchy - // - // In order to try and avoid adding even more complexity to UI components which use - // this 'TappableLabel' we are going some view hierarchy manipulation and forcing - // these elements to maintain the same superview - highlightedMentionBackgroundView.removeFromSuperview() - superview?.insertSubview(highlightedMentionBackgroundView, belowSubview: self) - } - public override func layoutSubviews() { super.layoutSubviews() textContainer.size = bounds.size - highlightedMentionBackgroundView.frame = self.frame.insetBy( - dx: -highlightedMentionBackgroundView.maxPadding, - dy: -highlightedMentionBackgroundView.maxPadding - ) + + if preferredMaxLayoutWidth != bounds.width { + preferredMaxLayoutWidth = bounds.width + invalidateIntrinsicContentSize() + } + } + + public override var intrinsicContentSize: CGSize { + // Compute layout with the current/expected width + let width = preferredMaxLayoutWidth > 0 ? preferredMaxLayoutWidth : bounds.width + let targetWidth = (width > 0) ? width : UIScreen.main.bounds.width + + textContainer.size = CGSize(width: targetWidth, height: .greatestFiniteMagnitude) + _ = layoutManager.glyphRange(for: textContainer) // forces layout + let used = layoutManager.usedRect(for: textContainer) + + // Ceil to avoid fractional sizes causing extra lines/clipping + return CGSize(width: ceil(used.width), height: ceil(used.height)) + } + + public override func sizeThatFits(_ size: CGSize) -> CGSize { + let targetWidth = size.width > 0 ? size.width : UIScreen.main.bounds.width + textContainer.size = CGSize(width: targetWidth, height: .greatestFiniteMagnitude) + _ = layoutManager.glyphRange(for: textContainer) + let used = layoutManager.usedRect(for: textContainer) + return CGSize(width: min(ceil(used.width), targetWidth), height: ceil(used.height)) } // MARK: - Functions diff --git a/SessionUIKit/Configuration.swift b/SessionUIKit/Configuration.swift index 41e8c9826f..b6545140cb 100644 --- a/SessionUIKit/Configuration.swift +++ b/SessionUIKit/Configuration.swift @@ -2,6 +2,7 @@ import UIKit import AVFoundation +import UniformTypeIdentifiers public typealias ThemeSettings = (theme: Theme?, primaryColor: Theme.PrimaryColor?, matchSystemNightModeSetting: Bool?) @@ -17,7 +18,12 @@ public actor SNUIKit { func cacheContextualActionInfo(tableViewHash: Int, sideKey: String, actionIndex: Int, actionInfo: Any) func removeCachedContextualActionInfo(tableViewHash: Int, keys: [String]) func shouldShowStringKeys() -> Bool - func asset(for path: String, mimeType: String, sourceFilename: String?) -> (asset: AVURLAsset, cleanup: () -> Void)? + func assetInfo(for path: String, utType: UTType, sourceFilename: String?) -> (asset: AVURLAsset, isValidVideo: Bool, cleanup: () -> Void)? + + func mediaDecoderDefaultImageOptions() -> CFDictionary + func mediaDecoderDefaultThumbnailOptions(maxDimension: CGFloat) -> CFDictionary + func mediaDecoderSource(for url: URL) -> CGImageSource? + func mediaDecoderSource(for data: Data) -> CGImageSource? } @MainActor public static var mainWindow: UIWindow? = nil @@ -67,9 +73,25 @@ public actor SNUIKit { return config.shouldShowStringKeys() } - internal static func asset(for path: String, mimeType: String, sourceFilename: String?) -> (asset: AVURLAsset, cleanup: () -> Void)? { + internal static func assetInfo(for path: String, utType: UTType, sourceFilename: String?) -> (asset: AVURLAsset, isValidVideo: Bool, cleanup: () -> Void)? { guard let config: ConfigType = self.config else { return nil } - return config.asset(for: path, mimeType: mimeType, sourceFilename: sourceFilename) + return config.assetInfo(for: path, utType: utType, sourceFilename: sourceFilename) + } + + internal static func mediaDecoderDefaultImageOptions() -> CFDictionary? { + return config?.mediaDecoderDefaultImageOptions() + } + + internal static func mediaDecoderDefaultThumbnailOptions(maxDimension: CGFloat) -> CFDictionary? { + return config?.mediaDecoderDefaultThumbnailOptions(maxDimension: maxDimension) + } + + internal static func mediaDecoderSource(for url: URL) -> CGImageSource? { + return config?.mediaDecoderSource(for: url) + } + + internal static func mediaDecoderSource(for data: Data) -> CGImageSource? { + return config?.mediaDecoderSource(for: data) } } diff --git a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift index 9c07b2d702..a13d429b3e 100644 --- a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift +++ b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift @@ -9,10 +9,6 @@ public extension NSAttributedString.Key { .themeForegroundColor, .themeBackgroundColor, .themeStrokeColor, .themeUnderlineColor, .themeStrikethroughColor ] - internal static let keysToIgnoreValidation: Set = [ - .currentUserMentionBackgroundColor, .currentUserMentionBackgroundCornerRadius, .currentUserMentionBackgroundPadding - ] - static let themeForegroundColor = NSAttributedString.Key("org.getsession.themeForegroundColor") static let themeBackgroundColor = NSAttributedString.Key("org.getsession.themeBackgroundColor") static let themeStrokeColor = NSAttributedString.Key("org.getsession.themeStrokeColor") @@ -34,37 +30,62 @@ public extension NSAttributedString.Key { // MARK: - ThemedAttributedString public class ThemedAttributedString: Equatable, Hashable { - internal let value: NSMutableAttributedString + internal var value: NSMutableAttributedString { + if let image = imageAttachmentGenerator?() { + let attachment = NSTextAttachment(image: image) + if let font = imageAttachmentReferenceFont { + attachment.bounds = CGRect( + x: 0, + y: font.capHeight / 2 - image.size.height / 2, + width: image.size.width, + height: image.size.height + ) + } + + return NSMutableAttributedString(attachment: attachment) + } + return attributedString + } public var string: String { value.string } public var length: Int { value.length } + internal var imageAttachmentGenerator: (() -> UIImage?)? + internal var imageAttachmentReferenceFont: UIFont? + internal var attributedString: NSMutableAttributedString public init() { - self.value = NSMutableAttributedString() + self.attributedString = NSMutableAttributedString() } public init(attributedString: ThemedAttributedString) { - self.value = attributedString.value + self.attributedString = attributedString.attributedString + self.imageAttachmentGenerator = attributedString.imageAttachmentGenerator } public init(attributedString: NSAttributedString) { #if DEBUG ThemedAttributedString.validateAttributes(attributedString) #endif - self.value = NSMutableAttributedString(attributedString: attributedString) + self.attributedString = NSMutableAttributedString(attributedString: attributedString) } public init(string: String, attributes: [NSAttributedString.Key: Any] = [:]) { #if DEBUG ThemedAttributedString.validateAttributes(attributes) #endif - self.value = NSMutableAttributedString(string: string, attributes: attributes) + self.attributedString = NSMutableAttributedString(string: string, attributes: attributes) } public init(attachment: NSTextAttachment, attributes: [NSAttributedString.Key: Any] = [:]) { #if DEBUG ThemedAttributedString.validateAttributes(attributes) #endif - self.value = NSMutableAttributedString(attachment: attachment) + self.attributedString = NSMutableAttributedString(attachment: attachment) + } + + public init(imageAttachmentGenerator: @escaping (() -> UIImage?), referenceFont: UIFont?) { + self.attributedString = NSMutableAttributedString() + self.imageAttachmentGenerator = imageAttachmentGenerator + self.imageAttachmentReferenceFont = referenceFont } required init?(coder: NSCoder) { @@ -89,7 +110,7 @@ public class ThemedAttributedString: Equatable, Hashable { #if DEBUG ThemedAttributedString.validateAttributes(attributes ?? [:]) #endif - value.append(NSAttributedString(string: string, attributes: attributes)) + self.attributedString.append(NSAttributedString(string: string, attributes: attributes)) return self } @@ -97,23 +118,23 @@ public class ThemedAttributedString: Equatable, Hashable { #if DEBUG ThemedAttributedString.validateAttributes(attributedString) #endif - value.append(attributedString) + self.attributedString.append(attributedString) } public func append(_ attributedString: ThemedAttributedString) { - value.append(attributedString.value) + self.attributedString.append(attributedString.value) } public func appending(_ attributedString: NSAttributedString) -> ThemedAttributedString { #if DEBUG ThemedAttributedString.validateAttributes(attributedString) #endif - value.append(attributedString) + self.attributedString.append(attributedString) return self } public func appending(_ attributedString: ThemedAttributedString) -> ThemedAttributedString { - value.append(attributedString.value) + self.attributedString.append(attributedString.value) return self } @@ -122,7 +143,7 @@ public class ThemedAttributedString: Equatable, Hashable { ThemedAttributedString.validateAttributes([name: value]) #endif let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) - value.addAttribute(name, value: attrValue, range: targetRange) + self.attributedString.addAttribute(name, value: attrValue, range: targetRange) } public func addingAttribute(_ name: NSAttributedString.Key, value attrValue: Any, range: NSRange? = nil) -> ThemedAttributedString { @@ -130,7 +151,7 @@ public class ThemedAttributedString: Equatable, Hashable { ThemedAttributedString.validateAttributes([name: value]) #endif let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) - value.addAttribute(name, value: attrValue, range: targetRange) + self.attributedString.addAttribute(name, value: attrValue, range: targetRange) return self } @@ -139,7 +160,7 @@ public class ThemedAttributedString: Equatable, Hashable { ThemedAttributedString.validateAttributes(attrs) #endif let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) - value.addAttributes(attrs, range: targetRange) + self.attributedString.addAttributes(attrs, range: targetRange) } public func addingAttributes(_ attrs: [NSAttributedString.Key: Any], range: NSRange? = nil) -> ThemedAttributedString { @@ -147,12 +168,16 @@ public class ThemedAttributedString: Equatable, Hashable { ThemedAttributedString.validateAttributes(attrs) #endif let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) - value.addAttributes(attrs, range: targetRange) + self.attributedString.addAttributes(attrs, range: targetRange) return self } public func boundingRect(with size: CGSize, options: NSStringDrawingOptions = [], context: NSStringDrawingContext?) -> CGRect { - return value.boundingRect(with: size, options: options, context: context) + return self.attributedString.boundingRect(with: size, options: options, context: context) + } + + public func replaceCharacters(in range: NSRange, with attributedString: NSAttributedString) { + self.attributedString.replaceCharacters(in: range, with: attributedString) } // MARK: - Convenience @@ -162,8 +187,7 @@ public class ThemedAttributedString: Equatable, Hashable { for (key, value) in attributes { guard key.originalKey == nil && - NSAttributedString.Key.themedKeys.contains(key) == false && - NSAttributedString.Key.keysToIgnoreValidation.contains(key) == false + NSAttributedString.Key.themedKeys.contains(key) == false else { continue } if value is ThemeValue { diff --git a/SessionUIKit/Types/ImageDataManager.swift b/SessionUIKit/Types/ImageDataManager.swift index 9e0dd29674..1ed75194df 100644 --- a/SessionUIKit/Types/ImageDataManager.swift +++ b/SessionUIKit/Types/ImageDataManager.swift @@ -1,6 +1,7 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. import UIKit +import Lucide import AVFoundation import ImageIO @@ -12,18 +13,18 @@ public actor ImageDataManager: ImageDataManagerType { ) /// Max memory size for a decoded animation to be considered "small" enough to be fully cached - private static let decodedAnimationCacheLimit: Int = 20 * 1024 * 1024 // 20 M + private static let maxCachableSize: Int = 20 * 1024 * 1024 // 20 M private static let maxAnimatedImageDownscaleDimention: CGFloat = 4096 /// `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` - private let cache: NSCache = { - let result: NSCache = NSCache() + private let cache: NSCache = { + let result: NSCache = NSCache() result.totalCostLimit = 200 * 1024 * 1024 // Max 200MB of image data return result }() - private var activeLoadTasks: [String: Task] = [:] + private var activeLoadTasks: [String: Task] = [:] // MARK: - Initialization @@ -31,41 +32,41 @@ public actor ImageDataManager: ImageDataManagerType { // MARK: - Functions - @discardableResult public func load(_ source: DataSource) async -> ProcessedImageData? { + @discardableResult public func load(_ source: DataSource) async -> FrameBuffer? { let identifier: String = source.identifier - if let cachedData: ProcessedImageData = cache.object(forKey: identifier as NSString) { + if let cachedData: FrameBuffer = cache.object(forKey: identifier as NSString) { return cachedData } - if let existingTask: Task = activeLoadTasks[identifier] { + if let existingTask: Task = activeLoadTasks[identifier] { return await existingTask.value } /// Kick off a new processing task in the background - let newTask: Task = Task.detached(priority: .userInitiated) { + let newTask: Task = Task.detached(priority: .userInitiated) { await ImageDataManager.processSource(source) } activeLoadTasks[identifier] = newTask /// Wait for the result then cache and return it - let processedData: ProcessedImageData? = await newTask.value + let maybeBuffer: FrameBuffer? = await newTask.value - if let data: ProcessedImageData = processedData, data.isCacheable { - self.cache.setObject(data, forKey: identifier as NSString, cost: data.estimatedCacheCost) + if let buffer: FrameBuffer = maybeBuffer { + self.cache.setObject(buffer, forKey: identifier as NSString, cost: buffer.estimatedCacheCost) } self.activeLoadTasks[identifier] = nil - return processedData + return maybeBuffer } @MainActor public func load( _ source: ImageDataManager.DataSource, - onComplete: @MainActor @escaping (ImageDataManager.ProcessedImageData?) -> Void + onComplete: @MainActor @escaping (ImageDataManager.FrameBuffer?) -> Void ) { Task { [weak self] in - let result: ImageDataManager.ProcessedImageData? = await self?.load(source) + let result: ImageDataManager.FrameBuffer? = await self?.load(source) await MainActor.run { onComplete(result) @@ -73,7 +74,7 @@ public actor ImageDataManager: ImageDataManagerType { } } - public func cachedImage(identifier: String) async -> ProcessedImageData? { + public func cachedImage(identifier: String) async -> FrameBuffer? { return cache.object(forKey: identifier as NSString) } @@ -87,49 +88,50 @@ public actor ImageDataManager: ImageDataManagerType { // MARK: - Internal Functions - private static func processSource(_ dataSource: DataSource) async -> ProcessedImageData? { + private static func processSource(_ dataSource: DataSource) async -> FrameBuffer? { switch dataSource { + case .icon(let icon, let size, let renderingMode): + guard let image: UIImage = Lucide.image(icon: icon, size: size) else { return nil } + + return FrameBuffer(image: image.withRenderingMode(renderingMode)) + /// 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) - ) + return FrameBuffer(image: image) /// Custom handle `videoUrl` values since it requires thumbnail generation - case .videoUrl(let url, let mimeType, let sourceFilename, let thumbnailManager): + case .videoUrl(let url, let utType, 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 existingThumbCgImage: CGImage = existingThumbnail.cgImage, + let existingThumbnailSource: ImageDataManager.DataSource = thumbnailManager + .existingThumbnail(name: url.lastPathComponent, size: .large), + let source: CGImageSource = existingThumbnailSource.createImageSource(), + let existingThumbCgImage: CGImage = createCGImage(source, index: 0, maxDimensionInPixels: nil), let decodingContext: CGContext = createDecodingContext( width: existingThumbCgImage.width, height: existingThumbCgImage.height ), let decodedImage: UIImage = predecode(cgImage: existingThumbCgImage, using: decodingContext) { - let processedData: ProcessedImageData = ProcessedImageData( - type: .staticImage(decodedImage) - ) - - return processedData + return FrameBuffer(image: decodedImage) } /// 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 asset: AVURLAsset = assetInfo?.asset, - asset.isValidVideo + let assetInfo: (asset: AVURLAsset, isValidVideo: Bool, cleanup: () -> Void) = SNUIKit.assetInfo( + for: url.path, + utType: utType, + sourceFilename: sourceFilename + ) else { return nil } + defer { assetInfo.cleanup() } + + guard assetInfo.isValidVideo else { return nil } let time: CMTime = CMTimeMake(value: 1, timescale: 60) - let generator: AVAssetImageGenerator = AVAssetImageGenerator(asset: asset) + let generator: AVAssetImageGenerator = AVAssetImageGenerator(asset: assetInfo.asset) generator.appliesPreferredTrackTransform = true guard @@ -141,71 +143,101 @@ public actor ImageDataManager: ImageDataManagerType { let decodedImage: UIImage = predecode(cgImage: cgImage, using: decodingContext) else { return nil } - let processedData: ProcessedImageData = ProcessedImageData( - type: .staticImage(decodedImage) - ) - assetInfo?.cleanup() + let result: FrameBuffer = FrameBuffer(image: decodedImage) /// Since we generated a new thumbnail we should save it to disk - saveThumbnailToDisk( - image: decodedImage, - url: url, - size: .large, - thumbnailManager: thumbnailManager - ) + Task.detached(priority: .background) { + saveThumbnailToDisk( + name: url.lastPathComponent, + frames: [decodedImage], + durations: [], /// Static image so no durations + hasAlpha: false, /// Video can't have alpha + size: .large, + thumbnailManager: thumbnailManager + ) + } - return processedData + return result /// Custom handle `urlThumbnail` generation case .urlThumbnail(let url, let size, let thumbnailManager): + let maxDimensionInPixels: CGFloat = await size.pixelDimension() + let flooredPixels: Int = Int(floor(maxDimensionInPixels)) + /// If we had already generated a thumbnail then use that if - let existingThumbnail: UIImage = thumbnailManager.existingThumbnailImage(url: url, size: .large), - let existingThumbCgImage: CGImage = existingThumbnail.cgImage, - let decodingContext: CGContext = createDecodingContext( - width: existingThumbCgImage.width, - height: existingThumbCgImage.height - ), - let decodedImage: UIImage = predecode(cgImage: existingThumbCgImage, using: decodingContext) + let existingThumbnailSource: ImageDataManager.DataSource = thumbnailManager + .existingThumbnail(name: url.lastPathComponent, size: size), + let source: CGImageSource = existingThumbnailSource.createImageSource() { - let processedData: ProcessedImageData = ProcessedImageData( - type: .staticImage(decodedImage) + return await createBuffer( + source, + orientation: .up, /// Thumbnails will always have their orientation removed + sourceWidth: flooredPixels, + sourceHeight: flooredPixels ) - - return processedData } - /// Otherwise we need to generate a new one - let maxDimensionInPixels: CGFloat = await size.pixelDimension() - let options: [CFString: Any] = [ - kCGImageSourceShouldCache: false, - kCGImageSourceShouldCacheImmediately: false, - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels - ] - + /// If not then check whether there would be any benefit in creating a thumbnail guard - let source: CGImageSource = dataSource.createImageSource(options: options), - let cgImage: CGImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary), - let decodingContext: CGContext = createDecodingContext( - width: cgImage.width, - height: cgImage.height - ), - let decodedImage: UIImage = predecode(cgImage: cgImage, using: decodingContext) + let newThumbnailSource: CGImageSource = dataSource.createImageSource(), + let properties: [String: Any] = CGImageSourceCopyPropertiesAtIndex(newThumbnailSource, 0, nil) as? [String: Any], + let sourceWidth: Int = properties[kCGImagePropertyPixelWidth as String] as? Int, + let sourceHeight: Int = properties[kCGImagePropertyPixelHeight as String] as? Int, + sourceWidth > 0, + sourceHeight > 0 else { return nil } - /// Since we generated a new thumbnail we should save it to disk - saveThumbnailToDisk( - image: decodedImage, - url: url, - size: size, - thumbnailManager: thumbnailManager - ) + /// If the source is smaller than the target thumbnail size then we should just return the target directly + guard sourceWidth > flooredPixels || sourceHeight > flooredPixels else { + return await processSource(.url(url)) + } - return ProcessedImageData( - type: .staticImage(decodedImage) - ) + /// Otherwise, generate the thumbnail + guard + let result: FrameBuffer = await createBuffer( + newThumbnailSource, + orientation: .up, /// Thumbnails will always have their orientation removed + sourceWidth: sourceWidth, + sourceHeight: sourceHeight, + maxDimensionInPixels: maxDimensionInPixels, + customLoaderGenerator: { + /// If we had already generated a thumbnail then use that + if + let existingThumbnailSource: ImageDataManager.DataSource = thumbnailManager + .existingThumbnail(name: url.lastPathComponent, size: size), + let source: CGImageSource = existingThumbnailSource.createImageSource() + { + let existingThumbnailBuffer: FrameBuffer? = await createBuffer( + source, + orientation: .up, /// Thumbnails will always have their orientation removed + sourceWidth: flooredPixels, + sourceHeight: flooredPixels + ) + + return await existingThumbnailBuffer?.generateLoadClosure?() + } + + return nil + } + ) + else { return nil } + + /// Since we generated a new thumbnail we should save it to disk (only do this if we created a new thumbnail) + Task.detached(priority: .background) { + let allFrames: [UIImage] = await result.allFramesOnceLoaded() + + saveThumbnailToDisk( + name: url.lastPathComponent, + frames: allFrames, + durations: result.durations, + hasAlpha: (properties[kCGImagePropertyHasAlpha as String] as? Bool), + size: size, + thumbnailManager: thumbnailManager + ) + } + + return result /// Custom handle `placeholderIcon` generation case .placeholderIcon(let seed, let text, let size): @@ -218,15 +250,9 @@ public actor ImageDataManager: ImageDataManagerType { height: cgImage.height ), let decodedImage: UIImage = predecode(cgImage: cgImage, using: decodingContext) - else { - return ProcessedImageData( - type: .staticImage(image) - ) - } + else { return FrameBuffer(image: image) } - return ProcessedImageData( - type: .staticImage(decodedImage) - ) + return FrameBuffer(image: decodedImage) case .asyncSource(_, let sourceRetriever): guard let source: DataSource = await sourceRetriever() else { return nil } @@ -248,174 +274,208 @@ public actor ImageDataManager: ImageDataManagerType { sourceHeight > 0, sourceHeight < ImageDataManager.DataSource.maxValidDimension else { return nil } - + + return await createBuffer( + source, + orientation: orientation(from: properties), + sourceWidth: sourceWidth, + sourceHeight: sourceHeight + ) + } + + private static func orientation(from properties: [String: Any]) -> UIImage.Orientation { + if + let rawCgOrientation: UInt32 = properties[kCGImagePropertyOrientation as String] as? UInt32, + let cgOrientation: CGImagePropertyOrientation = CGImagePropertyOrientation(rawValue: rawCgOrientation) + { + return UIImage.Orientation(cgOrientation) + } + + return .up + } + + private static func createBuffer( + _ source: CGImageSource, + orientation: UIImage.Orientation, + sourceWidth: Int, + sourceHeight: Int, + maxDimensionInPixels: CGFloat? = nil, + customLoaderGenerator: (() async -> AsyncLoadStream.Loader?)? = nil + ) async -> FrameBuffer? { /// Get the number of frames in the image let count: Int = CGImageSourceGetCount(source) - switch count { - /// Invalid image - case ..<1: return nil - - /// Static image - case 1: - /// Extract image orientation if present - var orientation: UIImage.Orientation = .up - - if - let rawCgOrientation: UInt32 = properties[kCGImagePropertyOrientation as String] as? UInt32, - let cgOrientation: CGImagePropertyOrientation = CGImagePropertyOrientation(rawValue: rawCgOrientation) - { - orientation = UIImage.Orientation(cgOrientation) - } - - /// Try to decode the image direct from the `CGImage` - let options: [CFString: Any] = [ - kCGImageSourceShouldCache: false, - kCGImageSourceShouldCacheImmediately: false - ] - - guard - let cgImage: CGImage = CGImageSourceCreateImageAtIndex(source, 0, options as CFDictionary), - let decodingContext = createDecodingContext(width: cgImage.width, height: cgImage.height), - let decodedImage: UIImage = predecode(cgImage: cgImage, using: decodingContext), - let decodedCgImage: CGImage = decodedImage.cgImage - else { return nil } - - let finalImage: UIImage = UIImage(cgImage: decodedCgImage, scale: 1, orientation: orientation) - - return ProcessedImageData( - type: .staticImage(finalImage) + /// Invalid image + guard count > 0 else { return nil } + + /// Load the first frame + guard + let firstFrameCgImage: CGImage = createCGImage( + source, + index: 0, + maxDimensionInPixels: maxDimensionInPixels + ) + else { return nil } + + /// The share extension has limited RAM (~120Mb on an iPhone X) and pre-decoding an image results in approximately `3x` + /// the RAM usage of the standard lazy loading (as buffers need to be allocated and image data copied during the pre-decode), + /// in order to avoid this we check if the estimated pre-decoded image RAM usage is smaller than `80%` of the currently + /// available RAM and if not we just rely on lazy `UIImage` loading and the OS + let hasEnoughMemoryToPreDecode: Bool = { + #if targetEnvironment(simulator) + /// On the simulator `os_proc_available_memory` seems to always return `0` so just assume we have enough memort + return true + #else + let estimatedMemorySize: Int = (sourceWidth * sourceHeight * 4) + let estimatedMemorySizeToLoad: Int = (estimatedMemorySize * 3) + let currentAvailableMemory: Int = os_proc_available_memory() + + return (estimatedMemorySizeToLoad < Int(floor(CGFloat(currentAvailableMemory) * 0.8))) + #endif + }() + + guard hasEnoughMemoryToPreDecode else { + return FrameBuffer( + image: UIImage(cgImage: firstFrameCgImage, scale: 1, orientation: orientation) + ) + } + + /// Otherwise we want to "predecode" the first (and other) frames while in the background to reduce the load on the UI thread + guard + let firstFrameContext: CGContext = createDecodingContext( + width: firstFrameCgImage.width, + height: firstFrameCgImage.height + ), + let decodedFirstFrameImage: UIImage = predecode(cgImage: firstFrameCgImage, using: firstFrameContext), + let decodedCgImage: CGImage = decodedFirstFrameImage.cgImage + else { return nil } + + /// Static image + guard count > 1 else { + return FrameBuffer( + image: UIImage(cgImage: decodedCgImage, scale: 1, orientation: orientation) + ) + } + + /// Animated Image + let durations: [TimeInterval] = getFrameDurations(from: source, count: count) + let standardLoaderGenerator: AsyncLoadStream.Loader = { stream, buffer in + /// Since the `AsyncLoadStream.Loader` gets run in it's own task we need to create a context within the task + guard + let decodingContext: CGContext = createDecodingContext( + width: firstFrameCgImage.width, + height: firstFrameCgImage.height ) + else { return } + + var (frameIndexesToBuffer, probeFrames) = await self.calculateHeuristicBuffer( + startIndex: 1, /// We have already decoded the first frame so skip it + source: source, + durations: durations, + maxDimensionInPixels: maxDimensionInPixels, + using: decodingContext + ) + let lastBufferedFrameIndex: Int = ( + frameIndexesToBuffer.max() ?? + probeFrames.count + ) + + /// Immediately yield the frames decoded when calculating the buffer size + for (index, frame) in probeFrames.enumerated() { + if Task.isCancelled { break } - /// Animated Image - default: - /// Load the first frame - guard - let firstFrameCgImage: CGImage = CGImageSourceCreateImageAtIndex(source, 0, nil), - let decodingContext: CGContext = createDecodingContext( - width: firstFrameCgImage.width, - height: firstFrameCgImage.height - ), - let decodedFirstFrameImage: UIImage = predecode(cgImage: firstFrameCgImage, using: decodingContext) - else { return nil } - - /// If the memory usage of the full animation when is small enough then we should fully decode and cache the decoded - /// result in memory, otherwise we don't want to cache the decoded data, but instead want to generate a buffered stream - /// of frame data to start playing the animation as soon as possible whilst we continue to decode in the background - let decodedMemoryCost: Int = (firstFrameCgImage.width * firstFrameCgImage.height * 4 * count) - let durations: [TimeInterval] = getFrameDurations(from: source, count: count) + /// We `+ 1` because the first frame is always manually assigned + let bufferIndex: Int = (index + 1) + buffer.setFrame(frame, at: bufferIndex) + await stream.send(.frameLoaded(index: bufferIndex)) + } + + /// Clear out the `proveFrames` array so we don't use the extra memory + probeFrames.removeAll(keepingCapacity: false) + + /// Load in any additional buffer frames needed + for i in frameIndexesToBuffer { + guard !Task.isCancelled else { + await stream.cancel() + return + } - guard decodedMemoryCost > decodedAnimationCacheLimit else { - var frames: [UIImage] = [decodedFirstFrameImage] - - for i in 1.. = AsyncStream { continuation in - let task = Task.detached(priority: .userInitiated) { - var (frameIndexesToBuffer, probeFrames) = await self.calculateHeuristicBuffer( - startIndex: 1, /// We have already decoded the first frame so skip it - source: source, - durations: durations, + if let frame: UIImage = decodedFrame { + buffer.setFrame(frame, at: i) + await stream.send(.frameLoaded(index: i)) + } + } + + /// Now that we have buffered enough frames we can start the animation + if !Task.isCancelled { + await stream.send(.readyToAnimate) + } + + /// Start loading the remaining frames (`+ 1` as we want to start from the index after the last buffered index) + if lastBufferedFrameIndex < count { + for i in (lastBufferedFrameIndex + 1).. CGImage? { + /// If we don't have a `maxDimension` then we should just load the full image + guard let maxDimension: CGFloat = maxDimensionInPixels else { + return CGImageSourceCreateImageAtIndex(source, index, SNUIKit.mediaDecoderDefaultImageOptions()) } + + /// Otherwise we should create a thumbnail + let options: CFDictionary? = SNUIKit.mediaDecoderDefaultThumbnailOptions(maxDimension: maxDimension) + + return CGImageSourceCreateThumbnailAtIndex(source, index, options) } private static func createDecodingContext(width: Int, height: Int) -> CGContext? { @@ -497,10 +557,11 @@ public actor ImageDataManager: ImageDataManagerType { startIndex: Int, source: CGImageSource, durations: [TimeInterval], + maxDimensionInPixels: CGFloat?, using context: CGContext ) async -> (frameIndexesToBuffer: [Int], probeFrames: [UIImage]) { - let probeFrameCount: Int = 5 /// Number of frames to decode in order to calculate the approx. time to load each frame - let safetyMargin: Double = 2 /// Number of extra frames to be buffered just in case + let probeFrameCount: Int = 8 /// Number of frames to decode in order to calculate the approx. time to load each frame + let safetyMargin: Double = 4 /// Number of extra frames to be buffered just in case guard durations.count > (startIndex + probeFrameCount) else { return (Array(startIndex.. 0.001 else { return ([], probeFrames) } @@ -538,17 +599,20 @@ public actor ImageDataManager: ImageDataManagerType { } private static func saveThumbnailToDisk( - image: UIImage, - url: URL, + name: String, + frames: [UIImage], + durations: [TimeInterval], + hasAlpha: Bool?, size: ImageDataManager.ThumbnailSize, thumbnailManager: ThumbnailManager ) { - /// Don't want to block updating the UI so detatch this task - Task.detached(priority: .background) { - guard let data: Data = image.jpegData(compressionQuality: 0.85) else { return } - - thumbnailManager.saveThumbnail(data: data, size: size, url: url) - } + thumbnailManager.saveThumbnail( + name: name, + frames: frames, + durations: durations, + hasAlpha: hasAlpha, + size: size + ) } } @@ -558,8 +622,9 @@ public extension ImageDataManager { enum DataSource: Sendable, Equatable, Hashable { case url(URL) case data(String, Data) + case icon(Lucide.Icon, size: CGFloat, renderingMode: UIImage.RenderingMode = .alwaysOriginal) case image(String, UIImage?) - case videoUrl(URL, String, String?, ThumbnailManager) + case videoUrl(URL, UTType, String?, ThumbnailManager) case urlThumbnail(URL, ImageDataManager.ThumbnailSize, ThumbnailManager) case placeholderIcon(seed: String, text: String, size: CGFloat) case asyncSource(String, @Sendable () async -> DataSource?) @@ -568,6 +633,9 @@ public extension ImageDataManager { switch self { case .url(let url): return url.absoluteString case .data(let identifier, _): return identifier + case .icon(let icon, let size, let renderingMode): + return "\(icon.rawValue)-\(Int(floor(size)))-\(renderingMode.rawValue)" + case .image(let identifier, _): return identifier case .videoUrl(let url, _, _, _): return url.absoluteString case .urlThumbnail(let url, let size, _): @@ -586,41 +654,26 @@ public extension ImageDataManager { } } - public var imageData: Data? { + public var contentExists: Bool { switch self { - case .url(let url): return try? Data(contentsOf: url, options: [.dataReadingMapped]) - case .data(_, let data): return data - case .image(_, let image): return image?.pngData() - case .videoUrl: return nil - case .urlThumbnail: return nil - case .placeholderIcon: return nil - case .asyncSource: return nil - } - } - - public var directImage: UIImage? { - switch self { - case .image(_, let image): return image - default: return nil + case .url(let url), .videoUrl(let url, _, _, _), .urlThumbnail(let url, _, _): + return FileManager.default.fileExists(atPath: url.path) + + case .data(_, let data): return !data.isEmpty + case .image(_, let image): return (image != nil) + case .icon, .placeholderIcon: return true + case .asyncSource: return true /// Need to assume it exists } } - fileprivate func createImageSource(options: [CFString: Any]? = nil) -> CGImageSource? { - let finalOptions: CFDictionary = ( - options ?? - [ - kCGImageSourceShouldCache: false, - kCGImageSourceShouldCacheImmediately: false - ] - ) as CFDictionary - + public func createImageSource() -> CGImageSource? { switch self { - case .url(let url): return CGImageSourceCreateWithURL(url as CFURL, finalOptions) - case .data(_, let data): return CGImageSourceCreateWithData(data as CFData, finalOptions) - case .urlThumbnail(let url, _, _): return CGImageSourceCreateWithURL(url as CFURL, finalOptions) + case .url(let url): return SNUIKit.mediaDecoderSource(for: url) + case .data(_, let data): return SNUIKit.mediaDecoderSource(for: data) + case .urlThumbnail(let url, _, _): return SNUIKit.mediaDecoderSource(for: url) // These cases have special handling which doesn't use `createImageSource` - case .image, .videoUrl, .placeholderIcon, .asyncSource: return nil + case .icon, .image, .videoUrl, .placeholderIcon, .asyncSource: return nil } } @@ -633,14 +686,21 @@ public extension ImageDataManager { lhsData == rhsData ) + case (.icon(let lhsIcon, let lhsSize, let lhsRenderingMode), .icon(let rhsIcon, let rhsSize, let rhsRenderingMode)): + return ( + lhsIcon == rhsIcon && + lhsSize == rhsSize && + lhsRenderingMode == rhsRenderingMode + ) + 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, let lhsMimeType, let lhsSourceFilename, _), .videoUrl(let rhsUrl, let rhsMimeType, let rhsSourceFilename, _)): + case (.videoUrl(let lhsUrl, let lhsUTType, let lhsSourceFilename, _), .videoUrl(let rhsUrl, let rhsUTType, let rhsSourceFilename, _)): return ( lhsUrl == rhsUrl && - lhsMimeType == rhsMimeType && + lhsUTType == rhsUTType && lhsSourceFilename == rhsSourceFilename ) @@ -671,13 +731,18 @@ public extension ImageDataManager { identifier.hash(into: &hasher) data.hash(into: &hasher) + case .icon(let icon, let size, let renderingMode): + icon.hash(into: &hasher) + size.hash(into: &hasher) + renderingMode.hash(into: &hasher) + case .image(let identifier, _): /// `UIImage` is not actually hashable so we need to provide a separate identifier to use instead identifier.hash(into: &hasher) - case .videoUrl(let url, let mimeType, let sourceFilename, _): + case .videoUrl(let url, let utType, let sourceFilename, _): url.hash(into: &hasher) - mimeType.hash(into: &hasher) + utType.hash(into: &hasher) sourceFilename.hash(into: &hasher) case .urlThumbnail(let url, let size, _): @@ -696,80 +761,215 @@ public extension ImageDataManager { } } -// MARK: - ImageDataManager.DataType - -public extension ImageDataManager { - enum DataType { - case staticImage(UIImage) - case animatedImage(frames: [UIImage], durations: [TimeInterval]) - case bufferedAnimatedImage( - firstFrame: UIImage, - durations: [TimeInterval], - bufferedFrameStream: AsyncStream - ) - } - - enum BufferedFrameStreamEvent { - case frame(index: Int, frame: UIImage) - case readyToPlay - } -} - // MARK: - ImageDataManager.isAnimatedImage public extension ImageDataManager { - static func isAnimatedImage(_ imageData: Data?) -> Bool { - guard let data: Data = imageData, let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else { - return false - } - let frameCount = CGImageSourceGetCount(imageSource) - return frameCount > 1 + static func isAnimatedImage(_ source: ImageDataManager.DataSource?) -> Bool { + guard let source, let imageSource: CGImageSource = source.createImageSource() else { return false } + + return (CGImageSourceGetCount(imageSource) > 1) } } -// MARK: - ImageDataManager.ProcessedImageData +// MARK: - ImageDataManager.FrameBuffer public extension ImageDataManager { - class ProcessedImageData: @unchecked Sendable { - public let type: DataType + enum AsyncLoadEvent: Equatable { + case frameLoaded(index: Int) + case readyToAnimate + case completed + } + + final class FrameBuffer: @unchecked Sendable { + fileprivate final class Box: @unchecked Sendable { + var frameBuffer: FrameBuffer? + } + + private let lock: NSLock = NSLock() public let frameCount: Int + public let firstFrame: UIImage + public let durations: [TimeInterval] public let estimatedCacheCost: Int + public var stream: AsyncStream { + loadIfNeeded() + return asyncLoadStream.stream + } - public var isCacheable: Bool { - switch type { - case .staticImage, .animatedImage: return true - case .bufferedAnimatedImage: return false - } + public var isComplete: Bool { + lock.lock() + defer { lock.unlock() } + + return _isComplete + } + public var framesPurged: Bool { + lock.lock() + defer { lock.unlock() } + + return _framesPurged + } + + fileprivate let generateLoadClosure: (() async -> AsyncLoadStream.Loader)? + private let asyncLoadStream: AsyncLoadStream + private let purgeable: Bool + private var _isLoading: Bool = false + private var _isComplete: Bool = false + private var _framesPurged: Bool = false + private var activeObservers: Set = [] + private var otherFrames: [UIImage?] + + // MARK: - Initialization + + public init(image: UIImage) { + self.frameCount = 1 + self.firstFrame = image + self.durations = [] + self.estimatedCacheCost = FrameBuffer.calculateCost( + forPixelSize: image.size, + count: 1, + bitsPerPixel: image.cgImage?.bitsPerPixel + ) + self.generateLoadClosure = nil + self.purgeable = false + self.asyncLoadStream = .completed + self._isComplete = true + self.otherFrames = [] } - init(type: DataType) { - self.type = type + fileprivate init( + firstFrame: UIImage, + durations: [TimeInterval], + shouldAutoPurgeIfEstimatedCostExceedsLimit cacheLimit: Int, + generateLoadClosure: @escaping @Sendable () async -> AsyncLoadStream.Loader + ) { + let fullCost: Int = FrameBuffer.calculateCost( + forPixelSize: firstFrame.size, + count: durations.count, + bitsPerPixel: firstFrame.cgImage?.bitsPerPixel + ) - switch type { - case .staticImage(let image): - frameCount = 1 - estimatedCacheCost = ProcessedImageData.calculateCost(for: [image]) - - case .animatedImage(let frames, _): - frameCount = frames.count - estimatedCacheCost = ProcessedImageData.calculateCost(for: frames) - - case .bufferedAnimatedImage(_, let durations, _): - frameCount = durations.count - estimatedCacheCost = 0 + self.frameCount = durations.count + self.firstFrame = firstFrame + self.durations = durations + self.purgeable = (fullCost > cacheLimit) + self.otherFrames = Array(repeating: nil, count: max(0, (durations.count - 1))) + self.generateLoadClosure = generateLoadClosure + self.asyncLoadStream = AsyncLoadStream() + + /// For purgeable buffers we don't keep the full images in the cache (just the first frame) and we release the remaining + /// frames once the final observers have stopped observing + self.estimatedCacheCost = (!purgeable ? + fullCost : + FrameBuffer.calculateCost( + forPixelSize: firstFrame.size, + count: 1, + bitsPerPixel: firstFrame.cgImage?.bitsPerPixel + ) + ) + } + + // MARK: - Functions + + public func getFrame(at index: Int) -> UIImage? { + loadIfNeeded() + + if index == 0 { + return firstFrame } + + lock.lock() + defer { lock.unlock() } + + let otherIndex: Int = (index - 1) + guard otherIndex >= 0, otherIndex < otherFrames.count else { return nil } + + return otherFrames[otherIndex] } - static func calculateCost(for images: [UIImage]) -> Int { - return images.reduce(0) { totalCost, image in - guard let cgImage: CGImage = image.cgImage else { return totalCost } + // MARK: - Internal Functions + + fileprivate func setFrame(_ frame: UIImage, at index: Int) { + lock.lock() + defer { lock.unlock() } + + guard index > 0, index < (otherFrames.count + 1) else { return } + + otherFrames[index - 1] = frame + } + + fileprivate func markComplete() { + lock.lock() + defer { lock.unlock() } + + _isComplete = true + _isLoading = false + } + + fileprivate func allFramesOnceLoaded() async -> [UIImage] { + _ = await asyncLoadStream.stream.first(where: { $0 == .completed }) + + return getAllLoadedFrames() + } + + private func loadIfNeeded() { + let needsLoad: Bool = { + lock.lock() + defer { lock.unlock() } - let bytesPerPixel: Int = (cgImage.bitsPerPixel / 8) - let imagePixels: Int = (cgImage.width * cgImage.height) + return ( + !_isLoading && ( + _framesPurged || + !_isComplete + ) + ) + }() + + guard needsLoad, let generateLoadClosure = generateLoadClosure else { return } + + /// Update the loading and purged states + lock.lock() + _isLoading = true + _framesPurged = false + lock.unlock() + + Task.detached { [weak self] in + guard let self else { return } - return totalCost + (imagePixels * (bytesPerPixel > 0 ? bytesPerPixel : 4)) + await asyncLoadStream.start(with: generateLoadClosure(), buffer: self) } } + + private func getAllLoadedFrames() -> [UIImage] { + lock.lock() + defer { lock.unlock() } + + return [firstFrame] + otherFrames.compactMap { $0 } + } + + fileprivate func purgeIfNeeded() { + guard purgeable else { return } + + lock.lock() + defer { lock.unlock() } + + guard !_framesPurged else { return } + + /// Keep first frame, clear others + otherFrames = Array(repeating: nil, count: otherFrames.count) + _framesPurged = true + _isComplete = false + } + + private static func calculateCost( + forPixelSize size: CGSize, + count: Int, + bitsPerPixel: Int? + ) -> Int { + /// Assume the standard 32 bits per pixel + let imagePixels: Int = Int(size.width * size.height) + let bytesPerPixel: Int = ((bitsPerPixel ?? 32) / 8) + + return (count * (imagePixels * bytesPerPixel)) + } } } @@ -778,34 +978,16 @@ public extension ImageDataManager { /// Needed for `actor` usage (ie. assume safe access) extension UIImage: @unchecked Sendable {} -extension AVAsset { - var isValidVideo: Bool { - var maxTrackSize = CGSize.zero - - for track: AVAssetTrack in tracks(withMediaType: .video) { - let trackSize: CGSize = track.naturalSize - maxTrackSize.width = max(maxTrackSize.width, trackSize.width) - maxTrackSize.height = max(maxTrackSize.height, trackSize.height) - } - - return ( - maxTrackSize.width >= 1 && - maxTrackSize.height >= 1 && - maxTrackSize.width < (3 * 1024) && - maxTrackSize.height < (3 * 1024) - ) - } -} - public extension ImageDataManager.DataSource { /// We need to ensure that the image size is "reasonable", otherwise trying to load it could cause out-of-memory crashes static let maxValidDimension: Int = 1 << 18 // 262,144 pixels @MainActor - var sizeFromMetadata: CGSize? { + var displaySizeFromMetadata: 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 .icon(_, let size, _): return CGSize(width: size, height: size) case .image(_, let image): guard let image: UIImage = image else { break } @@ -832,7 +1014,48 @@ public extension ImageDataManager.DataSource { sourceHeight < ImageDataManager.DataSource.maxValidDimension else { return nil } - return CGSize(width: sourceWidth, height: sourceHeight) + /// Since we want the "display size" (ie. size after the orientation has been applied) we may need to rotate the resolution + let orientation: UIImage.Orientation? = { + guard + let rawCgOrientation: UInt32 = properties[kCGImagePropertyOrientation as String] as? UInt32, + let cgOrientation: CGImagePropertyOrientation = CGImagePropertyOrientation(rawValue: rawCgOrientation) + else { return nil } + + return UIImage.Orientation(cgOrientation) + }() + + switch orientation { + case .up, .upMirrored, .down, .downMirrored, .none: + return CGSize(width: sourceWidth, height: sourceHeight) + + case .leftMirrored, .left, .rightMirrored, .right: + return CGSize(width: sourceHeight, height: sourceWidth) + + @unknown default: return CGSize(width: sourceWidth, height: sourceHeight) + } + } + + @MainActor + var orientationFromMetadata: UIImage.Orientation { + /// 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 .icon, .urlThumbnail, .placeholderIcon: return .up + case .image(_, let image): + guard let image: UIImage = image else { break } + + return image.imageOrientation + + case .url, .data, .videoUrl, .asyncSource: break + } + + /// Since we don't have a direct size, try to extract it from the data + guard + let source: CGImageSource = createImageSource(), + let properties: [String: Any] = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] + else { return .up } + + return ImageDataManager.orientation(from: properties) } } @@ -863,15 +1086,15 @@ public extension ImageDataManager { // MARK: - ImageDataManagerType public protocol ImageDataManagerType { - @discardableResult func load(_ source: ImageDataManager.DataSource) async -> ImageDataManager.ProcessedImageData? + @discardableResult func load(_ source: ImageDataManager.DataSource) async -> ImageDataManager.FrameBuffer? @MainActor func load( _ source: ImageDataManager.DataSource, - onComplete: @MainActor @escaping (ImageDataManager.ProcessedImageData?) -> Void + onComplete: @MainActor @escaping (ImageDataManager.FrameBuffer?) -> Void ) - func cachedImage(identifier: String) async -> ImageDataManager.ProcessedImageData? + func cachedImage(identifier: String) async -> ImageDataManager.FrameBuffer? func removeImage(identifier: String) async func clearCache() async } @@ -879,6 +1102,128 @@ public protocol ImageDataManagerType { // MARK: - ThumbnailManager public protocol ThumbnailManager: Sendable { - func existingThumbnailImage(url: URL, size: ImageDataManager.ThumbnailSize) -> UIImage? - func saveThumbnail(data: Data, size: ImageDataManager.ThumbnailSize, url: URL) + func existingThumbnail(name: String, size: ImageDataManager.ThumbnailSize) -> ImageDataManager.DataSource? + func saveThumbnail( + name: String, + frames: [UIImage], + durations: [TimeInterval], + hasAlpha: Bool?, + size: ImageDataManager.ThumbnailSize + ) +} + +// MARK: AsyncLoadStream + +public actor AsyncLoadStream { + public typealias Loader = @Sendable (AsyncLoadStream, ImageDataManager.FrameBuffer) async -> Void + + fileprivate static let completed: AsyncLoadStream = AsyncLoadStream(isFinished: true) + + private var continuations: [UUID: AsyncStream.Continuation] = [:] + private var lastEvent: ImageDataManager.AsyncLoadEvent? + private var isFinished: Bool = false + + /// This being `nonisolated(unsafe)` is ok because it only gets set in `init` or accessed from isolated methods (`send` + /// and `cancel`) + private nonisolated(unsafe) var loadingTask: Task? + private weak var frameBuffer: ImageDataManager.FrameBuffer? + + public nonisolated var stream: AsyncStream { + AsyncStream { continuation in + let id = UUID() + Task { + guard await !self.isFinished else { + if let lastEvent = await self.lastEvent { + continuation.yield(lastEvent) + } + + /// Don't finish, add to continuations to keep the observer registered and the `FrameBuffer` alive (in case + /// it's purgeable) + await self.addContinuation(id: id, continuation: continuation) + return + } + + // Replay the last event if there is one + if let lastEvent = await self.lastEvent { + continuation.yield(lastEvent) + } + + await self.addContinuation(id: id, continuation: continuation) + } + continuation.onTermination = { _ in + Task { await self.removeContinuation(id: id) } + } + } + } + + // MARK: - Initialization + + fileprivate init() {} + + private init(isFinished: Bool) { + self.lastEvent = .completed + self.isFinished = isFinished + self.loadingTask = nil + } + + // MARK: - Functions + + public func start( + priority: TaskPriority? = nil, + with load: @escaping Loader, + buffer: ImageDataManager.FrameBuffer + ) { + loadingTask?.cancel() + loadingTask = nil + + lastEvent = nil + isFinished = false + frameBuffer = buffer + loadingTask = Task.detached(priority: .userInitiated) { [weak self] in + guard let self else { return } + + await load(self, buffer) + } + } + + public func send(_ event: ImageDataManager.AsyncLoadEvent) { + guard !isFinished else { return } + + lastEvent = event + continuations.values.forEach { $0.yield(event) } + + /// Mark as finished by **don't** `finish` the streams so we don't unintentionally purge memory + if case .completed = event { + isFinished = true + loadingTask = nil + } + } + + public func cancel() { + loadingTask?.cancel() + loadingTask = nil + continuations.values.forEach { $0.finish() } + continuations.removeAll() + isFinished = true + } + + // MARK: - Internal Functions + + private func addContinuation(id: UUID, continuation: AsyncStream.Continuation) { + continuations[id] = continuation + } + + private func removeContinuation(id: UUID) { + continuations.removeValue(forKey: id) + + /// When last observer removed, trigger purge check + if continuations.isEmpty { + loadingTask?.cancel() + loadingTask = nil + + Task.detached { [weak frameBuffer] in + frameBuffer?.purgeIfNeeded() + } + } + } } diff --git a/SessionUIKit/Types/Localization.swift b/SessionUIKit/Types/Localization.swift index a45a924cf5..d34160e8d5 100644 --- a/SessionUIKit/Types/Localization.swift +++ b/SessionUIKit/Types/Localization.swift @@ -3,6 +3,7 @@ // stringlint:disable import UIKit +import NaturalLanguage // MARK: - LocalizationHelper @@ -83,6 +84,12 @@ final public class LocalizationHelper: CustomStringConvertible { // Replace html tag "
" with "\n" localizedString = localizedString.replacingOccurrences(of: "
", with: "\n") + // Add RTL mark for RTL-dominant strings to try to ensure proper rendering when starting/ending + // with English variables + if localizedString.isMostlyRTL { + localizedString = "\u{200F}" + localizedString + "\u{200F}" + } + return localizedString } @@ -145,3 +152,23 @@ public extension String { return LocalizationHelper(template: self).localizedDeformatted() } } + +private extension String { + /// Determines if the string's dominant language is Right-to-Left (RTL). + /// + /// This uses `NLLanguageRecognizer` to find the string's dominant language + /// and then checks that language's character direction using `Locale`. + /// + /// - Returns: `true` if the dominant language is RTL (e.g., Arabic, Hebrew); + /// otherwise, `false`. + var isMostlyRTL: Bool { + let recognizer: NLLanguageRecognizer = NLLanguageRecognizer() + recognizer.processString(self) + + guard let language: NLLanguage = recognizer.dominantLanguage else { + return false // If no dominant language is recognized, assume not RTL. + } + // Check the character direction for the determined dominant language. + return (Locale.characterDirection(forLanguage: language.rawValue) == .rightToLeft) + } +} diff --git a/SessionUIKit/Types/SUIKImageFormat.swift b/SessionUIKit/Types/SUIKImageFormat.swift deleted file mode 100644 index 39906bfb69..0000000000 --- a/SessionUIKit/Types/SUIKImageFormat.swift +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -/// This type should match the `ImageFormat` type in `SessionUtilitiesKit` -public enum SUIKImageFormat { - case unknown - case png - case gif - case tiff - case jpeg - case bmp - case webp - - var nullIfUnknown: SUIKImageFormat? { - switch self { - case .unknown: return nil - default: return self - } - } -} diff --git a/SessionUIKit/Utilities/MentionUtilities.swift b/SessionUIKit/Utilities/MentionUtilities.swift index 58b19576b5..844df5a58d 100644 --- a/SessionUIKit/Utilities/MentionUtilities.swift +++ b/SessionUIKit/Utilities/MentionUtilities.swift @@ -66,82 +66,13 @@ public enum MentionUtilities { currentUserSessionIds: Set, displayNameRetriever: (String, Bool) -> String? ) -> String { - /// **Note:** We are returning the string here so the 'textColor' and 'primaryColor' values are irrelevant - return highlightMentions( + let (string, _) = getMentions( in: string, currentUserSessionIds: currentUserSessionIds, - location: .styleFree, - textColor: .black, - attributes: [:], displayNameRetriever: displayNameRetriever ) - .string - .deformatted() - } - - public static func highlightMentions( - in string: String, - currentUserSessionIds: Set, - location: MentionLocation, - textColor: ThemeValue, - attributes: [NSAttributedString.Key: Any], - displayNameRetriever: (String, Bool) -> String? - ) -> ThemedAttributedString { - let (string, mentions) = getMentions( - in: string, - currentUserSessionIds: currentUserSessionIds, - displayNameRetriever: displayNameRetriever - ) - - let sizeDiff: CGFloat = (Values.smallFontSize / Values.mediumFontSize) - let result: ThemedAttributedString = ThemedAttributedString(string: string, attributes: attributes) - mentions.forEach { mention in - result.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: Values.smallFontSize), range: mention.range) - - if mention.isCurrentUser && location == .incomingMessage { - // Note: The designs don't match with the dynamic sizing so these values need to be calculated - // to maintain a "rounded rect" effect rather than a "pill" effect - result.addAttribute(.currentUserMentionBackgroundCornerRadius, value: (8 * sizeDiff), range: mention.range) - result.addAttribute(.currentUserMentionBackgroundPadding, value: (3 * sizeDiff), range: mention.range) - result.addAttribute(.currentUserMentionBackgroundColor, value: ThemeValue.primary, range: mention.range) - - // Only add the additional kern if the mention isn't at the end of the string (otherwise this - // would crash due to an index out of bounds exception) - if mention.range.upperBound < result.length { - result.addAttribute(.kern, value: (3 * sizeDiff), range: NSRange(location: mention.range.upperBound, length: 1)) - } - } - - var targetColor: ThemeValue = textColor - - switch (location, mention.isCurrentUser) { - // 1 - Incoming messages where the mention is for the current user - case (.incomingMessage, true): - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .black) - - // 2 - Incoming messages where the mention is for another user - case (.incomingMessage, false): - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .primary) - - // 3 - Outgoing messages - case (.outgoingMessage, _): - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .black) - - // 4 - Mentions in quotes - case (.outgoingQuote, _): - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .black) - case (.incomingQuote, _): - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .primary) - - // 5 - Mentions in quote drafts - case (.quoteDraft, _), (.styleFree, _): - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: textColor) - } - - result.addAttribute(.themeForegroundColor, value: targetColor, range: mention.range) - } - return result + return string } } diff --git a/SessionUIKit/Utilities/QRCode.swift b/SessionUIKit/Utilities/QRCode.swift index a5df2c5dc8..ee7482b0b5 100644 --- a/SessionUIKit/Utilities/QRCode.swift +++ b/SessionUIKit/Utilities/QRCode.swift @@ -53,13 +53,16 @@ public enum QRCode { let iconName = iconName, let icon: UIImage = UIImage(named: iconName) { - let iconPercent: CGFloat = 0.25 - let iconSize = size.width * iconPercent + let iconPercent: CGFloat = 0.2 + let iconSize: CGSize = CGSize( + width: size.width * iconPercent, + height: icon.size.height * (size.width * iconPercent) / icon.size.width + ) let iconRect = CGRect( - x: (size.width - iconSize) / 2, - y: (size.height - iconSize) / 2, - width: iconSize, - height: iconSize + x: (size.width - iconSize.width) / 2, + y: (size.height - iconSize.height) / 2, + width: iconSize.width, + height: iconSize.height ) // Clear the area under the icon @@ -77,24 +80,13 @@ public enum QRCode { return finalImage ?? qrUIImage } - static func qrCodeImageWithTintAndBackground( + public static func qrCodeImageWithBackground( image: UIImage, - themeStyle: UIUserInterfaceStyle, size: CGSize? = nil, insets: UIEdgeInsets = .zero ) -> UIImage { - var backgroundColor: UIColor { - switch themeStyle { - case .light: return .classicDark1 - default: return .white - } - } - var tintColor: UIColor { - switch themeStyle { - case .light: return .white - default: return .classicDark1 - } - } + var backgroundColor: UIColor = .white + var tintColor: UIColor = .classicDark1 let outputSize = size ?? image.size let renderer = UIGraphicsImageRenderer(size: outputSize) diff --git a/SessionUIKit/Utilities/String+SessionProBadge.swift b/SessionUIKit/Utilities/String+SessionProBadge.swift deleted file mode 100644 index 15f36582cc..0000000000 --- a/SessionUIKit/Utilities/String+SessionProBadge.swift +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import UIKit - -public extension String { - enum SessionProBadgePosition { - case leading, trailing - } - - func addProBadge( - at postion: SessionProBadgePosition, - font: UIFont, - textColor: ThemeValue = .textPrimary, - proBadgeSize: SessionProBadge.Size, - spacing: String = " " - ) -> NSMutableAttributedString { - let image: UIImage = SessionProBadge(size: proBadgeSize).toImage() - let base = NSMutableAttributedString() - let attachment = NSTextAttachment() - attachment.image = image - - // Vertical alignment tweak to align to baseline - let cap = font.capHeight - let dy = (cap - image.size.height) / 2 - attachment.bounds = CGRect(x: 0, y: dy, width: image.size.width, height: image.size.height) - - switch postion { - case .leading: - base.append(NSAttributedString(attachment: attachment)) - base.append(NSAttributedString(string: spacing)) - base.append(NSAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) - case .trailing: - base.append(NSAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) - base.append(NSAttributedString(string: spacing)) - base.append(NSAttributedString(attachment: attachment)) - } - - return base - } -} diff --git a/SessionUIKit/Utilities/UIImage+Utilities.swift b/SessionUIKit/Utilities/UIImage+Utilities.swift index 0c2e894c15..09806d8ad4 100644 --- a/SessionUIKit/Utilities/UIImage+Utilities.swift +++ b/SessionUIKit/Utilities/UIImage+Utilities.swift @@ -87,4 +87,9 @@ public extension UIImage { return renderedImage } + + func flippedHorizontally() -> UIImage? { + guard let cgImage = self.cgImage else { return nil } + return UIImage(cgImage: cgImage, scale: scale, orientation: .upMirrored) + } } diff --git a/SessionUIKit/Utilities/UILabel+Utilities.swift b/SessionUIKit/Utilities/UILabel+Utilities.swift new file mode 100644 index 0000000000..ef6476a079 --- /dev/null +++ b/SessionUIKit/Utilities/UILabel+Utilities.swift @@ -0,0 +1,72 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +public extension UILabel { + /// Appends a rendered snapshot of `view` as an inline image attachment. + func attachTrailing(_ imageGenerator: (() -> UIImage?)?, spacing: String = " ") { + guard let imageGenerator else { return } + + let base = ThemedAttributedString() + if let existing = attributedText, existing.length > 0 { + base.append(existing) + } else if let t = text { + base.append(NSAttributedString(string: t, attributes: [.font: font as Any, .foregroundColor: textColor as Any])) + } + + base.append(NSAttributedString(string: spacing)) + base.append(ThemedAttributedString(imageAttachmentGenerator: imageGenerator, referenceFont: font)) + + themeAttributedText = base + numberOfLines = 0 + lineBreakMode = .byWordWrapping + } + + /// Returns true if `point` (in this label's coordinate space) hits a drawn NSTextAttachment at the end of the string. + /// Works with multi-line labels, alignment, and truncation. + func isPointOnTrailingAttachment(_ point: CGPoint, hitPadding: CGFloat = 0) -> Bool { + guard let attributed = attributedText, attributed.length > 0 else { return false } + + // Reuse the general function but also ensure the attachment range ends at string end. + // We re-run the minimal parts to get the effectiveRange. + let layoutManager = NSLayoutManager() + let textContainer = NSTextContainer(size: CGSize(width: bounds.width, height: .greatestFiniteMagnitude)) + textContainer.lineFragmentPadding = 0 + textContainer.maximumNumberOfLines = numberOfLines + textContainer.lineBreakMode = lineBreakMode + + let textStorage = NSTextStorage(attributedString: attributed) + textStorage.addLayoutManager(layoutManager) + layoutManager.addTextContainer(textContainer) + layoutManager.ensureLayout(for: textContainer) + + let glyphRange = layoutManager.glyphRange(for: textContainer) + if glyphRange.length == 0 { return false } + let textBounds = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + + var textOrigin = CGPoint.zero + switch textAlignment { + case .center: textOrigin.x = (bounds.width - textBounds.width) / 2.0 + case .right: textOrigin.x = bounds.width - textBounds.width + case .natural where effectiveUserInterfaceLayoutDirection == .rightToLeft: + textOrigin.x = bounds.width - textBounds.width + default: break + } + + let pt = CGPoint(x: point.x - textOrigin.x, y: point.y - textOrigin.y) + if !textBounds.insetBy(dx: -hitPadding, dy: -hitPadding).contains(pt) { return false } + + let idx = layoutManager.characterIndex(for: pt, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil) + guard idx < attributed.length else { return false } + + var range = NSRange(location: 0, length: 0) + guard attributed.attribute(.attachment, at: idx, effectiveRange: &range) is NSTextAttachment, + NSMaxRange(range) == attributed.length else { + return false + } + + let attGlyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) + let attRect = layoutManager.boundingRect(forGlyphRange: attGlyphRange, in: textContainer) + return attRect.insetBy(dx: -hitPadding, dy: -hitPadding).contains(pt) + } +} diff --git a/SessionUtilitiesKit/Crypto/Crypto.swift b/SessionUtilitiesKit/Crypto/Crypto.swift index 8ab0c151f8..16d0259aba 100644 --- a/SessionUtilitiesKit/Crypto/Crypto.swift +++ b/SessionUtilitiesKit/Crypto/Crypto.swift @@ -4,6 +4,12 @@ import Foundation +// MARK: - Log.Category + +public extension Log.Category { + static let crypto: Log.Category = .create("Crypto", defaultLevel: .info) +} + // MARK: - Singleton public extension Singleton { @@ -26,7 +32,7 @@ public extension CryptoType { } func generateResult(_ generator: Crypto.Generator) -> Result { - return Result(try tryGenerate(generator)) + return Result { try tryGenerate(generator) } } } diff --git a/SessionUtilitiesKit/Crypto/CryptoError.swift b/SessionUtilitiesKit/Crypto/CryptoError.swift index 9ed1bfe208..7ff3a652aa 100644 --- a/SessionUtilitiesKit/Crypto/CryptoError.swift +++ b/SessionUtilitiesKit/Crypto/CryptoError.swift @@ -1,9 +1,12 @@ // Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import Foundation -public enum CryptoError: Error { +public enum CryptoError: Error, CustomStringConvertible { case invalidSeed + case invalidPublicKey case keyGenerationFailed case randomGenerationFailed case signatureGenerationFailed @@ -14,4 +17,21 @@ public enum CryptoError: Error { case missingUserSecretKey case invalidAuthentication case invalidBase64EncodedData + + public var description: String { + switch self { + case .invalidSeed: return "CryptoError: Invalid seed" + case .invalidPublicKey: return "CryptoError: Invalid public key" + case .keyGenerationFailed: return "CryptoError: Key generation failed" + case .randomGenerationFailed: return "CryptoError: Random generation failed" + case .signatureGenerationFailed: return "CryptoError: Signature generation failed" + case .signatureVerificationFailed: return "CryptoError: Signature verification failed" + case .encryptionFailed: return "CryptoError: Encryption failed" + case .decryptionFailed: return "CryptoError: Decryption failed" + case .failedToGenerateOutput: return "CryptoError: Failed to generate output" + case .missingUserSecretKey: return "CryptoError: Missing user secret key" + case .invalidAuthentication: return "CryptoError: Invalid authentication" + case .invalidBase64EncodedData: return "CryptoError: Invalid Base64 encoded data" + } + } } diff --git a/SessionUtilitiesKit/Database/Models/Job.swift b/SessionUtilitiesKit/Database/Models/Job.swift index 1d6a0df046..7c941b25f3 100644 --- a/SessionUtilitiesKit/Database/Models/Job.swift +++ b/SessionUtilitiesKit/Database/Models/Job.swift @@ -58,9 +58,8 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, /// at the timestamp of the next disappearing message case disappearingMessages = 0 - /// This is a recurring job that checks if the user needs to update their profile picture on launch, and if so - /// attempt to download the latest - case updateProfilePicture = 2 + /// This is a recurring job that checks if the user needs to re-upload their profile picture on launch + case reuploadUserDisplayPicture = 2 /// This is a recurring job that ensures the app fetches the default open group rooms on launch case retrieveDefaultOpenGroupRooms diff --git a/SessionUtilitiesKit/Dependency Injection/Dependencies.swift b/SessionUtilitiesKit/Dependency Injection/Dependencies.swift index 4c6578106a..8308765f4e 100644 --- a/SessionUtilitiesKit/Dependency Injection/Dependencies.swift +++ b/SessionUtilitiesKit/Dependency Injection/Dependencies.swift @@ -117,6 +117,12 @@ public class Dependencies { // MARK: - Instance management + public func has(singleton: SingletonConfig) -> Bool { + let key: DependencyStorage.Key = DependencyStorage.Key.Variant.singleton.key(singleton.identifier) + + return (_storage.performMap({ $0.instances[key]?.value(as: S.self) }) != nil) + } + public func warmCache(cache: CacheConfig) { _ = getOrCreate(cache) } diff --git a/SessionUtilitiesKit/General/Feature.swift b/SessionUtilitiesKit/General/Feature.swift index 55998cbed9..04ce1e090f 100644 --- a/SessionUtilitiesKit/General/Feature.swift +++ b/SessionUtilitiesKit/General/Feature.swift @@ -46,6 +46,10 @@ public extension FeatureStorage { defaultOption: 100 ) + static let groupsShowPubkeyInConversationSettings: FeatureConfig = Dependencies.create( + identifier: "groupsShowPubkeyInConversationSettings" + ) + static let updatedGroupsDisableAutoApprove: FeatureConfig = Dependencies.create( identifier: "updatedGroupsDisableAutoApprove" ) @@ -90,17 +94,46 @@ public extension FeatureStorage { identifier: "mockCurrentUserSessionPro" ) - static let treatAllIncomingMessagesAsProMessages: FeatureConfig = Dependencies.create( - identifier: "treatAllIncomingMessagesAsProMessages" + static let allUsersSessionPro: FeatureConfig = Dependencies.create( + identifier: "allUsersSessionPro" + ) + + static let messageFeatureProBadge: FeatureConfig = Dependencies.create( + identifier: "messageFeatureProBadge" + ) + + static let messageFeatureLongMessage: FeatureConfig = Dependencies.create( + identifier: "messageFeatureLongMessage" + ) + + static let messageFeatureAnimatedAvatar: FeatureConfig = Dependencies.create( + identifier: "messageFeatureAnimatedAvatar" ) static let shortenFileTTL: FeatureConfig = Dependencies.create( identifier: "shortenFileTTL" ) - + + static let deterministicAttachmentEncryption: FeatureConfig = Dependencies.create( + identifier: "deterministicAttachmentEncryption" + ) + static let simulateAppReviewLimit: FeatureConfig = Dependencies.create( identifier: "simulateAppReviewLimit" ) + + static let usePngInsteadOfWebPForFallbackImageType: FeatureConfig = Dependencies.create( + identifier: "usePngInsteadOfWebPForFallbackImageType" + ) + + static let versionDeprecationWarning: FeatureConfig = Dependencies.create( + identifier: "versionDeprecationWarning" + ) + + static let versionDeprecationMinimum: FeatureConfig = Dependencies.create( + identifier: "versionDeprecationMinimum", + defaultOption: 16 + ) } // MARK: - FeatureOption diff --git a/SessionUtilitiesKit/General/General.swift b/SessionUtilitiesKit/General/General.swift index 1c7f764c4e..cef2c6f3fb 100644 --- a/SessionUtilitiesKit/General/General.swift +++ b/SessionUtilitiesKit/General/General.swift @@ -14,6 +14,15 @@ public extension Cache { ) } +public extension Cache { + static let generalUI: CacheConfig = Dependencies.create( + identifier: "generalUI", + createInstance: { dependencies in General.UICache() }, + mutableInstance: { $0 }, + immutableInstance: { $0 } + ) +} + // MARK: - General.Cache public enum General { @@ -58,6 +67,18 @@ public enum General { self.ed25519SecretKey = ed25519SecretKey } } + + public class UICache: GeneralUICacheType { + private let cache: NSCache = NSCache() + + public func cache(_ image: UIImage, for key: String) { + cache.setObject(image, forKey: key as NSString) + } + + public func get(for key: String) -> UIImage? { + return cache.object(forKey: key as NSString) + } + } } // MARK: - GeneralCacheType @@ -82,3 +103,14 @@ public protocol GeneralCacheType: ImmutableGeneralCacheType, MutableCacheType { func setSecretKey(ed25519SecretKey: [UInt8]) } + +// MARK: Cache.GeneralUI + +/// This is a read-only version of the Cache designed to avoid unintentionally mutating the instance in a non-thread-safe way +public protocol ImmutableGeneralUICacheType: ImmutableCacheType { + func get(for key: String) -> UIImage? +} + +public protocol GeneralUICacheType: ImmutableGeneralUICacheType, MutableCacheType { + func cache(_ image: UIImage, for key: String) +} diff --git a/SessionUtilitiesKit/General/Logging.swift b/SessionUtilitiesKit/General/Logging.swift index 67b529d8ec..64dc28534c 100644 --- a/SessionUtilitiesKit/General/Logging.swift +++ b/SessionUtilitiesKit/General/Logging.swift @@ -192,8 +192,7 @@ public enum Log { else { return logFiles[0] } // The file is too small so lets create a temp file to share instead - let tempDirectory: String = NSTemporaryDirectory() - let tempFilePath: String = URL(fileURLWithPath: tempDirectory) + let tempFilePath: String = URL(fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectory) .appendingPathComponent(URL(fileURLWithPath: logFiles[1]).lastPathComponent) .path diff --git a/SessionUtilitiesKit/General/String+Utilities.swift b/SessionUtilitiesKit/General/String+Utilities.swift index 59509afca4..17bb313d61 100644 --- a/SessionUtilitiesKit/General/String+Utilities.swift +++ b/SessionUtilitiesKit/General/String+Utilities.swift @@ -223,6 +223,14 @@ public extension String { return self.trimmingCharacters(in: .whitespacesAndNewlines) } + var replacingWhitespacesWithUnderscores: String { + let sanitizedFileNameComponents = components(separatedBy: .whitespaces) + + return sanitizedFileNameComponents + .filter { !$0.isEmpty } + .joined(separator: "_") + } + private var hasExcessiveDiacriticals: Bool { for char in self.enumerated() { let scalarCount = String(char.element).unicodeScalars.count diff --git a/SessionUtilitiesKit/LibSession/Types/ObservingDatabase.swift b/SessionUtilitiesKit/LibSession/Types/ObservingDatabase.swift index 34a9bb0582..bb0cb7ab73 100644 --- a/SessionUtilitiesKit/LibSession/Types/ObservingDatabase.swift +++ b/SessionUtilitiesKit/LibSession/Types/ObservingDatabase.swift @@ -26,6 +26,10 @@ public class ObservingDatabase { // MARK: - Functions + public func currentEvents() -> [ObservedEvent] { + return events + } + public func addEvent(_ event: ObservedEvent) { events.append(event) } diff --git a/SessionUtilitiesKit/Media/DataSource.swift b/SessionUtilitiesKit/Media/DataSource.swift deleted file mode 100644 index 84fcddb595..0000000000 --- a/SessionUtilitiesKit/Media/DataSource.swift +++ /dev/null @@ -1,279 +0,0 @@ -// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import CoreGraphics -import ImageIO -import UniformTypeIdentifiers - -// MARK: - DataSource - -public protocol DataSource: Equatable { - var dependencies: Dependencies { get } - var data: Data { get } - var dataUrl: URL? { get } - - /// The file path for the data, if it already exists on disk. - /// - /// This method is safe to call as it will not do any expensive reads or writes. - /// - /// May return nil if the data does not (yet) reside on disk. - /// - /// Use `dataUrl` instead if you need to access the data; it will ensure the data is on disk and return a URL, barring an error. - var dataPathIfOnDisk: String? { get } - - var dataLength: Int { get } - var sourceFilename: String? { get set } - var fileExtension: String { get } - var mimeType: String? { get } - var shouldDeleteOnDeinit: Bool { get } - - // MARK: - Functions - - func write(to path: String) throws -} - -public extension DataSource { - var imageSize: CGSize? { - let type: UTType? = UTType(sessionFileExtension: fileExtension) - let options: CFDictionary = [ - kCGImageSourceShouldCache: false, - kCGImageSourceShouldCacheImmediately: false - ] as CFDictionary - let maybeSource: CGImageSource? = { - switch self.dataPathIfOnDisk { - case .some(let path): return CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, options) - case .none: return CGImageSourceCreateWithData(data as CFData, options) - } - }() - - guard let source: CGImageSource = maybeSource else { return nil } - - return MediaUtils.MediaMetadata(source: source)?.pixelSize - } - - var isValidImage: Bool { - let type: UTType? = UTType(sessionFileExtension: fileExtension) - - switch self.dataPathIfOnDisk { - case .some(let path): return MediaUtils.isValidImage(at: path, type: type, using: dependencies) - case .none: return MediaUtils.isValidImage(data: data, type: type) - } - } - - var isValidVideo: Bool { - guard let dataUrl: URL = self.dataUrl else { return false } - - return MediaUtils.isValidVideo( - path: dataUrl.path, - mimeType: mimeType, - sourceFilename: sourceFilename, - using: dependencies - ) - } -} - -// MARK: - DataSourceValue - -public class DataSourceValue: DataSource { - public static func empty(using dependencies: Dependencies) -> DataSourceValue { - return DataSourceValue(data: Data(), fileExtension: UTType.fileExtensionText, using: dependencies) - } - - public let dependencies: Dependencies - public var data: Data - public var sourceFilename: String? - public var fileExtension: String - var cachedFilePath: String? - public var shouldDeleteOnDeinit: Bool - - public var dataUrl: URL? { dataPath.map { URL(fileURLWithPath: $0) } } - public var dataPathIfOnDisk: String? { cachedFilePath } - public var dataLength: Int { data.count } - public var mimeType: String? { UTType.sessionMimeType(for: fileExtension) } - - var dataPath: String? { - let fileExtension: String = self.fileExtension - - return DataSourceValue.synced(self) { [weak self, dependencies] in - guard let cachedFilePath: String = self?.cachedFilePath else { - let filePath: String = dependencies[singleton: .fileManager].temporaryFilePath(fileExtension: fileExtension) - - do { try self?.write(to: filePath) } - catch { return nil } - - self?.cachedFilePath = filePath - return filePath - } - - return cachedFilePath - } - } - - // MARK: - Initialization - - public init(data: Data, fileExtension: String, using dependencies: Dependencies) { - self.dependencies = dependencies - self.data = data - self.fileExtension = fileExtension - self.shouldDeleteOnDeinit = true - } - - convenience init?(data: Data?, fileExtension: String, using dependencies: Dependencies) { - guard let data: Data = data else { return nil } - - self.init(data: data, fileExtension: fileExtension, using: dependencies) - } - - public convenience init?(data: Data?, dataType: UTType, using dependencies: Dependencies) { - guard let fileExtension: String = dataType.sessionFileExtension(sourceFilename: nil) else { return nil } - - self.init(data: data, fileExtension: fileExtension, using: dependencies) - } - - public convenience init?(text: String?, using dependencies: Dependencies) { - guard - let text: String = text, - let data: Data = text.filteredForDisplay.data(using: .utf8) - else { return nil } - - self.init(data: data, fileExtension: UTType.fileExtensionText, using: dependencies) - } - - deinit { - guard - shouldDeleteOnDeinit, - let filePath: String = cachedFilePath - else { return } - - DispatchQueue.global(qos: .default).async { [dependencies] in - try? dependencies[singleton: .fileManager].removeItem(atPath: filePath) - } - } - - // MARK: - Functions - - @discardableResult private static func synced(_ lock: Any, closure: () -> T) -> T { - objc_sync_enter(lock) - let result: T = closure() - objc_sync_exit(lock) - return result - } - - public func write(to path: String) throws { - try data.write(to: URL(fileURLWithPath: path), options: .atomic) - } - - public static func == (lhs: DataSourceValue, rhs: DataSourceValue) -> Bool { - return ( - lhs.data == rhs.data && - lhs.sourceFilename == rhs.sourceFilename && - lhs.fileExtension == rhs.fileExtension && - lhs.shouldDeleteOnDeinit == rhs.shouldDeleteOnDeinit - ) - } -} - -// MARK: - DataSourcePath - -public class DataSourcePath: DataSource { - public let dependencies: Dependencies - public var filePath: String - public var sourceFilename: String? - public var fileExtension: String { URL(fileURLWithPath: filePath).pathExtension } - var cachedData: Data? - var cachedDataLength: Int? - public var shouldDeleteOnDeinit: Bool - - public var data: Data { - let filePath: String = self.filePath - - return DataSourcePath.synced(self) { [weak self] in - if let cachedData: Data = self?.cachedData { - return cachedData - } - - let data: Data = ((try? Data(contentsOf: URL(fileURLWithPath: filePath))) ?? Data()) - self?.cachedData = data - return data - } - } - - public var dataUrl: URL? { URL(fileURLWithPath: filePath) } - public var dataPathIfOnDisk: String? { filePath } - - public var dataLength: Int { - let filePath: String = self.filePath - - return DataSourcePath.synced(self) { [weak self, dependencies] in - if let cachedDataLength: Int = self?.cachedDataLength { - return cachedDataLength - } - - let attrs: [FileAttributeKey: Any]? = try? dependencies[singleton: .fileManager].attributesOfItem(atPath: filePath) - let length: Int = ((attrs?[FileAttributeKey.size] as? Int) ?? 0) - self?.cachedDataLength = length - return length - } - } - - public var mimeType: String? { UTType.sessionMimeType(for: URL(fileURLWithPath: filePath).pathExtension) } - - // MARK: - Initialization - - public init( - filePath: String, - sourceFilename: String?, - shouldDeleteOnDeinit: Bool, - using dependencies: Dependencies - ) { - self.dependencies = dependencies - self.filePath = filePath - self.sourceFilename = sourceFilename - self.shouldDeleteOnDeinit = shouldDeleteOnDeinit - } - - public convenience init?( - fileUrl: URL?, - sourceFilename: String?, - shouldDeleteOnDeinit: Bool, - using dependencies: Dependencies - ) { - guard let fileUrl: URL = fileUrl, fileUrl.isFileURL else { return nil } - - self.init( - filePath: fileUrl.path, - sourceFilename: (sourceFilename ?? fileUrl.lastPathComponent), - shouldDeleteOnDeinit: shouldDeleteOnDeinit, - using: dependencies - ) - } - - deinit { - guard shouldDeleteOnDeinit else { return } - - DispatchQueue.global(qos: .default).async { [filePath, dependencies] in - try? dependencies[singleton: .fileManager].removeItem(atPath: filePath) - } - } - - // MARK: - Functions - - @discardableResult private static func synced(_ lock: Any, closure: () -> T) -> T { - objc_sync_enter(lock) - let result: T = closure() - objc_sync_exit(lock) - return result - } - - public func write(to path: String) throws { - try dependencies[singleton: .fileManager].copyItem(atPath: filePath, toPath: path) - } - - public static func == (lhs: DataSourcePath, rhs: DataSourcePath) -> Bool { - return ( - lhs.filePath == rhs.filePath && - lhs.sourceFilename == rhs.sourceFilename && - lhs.shouldDeleteOnDeinit == rhs.shouldDeleteOnDeinit - ) - } -} diff --git a/SessionUtilitiesKit/Media/ImageFormat.swift b/SessionUtilitiesKit/Media/ImageFormat.swift deleted file mode 100644 index cac74ed4ee..0000000000 --- a/SessionUtilitiesKit/Media/ImageFormat.swift +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -public enum ImageFormat { - case unknown - case png - case gif - case tiff - case jpeg - case bmp - case webp - - // stringlint:ignore_contents - public var fileExtension: String { - switch self { - case .jpeg, .unknown: return "jpg" - case .png: return "png" - case .gif: return "gif" - case .tiff: return "tiff" - case .bmp: return "bmp" - case .webp: return "webp" - } - } -} diff --git a/SessionUtilitiesKit/Media/MediaUtils.swift b/SessionUtilitiesKit/Media/MediaUtils.swift index ae3dec2938..d4a4c494c3 100644 --- a/SessionUtilitiesKit/Media/MediaUtils.swift +++ b/SessionUtilitiesKit/Media/MediaUtils.swift @@ -1,8 +1,19 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import UIKit import AVFoundation +// MARK: - Singleton + +public extension Singleton { + static let mediaDecoder: SingletonConfig = Dependencies.create( + identifier: "mediaDecoder", + createInstance: { _ in MediaDecoder() } + ) +} + // MARK: - Log.Category public extension Log.Category { @@ -18,24 +29,124 @@ public enum MediaError: Error { // MARK: - MediaUtils public enum MediaUtils { - public struct MediaMetadata { + public static let unsafeMetadataKeys: Set = [ + kCGImagePropertyExifDictionary, /// Camera settings, dates + kCGImagePropertyGPSDictionary, /// Location data + kCGImagePropertyIPTCDictionary, /// Copyright, captions + kCGImagePropertyTIFFDictionary, /// Camera make/model, software + kCGImagePropertyMakerAppleDictionary, /// Apple device info + kCGImagePropertyExifAuxDictionary, /// Lens info, etc. + kCGImageProperty8BIMDictionary, /// Photoshop data + kCGImagePropertyDNGDictionary, /// RAW camera data + kCGImagePropertyCIFFDictionary, /// Canon RAW + kCGImagePropertyMakerCanonDictionary, + kCGImagePropertyMakerNikonDictionary, + kCGImagePropertyMakerMinoltaDictionary, + kCGImagePropertyMakerFujiDictionary, + kCGImagePropertyMakerOlympusDictionary, + kCGImagePropertyMakerPentaxDictionary + ] + public static let possiblySafeMetadataKeys: Set = [ + kCGImagePropertyPNGDictionary, + kCGImagePropertyGIFDictionary, + kCGImagePropertyJFIFDictionary, + kCGImagePropertyHEICSDictionary + ] + public static let safeMetadataKeys: Set = [ + kCGImagePropertyPixelWidth, + kCGImagePropertyPixelHeight, + kCGImagePropertyDepth, + kCGImagePropertyHasAlpha, + kCGImagePropertyColorModel, + kCGImagePropertyOrientation, + kCGImagePropertyGIFLoopCount, + kCGImagePropertyGIFHasGlobalColorMap, + kCGImagePropertyGIFDelayTime, + kCGImagePropertyGIFUnclampedDelayTime, + kCGImageDestinationLossyCompressionQuality + ] + + public struct MediaMetadata: Sendable, Equatable, Hashable { + /// The pixel size of the media (or it's first frame) public let pixelSize: CGSize - public let frameCount: Int + + /// The size of the file this media is stored in + /// + /// **Note:** This value could be `0` if initialised with a `UIImage` (since the eventual file size would depend on the the + /// file type when written to disk) + public let fileSize: UInt64 + + /// The duration of each frame (this will contain a single element of `0` for static images, and be empty for anything else) + public let frameDurations: [TimeInterval] + + /// The duration of the content (will be `0` for static images) + public let duration: TimeInterval + + /// A flag indicating whether the media may contain unsafe metadata + public let hasUnsafeMetadata: Bool + + /// The number of bits in each color sample of each pixel public let depthBytes: CGFloat? + + /// A flag indicating whether the media has transparent content public let hasAlpha: Bool? + + /// The color model of the image such as "RGB", "CMYK", "Gray", or "Lab" public let colorModel: String? + + /// The orientation of the media public let orientation: UIImage.Orientation? + /// The type of the media content + public let utType: UTType? + + /// The number of frames this media has + public var frameCount: Int { frameDurations.count } + + /// A flag indicating whether the media has valid dimensions (this is primarily here to avoid a "GIF bomb" situation) public var hasValidPixelSize: Bool { - pixelSize.width > 0 && - pixelSize.width < CGFloat(SNUtilitiesKit.maxValidImageDimension) && - pixelSize.height > 0 && - pixelSize.height < CGFloat(SNUtilitiesKit.maxValidImageDimension) + /// If the content isn't visual media then it should have a `zero` size + guard utType?.isVisualMedia == true else { return (pixelSize == .zero) } + + /// Otherwise just ensure it's a sane size + return ( + pixelSize.width > 0 && + pixelSize.width < CGFloat(SNUtilitiesKit.maxValidImageDimension) && + pixelSize.height > 0 && + pixelSize.height < CGFloat(SNUtilitiesKit.maxValidImageDimension) + ) + } + + /// A flag indicating whether the media has a valid duration for it's type + public var hasValidDuration: Bool { + if utType?.isAudio == true || utType?.isVideo == true { + return (duration > 0) + } + + if utType?.isAnimated == true && frameDurations.count > 1 { + return (duration > 0) + } + + /// Other types shouldn't have a duration + return (duration == 0) + } + + public var displaySize: CGSize { + /// If the metadata doesn't have an orientation then don't rotate the size (WebP and videos shouldn't have orientations) + guard let orientation: UIImage.Orientation = orientation else { return pixelSize } + + switch orientation { + case .up, .upMirrored, .down, .downMirrored: return pixelSize + case .leftMirrored, .left, .rightMirrored, .right: + return CGSize(width: pixelSize.height, height: pixelSize.width) + + @unknown default: return pixelSize + } } // MARK: - Initialization - public init?(source: CGImageSource) { + public init?(source: CGImageSource, fileSize: UInt64) { let count: Int = CGImageSourceGetCount(source) guard @@ -46,7 +157,42 @@ public enum MediaUtils { else { return nil } self.pixelSize = CGSize(width: width, height: height) - self.frameCount = count + self.fileSize = fileSize + self.frameDurations = { + guard count > 1 else { return [0] } + + return (0.. = Set(properties.keys) + + /// If we have one of the unsafe metadata keys then no need to process further + guard allKeys.isDisjoint(with: unsafeMetadataKeys) else { + return true + } + + /// A number of the properties required for media decoding are included at both the top level and in child data so + /// we need to check if there are any "non-allowed" keys in the child data in order to make a decision + for key in possiblySafeMetadataKeys { + guard + let childProperties: [CFString: Any] = properties[key] as? [CFString: Any], + !childProperties.isEmpty + else { continue } + + let allChildKeys: Set = Set(childProperties.keys) + let unsafeKeys: Set = allChildKeys.subtracting(safeMetadataKeys) + + if !unsafeKeys.isEmpty { + return true + } + + continue + } + + /// If we get here then there is no unsafe metadata + return false + }() self.depthBytes = { /// The number of bits in each color sample of each pixel. The value of this key is a CFNumberRef guard let depthBits: UInt = properties[kCGImagePropertyDepth] as? UInt else { return nil } @@ -65,207 +211,239 @@ public enum MediaUtils { return UIImage.Orientation(cgOrientation) }() + self.utType = (CGImageSourceGetType(source) as? String).map { UTType($0) } } public init( pixelSize: CGSize, + fileSize: UInt64 = 0, + frameDurations: [TimeInterval] = [0], + hasUnsafeMetadata: Bool, depthBytes: CGFloat? = nil, hasAlpha: Bool? = nil, colorModel: String? = nil, - orientation: UIImage.Orientation? = nil + orientation: UIImage.Orientation? = nil, + utType: UTType? = nil ) { self.pixelSize = pixelSize - self.frameCount = 1 + self.fileSize = fileSize + self.frameDurations = frameDurations + self.duration = frameDurations.reduce(0, +) + self.hasUnsafeMetadata = hasUnsafeMetadata self.depthBytes = depthBytes self.hasAlpha = hasAlpha self.colorModel = colorModel self.orientation = orientation + self.utType = utType + } + + public init?(image: UIImage) { + guard let cgImage = image.cgImage else { return nil } + + self.pixelSize = image.size + self.fileSize = 0 /// Unknown for `UIImage` in memory + self.frameDurations = [0] + self.duration = 0 + self.hasUnsafeMetadata = false /// `UIImage` in memory has no file metadata + self.depthBytes = { + let bitsPerPixel = cgImage.bitsPerPixel + return ceil(CGFloat(bitsPerPixel) / 8.0) + }() + let hasAlphaChannel: Bool = { + switch cgImage.alphaInfo { + case .none, .noneSkipFirst, .noneSkipLast: return false + case .first, .last, .premultipliedFirst, .premultipliedLast, .alphaOnly: return true + @unknown default: return false + } + }() + self.hasAlpha = hasAlphaChannel + self.colorModel = { + switch cgImage.colorSpace?.model { + case .monochrome: return "Gray" + case .rgb: return "RGB" + case .cmyk: return "CMYK" + case .lab: return "Lab" + default: return nil + } + }() + self.orientation = image.imageOrientation + self.utType = nil /// An in-memory `UIImage` is just decoded pixels so doesn't have a `UTType` } public init?( from path: String, - type: UTType?, - mimeType: String?, + utType: UTType?, sourceFilename: String?, using dependencies: Dependencies ) { /// Videos don't have the same metadata as images so need custom handling - guard type?.isVideo != true else { - let assetInfo: (asset: AVURLAsset, cleanup: () -> Void)? = AVURLAsset.asset( + guard utType?.isVideo != true else { + let assetInfo: (asset: AVURLAsset, utType: UTType, cleanup: () -> Void)? = AVURLAsset.asset( for: path, - mimeType: mimeType, + utType: utType, sourceFilename: sourceFilename, using: dependencies ) + defer { assetInfo?.cleanup() } guard + let fileSize: UInt64 = dependencies[singleton: .fileManager].fileSize(of: path), let asset: AVURLAsset = assetInfo?.asset, - let track: AVAssetTrack = asset.tracks(withMediaType: .video).first + !asset.tracks(withMediaType: .video).isEmpty 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) + /// Get the maximum size of any video track in the file + let maxTrackSize: CGSize = asset.maxVideoTrackSize + + guard maxTrackSize.width > 0, maxTrackSize.height > 0 else { return nil } + + self.pixelSize = maxTrackSize + self.fileSize = fileSize + self.frameDurations = [] /// Rather than try to extract the frames, or give it an "incorrect" value, make it explicitly invalid + self.duration = ( /// According to the CMTime docs "value/timescale = seconds" + TimeInterval(asset.duration.value) / TimeInterval(asset.duration.timescale) ) + self.hasUnsafeMetadata = false /// Don't current support stripping this so just hard-code + self.depthBytes = nil + self.hasAlpha = false + self.colorModel = nil + self.orientation = nil + self.utType = (assetInfo?.utType ?? utType) + return + } + + /// Audio also needs custom handling + guard utType?.isAudio != true else { + guard let fileSize: UInt64 = dependencies[singleton: .fileManager].fileSize(of: path) else { + return nil + } - guard videoSize.width > 0, videoSize.height > 0 else { return nil } + self.pixelSize = .zero + self.fileSize = fileSize + self.frameDurations = [] - self.pixelSize = videoSize - self.frameCount = -1 /// Rather than try to extract the frames, or give it an "incorrect" value, make it explicitly invalid + do { self.duration = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path)).duration } + catch { return nil } + + self.hasUnsafeMetadata = false /// Don't current support stripping this so just hard-code self.depthBytes = nil self.hasAlpha = false self.colorModel = nil self.orientation = nil + self.utType = utType return } + /// Load the image source and use that initializer to extract the metadata guard - let imageSource = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil), - let metadata: MediaMetadata = MediaMetadata(source: imageSource) + let fileSize: UInt64 = dependencies[singleton: .fileManager].fileSize(of: path), + let imageSource = dependencies[singleton: .mediaDecoder].source(forPath: path), + let metadata: MediaMetadata = MediaMetadata(source: imageSource, fileSize: fileSize) else { return nil } self = metadata } - - // MARK: - Functions - - public func apply(orientation: UIImage.Orientation) -> CGSize { - switch orientation { - case .up, .upMirrored, .down, .downMirrored: return pixelSize - case .leftMirrored, .left, .rightMirrored, .right: - return CGSize(width: pixelSize.height, height: pixelSize.width) - - @unknown default: return pixelSize - } - } } - 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 - } - guard let type: String = type, UTType.isVideo(type) else { - Log.error(.media, "Media file has invalid content type.") - return false - } - - guard let fileSize: UInt64 = dependencies[singleton: .fileManager].fileSize(of: path) else { - Log.error(.media, "Media file has unknown length.") - return false - } - return UInt(fileSize) <= SNUtilitiesKit.maxFileSize - } - - public static func isValidVideo(asset: AVURLAsset) -> Bool { - var maxTrackSize = CGSize.zero - - for track: AVAssetTrack in asset.tracks(withMediaType: .video) { - let trackSize: CGSize = track.naturalSize - maxTrackSize.width = max(maxTrackSize.width, trackSize.width) - maxTrackSize.height = max(maxTrackSize.height, trackSize.height) - } - - return MediaMetadata(pixelSize: maxTrackSize).hasValidPixelSize + public static func isValidVideo(asset: AVURLAsset, utType: UTType) -> Bool { + return MediaMetadata( + pixelSize: asset.maxVideoTrackSize, + hasUnsafeMetadata: false, + utType: utType + ).hasValidPixelSize } /// 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 { + public static func isValidVideo(path: String, utType: UTType?, sourceFilename: String?, using dependencies: Dependencies) -> Bool { guard - let assetInfo: (asset: AVURLAsset, cleanup: () -> Void) = AVURLAsset.asset( + let assetInfo: (asset: AVURLAsset, utType: UTType, cleanup: () -> Void) = AVURLAsset.asset( for: path, - mimeType: mimeType, + utType: utType, sourceFilename: sourceFilename, using: dependencies ) else { return false } - let result: Bool = isValidVideo(asset: assetInfo.asset) + let result: Bool = isValidVideo(asset: assetInfo.asset, utType: assetInfo.utType) assetInfo.cleanup() return result } - public static func isValidImage(data: Data, type: UTType? = nil) -> Bool { - let options: CFDictionary = [ - kCGImageSourceShouldCache: false, - kCGImageSourceShouldCacheImmediately: false - ] as CFDictionary - - guard - data.count < SNUtilitiesKit.maxFileSize, - let type: UTType = type, - (type.isImage || type.isAnimated), - let source: CGImageSource = CGImageSourceCreateWithData(data as CFData, options), - let metadata: MediaMetadata = MediaMetadata(source: source) - else { return false } - - return metadata.hasValidPixelSize - } - - public static func isValidImage(at path: String, type: UTType? = nil, using dependencies: Dependencies) -> Bool { - let options: CFDictionary = [ - kCGImageSourceShouldCache: false, - kCGImageSourceShouldCacheImmediately: false - ] as CFDictionary - - guard - let type: UTType = type, - let fileSize: UInt64 = dependencies[singleton: .fileManager].fileSize(of: path), - fileSize <= SNUtilitiesKit.maxFileSize, - (type.isImage || type.isAnimated), - let source: CGImageSource = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, options), - let metadata: MediaMetadata = MediaMetadata(source: source) - else { return false } - - return metadata.hasValidPixelSize - } - - public static func unrotatedSize( + public static func displaySize( for path: String, - type: UTType?, - mimeType: String?, + utType: UTType?, sourceFilename: String?, using dependencies: Dependencies ) -> CGSize { guard let metadata: MediaMetadata = MediaMetadata( from: path, - type: type, - mimeType: mimeType, + utType: utType, sourceFilename: sourceFilename, using: dependencies ) else { return .zero } - /// If the metadata doesn't ahve an orientation then don't rotate the size (WebP and videos shouldn't have orientations) - guard let orientation: UIImage.Orientation = metadata.orientation else { return metadata.pixelSize } - - return metadata.apply(orientation: orientation) + return metadata.displaySize } - public static func guessedImageFormat(data: Data) -> ImageFormat { - let twoBytesLength: Int = 2 - - guard data.count > twoBytesLength else { return .unknown } + private static func getFrameDuration(from source: CGImageSource, at index: Int) -> TimeInterval { + guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [String: Any] else { + return 0.1 + } + + /// Try to process it as a GIF + if let gifProps = properties[kCGImagePropertyGIFDictionary as String] as? [String: Any] { + if + let unclampedDelayTime = gifProps[kCGImagePropertyGIFUnclampedDelayTime as String] as? Double, + unclampedDelayTime > 0 + { + return unclampedDelayTime + } + + if + let delayTime = gifProps[kCGImagePropertyGIFDelayTime as String] as? Double, + delayTime > 0 + { + return delayTime + } + } - var bytes: [UInt8] = [UInt8](repeating: 0, count: twoBytesLength) - data.copyBytes(to: &bytes, from: (data.startIndex.. 0 + { + return delayTime + } + } - switch (bytes[0], bytes[1]) { - case (0x47, 0x49): return .gif - case (0x89, 0x50): return .png - case (0xff, 0xd8): return .jpeg - case (0x42, 0x4d): return .bmp - case (0x4D, 0x4D): return .tiff // Motorola byte order TIFF - case (0x49, 0x49): return .tiff // Intel byte order TIFF - case (0x52, 0x49): return .webp // First two letters of WebP - - default: return .unknown + /// Try to process it as a WebP + if + let webpProps = properties[kCGImagePropertyWebPDictionary as String] as? [String: Any], + let delayTime = webpProps[kCGImagePropertyWebPDelayTime as String] as? Double, + delayTime > 0 + { + return delayTime } + + return 0.1 /// Fallback + } +} + +// MARK: - Convenience + +public extension MediaUtils.MediaMetadata { + var isValidImage: Bool { + guard + let utType: UTType = utType, + (utType.isImage || utType.isAnimated) + else { return false } + + return (hasValidPixelSize && hasValidDuration) } } @@ -283,3 +461,45 @@ private extension UIImage.Orientation { } } } + +// MARK: - MediaDecoder + +public protocol MediaDecoderType { + var defaultImageOptions: CFDictionary { get } + + func defaultThumbnailOptions(maxDimension: CGFloat) -> CFDictionary + + func source(for url: URL) -> CGImageSource? + func source(for data: Data) -> CGImageSource? +} + +public extension MediaDecoderType { + func source(forPath path: String) -> CGImageSource? { + return source(for: URL(fileURLWithPath: path)) + } +} + +public final class MediaDecoder: MediaDecoderType { + public let defaultImageOptions: CFDictionary = [ + kCGImageSourceShouldCache: false, + kCGImageSourceShouldCacheImmediately: false + ] as CFDictionary + + public func defaultThumbnailOptions(maxDimension: CGFloat) -> CFDictionary { + return [ + kCGImageSourceShouldCache: false, + kCGImageSourceShouldCacheImmediately: false, + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maxDimension + ] as CFDictionary + } + + public func source(for url: URL) -> CGImageSource? { + return CGImageSourceCreateWithURL(url as CFURL, defaultImageOptions) + } + + public func source(for data: Data) -> CGImageSource? { + return CGImageSourceCreateWithData(data as CFData, defaultImageOptions) + } +} diff --git a/SessionUtilitiesKit/Media/UTType+Utilities.swift b/SessionUtilitiesKit/Media/UTType+Utilities.swift index 2e3673c710..c7c8a524f4 100644 --- a/SessionUtilitiesKit/Media/UTType+Utilities.swift +++ b/SessionUtilitiesKit/Media/UTType+Utilities.swift @@ -8,16 +8,11 @@ import UniformTypeIdentifiers public extension UTType { /// This is an invalid type used to improve DSL for UTType usage - static let invalid: UTType = UTType(exportedAs: "invalid") - static let fileExtensionText: String = "txt" - static let fileExtensionDefault: String = "bin" + static let invalid: UTType = UTType(exportedAs: "org.getsession.invalid") static let fileExtensionDefaultImage: String = "png" static let mimeTypeDefault: String = "application/octet-stream" static let mimeTypeJpeg: String = "image/jpeg" static let mimeTypePdf: String = "application/pdf" - - static let xTiff: UTType = UTType(mimeType: "image/x-tiff")! - static let xWinBpm: UTType = UTType(mimeType: "image/x-windows-bmp")! static let supportedAnimatedImageTypes: Set = [ .gif, .webP @@ -100,10 +95,10 @@ public extension UTType { ].compactMap { $0 }.asSet() var isAnimated: Bool { UTType.supportedAnimatedImageTypes.contains(self) } - var isImage: Bool { UTType.supportedImageTypes.contains(self) } - var isVideo: Bool { UTType.supportedVideoTypes.contains(self) } - var isAudio: Bool { UTType.supportedAudioTypes.contains(self) } - var isText: Bool { UTType.supportedTextTypes.contains(self) } + var isImage: Bool { conforms(to: .image) } + var isVideo: Bool { conforms(to: .video) || conforms(to: .movie) } + var isAudio: Bool { conforms(to: .audio) } + var isText: Bool { conforms(to: .text) } var isMicrosoftDoc: Bool { UTType.supportedMicrosoftDocTypes.contains(self) } var isVisualMedia: Bool { isImage || isVideo || isAnimated } var sessionMimeType: String? { @@ -158,6 +153,17 @@ public extension UTType { self = result } + init?(imageData: Data, using dependencies: Dependencies) { + guard + let imageSource: CGImageSource = dependencies[singleton: .mediaDecoder].source(for: imageData), + let typeString: String = CGImageSourceGetType(imageSource) as? String, + let result: UTType = UTType(typeString) + else { return nil } + + self = result + } + + // MARK: - Convenience static func isAnimated(_ mimeType: String) -> Bool { diff --git a/SessionUtilitiesKit/Types/FileManager.swift b/SessionUtilitiesKit/Types/FileManager.swift index 6c49165f86..09fc34c11c 100644 --- a/SessionUtilitiesKit/Types/FileManager.swift +++ b/SessionUtilitiesKit/Types/FileManager.swift @@ -19,7 +19,6 @@ public protocol FileManagerType { var temporaryDirectory: String { get } var documentsDirectoryPath: String { get } var appSharedDataDirectoryPath: String { get } - var temporaryDirectoryAccessibleAfterFirstAuth: String { get } /// **Note:** We need to call this method on launch _and_ every time the app becomes active, /// since file protection may prevent it from succeeding in the background. @@ -28,8 +27,11 @@ public protocol FileManagerType { func ensureDirectoryExists(at path: String, fileProtectionType: FileProtectionType) throws func protectFileOrFolder(at path: String, fileProtectionType: FileProtectionType) throws func fileSize(of path: String) -> UInt64? + + func isLocatedInTemporaryDirectory(_ path: String) -> Bool func temporaryFilePath(fileExtension: String?) -> String - func write(data: Data, toTemporaryFileWithExtension fileExtension: String?) throws -> String? + func write(data: Data, toTemporaryFileWithExtension fileExtension: String?) throws -> String + func write(data: Data, toPath path: String) throws // MARK: - Forwarded NSFileManager @@ -75,6 +77,14 @@ public extension FileManagerType { try protectFileOrFolder(at: path, fileProtectionType: .completeUntilFirstUserAuthentication) } + func temporaryFilePath() -> String { + return temporaryFilePath(fileExtension: nil) + } + + func write(dataToTemporaryFile data: Data) throws -> String { + return try write(data: data, toTemporaryFileWithExtension: nil) + } + func enumerator(at url: URL, includingPropertiesForKeys keys: [URLResourceKey]?) -> FileManager.DirectoryEnumerator? { return enumerator(at: url, includingPropertiesForKeys: keys, options: [], errorHandler: nil) } @@ -133,6 +143,8 @@ public extension SessionFileManager { // MARK: - SessionFileManager public class SessionFileManager: FileManagerType { + private static let temporaryDirectoryPrefix: String = "sesh_temp_" + private let dependencies: Dependencies private let fileManager: FileManager = .default public var temporaryDirectory: String @@ -147,20 +159,13 @@ public class SessionFileManager: FileManagerType { .defaulting(to: "") } - public var temporaryDirectoryAccessibleAfterFirstAuth: String { - let dirPath: String = NSTemporaryDirectory() - try? ensureDirectoryExists(at: dirPath, fileProtectionType: .completeUntilFirstUserAuthentication) - - return dirPath - } - // MARK: - Initialization init(using dependencies: Dependencies) { self.dependencies = dependencies - // Create a new temp directory for this instance - let dirName: String = "ows_temp_\(UUID().uuidString)" + /// Create a new temp directory for this instance + let dirName: String = "\(SessionFileManager.temporaryDirectoryPrefix)\(UUID().uuidString)" self.temporaryDirectory = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent(dirName) .path @@ -170,31 +175,32 @@ public class SessionFileManager: FileManagerType { // MARK: - Functions public func clearOldTemporaryDirectories() { - // We use the lowest priority queue for this, and wait N seconds - // to avoid interfering with app startup. + /// We use the lowest priority queue for this, and wait N seconds to avoid interfering with app startup DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + .seconds(3), using: dependencies) { [temporaryDirectory, fileManager, dependencies] in - // Abort if app not active + /// Abort if app not active guard dependencies[singleton: .appContext].isAppForegroundAndActive else { return } - // Ignore the "current" temp directory. + /// Ignore the "current" temp directory let thresholdDate: Date = dependencies[singleton: .appContext].appLaunchTime let currentTempDirName: String = URL(fileURLWithPath: temporaryDirectory).lastPathComponent let dirPath: String = NSTemporaryDirectory() - guard let fileNames: [String] = try? fileManager.contentsOfDirectory(atPath: dirPath) else { return } + guard let fileNames: [String] = try? fileManager.contentsOfDirectory(atPath: dirPath) else { + return + } fileNames.forEach { fileName in guard fileName != currentTempDirName else { return } - // Delete files with either: - // - // a) "ows_temp" name prefix. - // b) modified time before app launch time. + /// Delete files with either: + /// + /// a) `temporaryDirectoryPrefix` name prefix. + /// b) modified time before app launch time. let filePath: String = URL(fileURLWithPath: dirPath).appendingPathComponent(fileName).path - if !fileName.hasPrefix("ows_temp") { - // It's fine if we can't get the attributes (the file may have been deleted since we found it), - // also don't delete files which were created in the last N minutes + if !fileName.hasPrefix(SessionFileManager.temporaryDirectoryPrefix) { + /// It's fine if we can't get the attributes (the file may have been deleted since we found it), also don't delete + /// files which were created in the last N minutes guard let attributes: [FileAttributeKey: Any] = try? fileManager.attributesOfItem(atPath: filePath), let modificationDate: Date = attributes[.modificationDate] as? Date, @@ -202,8 +208,7 @@ public class SessionFileManager: FileManagerType { else { return } } - // This can happen if the app launches before the phone is unlocked. - // Clean up will occur when app becomes active. + /// This can happen if the app launches before the phone is unlocked, clean up will occur when app becomes active try? fileManager.removeItem(atPath: filePath) } } @@ -245,6 +250,14 @@ public class SessionFileManager: FileManagerType { return (attributes[.size] as? UInt64) } + public func isLocatedInTemporaryDirectory(_ path: String) -> Bool { + let prefix: String = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(SessionFileManager.temporaryDirectoryPrefix) + .path + + return path.hasPrefix(prefix) + } + public func temporaryFilePath(fileExtension: String?) -> String { var tempFileName: String = UUID().uuidString @@ -257,7 +270,7 @@ public class SessionFileManager: FileManagerType { .path } - public func write(data: Data, toTemporaryFileWithExtension fileExtension: String?) throws -> String? { + public func write(data: Data, toTemporaryFileWithExtension fileExtension: String?) throws -> String { let tempFilePath: String = temporaryFilePath(fileExtension: fileExtension) try data.write(to: URL(fileURLWithPath: tempFilePath), options: .atomic) @@ -266,6 +279,11 @@ public class SessionFileManager: FileManagerType { return tempFilePath } + public func write(data: Data, toPath path: String) throws { + try data.write(to: URL(fileURLWithPath: path), options: .atomic) + try protectFileOrFolder(at: path) + } + // MARK: - Forwarded NSFileManager public var currentDirectoryPath: String { fileManager.currentDirectoryPath } @@ -367,7 +385,7 @@ public class SessionFileManager: FileManagerType { withItemAt: URL(fileURLWithPath: newItemPath), backupItemName: backupItemName, options: options - )?.absoluteString + )?.path } public func replaceItemAt(_ originalItemURL: URL, withItemAt newItemURL: URL, backupItemName: String?, options: FileManager.ItemReplacementOptions) throws -> URL? { diff --git a/SessionUtilitiesKit/Types/StringCache.swift b/SessionUtilitiesKit/Types/StringCache.swift new file mode 100644 index 0000000000..eddc62a92a --- /dev/null +++ b/SessionUtilitiesKit/Types/StringCache.swift @@ -0,0 +1,47 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public final class StringCache: @unchecked Sendable { + /// `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 let cache: NSCache = NSCache() + + public init( + name: String? = nil, + countLimit: Int? = nil, + totalCostLimit: Int? = nil + ) { + if let name: String = name { + cache.name = name + } + + if let countLimit: Int = countLimit { + cache.countLimit = countLimit + } + + if let totalCostLimit: Int = totalCostLimit { + cache.totalCostLimit = totalCostLimit + } + } + + // MARK: - Functions + + public func object(forKey key: String) -> String? { + return cache.object(forKey: key as NSString) as? String + } + + public func setObject(_ value: String, forKey key: String, cost: Int = 0) { + cache.setObject(value as NSString, forKey: key as NSString, cost: cost) + } + + public func removeObject(forKey key: String) { + cache.removeObject(forKey: key as NSString) + } + + public func removeAllObjects() { + cache.removeAllObjects() + } +} diff --git a/SessionUtilitiesKit/Types/Update.swift b/SessionUtilitiesKit/Types/Update.swift new file mode 100644 index 0000000000..1a243a5d9d --- /dev/null +++ b/SessionUtilitiesKit/Types/Update.swift @@ -0,0 +1,15 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum Update { + case set(to: T) + case useExisting + + public func or(_ existing: T) -> T { + switch self { + case .set(let value): return value + case .useExisting: return existing + } + } +} diff --git a/SessionUtilitiesKit/Types/UserDefaultsType.swift b/SessionUtilitiesKit/Types/UserDefaultsType.swift index 51aaa0ffe3..528858ebaf 100644 --- a/SessionUtilitiesKit/Types/UserDefaultsType.swift +++ b/SessionUtilitiesKit/Types/UserDefaultsType.swift @@ -180,14 +180,14 @@ public extension UserDefaults.BoolKey { /// Idicates whether app review prompt was ignored or no iteraction was done to dismiss it (closed app) static let didActionAppReviewPrompt: UserDefaults.BoolKey = "didActionAppReviewPrompt" + + /// Indicates wheter the user should be reminded to grant camera permission for calls + static let shouldRemindGrantingCameraPermissionForCalls: UserDefaults.BoolKey = "shouldRemindGrantingCameraPermissionForCalls" } public extension UserDefaults.DateKey { - /// The date/time when the users profile picture was last uploaded to the server (used to rate-limit re-uploading) - static let lastProfilePictureUpload: UserDefaults.DateKey = "lastProfilePictureUpload" - - /// The date/time when the users profile picture expires on the server - static let profilePictureExpiresDate: UserDefaults.DateKey = "profilePictureExpiresDate" + /// The date/time when we re-uploaded or extended the TTL of the users display picture (used for rate-limiting) + static let lastUserDisplayPictureRefresh: UserDefaults.DateKey = "lastProfilePictureUpload" /// The date/time when any open group last had a successful poll (used as a fallback date/time if the open group hasn't been polled /// this session) diff --git a/SessionUtilitiesKit/Utilities/AVURLAsset+Utilities.swift b/SessionUtilitiesKit/Utilities/AVURLAsset+Utilities.swift index 4a9b41b448..28be0407a8 100644 --- a/SessionUtilitiesKit/Utilities/AVURLAsset+Utilities.swift +++ b/SessionUtilitiesKit/Utilities/AVURLAsset+Utilities.swift @@ -1,25 +1,48 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. import AVFoundation +import UniformTypeIdentifiers public extension AVURLAsset { - static func asset(for path: String, mimeType: String?, sourceFilename: String?, using dependencies: Dependencies) -> (asset: AVURLAsset, cleanup: () -> Void)? { + var maxVideoTrackSize: CGSize { + var result: CGSize = .zero + + for track: AVAssetTrack in tracks(withMediaType: .video) { + let trackSize: CGSize = track.naturalSize + let transformedSize: CGSize = trackSize.applying(track.preferredTransform) + result.width = max(result.width, abs(transformedSize.width)) + result.height = max(result.height, abs(transformedSize.height)) + } + + return result + } + + static func asset(for path: String, utType: UTType?, sourceFilename: String?, using dependencies: Dependencies) -> (asset: AVURLAsset, utType: UTType, 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 + let finalUTType: UTType - switch (mimeType, sourceFilename) { + switch (utType, sourceFilename) { case (.none, .none): return nil - case (.some(let mimeType), _): finalMimeType = mimeType + case (.some(let utType), _): + guard let mimeType: String = utType.sessionMimeType else { + return nil + } + + finalMimeType = mimeType + finalUTType = utType + case (.none, .some(let sourceFilename)): guard - let type: UTType = UTType( + let utType: UTType = UTType( sessionFileExtension: URL(fileURLWithPath: sourceFilename).pathExtension ), - let mimeType: String = type.sessionMimeType + let mimeType: String = utType.sessionMimeType else { return nil } finalMimeType = mimeType + finalUTType = utType } return ( @@ -27,32 +50,35 @@ public extension AVURLAsset { url: URL(fileURLWithPath: path), options: [AVURLAssetOverrideMIMETypeKey: finalMimeType] ), + finalUTType, {} ) } else { /// Since `mimeType` and/or `sourceFilename` can be null we need to try to resolve them both to values let finalExtension: String + let finalUTType: UTType - switch (mimeType, sourceFilename) { + switch (utType, sourceFilename) { case (.none, .none): return nil case (.none, .some(let sourceFilename)): guard - let type: UTType = UTType( + let utType: UTType = UTType( sessionFileExtension: URL(fileURLWithPath: sourceFilename).pathExtension ), - let fileExtension: String = type.sessionFileExtension(sourceFilename: sourceFilename) + let fileExtension: String = utType.sessionFileExtension(sourceFilename: sourceFilename) else { return nil } finalExtension = fileExtension + finalUTType = utType - case (.some(let mimeType), let sourceFilename): - guard - let fileExtension: String = UTType(sessionMimeType: mimeType)? - .sessionFileExtension(sourceFilename: sourceFilename) - else { return nil } + case (.some(let utType), let sourceFilename): + guard let fileExtension: String = utType.sessionFileExtension(sourceFilename: sourceFilename) else { + return nil + } finalExtension = fileExtension + finalUTType = utType } let tmpPath: String = URL(fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectory) @@ -64,6 +90,7 @@ public extension AVURLAsset { return ( AVURLAsset(url: URL(fileURLWithPath: tmpPath), options: nil), + finalUTType, { [dependencies] in try? dependencies[singleton: .fileManager].removeItem(atPath: tmpPath) } ) } diff --git a/SessionUtilitiesKit/Utilities/AsyncStream+Utilities.swift b/SessionUtilitiesKit/Utilities/AsyncStream+Utilities.swift new file mode 100644 index 0000000000..812463b7a5 --- /dev/null +++ b/SessionUtilitiesKit/Utilities/AsyncStream+Utilities.swift @@ -0,0 +1,13 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension AsyncStream { + func first() async -> Element? { + return await first(where: { _ in true }) + } + + func first(defaultValue: Element) async -> Element { + return (await first(where: { _ in true }) ?? defaultValue) + } +} diff --git a/SessionUtilitiesKit/Utilities/Result+Utilities.swift b/SessionUtilitiesKit/Utilities/Result+Utilities.swift index 69b3d09444..116de5dcdc 100644 --- a/SessionUtilitiesKit/Utilities/Result+Utilities.swift +++ b/SessionUtilitiesKit/Utilities/Result+Utilities.swift @@ -3,9 +3,9 @@ import Foundation public extension Result where Failure == Error { - init(_ closure: @autoclosure () throws -> Success) { - do { self = Result.success(try closure()) } - catch { self = Result.failure(error) } + init(catching closure: () async throws -> Success) async { + do { self = .success(try await closure()) } + catch { self = .failure(error) } } func onFailure(closure: (Failure) -> ()) -> Result { diff --git a/SessionUtilitiesKit/Utilities/UIImage+Utilities.swift b/SessionUtilitiesKit/Utilities/UIImage+Utilities.swift index 25307752e4..2fe1a1096f 100644 --- a/SessionUtilitiesKit/Utilities/UIImage+Utilities.swift +++ b/SessionUtilitiesKit/Utilities/UIImage+Utilities.swift @@ -3,185 +3,286 @@ import UIKit.UIImage public extension UIImage { + enum ResizeMode: Sendable, Equatable, Hashable { + case fill /// Aspect-fill (crops to fill size) + case fit /// Aspect-fit (fits within size, may have empty space) + } + func normalizedImage() -> UIImage { guard imageOrientation != .up else { return self } + guard let cgImage: CGImage = self.cgImage else { return self } - // The actual resize: draw the image on a new context, applying a transform matrix - let bounds: CGRect = CGRect(x: 0, y: 0, width: size.width, height: size.height) - let format = UIGraphicsImageRendererFormat() - format.scale = self.scale - format.opaque = false - - // Note: We use the UIImage.draw function here instead of using the CGContext because UIImage - // automatically deals with orientations so we don't have to - return UIGraphicsImageRenderer(bounds: bounds, format: format).image { _ in - self.draw(in: bounds) - } + return UIImage( + cgImage: cgImage.normalized(orientation: imageOrientation), + scale: self.scale, + orientation: .up + ) } /// This function can be used to resize an image to a different size, it **should not** be used within the UI for rendering smaller /// images as it's fairly inefficient (instead the image should be contained within another view and sized explicitly that way) - func resized(toFillPixelSize dstSize: CGSize) -> UIImage { - let normalized: UIImage = self.normalizedImage() - - guard - let normalizedRef: CGImage = normalized.cgImage, - let imgRef: CGImage = self.cgImage - else { return self } - - // Get the size in pixels, not points - let srcSize: CGSize = CGSize(width: normalizedRef.width, height: normalizedRef.height) - let widthRatio: CGFloat = (srcSize.width / srcSize.height) - let heightRatio: CGFloat = (srcSize.height / srcSize.height) - let drawRect: CGRect = { - guard widthRatio <= heightRatio else { - let targetWidth: CGFloat = (dstSize.height * srcSize.width / srcSize.height) - - return CGRect( - x: (targetWidth - dstSize.width) * -0.5, - y: 0, - width: targetWidth, - height: dstSize.height - ) - } - - let targetHeight: CGFloat = (dstSize.width * srcSize.height / srcSize.width) - - return CGRect( - x: 0, - y: (targetHeight - dstSize.height) * -0.5, - width: dstSize.width, - height: targetHeight - ) - }() + func resized( + toPixelSize dstSize: CGSize, + mode: ResizeMode = .fill, + opaque: Bool = false, + cropRect: CGRect? = nil + ) -> UIImage { + guard let imgRef: CGImage = self.cgImage else { return self } - let bounds: CGRect = CGRect(x: 0, y: 0, width: dstSize.width, height: dstSize.height) - let format = UIGraphicsImageRendererFormat() - format.scale = 1 // We are specifying a specific pixel size rather than a point size - format.opaque = false + let result: CGImage = imgRef.resized( + toPixelSize: dstSize, + mode: mode, + opaque: opaque, + cropRect: cropRect, + orientation: self.imageOrientation + ) - let renderer: UIGraphicsImageRenderer = UIGraphicsImageRenderer(bounds: bounds, format: format) + return UIImage(cgImage: result, scale: 1.0, orientation: .up) + } +} - return renderer.image { rendererContext in - rendererContext.cgContext.interpolationQuality = .high - - // we use srcSize (and not dstSize) as the size to specify is in user space (and we use the CTM to apply a - // scaleRatio) - rendererContext.cgContext.draw(imgRef, in: drawRect, byTiling: false) - } +public extension CGImage { + func normalized(orientation: UIImage.Orientation) -> CGImage { + guard orientation != .up else { return self } + + let pixelSize: CGSize = CGSize(width: self.width, height: self.height) + + return self.resized( + toPixelSize: pixelSize, + mode: .fit, + opaque: (self.alphaInfo == .none || self.alphaInfo == .noneSkipFirst), + cropRect: nil, + orientation: orientation + ) } - /// This function can be used to resize an image to a different size, it **should not** be used within the UI for rendering smaller - /// images as it's fairly inefficient (instead the image should be contained within another view and sized explicitly that way) - func resized(maxDimensionPoints: CGFloat) -> UIImage? { - guard let imgRef: CGImage = self.cgImage else { return nil } + func resized( + toPixelSize dstSize: CGSize, + mode: UIImage.ResizeMode = .fill, + opaque: Bool = false, + cropRect: CGRect? = nil, + orientation: UIImage.Orientation = .up + ) -> CGImage { + // Determine actual dimensions accounting for orientation + let needsRotation: Bool = [.left, .leftMirrored, .right, .rightMirrored].contains(orientation) + let srcSize: CGSize = (needsRotation ? + CGSize(width: self.height, height: self.width) : + CGSize(width: self.width, height: self.height) + ) - let originalSize: CGSize = self.size - let maxOriginalDimensionPoints: CGFloat = max(originalSize.width, originalSize.height) + // Calculate what portion we're rendering (in oriented coordinate space) + let sourceRect: CGRect - guard originalSize.width > 0 && originalSize.height > 0 else { return nil } + if let crop: CGRect = cropRect, crop != CGRect(x: 0, y: 0, width: 1, height: 1) { + // User-specified crop in normalized coordinates + sourceRect = CGRect( + x: (crop.origin.x * srcSize.width), + y: (crop.origin.y * srcSize.height), + width: (crop.size.width * srcSize.width), + height: (crop.size.height * srcSize.height) + ) + } else { + // Default: aspect-fill crop (center) + let srcAspect: CGFloat = (srcSize.width / srcSize.height) + let dstAspect: CGFloat = (dstSize.width / dstSize.height) + + switch mode { + case .fill: + // Aspect-fill: crop to fill destination + if srcAspect > dstAspect { + // Source is wider - crop sides + let targetWidth: CGFloat = (srcSize.height * dstAspect) + sourceRect = CGRect( + x: ((srcSize.width - targetWidth) / 2), + y: 0, + width: targetWidth, + height: srcSize.height + ) + } else { + // Source is taller - crop top/bottom + let targetHeight: CGFloat = (srcSize.width / dstAspect) + sourceRect = CGRect( + x: 0, + y: ((srcSize.height - targetHeight) / 2), + width: srcSize.width, + height: targetHeight + ) + } + + case .fit: + // Aspect-fit: use entire source, will fit within destination + sourceRect = CGRect(origin: .zero, size: srcSize) + } + } - // Don't bother scaling an image that is already smaller than the max dimension. - guard maxOriginalDimensionPoints > maxDimensionPoints else { return self } + // Calculate final size + let finalSize: CGSize - let thumbnailSize: CGSize = { - guard originalSize.width <= originalSize.height else { - return CGSize( - width: maxDimensionPoints, - height: round(maxDimensionPoints * originalSize.height / originalSize.width) - ) - } - - return CGSize( - width: round(maxDimensionPoints * originalSize.width / originalSize.height), - height: maxDimensionPoints - ) - }() + switch mode { + case .fill: + // Never scale up + if sourceRect.width <= dstSize.width && sourceRect.height <= dstSize.height { + finalSize = sourceRect.size + } else { + finalSize = dstSize + } + + case .fit: + if sourceRect.width <= dstSize.width && sourceRect.height <= dstSize.height { + // Already fits - use original size + finalSize = sourceRect.size + } else { + // Needs scaling down - fit within destination bounds + let srcAspect: CGFloat = (sourceRect.width / sourceRect.height) + let dstAspect: CGFloat = (dstSize.width / dstSize.height) + + if srcAspect > dstAspect { + // Width constrained + finalSize = CGSize( + width: dstSize.width, + height: (dstSize.width / srcAspect) + ) + } else { + // Height constrained + finalSize = CGSize( + width: (dstSize.height * srcAspect), + height: dstSize.height + ) + } + } + } - guard thumbnailSize.width > 0 && thumbnailSize.height > 0 else { return nil } + // Check if any processing is needed + if orientation == .up && sourceRect == CGRect(origin: .zero, size: srcSize) && finalSize == srcSize { + // No processing needed - return original + return self + } - // the below values are regardless of orientation : for UIImages from Camera, width>height (landscape) - // - // Note: Not equivalent to self.size (which is dependant on the imageOrientation)! - let srcSize: CGSize = CGSize(width: imgRef.width, height: imgRef.height) - var dstSize: CGSize = thumbnailSize + // Render with orientation transform + let bitmapInfo: UInt32 + let colorSpace = (self.colorSpace ?? CGColorSpaceCreateDeviceRGB()) + let scale: CGFloat = (mode == .fill ? + max(finalSize.width / sourceRect.width, finalSize.height / sourceRect.height) : + min(finalSize.width / sourceRect.width, finalSize.height / sourceRect.height) + ) + + if colorSpace.model == .monochrome { + bitmapInfo = (opaque ? + CGImageAlphaInfo.none.rawValue : + CGImageAlphaInfo.alphaOnly.rawValue + ) + } else { + bitmapInfo = (opaque ? + CGImageAlphaInfo.noneSkipFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue : + CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue + ) + } - // Don't resize if we already meet the required destination size - guard dstSize != srcSize else { return self } + guard let ctx: CGContext = CGContext( + data: nil, + width: Int(finalSize.width), + height: Int(finalSize.height), + bitsPerComponent: 8, + bytesPerRow: 0, // Let the system calculate + space: colorSpace, + bitmapInfo: bitmapInfo + ) else { return self } - let scaleRatio: CGFloat = (dstSize.width / srcSize.width) - let orient: UIImage.Orientation = self.imageOrientation - var transform: CGAffineTransform = .identity + // Transform the context to have the correct orientation, positioning and scale (order matters here) + let drawRect: CGRect = CGRect(origin: .zero, size: CGSize(width: self.width, height: self.height)) + ctx.interpolationQuality = .high + ctx.applyOrientationTransform(orientation: orientation, size: finalSize) - switch orient { - case .up: break // EXIF = 1 - case .upMirrored: // EXIF = 2 - transform = CGAffineTransform(translationX: srcSize.width, y: 0) - .scaledBy(x: -1, y: 1) - - case .down: // EXIF = 3 - transform = CGAffineTransform(translationX: srcSize.width, y: srcSize.height) - .rotated(by: CGFloat.pi) + // After orientation, we need to translate/scale in the NEW coordinate space + // For rotated orientations, the coordinate axes are swapped + let translateX: CGFloat + let translateY: CGFloat - case .downMirrored: // EXIF = 4 - transform = CGAffineTransform(translationX: 0, y: srcSize.height) - .scaledBy(x: 1, y: -1) + switch orientation { + case .up: + translateX = -sourceRect.origin.x + translateY = -(srcSize.height - sourceRect.maxY) - case .leftMirrored: // EXIF = 5 - dstSize = CGSize(width: dstSize.height, height: dstSize.width) - transform = CGAffineTransform(translationX: srcSize.height, y: srcSize.width) - .scaledBy(x: -1, y: 1) - .rotated(by: (3 * (CGFloat.pi / 2))) + case .upMirrored: + translateX = -(srcSize.width - sourceRect.maxX) + translateY = -(srcSize.height - sourceRect.maxY) - case .left: // EXIF = 6 - dstSize = CGSize(width: dstSize.height, height: dstSize.width) - transform = CGAffineTransform(translationX: 0, y: srcSize.width) - .scaledBy(x: -1, y: 1) - .rotated(by: (3 * (CGFloat.pi / 2))) + case .down: + translateX = -(srcSize.width - sourceRect.maxX) + translateY = -sourceRect.origin.y - case .rightMirrored: // EXIF = 7 - dstSize = CGSize(width: dstSize.height, height: dstSize.width) - transform = CGAffineTransform(scaleX: -1, y: 1) - .rotated(by: (CGFloat.pi / 2)) + case .downMirrored: + translateX = -sourceRect.origin.x + translateY = -sourceRect.origin.y + + case .left: + translateX = -(srcSize.height - sourceRect.maxY) + translateY = -(srcSize.width - sourceRect.maxX) - case .right: // EXIF = 8 - dstSize = CGSize(width: dstSize.height, height: dstSize.width) - transform = CGAffineTransform(translationX: srcSize.height, y: 0) - .rotated(by: (CGFloat.pi / 2)) + case .leftMirrored: + translateX = -sourceRect.origin.y + translateY = -(srcSize.width - sourceRect.maxX) - @unknown default: return nil + case .right: + translateX = -sourceRect.origin.y + translateY = -sourceRect.origin.x + + case .rightMirrored: + translateX = -(srcSize.height - sourceRect.maxY) + translateY = -sourceRect.origin.x + + @unknown default: + translateX = -sourceRect.origin.x + translateY = -sourceRect.origin.y } - - // The actual resize: draw the image on a new context, applying a transform matrix - let bounds: CGRect = CGRect(x: 0, y: 0, width: dstSize.width, height: dstSize.height) - let format = UIGraphicsImageRendererFormat() - format.scale = self.scale - format.opaque = false - let renderer: UIGraphicsImageRenderer = UIGraphicsImageRenderer(bounds: bounds, format: format) + ctx.scaleBy(x: scale, y: scale) + ctx.translateBy(x: translateX, y: translateY) + ctx.draw(self, in: drawRect, byTiling: false) + + return (ctx.makeImage() ?? self) + } +} - return renderer.image { rendererContext in - rendererContext.cgContext.interpolationQuality = .high - - switch orient { - case .right, .left: - rendererContext.cgContext.scaleBy(x: -scaleRatio, y: scaleRatio) - rendererContext.cgContext.translateBy(x: -srcSize.height, y: 0) - - default: - rendererContext.cgContext.scaleBy(x: scaleRatio, y: -scaleRatio) - rendererContext.cgContext.translateBy(x: 0, y: -srcSize.height) - } - - rendererContext.cgContext.concatenate(transform) - - // we use srcSize (and not dstSize) as the size to specify is in user space (and we use the CTM to apply a - // scaleRatio) - rendererContext.cgContext.draw( - imgRef, - in: CGRect(x: 0, y: 0, width: srcSize.width, height: srcSize.height), - byTiling: false - ) +// MARK: - Conveneince + +private extension CGContext { + func applyOrientationTransform(orientation: UIImage.Orientation, size: CGSize) { + switch orientation { + case .up: break + case .down: + translateBy(x: size.width, y: size.height) + rotate(by: .pi) + + case .left: + translateBy(x: size.width, y: 0) + rotate(by: .pi / 2) + + case .right: + translateBy(x: 0, y: size.height) + rotate(by: -.pi / 2) + + case .upMirrored: + translateBy(x: size.width, y: 0) + scaleBy(x: -1, y: 1) + + case .downMirrored: + translateBy(x: 0, y: size.height) + scaleBy(x: 1, y: -1) + + case .leftMirrored: + translateBy(x: size.width, y: 0) + rotate(by: .pi / 2) + translateBy(x: size.height, y: 0) + scaleBy(x: -1, y: 1) + + case .rightMirrored: + translateBy(x: 0, y: size.height) + rotate(by: -.pi / 2) + translateBy(x: size.width, y: 0) + scaleBy(x: -1, y: 1) + + @unknown default: break } } } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift index 179a49d32d..fb189e32a6 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift @@ -22,7 +22,7 @@ class AttachmentApprovalInputAccessoryView: UIView { return attachmentTextToolbar.inputView?.isFirstResponder ?? false } - private var currentAttachmentItem: SignalAttachmentItem? + private var currentAttachmentItem: PendingAttachmentRailItem? let kGalleryRailViewHeight: CGFloat = 72 @@ -95,7 +95,7 @@ class AttachmentApprovalInputAccessoryView: UIView { } } - public func update(currentAttachmentItem: SignalAttachmentItem?, shouldHideControls: Bool) { + public func update(currentAttachmentItem: PendingAttachmentRailItem?, shouldHideControls: Bool) { self.currentAttachmentItem = currentAttachmentItem self.shouldHideControls = shouldHideControls diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index e3d4039eb8..2cbef2e093 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -21,7 +21,7 @@ private extension Log.Category { public protocol AttachmentApprovalViewControllerDelegate: AnyObject { func attachmentApproval( _ attachmentApproval: AttachmentApprovalViewController, - didApproveAttachments attachments: [SignalAttachment], + didApproveAttachments attachments: [PendingAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, messageText: String? @@ -36,7 +36,7 @@ public protocol AttachmentApprovalViewControllerDelegate: AnyObject { func attachmentApproval( _ attachmentApproval: AttachmentApprovalViewController, - didRemoveAttachment attachment: SignalAttachment + didRemoveAttachment attachment: PendingAttachment ) func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) @@ -75,16 +75,17 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC var isKeyboardVisible: Bool = false private let disableLinkPreviewImageDownload: Bool + private let didLoadLinkPreview: ((LinkPreviewDraft) -> Void)? public weak var approvalDelegate: AttachmentApprovalViewControllerDelegate? - let attachmentItemCollection: AttachmentItemCollection + let attachmentRailItemCollection: PendingAttachmentRailItemCollection - var attachmentItems: [SignalAttachmentItem] { - return attachmentItemCollection.attachmentItems + var attachmentItems: [PendingAttachmentRailItem] { + return attachmentRailItemCollection.attachmentItems } - var attachments: [SignalAttachment] { + var attachments: [PendingAttachment] { return attachmentItems.map { (attachmentItem) in autoreleasepool { return self.processedAttachment(forAttachmentItem: attachmentItem) @@ -100,7 +101,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC return pageViewControllers?.first } - var currentItem: SignalAttachmentItem? { + var currentItem: PendingAttachmentRailItem? { get { return currentPageViewController?.attachmentItem } set { setCurrentItem(newValue, direction: .forward, animated: false) } } @@ -140,8 +141,9 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC mode: Mode, threadId: String, threadVariant: SessionThread.Variant, - attachments: [SignalAttachment], + attachments: [PendingAttachment], disableLinkPreviewImageDownload: Bool, + didLoadLinkPreview: ((LinkPreviewDraft) -> Void)?, using dependencies: Dependencies ) { guard !attachments.isEmpty else { return nil } @@ -150,11 +152,14 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC self.mode = mode self.threadId = threadId self.threadVariant = threadVariant - let attachmentItems = attachments.map { SignalAttachmentItem(attachment: $0, using: dependencies)} + let attachmentItems = attachments.map { + PendingAttachmentRailItem(attachment: $0, using: dependencies) + } self.isAddMoreVisible = (mode == .sharedNavigation) self.disableLinkPreviewImageDownload = disableLinkPreviewImageDownload + self.didLoadLinkPreview = didLoadLinkPreview - self.attachmentItemCollection = AttachmentItemCollection(attachmentItems: attachmentItems, isAddMoreVisible: isAddMoreVisible) + self.attachmentRailItemCollection = PendingAttachmentRailItemCollection(attachmentItems: attachmentItems, isAddMoreVisible: isAddMoreVisible) super.init( transitionStyle: .scroll, @@ -181,9 +186,10 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC public class func wrappedInNavController( threadId: String, threadVariant: SessionThread.Variant, - attachments: [SignalAttachment], + attachments: [PendingAttachment], approvalDelegate: AttachmentApprovalViewControllerDelegate, disableLinkPreviewImageDownload: Bool, + didLoadLinkPreview: ((LinkPreviewDraft) -> Void)?, using dependencies: Dependencies ) -> UINavigationController? { guard let vc = AttachmentApprovalViewController( @@ -192,6 +198,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC threadVariant: threadVariant, attachments: attachments, disableLinkPreviewImageDownload: disableLinkPreviewImageDownload, + didLoadLinkPreview: didLoadLinkPreview, using: dependencies ) else { return nil } vc.approvalDelegate = approvalDelegate @@ -249,8 +256,14 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC // If the first item is just text, or is a URL and LinkPreviews are disabled // then just fill the 'message' box with it - if firstItem.attachment.isText || (firstItem.attachment.isUrl && LinkPreview.previewUrl(for: firstItem.attachment.text(), using: dependencies) == nil) { - bottomToolView.attachmentTextToolbar.text = firstItem.attachment.text() + let firstItemIsPlainText: Bool = { + switch firstItem.attachment.source { + case .text: return true + default: return false + } + }() + if firstItemIsPlainText || (firstItem.attachment.utType.conforms(to: .url) && LinkPreview.previewUrl(for: firstItem.attachment.toText(), using: dependencies) == nil) { + bottomToolView.attachmentTextToolbar.text = firstItem.attachment.toText() } } @@ -289,7 +302,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC if pageViewControllers?.count == 1 { currentPageViewController = pageViewControllers?.first } - let currentAttachmentItem: SignalAttachmentItem? = currentPageViewController?.attachmentItem + let currentAttachmentItem: PendingAttachmentRailItem? = currentPageViewController?.attachmentItem let hasPresentedView = (self.presentedViewController != nil) let isToolbarFirstResponder = bottomToolView.hasFirstResponder @@ -339,12 +352,12 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC // MARK: - View Helpers - func remove(attachmentItem: SignalAttachmentItem) { + func remove(attachmentItem: PendingAttachmentRailItem) { if attachmentItem.isEqual(to: currentItem) { - if let nextItem = attachmentItemCollection.itemAfter(item: attachmentItem) { + if let nextItem = attachmentRailItemCollection.itemAfter(item: attachmentItem) { setCurrentItem(nextItem, direction: .forward, animated: true) } - else if let prevItem = attachmentItemCollection.itemBefore(item: attachmentItem) { + else if let prevItem = attachmentRailItemCollection.itemBefore(item: attachmentItem) { setCurrentItem(prevItem, direction: .reverse, animated: true) } else { @@ -353,7 +366,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC } } - self.attachmentItemCollection.remove(item: attachmentItem) + self.attachmentRailItemCollection.remove(item: attachmentItem) self.approvalDelegate?.attachmentApproval(self, didRemoveAttachment: attachmentItem.attachment) self.updateMediaRail() } @@ -435,7 +448,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC } } - private func buildPage(item: SignalAttachmentItem) -> AttachmentPrepViewController? { + private func buildPage(item: PendingAttachmentRailItem) -> AttachmentPrepViewController? { if let cachedPage = cachedPages[item.uniqueIdentifier] { Log.debug(.cat, "Cache hit.") return cachedPage @@ -445,6 +458,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC let viewController = AttachmentPrepViewController( attachmentItem: item, disableLinkPreviewImageDownload: disableLinkPreviewImageDownload, + didLoadLinkPreview: didLoadLinkPreview, using: dependencies ) viewController.prepDelegate = self @@ -453,8 +467,8 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC return viewController } - private func setCurrentItem(_ item: SignalAttachmentItem?, direction: UIPageViewController.NavigationDirection, animated isAnimated: Bool) { - guard let item: SignalAttachmentItem = item, let page = self.buildPage(item: item) else { + private func setCurrentItem(_ item: PendingAttachmentRailItem?, direction: UIPageViewController.NavigationDirection, animated isAnimated: Bool) { + guard let item: PendingAttachmentRailItem = item, let page = self.buildPage(item: item) else { Log.error(.cat, "Unexpectedly unable to build new page") return } @@ -476,7 +490,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC case is AddMoreRailItem: return GalleryRailCellView() - case is SignalAttachmentItem: + case is PendingAttachmentRailItem: let cell = ApprovalRailCellView() cell.approvalRailCellDelegate = self return cell @@ -488,8 +502,8 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC } galleryRailView.configureCellViews( - album: (attachmentItemCollection.attachmentItems as [GalleryRailItem]) - .appending(attachmentItemCollection.isAddMoreVisible ? + album: (attachmentRailItemCollection.attachmentItems as [GalleryRailItem]) + .appending(attachmentRailItemCollection.isAddMoreVisible ? AddMoreRailItem() : nil ), @@ -501,7 +515,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC if isAddMoreVisible { galleryRailView.isHidden = false } - else if attachmentItemCollection.attachmentItems.count > 1 { + else if attachmentRailItemCollection.attachmentItems.count > 1 { galleryRailView.isHidden = false } else { @@ -510,13 +524,13 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC } // For any attachments edited with the image editor, returns a - // new SignalAttachment that reflects those changes. Otherwise, + // new PendingAttachment that reflects those changes. Otherwise, // returns the original attachment. // // If any errors occurs in the export process, we fail over to // sending the original attachment. This seems better than trying // to involve the user in resolving the issue. - func processedAttachment(forAttachmentItem attachmentItem: SignalAttachmentItem) -> SignalAttachment { + func processedAttachment(forAttachmentItem attachmentItem: PendingAttachmentRailItem) -> PendingAttachment { guard let imageEditorModel = attachmentItem.imageEditorModel else { // Image was not edited. return attachmentItem.attachment @@ -531,9 +545,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC } var dataType: UTType = .image let maybeDstData: Data? = { - let isLossy: Bool = ( - attachmentItem.attachment.mimeType.caseInsensitiveCompare(UTType.mimeTypeJpeg) == .orderedSame - ) + let isLossy: Bool = (attachmentItem.attachment.utType == .jpeg) if isLossy { dataType = .jpeg @@ -549,8 +561,9 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC Log.error(.cat, "Could not export for output.") return attachmentItem.attachment } - guard let dataSource = DataSourceValue(data: dstData, dataType: dataType, using: dependencies) else { - Log.error(.cat, "Could not prepare data source for output.") + + guard let filePath: String = try? dependencies[singleton: .fileManager].write(dataToTemporaryFile: dstData) else { + Log.error(.cat, "Could not save output to disk.") return attachmentItem.attachment } @@ -558,22 +571,21 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC var filename: String? = attachmentItem.attachment.sourceFilename if let sourceFilename = attachmentItem.attachment.sourceFilename { if let fileExtension: String = dataType.sessionFileExtension(sourceFilename: sourceFilename) { - filename = (sourceFilename as NSString).deletingPathExtension.appendingFileExtension(fileExtension) + filename = ((sourceFilename as NSString) + .deletingPathExtension as NSString) + .appendingPathExtension(fileExtension) } } - dataSource.sourceFilename = filename - - let dstAttachment = SignalAttachment.attachment(dataSource: dataSource, type: dataType, imageQuality: .medium, using: dependencies) - if let attachmentError = dstAttachment.error { - Log.error(.cat, "Could not prepare attachment for output: \(attachmentError).") - return attachmentItem.attachment - } - // Preserve caption text. - dstAttachment.captionText = attachmentItem.captionText - return dstAttachment + + return PendingAttachment( + source: .media(URL(fileURLWithPath: filePath)), + utType: dataType, + sourceFilename: filename, + using: dependencies + ) } - func attachmentItem(before currentItem: SignalAttachmentItem) -> SignalAttachmentItem? { + func attachmentItem(before currentItem: PendingAttachmentRailItem) -> PendingAttachmentRailItem? { guard let currentIndex = attachmentItems.firstIndex(of: currentItem) else { Log.error(.cat, "currentIndex was unexpectedly nil") return nil @@ -588,7 +600,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC return previousItem } - func attachmentItem(after currentItem: SignalAttachmentItem) -> SignalAttachmentItem? { + func attachmentItem(after currentItem: PendingAttachmentRailItem) -> PendingAttachmentRailItem? { guard let currentIndex = attachmentItems.firstIndex(of: currentItem) else { Log.error(.cat, "currentIndex was unexpectedly nil") return nil @@ -763,46 +775,42 @@ extension AttachmentApprovalViewController: AttachmentPrepViewControllerDelegate // MARK: GalleryRail -extension SignalAttachmentItem: GalleryRailItem { +extension PendingAttachmentRailItem: GalleryRailItem { func buildRailItemView(using dependencies: Dependencies) -> UIView { let imageView: SessionImageView = SessionImageView(dataManager: dependencies[singleton: .imageDataManager]) imageView.contentMode = .scaleAspectFill imageView.themeBackgroundColor = .backgroundSecondary - if let path: String = (attachment.dataSource.dataPathIfOnDisk ?? attachment.dataUrl?.absoluteString) { - let source: ImageDataManager.DataSource = { - /// Can't thumbnail animated images so just load the full file in this case - if attachment.isAnimatedImage { - 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.mimeType, - attachment.sourceFilename, - dependencies[singleton: .attachmentManager] - ) + switch attachment.source { + case .file, .voiceMessage, .text: break; + case .media(let dataSource): + Task.detached(priority: .userInitiated) { [attachment, attachmentManager = dependencies[singleton: .attachmentManager]] in + /// Can't thumbnail animated images so just load the full file in this case + if attachment.utType.isAnimated { + return await imageView.loadImage(dataSource) + } + + /// Videos have a custom method for generating their thumbnails so use that instead + if attachment.utType.isVideo { + return await imageView.loadImage(dataSource) + } + + /// We only support generating a thumbnail for a file that is on disk, so if the source isn't a `url` then just + /// load it directly + guard case .url(let url) = dataSource else { + return await imageView.loadImage(dataSource) + } + + /// Otherwise, generate the thumbnail + await imageView.loadImage(.urlThumbnail(url, .small, attachmentManager)) } - - return .urlThumbnail( - URL(fileURLWithPath: path), - .small, - dependencies[singleton: .attachmentManager] - ) - }() - - Task(priority: .userInitiated) { - await imageView.loadImage(source) - } } return imageView } func isEqual(to other: GalleryRailItem?) -> Bool { - guard let otherAttachmentItem: SignalAttachmentItem = other as? SignalAttachmentItem else { return false } + guard let otherAttachmentItem: PendingAttachmentRailItem = other as? PendingAttachmentRailItem else { return false } return (self.attachment == otherAttachmentItem.attachment) } @@ -817,12 +825,12 @@ extension AttachmentApprovalViewController: GalleryRailViewDelegate { return } - guard let targetItem = imageRailItem as? SignalAttachmentItem else { + guard let targetItem = imageRailItem as? PendingAttachmentRailItem else { Log.error(.cat, "Unexpected imageRailItem: \(imageRailItem)") return } - guard let currentItem: SignalAttachmentItem = currentItem, let currentIndex = attachmentItems.firstIndex(of: currentItem) else { + guard let currentItem: PendingAttachmentRailItem = currentItem, let currentIndex = attachmentItems.firstIndex(of: currentItem) else { Log.error(.cat, "currentIndex was unexpectedly nil") return } @@ -841,7 +849,7 @@ extension AttachmentApprovalViewController: GalleryRailViewDelegate { // MARK: - extension AttachmentApprovalViewController: ApprovalRailCellViewDelegate { - func approvalRailCellView(_ approvalRailCellView: ApprovalRailCellView, didRemoveItem attachmentItem: SignalAttachmentItem) { + func approvalRailCellView(_ approvalRailCellView: ApprovalRailCellView, didRemoveItem attachmentItem: PendingAttachmentRailItem) { remove(attachmentItem: attachmentItem) } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift index b636a474fc..5a675985b4 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift @@ -1,6 +1,7 @@ // Copyright (c) 2019 Open Whisper Systems. All rights reserved. import UIKit +import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit @@ -24,60 +25,56 @@ class AddMoreRailItem: GalleryRailItem { } } -class SignalAttachmentItem: Equatable { +class PendingAttachmentRailItem: Equatable { - enum SignalAttachmentItemError: Error { + enum PendingAttachmentRailItemError: Error { case noThumbnail } let uniqueIdentifier: UUID = UUID() - let attachment: SignalAttachment + let attachment: PendingAttachment // This might be nil if the attachment is not a valid image. var imageEditorModel: ImageEditorModel? - init(attachment: SignalAttachment, using dependencies: Dependencies) { + init(attachment: PendingAttachment, using dependencies: Dependencies) { self.attachment = attachment // Try and make a ImageEditorModel. // This will only apply for valid images. - if ImageEditorModel.isFeatureEnabled, - let dataUrl: URL = attachment.dataUrl, - dataUrl.isFileURL { - let path = dataUrl.path + if + ImageEditorModel.isFeatureEnabled && + attachment.utType.isImage && + attachment.duration == 0, + case .media = attachment.metadata + { do { - imageEditorModel = try ImageEditorModel(srcImagePath: path, using: dependencies) + imageEditorModel = try ImageEditorModel(attachment: attachment, using: dependencies) } catch { // Usually not an error; this usually indicates invalid input. - Log.warn("[SignalAttachmentItem] Could not create image editor: \(error)") + Log.warn("[PendingAttachmentRailItem] Could not create image editor: \(error)") } } } - // MARK: - - var captionText: String? { - return attachment.captionText - } - // MARK: Equatable - static func == (lhs: SignalAttachmentItem, rhs: SignalAttachmentItem) -> Bool { + static func == (lhs: PendingAttachmentRailItem, rhs: PendingAttachmentRailItem) -> Bool { return lhs.attachment == rhs.attachment } } // MARK: - -class AttachmentItemCollection { - private(set) var attachmentItems: [SignalAttachmentItem] +class PendingAttachmentRailItemCollection { + private(set) var attachmentItems: [PendingAttachmentRailItem] let isAddMoreVisible: Bool - init(attachmentItems: [SignalAttachmentItem], isAddMoreVisible: Bool) { + init(attachmentItems: [PendingAttachmentRailItem], isAddMoreVisible: Bool) { self.attachmentItems = attachmentItems self.isAddMoreVisible = isAddMoreVisible } - func itemAfter(item: SignalAttachmentItem) -> SignalAttachmentItem? { + func itemAfter(item: PendingAttachmentRailItem) -> PendingAttachmentRailItem? { guard let currentIndex = attachmentItems.firstIndex(of: item) else { Log.error("[AttachmentItemCollection] itemAfter currentIndex was unexpectedly nil.") return nil @@ -88,7 +85,7 @@ class AttachmentItemCollection { return attachmentItems[safe: nextIndex] } - func itemBefore(item: SignalAttachmentItem) -> SignalAttachmentItem? { + func itemBefore(item: PendingAttachmentRailItem) -> PendingAttachmentRailItem? { guard let currentIndex = attachmentItems.firstIndex(of: item) else { Log.error("[AttachmentItemCollection] itemBefore currentIndex was unexpectedly nil.") return nil @@ -99,7 +96,7 @@ class AttachmentItemCollection { return attachmentItems[safe: prevIndex] } - func remove(item: SignalAttachmentItem) { + func remove(item: PendingAttachmentRailItem) { attachmentItems = attachmentItems.filter { $0 != item } } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift index 3aee88512b..e18801e753 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift @@ -27,9 +27,10 @@ public class AttachmentPrepViewController: OWSViewController { private let dependencies: Dependencies weak var prepDelegate: AttachmentPrepViewControllerDelegate? - let attachmentItem: SignalAttachmentItem - var attachment: SignalAttachment { return attachmentItem.attachment } + let attachmentItem: PendingAttachmentRailItem + var attachment: PendingAttachment { return attachmentItem.attachment } private let disableLinkPreviewImageDownload: Bool + private let didLoadLinkPreview: ((LinkPreviewDraft) -> Void)? // MARK: - UI @@ -62,6 +63,7 @@ public class AttachmentPrepViewController: OWSViewController { attachment: attachment, mode: .attachmentApproval, disableLinkPreviewImageDownload: disableLinkPreviewImageDownload, + didLoadLinkPreview: didLoadLinkPreview, using: dependencies ) view.translatesAutoresizingMaskIntoConstraints = false @@ -100,19 +102,17 @@ public class AttachmentPrepViewController: OWSViewController { // MARK: - Initializers init( - attachmentItem: SignalAttachmentItem, + attachmentItem: PendingAttachmentRailItem, disableLinkPreviewImageDownload: Bool, + didLoadLinkPreview: ((LinkPreviewDraft) -> Void)?, using dependencies: Dependencies ) { self.dependencies = dependencies self.attachmentItem = attachmentItem self.disableLinkPreviewImageDownload = disableLinkPreviewImageDownload + self.didLoadLinkPreview = didLoadLinkPreview super.init(nibName: nil, bundle: nil) - - if attachment.hasError { - Log.error("[AttachmentPrepViewController] \(attachment.error.debugDescription)") - } } public required init?(coder aDecoder: NSCoder) { @@ -135,13 +135,13 @@ public class AttachmentPrepViewController: OWSViewController { let tapGesture = UITapGestureRecognizer(target: self, action: #selector(screenTapped)) mediaMessageView.addGestureRecognizer(tapGesture) - if attachment.isImage, let editorView: ImageEditorView = imageEditorView { + if attachment.utType.isImage && attachment.duration == 0, let editorView: ImageEditorView = imageEditorView { view.addSubview(editorView) imageEditorUpdateNavigationBar() } - if attachment.isVideo || attachment.isAudio { + if attachment.utType.isVideo || attachment.utType.isAudio { contentContainerView.addSubview(playButton) } @@ -199,8 +199,8 @@ public class AttachmentPrepViewController: OWSViewController { mediaMessageView.heightAnchor.constraint(equalTo: view.heightAnchor) ]) - if attachment.isImage, let editorView: ImageEditorView = imageEditorView { - let size: CGSize = (attachment.imageSize ?? CGSize.zero) + if attachment.utType.isImage && attachment.duration == 0, let editorView: ImageEditorView = imageEditorView { + let size: CGSize = (attachment.metadata?.pixelSize ?? CGSize.zero) let isPortrait: Bool = (size.height > size.width) NSLayoutConstraint.activate([ @@ -216,14 +216,18 @@ public class AttachmentPrepViewController: OWSViewController { ]) } - if attachment.isVideo || attachment.isAudio { + if attachment.utType.isVideo || attachment.utType.isAudio { let playButtonSize: CGFloat = Values.scaleFromIPhone5(70) + let playButtonVerticalOffset = (attachment.utType.isAudio ? + 0 : + -AttachmentPrepViewController.verticalCenterOffset + ) NSLayoutConstraint.activate([ playButton.centerXAnchor.constraint(equalTo: contentContainerView.centerXAnchor), playButton.centerYAnchor.constraint( equalTo: contentContainerView.centerYAnchor, - constant: -AttachmentPrepViewController.verticalCenterOffset + constant: playButtonVerticalOffset ), playButton.widthAnchor.constraint(equalToConstant: playButtonSize), playButton.heightAnchor.constraint(equalToConstant: playButtonSize), @@ -248,10 +252,13 @@ public class AttachmentPrepViewController: OWSViewController { } @objc public func playButtonTapped() { - guard let fileUrl: URL = attachment.dataUrl else { return Log.error(.media, "Missing video file") } + guard + case .media(let mediaSource) = self.attachment.source, + case .url(let fileUrl) = mediaSource + 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 + /// The `attachment` here is a `PendingAttachment` which is pointing to a temporary file which has 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() @@ -265,7 +272,7 @@ public class AttachmentPrepViewController: OWSViewController { // MARK: - Helpers var isZoomable: Bool { - return attachment.isImage || attachment.isVideo + return attachment.utType.isImage || attachment.utType.isAnimated || attachment.utType.isVideo } func zoomOut(animated: Bool) { diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift index c516ed9a2a..4ff96a1a59 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift @@ -90,7 +90,7 @@ class AttachmentTextToolbar: UIView, UITextViewDelegate { }() private lazy var sessionProBadge: SessionProBadge = { - let result: SessionProBadge = SessionProBadge(size: .small) + let result: SessionProBadge = SessionProBadge(size: .medium) result.isHidden = !dependencies[feature: .sessionProEnabled] || dependencies[cache: .libSession].isSessionPro return result @@ -214,5 +214,5 @@ extension AttachmentTextToolbar: InputTextViewDelegate { delegate?.attachmentTextToolbarDidChange(self) } - @MainActor func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage) {} + @MainActor func didPasteImageDataFromPasteboard(_ inputTextView: InputTextView, imageData: Data) {} } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorBrushViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorBrushViewController.swift index 35a39417a9..c4c9ee6152 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorBrushViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorBrushViewController.swift @@ -27,11 +27,12 @@ public class ImageEditorBrushViewController: OWSViewController { delegate: ImageEditorBrushViewControllerDelegate, model: ImageEditorModel, currentColor: ImageEditorColor, - bottomInset: CGFloat + bottomInset: CGFloat, + using dependencies: Dependencies ) { self.delegate = delegate self.model = model - self.canvasView = ImageEditorCanvasView(model: model) + self.canvasView = ImageEditorCanvasView(model: model, using: dependencies) self.paletteView = ImageEditorPaletteView(currentColor: currentColor) self.firstUndoOperationId = model.currentUndoOperationId() self.bottomInset = bottomInset diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift index 3fd75252e0..a065ac91b6 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift @@ -24,6 +24,7 @@ public class EditorTextLayer: CATextLayer { // A view for previewing an image editor model. public class ImageEditorCanvasView: UIView { + private let dependencies: Dependencies private let model: ImageEditorModel private let itemIdsToIgnore: [String] @@ -35,7 +36,8 @@ public class ImageEditorCanvasView: UIView { // We leave space for 10k items/layers of each type. private static let zPositionSpacing: CGFloat = 0.0001 - public required init(model: ImageEditorModel, itemIdsToIgnore: [String] = []) { + public required init(model: ImageEditorModel, itemIdsToIgnore: [String] = [], using dependencies: Dependencies) { + self.dependencies = dependencies self.model = model self.itemIdsToIgnore = itemIdsToIgnore @@ -134,31 +136,89 @@ public class ImageEditorCanvasView: UIView { } public func loadSrcImage() -> UIImage? { - return ImageEditorCanvasView.loadSrcImage(model: model) + return ImageEditorCanvasView.loadSrcImage(model: model, using: dependencies) } - public class func loadSrcImage(model: ImageEditorModel) -> UIImage? { - let srcImageData: Data - do { - let srcImagePath = model.srcImagePath - let srcImageUrl = URL(fileURLWithPath: srcImagePath) - srcImageData = try Data(contentsOf: srcImageUrl) - } catch { - Log.error("[ImageEditorCanvasView] Couldn't parse srcImageUrl") - return nil - } - // We use this constructor so that we can specify the scale. - // - // UIImage(contentsOfFile:) will sometimes use device scale. - guard let srcImage = UIImage(data: srcImageData, scale: 1.0) else { - Log.error("[ImageEditorCanvasView] Couldn't load background image.") - return nil + public class func loadSrcImage(model: ImageEditorModel, using dependencies: Dependencies) -> UIImage? { + let options: CFDictionary? = dependencies[singleton: .mediaDecoder].defaultImageOptions + + switch model.src { + case .url(let url): + // We use this constructor so that we can specify the scale. + // + // UIImage(contentsOfFile:) will sometimes use device scale. + guard + let source: CGImageSource = dependencies[singleton: .mediaDecoder].source(for: url), + let cgImage: CGImage = CGImageSourceCreateImageAtIndex(source, 0, options) + else { + Log.error("[ImageEditorCanvasView] Couldn't load source image.") + return nil + } + + // We normalize the image orientation here for the sake + // of code simplicity. We could modify the image layer's + // transform to handle the normalization, which would + // have perf benefits. + let orientation: UIImage.Orientation = { + guard + let properties: [CFString: Any] = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let rawCgOrientation: UInt32 = properties[kCGImagePropertyOrientation] as? UInt32, + let cgOrientation: CGImagePropertyOrientation = CGImagePropertyOrientation(rawValue: rawCgOrientation) + else { return .up } + + return UIImage.Orientation(cgOrientation) + }() + + return UIImage( + cgImage: cgImage.normalized(orientation: orientation), + scale: 1, + orientation: .up + ) + + case .data(_, let data): + // We use this constructor so that we can specify the scale. + // + // UIImage(contentsOfFile:) will sometimes use device scale. + guard + let source: CGImageSource = dependencies[singleton: .mediaDecoder].source(for: data), + let cgImage: CGImage = CGImageSourceCreateImageAtIndex(source, 0, options) + else { + Log.error("[ImageEditorCanvasView] Couldn't load source image.") + return nil + } + + // We normalize the image orientation here for the sake + // of code simplicity. We could modify the image layer's + // transform to handle the normalization, which would + // have perf benefits. + let orientation: UIImage.Orientation = { + guard + let properties: [CFString: Any] = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let rawCgOrientation: UInt32 = properties[kCGImagePropertyOrientation] as? UInt32, + let cgOrientation: CGImagePropertyOrientation = CGImagePropertyOrientation(rawValue: rawCgOrientation) + else { return .up } + + return UIImage.Orientation(cgOrientation) + }() + + return UIImage( + cgImage: cgImage.normalized(orientation: orientation), + scale: 1, + orientation: .up + ) + + case .image(_, let maybeImage): + guard let image: UIImage = maybeImage else { + Log.error("[ImageEditorCanvasView] Invalid source provided") + return nil + } + + return image.normalizedImage() + + default: + Log.error("[ImageEditorCanvasView] Invalid source provided") + return nil } - // We normalize the image orientation here for the sake - // of code simplicity. We could modify the image layer's - // transform to handle the normalization, which would - // have perf benefits. - return srcImage.normalizedImage() } // MARK: - Content @@ -324,10 +384,13 @@ public class ImageEditorCanvasView: UIView { return imageFrame } - private class func imageLayerForItem(model: ImageEditorModel, - transform: ImageEditorTransform, - viewSize: CGSize) -> CALayer? { - guard let srcImage = loadSrcImage(model: model) else { + private class func imageLayerForItem( + model: ImageEditorModel, + transform: ImageEditorTransform, + viewSize: CGSize, + using dependencies: Dependencies + ) -> CALayer? { + guard let srcImage = loadSrcImage(model: model, using: dependencies) else { Log.error("[ImageEditorCanvasView] Could not load src image.") return nil } @@ -606,13 +669,6 @@ public class ImageEditorCanvasView: UIView { let dstSizePixels = transform.outputSizePixels let dstScale: CGFloat = 1.0 // The size is specified in pixels, not in points. let viewSize = dstSizePixels - let hasAlpha: Bool = (MediaUtils.MediaMetadata( - from: model.srcImagePath, - type: nil, - mimeType: nil, - sourceFilename: nil, - using: dependencies - )?.hasAlpha == true) // We use an UIImageView + UIView.renderAsImage() instead of a CGGraphicsContext // Because CALayer.renderInContext() doesn't honor CALayer properties like frame, @@ -635,7 +691,7 @@ public class ImageEditorCanvasView: UIView { contentView.layer.setAffineTransform(transform.affineTransform(viewSize: viewSize)) - guard let imageLayer = imageLayerForItem(model: model, transform: transform, viewSize: viewSize) else { + guard let imageLayer = imageLayerForItem(model: model, transform: transform, viewSize: viewSize, using: dependencies) else { Log.error("[ImageEditorCanvasView] Could not load src image.") return nil } @@ -663,7 +719,7 @@ public class ImageEditorCanvasView: UIView { CATransaction.commit() - let image = view.toImage(isOpaque: !hasAlpha, scale: dstScale) + let image = view.toImage(isOpaque: (model.srcMetadata.hasAlpha != true), scale: dstScale) return image } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift index 1e6e22dfc1..bba4d928b8 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift @@ -2,6 +2,8 @@ import UIKit import UniformTypeIdentifiers +import SessionUIKit +import SessionMessagingKit import SessionUtilitiesKit // Used to represent undo/redo operations. @@ -43,7 +45,8 @@ public class ImageEditorModel { } private let dependencies: Dependencies - public let srcImagePath: String + public let src: ImageDataManager.DataSource + public let srcMetadata: MediaUtils.MediaMetadata public let srcImageSizePixels: CGSize private var contents: ImageEditorContents private var transform: ImageEditorTransform @@ -54,34 +57,31 @@ public class ImageEditorModel { // // * They are invalid. // * We can't determine their size / aspect-ratio. - public required init(srcImagePath: String, using dependencies: Dependencies) throws { + public required init(attachment: PendingAttachment, using dependencies: Dependencies) throws { self.dependencies = dependencies - self.srcImagePath = srcImagePath - - let srcFileName = (srcImagePath as NSString).lastPathComponent - let srcFileExtension = (srcFileName as NSString).pathExtension - guard let type: UTType = UTType(sessionFileExtension: srcFileExtension) else { - Log.error("[ImageEditorModel] Couldn't determine UTType for file.") + guard + let source: ImageDataManager.DataSource = attachment.visualMediaSource, + case .media(let metadata) = attachment.metadata + else { + Log.error("[ImageEditorModel] Couldn't extract media data.") throw ImageEditorError.invalidInput } - guard type.isImage && !type.isAnimated else { - Log.error("[ImageEditorModel] Invalid MIME type: \(type.preferredMIMEType ?? "unknown").") + guard attachment.utType.isImage && attachment.duration == 0 else { + Log.error("[ImageEditorModel] Invalid MIME type: \(attachment.utType.preferredMIMEType ?? "unknown").") throw ImageEditorError.invalidInput } - let srcImageSizePixels = MediaUtils.unrotatedSize( - for: srcImagePath, - type: type, - mimeType: nil, - sourceFilename: srcFileName, - using: dependencies - ) - guard srcImageSizePixels.width > 0, srcImageSizePixels.height > 0 else { + let displaySize: CGSize = metadata.displaySize + + guard displaySize.width > 0, displaySize.height > 0 else { Log.error("[ImageEditorModel] Couldn't determine image size.") throw ImageEditorError.invalidInput } - self.srcImageSizePixels = srcImageSizePixels + + self.src = source + self.srcMetadata = metadata + self.srcImageSizePixels = displaySize self.contents = ImageEditorContents() self.transform = ImageEditorTransform.defaultTransform(srcImageSizePixels: srcImageSizePixels) @@ -233,14 +233,6 @@ public class ImageEditorModel { private var temporaryFilePaths = [String]() - public func temporaryFilePath(withFileExtension fileExtension: String) -> String { - Log.assertOnMainThread() - - let filePath = dependencies[singleton: .fileManager].temporaryFilePath(fileExtension: fileExtension) - temporaryFilePaths.append(filePath) - return filePath - } - deinit { Log.assertOnMainThread() @@ -310,8 +302,7 @@ public class ImageEditorModel { } let hasAlpha: Bool = (MediaUtils.MediaMetadata( from: imagePath, - type: nil, - mimeType: nil, + utType: nil, sourceFilename: nil, using: dependencies )?.hasAlpha == true) diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorTextViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorTextViewController.swift index 4febd32c7b..ae7165489d 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorTextViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorTextViewController.swift @@ -114,7 +114,8 @@ public class ImageEditorTextViewController: OWSViewController, VAlignTextViewDel textItem: ImageEditorTextItem, isNewItem: Bool, maxTextWidthPoints: CGFloat, - bottomInset: CGFloat + bottomInset: CGFloat, + using dependencies: Dependencies ) { self.delegate = delegate self.model = model @@ -123,7 +124,8 @@ public class ImageEditorTextViewController: OWSViewController, VAlignTextViewDel self.maxTextWidthPoints = maxTextWidthPoints self.canvasView = ImageEditorCanvasView( model: model, - itemIdsToIgnore: [textItem.itemId] + itemIdsToIgnore: [textItem.itemId], + using: dependencies ) self.paletteView = ImageEditorPaletteView(currentColor: textItem.color) self.bottomInset = bottomInset diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorView.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorView.swift index be0b023167..5fb188a3ac 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorView.swift @@ -1,6 +1,7 @@ // Copyright (c) 2019 Open Whisper Systems. All rights reserved. import UIKit +import SessionUIKit import SessionUtilitiesKit @objc @@ -23,16 +24,41 @@ public class ImageEditorView: UIView { private let dependencies: Dependencies private let model: ImageEditorModel private let canvasView: ImageEditorCanvasView + + private lazy var uneditableImageView: SessionImageView = { + let result: SessionImageView = SessionImageView(dataManager: dependencies[singleton: .imageDataManager]) + result.contentMode = .scaleAspectFit + + return result + }() // TODO: We could hang this on the model or make this static // if we wanted more color continuity. private var currentColor = ImageEditorColor.defaultColor() + + /// The share extension has limited RAM (~120Mb on an iPhone X) so only allow image editing if there is likely enough RAM to do + /// so (if there isn't then it would just crash when trying to normalise the image since that requires `3x` RAM in order to allocate the + /// buffers needed for manipulating the image data), in order to avoid this we check if the estimated RAM usage is smaller than `80%` + /// of the currently available RAM and if not we don't allow image editing (instead we load the image in a `SessionImageView` + /// which falls back to lazy `UIImage` loading due to the memory limits) + public var canSupportImageEditing: Bool { + #if targetEnvironment(simulator) + /// On the simulator `os_proc_available_memory` seems to always return `0` so just assume we have enough memort + return true + #else + let estimatedMemorySize: Int = Int(floor((model.srcImageSizePixels.width * model.srcImageSizePixels.height * 4))) + let estimatedMemorySizeToLoad: Int = (estimatedMemorySize * 3) + let currentAvailableMemory: Int = os_proc_available_memory() + + return (estimatedMemorySizeToLoad < Int(floor(CGFloat(currentAvailableMemory) * 0.8))) + #endif + } public required init(model: ImageEditorModel, delegate: ImageEditorViewDelegate, using dependencies: Dependencies) { self.dependencies = dependencies self.model = model self.delegate = delegate - self.canvasView = ImageEditorCanvasView(model: model) + self.canvasView = ImageEditorCanvasView(model: model, using: dependencies) super.init(frame: .zero) @@ -52,9 +78,16 @@ public class ImageEditorView: UIView { @objc public func configureSubviews() -> Bool { - canvasView.configureSubviews() - self.addSubview(canvasView) - canvasView.pin(to: self) + if canSupportImageEditing { + canvasView.configureSubviews() + self.addSubview(canvasView) + canvasView.pin(to: self) + } + else { + uneditableImageView.loadImage(model.src) + self.addSubview(uneditableImageView) + uneditableImageView.pin(to: self) + } self.isUserInteractionEnabled = true @@ -92,14 +125,27 @@ public class ImageEditorView: UIView { return [] } - let undoButton = navigationBarButton(imageName: "image_editor_undo", - selector: #selector(didTapUndo(sender:))) - let brushButton = navigationBarButton(imageName: "image_editor_brush", - selector: #selector(didTapBrush(sender:))) - let cropButton = navigationBarButton(imageName: "image_editor_crop", - selector: #selector(didTapCrop(sender:))) - let newTextButton = navigationBarButton(imageName: "image_editor_text", - selector: #selector(didTapNewText(sender:))) + let canEditImage: Bool = canSupportImageEditing + let undoButton = navigationBarButton( + imageName: "image_editor_undo", + enabled: canEditImage, + selector: #selector(didTapUndo(sender:)) + ) + let brushButton = navigationBarButton( + imageName: "image_editor_brush", + enabled: canEditImage, + selector: #selector(didTapBrush(sender:)) + ) + let cropButton = navigationBarButton( + imageName: "image_editor_crop", + enabled: canEditImage, + selector: #selector(didTapCrop(sender:)) + ) + let newTextButton = navigationBarButton( + imageName: "image_editor_text", + enabled: canEditImage, + selector: #selector(didTapNewText(sender:)) + ) var buttons: [UIView] if model.canUndo() { @@ -135,7 +181,8 @@ public class ImageEditorView: UIView { delegate: self, model: model, currentColor: currentColor, - bottomInset: ((self.superview?.frame.height ?? 0) - self.frame.height) + bottomInset: ((self.superview?.frame.height ?? 0) - self.frame.height), + using: dependencies ) self.delegate?.imageEditor(presentFullScreenView: brushView, isTransparent: false) @@ -446,7 +493,8 @@ public class ImageEditorView: UIView { textItem: textItem, isNewItem: isNewItem, maxTextWidthPoints: maxTextWidthPoints, - bottomInset: ((self.superview?.frame.height ?? 0) - self.frame.height) + bottomInset: ((self.superview?.frame.height ?? 0) - self.frame.height), + using: dependencies ) self.delegate?.imageEditor(presentFullScreenView: textEditor, isTransparent: false) diff --git a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift index 4feb892afb..ef3a7188b4 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift @@ -18,12 +18,12 @@ public class MediaMessageView: UIView { // MARK: Properties private let dependencies: Dependencies - private var disposables: Set = Set() public let mode: Mode - public let attachment: SignalAttachment + public let attachment: PendingAttachment private let disableLinkPreviewImageDownload: Bool - private lazy var duration: TimeInterval? = attachment.duration(using: dependencies) + private let didLoadLinkPreview: (@MainActor (LinkPreviewDraft) -> Void)? private var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)? + private var linkPreviewLoadTask: Task? // MARK: Initializers @@ -34,21 +34,25 @@ public class MediaMessageView: UIView { // Currently we only use one mode (AttachmentApproval), so we could simplify this class, but it's kind // of nice that it's written in a flexible way in case we'd want to use it elsewhere again in the future. - public required init( - attachment: SignalAttachment, + @MainActor public required init( + attachment: PendingAttachment, mode: MediaMessageView.Mode, disableLinkPreviewImageDownload: Bool, + didLoadLinkPreview: (@MainActor (LinkPreviewDraft) -> Void)?, using dependencies: Dependencies ) { - if attachment.hasError { Log.error("[MediaMessageView] \(attachment.error.debugDescription)") } - self.dependencies = dependencies self.attachment = attachment self.mode = mode self.disableLinkPreviewImageDownload = disableLinkPreviewImageDownload + self.didLoadLinkPreview = didLoadLinkPreview // Set the linkPreviewUrl if it's a url - if attachment.isUrl, let linkPreviewURL: String = LinkPreview.previewUrl(for: attachment.text(), using: dependencies) { + if + attachment.utType.conforms(to: .url), + let attachmentText: String = attachment.toText(), + let linkPreviewURL: String = LinkPreview.previewUrl(for: attachmentText, using: dependencies) + { self.linkPreviewInfo = (url: linkPreviewURL, draft: nil) } @@ -60,6 +64,8 @@ public class MediaMessageView: UIView { deinit { NotificationCenter.default.removeObserver(self) + + linkPreviewLoadTask?.cancel() } // MARK: - UI @@ -109,53 +115,12 @@ public class MediaMessageView: UIView { view.themeTintColor = .textPrimary // Override the image to the correct one - if attachment.isImage || attachment.isAnimatedImage { - let maybeSource: ImageDataManager.DataSource? = { - guard attachment.isValidImage else { return nil } - - return ( - attachment.dataSource.dataPathIfOnDisk.map { .url(URL(fileURLWithPath: $0)) } ?? - attachment.dataSource.dataUrl.map { .url($0) } - ) - }() - - if let source: ImageDataManager.DataSource = maybeSource { - view.layer.minificationFilter = .trilinear - view.layer.magnificationFilter = .trilinear - view.loadImage(source) - } - else { - view.contentMode = .scaleAspectFit - view.image = UIImage(named: "FileLarge")?.withRenderingMode(.alwaysTemplate) - view.themeTintColor = .textPrimary - } - } - else if attachment.isVideo { - let maybeSource: ImageDataManager.DataSource? = { - guard attachment.isValidVideo else { return nil } - - return attachment.dataSource.dataUrl.map { url in - .videoUrl( - url, - attachment.mimeType, - attachment.sourceFilename, - dependencies[singleton: .attachmentManager] - ) - } - }() - - if let source: ImageDataManager.DataSource = maybeSource { - view.layer.minificationFilter = .trilinear - view.layer.magnificationFilter = .trilinear - view.loadImage(source) - } - else { - view.contentMode = .scaleAspectFit - view.image = UIImage(named: "FileLarge")?.withRenderingMode(.alwaysTemplate) - view.themeTintColor = .textPrimary - } + if attachment.isValidVisualMedia, let source: ImageDataManager.DataSource = attachment.visualMediaSource { + view.layer.minificationFilter = .trilinear + view.layer.magnificationFilter = .trilinear + view.loadImage(source) } - else if attachment.isUrl { + else if attachment.utType.conforms(to: .url) { view.clipsToBounds = true view.image = UIImage(named: "Link")?.withRenderingMode(.alwaysTemplate) view.themeTintColor = .messageBubble_outgoingText @@ -179,7 +144,7 @@ public class MediaMessageView: UIView { let stackView: UIStackView = UIStackView() stackView.translatesAutoresizingMaskIntoConstraints = false stackView.axis = .vertical - stackView.alignment = (attachment.isUrl && linkPreviewInfo?.url != nil ? .leading : .center) + stackView.alignment = (attachment.utType.conforms(to: .url) && linkPreviewInfo?.url != nil ? .leading : .center) stackView.distribution = .fill switch mode { @@ -211,7 +176,7 @@ public class MediaMessageView: UIView { } // Content - if attachment.isUrl { + if attachment.utType.conforms(to: .url) { // If we have no link preview info at this point then assume link previews are disabled if let linkPreviewURL: String = linkPreviewInfo?.url { label.font = .boldSystemFont(ofSize: Values.smallFontSize) @@ -225,7 +190,7 @@ public class MediaMessageView: UIView { } } // Title for everything except these types - else if !attachment.isImage && !attachment.isAnimatedImage && !attachment.isVideo { + else if !attachment.isValidVisualMedia { if let fileName: String = attachment.sourceFilename?.trimmingCharacters(in: .whitespacesAndNewlines), fileName.count > 0 { label.text = fileName } @@ -263,12 +228,12 @@ public class MediaMessageView: UIView { } // Content - if attachment.isUrl { + if attachment.utType.conforms(to: .url) { // We only load Link Previews for HTTPS urls so append an explanation for not if let linkPreviewURL: String = linkPreviewInfo?.url { let httpsScheme: String = "https" // stringlint:ignore - if let targetUrl: URL = URL(string: linkPreviewURL), targetUrl.scheme?.lowercased() != httpsScheme { + if URLComponents(string: linkPreviewURL)?.scheme?.lowercased() != httpsScheme { label.font = UIFont.systemFont(ofSize: Values.verySmallFontSize) label.text = "linkPreviewsErrorUnsecure".localized() label.themeTextColor = (mode == .attachmentApproval ? @@ -288,10 +253,11 @@ public class MediaMessageView: UIView { } } // Subtitle for everything else except these types - else if !attachment.isImage && !attachment.isAnimatedImage && !attachment.isVideo { + else if !attachment.isValidVisualMedia { // Format string for file size label in call interstitial view. // Embeds: {{file size as 'N mb' or 'N kb'}}. - let fileSize: UInt = attachment.dataLength + let fileSize: UInt = UInt(attachment.fileSize) + let duration: TimeInterval? = (attachment.duration > 0 ? attachment.duration : nil) label.text = duration .map { "\(Format.fileSize(fileSize)), \(Format.duration($0))" } .defaulting(to: Format.fileSize(fileSize)) @@ -306,9 +272,11 @@ public class MediaMessageView: UIView { // MARK: - Layout - private func setupViews(using dependencies: Dependencies) { - // Plain text will just be put in the 'message' input so do nothing - guard !attachment.isText else { return } + @MainActor private func setupViews(using dependencies: Dependencies) { + switch attachment.source { + case .text: return /// Plain text will just be put in the 'message' input so do nothing + default: break + } // Setup the view hierarchy addSubview(stackView) @@ -322,18 +290,17 @@ public class MediaMessageView: UIView { titleStackView.addArrangedSubview(subtitleLabel) imageView.alpha = 1 - imageView.set(.width, to: .width, of: stackView) imageView.addSubview(fileTypeImageView) // Type-specific configurations - if attachment.isAudio { + if attachment.utType.isAudio { // Hide the 'audioPlayPauseButton' if the 'audioPlayer' failed to get created fileTypeImageView.image = UIImage(named: "table_ic_notification_sound")? .withRenderingMode(.alwaysTemplate) fileTypeImageView.themeTintColor = .textPrimary fileTypeImageView.isHidden = false } - else if attachment.isUrl { + else if attachment.utType.conforms(to: .url) { imageView.alpha = 0 // Not 'isHidden' because we want it to take up space in the UIStackView loadingView.isHidden = false @@ -349,15 +316,20 @@ public class MediaMessageView: UIView { ) } } + else { + imageView.set(.width, to: .width, of: stackView) + } } - private func setupLayout() { - // Plain text will just be put in the 'message' input so do nothing - guard !attachment.isText else { return } + @MainActor private func setupLayout() { + switch attachment.source { + case .text: return /// Plain text will just be put in the 'message' input so do nothing + default: break + } // Sizing calculations let clampedRatio: CGFloat = { - if attachment.isUrl { + if attachment.utType.conforms(to: .url) { return 1 } @@ -369,17 +341,17 @@ public class MediaMessageView: UIView { }() let maybeImageSize: CGFloat? = { - if attachment.isImage || attachment.isAnimatedImage { - guard attachment.isValidImage else { return nil } + if attachment.utType.isImage || attachment.utType.isAnimated { + guard attachment.isValidVisualMedia else { return nil } // If we don't have a valid image then use the 'generic' case } - else if attachment.isValidVideo { - guard attachment.isValidVideo else { return nil } + else if attachment.utType.isVideo { + guard attachment.isValidVisualMedia else { return nil } // If we don't have a valid image then use the 'generic' case } - else if attachment.isUrl { + else if attachment.utType.conforms(to: .url) { return 80 } @@ -402,7 +374,8 @@ public class MediaMessageView: UIView { (maybeImageSize != nil ? stackView.widthAnchor.constraint( equalTo: widthAnchor, - constant: (attachment.isUrl ? -(32 * 2) : 0) // Inset stackView for urls + // Inset stackView for urls + constant: (attachment.utType.conforms(to: .url) ? -(32 * 2) : 0) ) : stackView.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor) ), @@ -442,7 +415,7 @@ public class MediaMessageView: UIView { } // No inset for the text for URLs but there is for all other layouts - if !attachment.isUrl { + if !attachment.utType.conforms(to: .url) { NSLayoutConstraint.activate([ titleLabel.widthAnchor.constraint(equalTo: stackView.widthAnchor, constant: -(32 * 2)), subtitleLabel.widthAnchor.constraint(equalTo: stackView.widthAnchor, constant: -(32 * 2)) @@ -452,59 +425,65 @@ public class MediaMessageView: UIView { // MARK: - Link Loading - private func loadLinkPreview( + @MainActor private func loadLinkPreview( linkPreviewURL: String, skipImageDownload: Bool, using dependencies: Dependencies ) { loadingView.startAnimating() - LinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL, skipImageDownload: skipImageDownload, using: dependencies) - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { [weak self] result in - switch result { - case .finished: break - case .failure: - self?.loadingView.alpha = 0 - self?.loadingView.stopAnimating() - self?.imageView.alpha = 1 - self?.titleLabel.numberOfLines = 1 // Truncates the URL at 1 line so the error is more readable - self?.subtitleLabel.isHidden = false - - // Set the error text appropriately - if let targetUrl: URL = URL(string: linkPreviewURL), targetUrl.scheme?.lowercased() != "https" { // stringlint:ignore - // This error case is handled already in the 'subtitleLabel' creation - } - else { - self?.subtitleLabel.font = UIFont.systemFont(ofSize: Values.verySmallFontSize) - self?.subtitleLabel.text = "linkPreviewsErrorLoad".localized() - self?.subtitleLabel.themeTextColor = (self?.mode == .attachmentApproval ? - .textSecondary : - .primary - ) - self?.subtitleLabel.textAlignment = .left - } - } - }, - receiveValue: { [weak self] draft in - // TODO: Look at refactoring this behaviour to consolidate attachment mutations - self?.attachment.linkPreviewDraft = draft - self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft) + linkPreviewLoadTask?.cancel() + linkPreviewLoadTask = Task.detached(priority: .userInitiated) { [weak self] in + do { + let draft: LinkPreviewDraft = try await LinkPreview.tryToBuildPreviewInfo( + previewUrl: linkPreviewURL, + skipImageDownload: skipImageDownload, + using: dependencies + ) + + await MainActor.run { [weak self] in + guard let self else { return } + + didLoadLinkPreview?(draft) + linkPreviewInfo = (url: linkPreviewURL, draft: draft) // Update the UI - self?.titleLabel.text = (draft.title ?? self?.titleLabel.text) - self?.loadingView.alpha = 0 - self?.loadingView.stopAnimating() - self?.imageView.alpha = 1 + titleLabel.text = (draft.title ?? titleLabel.text) + loadingView.alpha = 0 + loadingView.stopAnimating() + imageView.alpha = 1 - if let jpegImageData: Data = draft.jpegImageData, let loadedImage: UIImage = UIImage(data: jpegImageData) { - self?.imageView.image = loadedImage - self?.imageView.contentMode = .scaleAspectFill + if let imageSource: ImageDataManager.DataSource = draft.imageSource { + imageView.loadImage(imageSource) } } - ) - .store(in: &disposables) + } + catch { + await MainActor.run { [weak self] in + guard let self else { return } + + loadingView.alpha = 0 + loadingView.stopAnimating() + imageView.alpha = 1 + titleLabel.numberOfLines = 1 /// Truncates the URL at 1 line so the error is more readable + subtitleLabel.isHidden = false + + /// Set the error text appropriately + let httpsScheme: String = "https" // stringlint:ignore + if URLComponents(string: linkPreviewURL)?.scheme?.lowercased() != httpsScheme { + // This error case is handled already in the 'subtitleLabel' creation + } + else { + subtitleLabel.font = UIFont.systemFont(ofSize: Values.verySmallFontSize) + subtitleLabel.text = "linkPreviewsErrorLoad".localized() + subtitleLabel.themeTextColor = (mode == .attachmentApproval ? + .textSecondary : + .primary + ) + subtitleLabel.textAlignment = .left + } + } + } + } } } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/OWSViewController+ImageEditor.swift b/SignalUtilitiesKit/Media Viewing & Editing/OWSViewController+ImageEditor.swift index fc9563586f..f72d0f36d7 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/OWSViewController+ImageEditor.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/OWSViewController+ImageEditor.swift @@ -6,10 +6,11 @@ import UIKit import SessionUIKit public extension NSObject { - func navigationBarButton(imageName: String, selector: Selector) -> UIView { + func navigationBarButton(imageName: String, enabled: Bool = true, selector: Selector) -> UIView { let button = OWSButton() button.setImage(imageName: imageName) button.themeTintColor = .textPrimary + button.isEnabled = enabled button.addTarget(self, action: selector, for: .touchUpInside) return button diff --git a/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift b/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift index 7b80e15e5b..6bdf1b2086 100644 --- a/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift +++ b/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift @@ -12,7 +12,7 @@ import SessionUtilitiesKit public class ModalActivityIndicatorViewController: OWSViewController { let canCancel: Bool let message: String? - private let onAppear: (ModalActivityIndicatorViewController) -> Void + private let onAppear: ((ModalActivityIndicatorViewController) -> Void)? private var hasAppeared: Bool = false public var wasCancelled: Bool = false @@ -87,7 +87,7 @@ public class ModalActivityIndicatorViewController: OWSViewController { @MainActor public required init( canCancel: Bool = false, message: String? = nil, - onAppear: @escaping (ModalActivityIndicatorViewController) -> Void + onAppear: ((ModalActivityIndicatorViewController) -> Void)? = nil ) { self.canCancel = canCancel self.message = message @@ -114,14 +114,7 @@ public class ModalActivityIndicatorViewController: OWSViewController { ) } - public func dismiss(completion: @escaping () -> Void) { - guard Thread.isMainThread else { - DispatchQueue.main.async { [weak self] in - self?.dismiss(completion: completion) - } - return - } - + @MainActor public func dismiss(completion: (@MainActor () -> Void)? = nil) { if !wasDimissed { // Only dismiss once. self.dismiss(animated: false, completion: completion) @@ -129,9 +122,7 @@ public class ModalActivityIndicatorViewController: OWSViewController { } else { // If already dismissed, wait a beat then call completion. - DispatchQueue.main.async { - completion() - } + completion?() } } @@ -183,7 +174,7 @@ public class ModalActivityIndicatorViewController: OWSViewController { self.hasAppeared = true DispatchQueue.global().async { - self.onAppear(self) + self.onAppear?(self) } } } @@ -232,9 +223,11 @@ public extension Publisher { .flatMap { result -> AnyPublisher in Deferred { Future { resolver in - indicator.dismiss(completion: { - resolver(result) - }) + DispatchQueue.main.async { + indicator.dismiss(completion: { + resolver(result) + }) + } } }.eraseToAnyPublisher() } diff --git a/SignalUtilitiesKit/Shared Views/ApprovalRailCellView.swift b/SignalUtilitiesKit/Shared Views/ApprovalRailCellView.swift index 30fd976c21..69dc4d3ac9 100644 --- a/SignalUtilitiesKit/Shared Views/ApprovalRailCellView.swift +++ b/SignalUtilitiesKit/Shared Views/ApprovalRailCellView.swift @@ -5,7 +5,7 @@ import SessionUIKit import SessionUtilitiesKit protocol ApprovalRailCellViewDelegate: AnyObject { - func approvalRailCellView(_ approvalRailCellView: ApprovalRailCellView, didRemoveItem attachmentItem: SignalAttachmentItem) + func approvalRailCellView(_ approvalRailCellView: ApprovalRailCellView, didRemoveItem attachmentItem: PendingAttachmentRailItem) func canRemoveApprovalRailCellView(_ approvalRailCellView: ApprovalRailCellView) -> Bool } @@ -17,14 +17,14 @@ public class ApprovalRailCellView: GalleryRailCellView { lazy var deleteButton: UIButton = { let button = OWSButton { [weak self] in - guard let strongSelf = self else { return } + guard let self = self else { return } - guard let attachmentItem = strongSelf.item as? SignalAttachmentItem else { + guard let attachmentItem = item as? PendingAttachmentRailItem else { Log.error("[ApprovalRailCellView] attachmentItem was unexpectedly nil") return } - strongSelf.approvalRailCellDelegate?.approvalRailCellView(strongSelf, didRemoveItem: attachmentItem) + self.approvalRailCellDelegate?.approvalRailCellView(self, didRemoveItem: attachmentItem) } button.setImage(UIImage(named: "x-24")?.withRenderingMode(.alwaysTemplate), for: .normal) @@ -39,18 +39,6 @@ public class ApprovalRailCellView: GalleryRailCellView { return button }() - lazy var captionIndicator: UIView = { - let image = UIImage(named: "image_editor_caption")?.withRenderingMode(.alwaysTemplate) - let imageView = UIImageView(image: image) - imageView.themeTintColor = .white - imageView.themeShadowColor = .black - imageView.layer.shadowRadius = 2 - imageView.layer.shadowOpacity = 0.66 - imageView.layer.shadowOffset = .zero - - return imageView - }() - override func setIsSelected(_ isSelected: Bool) { super.setIsSelected(isSelected) @@ -66,26 +54,4 @@ public class ApprovalRailCellView: GalleryRailCellView { deleteButton.removeFromSuperview() } } - - override func configure(item: GalleryRailItem, delegate: GalleryRailCellViewDelegate, using dependencies: Dependencies) { - super.configure(item: item, delegate: delegate, using: dependencies) - - var hasCaption = false - if let attachmentItem = item as? SignalAttachmentItem { - if let captionText = attachmentItem.captionText { - hasCaption = captionText.count > 0 - } - } else { - Log.error("[ApprovalRailCellView] Invalid item") - } - - if hasCaption { - addSubview(captionIndicator) - - captionIndicator.pin(.top, to: .top, of: self, withInset: cellBorderWidth) - captionIndicator.pin(.leading, to: .leading, of: self, withInset: cellBorderWidth + 4) - } else { - captionIndicator.removeFromSuperview() - } - } } diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift index 1a3f2f7558..a79cf88c3f 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.swift +++ b/SignalUtilitiesKit/Utilities/AppSetup.swift @@ -2,6 +2,7 @@ import Foundation import GRDB +import SDWebImageWebPCoder import SessionUIKit import SessionNetworkingKit import SessionMessagingKit @@ -31,6 +32,9 @@ public enum AppSetup { at: NSTemporaryDirectory(), fileProtectionType: .completeUntilFirstUserAuthentication ) + + // Need to register the WebP coder for encoding purposes + SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared) SessionEnvironment.shared = SessionEnvironment( audioSession: OWSAudioSession(), diff --git a/SignalUtilitiesKit/Utilities/ImageCache.swift b/SignalUtilitiesKit/Utilities/ImageCache.swift deleted file mode 100644 index 2a8520717c..0000000000 --- a/SignalUtilitiesKit/Utilities/ImageCache.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. -// - -import Foundation -import UIKit - -class ImageCacheRecord: NSObject { - var variations: [CGFloat: UIImage] - init(variations: [CGFloat: UIImage]) { - self.variations = variations - } -} - -/** - * A two dimensional hash, allowing you to store variations under a single key. - * This is useful because we generate multiple diameters of an image, but when we - * want to clear out the images for a key we want to clear out *all* variations. - */ -@objc -public class ImageCache: NSObject { - - let backingCache: NSCache - - public override init() { - self.backingCache = NSCache() - } - - @objc - public func image(forKey key: AnyObject, diameter: CGFloat) -> UIImage? { - guard let record = backingCache.object(forKey: key) else { - return nil - } - return record.variations[diameter] - } - - @objc - public func setImage(_ image: UIImage, forKey key: AnyObject, diameter: CGFloat) { - if let existingRecord = backingCache.object(forKey: key) { - existingRecord.variations[diameter] = image - backingCache.setObject(existingRecord, forKey: key) - } else { - let newRecord = ImageCacheRecord(variations: [diameter: image]) - backingCache.setObject(newRecord, forKey: key) - } - } - - @objc - public func removeAllImages() { - backingCache.removeAllObjects() - } - - @objc - public func removeAllImages(forKey key: AnyObject) { - backingCache.removeObject(forKey: key) - } -} diff --git a/SignalUtilitiesKit/Utilities/OWSSignalAddress.swift b/SignalUtilitiesKit/Utilities/OWSSignalAddress.swift deleted file mode 100644 index ce04d60137..0000000000 --- a/SignalUtilitiesKit/Utilities/OWSSignalAddress.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -import Foundation - -public enum OWSSignalAddressError: Error { - case assertionError(description: String) -} - -@objc -public class OWSSignalAddress: NSObject { - @objc - public let recipientId: String - - @objc - public let deviceId: UInt - - // MARK: Initializers - - @objc public init(recipientId: String, deviceId: UInt) throws { - guard recipientId.count > 0 else { - throw OWSSignalAddressError.assertionError(description: "Invalid recipient id: \(deviceId)") - } - - guard deviceId > 0 else { - throw OWSSignalAddressError.assertionError(description: "Invalid device id: \(deviceId)") - } - - self.recipientId = recipientId - self.deviceId = deviceId - } -} diff --git a/SignalUtilitiesKit/Utilities/ReverseDispatchQueue.swift b/SignalUtilitiesKit/Utilities/ReverseDispatchQueue.swift deleted file mode 100644 index f42281ec49..0000000000 --- a/SignalUtilitiesKit/Utilities/ReverseDispatchQueue.swift +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. - -import Foundation -import SessionUtilitiesKit - -// This is intended to be a drop-in replacement for DispatchQueue -// that processes its queue in reverse order. -@objc -public class ReverseDispatchQueue: NSObject { - - private static let isVerbose: Bool = false - - private let label: String - private let serialQueue: DispatchQueue - - // TODO: We could allow creation with various QOS. - @objc - public required init(label: String) { - self.label = label - serialQueue = DispatchQueue(label: label) - - super.init() - } - - public typealias WorkBlock = () -> Void - - private class Item { - let workBlock: WorkBlock - let index: UInt64 - - required init(workBlock : @escaping WorkBlock, index: UInt64) { - self.workBlock = workBlock - self.index = index - } - } - - // These properties should only be accessed on serialQueue. - private var items = [Item]() - private var indexCounter: UInt64 = 0 - - @objc - public func async(workBlock : @escaping WorkBlock) { - serialQueue.async { - self.indexCounter = self.indexCounter + 1 - let index = self.indexCounter - let item = Item(workBlock: workBlock, index: index ) - self.items.append(item) - - if ReverseDispatchQueue.isVerbose { - Log.verbose("[ReverseDispatchQueue] Enqueued[\(self.label)]: \(item.index)") - } - - self.process() - } - } - - private func process() { - serialQueue.async { - // Note that we popLast() so that we process - // the queue in the _reverse_ order from - // which it was enqueued. - guard let item = self.items.popLast() else { - // No enqueued work to do. - return - } - if ReverseDispatchQueue.isVerbose { - Log.verbose("[ReverseDispatchQueue] Processing[\(self.label)]: \(item.index)") - } - item.workBlock() - - self.process() - } - } -} diff --git a/_SharedTestUtilities/Async+Utilities.swift b/_SharedTestUtilities/Async+Utilities.swift new file mode 100644 index 0000000000..d686ec3898 --- /dev/null +++ b/_SharedTestUtilities/Async+Utilities.swift @@ -0,0 +1,16 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension AsyncStream { + static func singleValue(value: Element) -> AsyncStream { + var hasEmittedValue: Bool = false + + return AsyncStream(unfolding: { + guard !hasEmittedValue else { return nil } + + hasEmittedValue = true + return value + }) + } +} diff --git a/_SharedTestUtilities/MockFileManager.swift b/_SharedTestUtilities/MockFileManager.swift index 473fad7b93..0c8e6c9ba7 100644 --- a/_SharedTestUtilities/MockFileManager.swift +++ b/_SharedTestUtilities/MockFileManager.swift @@ -7,7 +7,6 @@ class MockFileManager: Mock, FileManagerType { var temporaryDirectory: String { mock() } var documentsDirectoryPath: String { mock() } var appSharedDataDirectoryPath: String { mock() } - var temporaryDirectoryAccessibleAfterFirstAuth: String { mock() } func clearOldTemporaryDirectories() { mockNoReturn() } @@ -24,14 +23,22 @@ class MockFileManager: Mock, FileManagerType { return mock(args: [path]) } + func isLocatedInTemporaryDirectory(_ path: String) -> Bool { + return mock(args: [path]) + } + func temporaryFilePath(fileExtension: String?) -> String { return mock(args: [fileExtension]) } - func write(data: Data, toTemporaryFileWithExtension fileExtension: String?) throws -> String? { + func write(data: Data, toTemporaryFileWithExtension fileExtension: String?) throws -> String { return try mockThrowing(args: [data, fileExtension]) } + func write(data: Data, toPath path: String) throws { + try mockThrowingNoReturn(args: [data, path]) + } + // MARK: - Forwarded NSFileManager var currentDirectoryPath: String { mock() } @@ -112,9 +119,14 @@ extension Mock where T == FileManagerType { 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.fileSize(of: .any) }.thenReturn(1024) + self.when { $0.isLocatedInTemporaryDirectory(.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.write(dataToTemporaryFile: .any) }.thenReturn("tmpFile") + self.when { try $0.write(data: .any, toPath: .any) }.thenReturn(()) self.when { try $0.setAttributes(.any, ofItemAtPath: .any) }.thenReturn(()) + self.when { try $0.copyItem(atPath: .any, toPath: .any) }.thenReturn(()) self.when { try $0.moveItem(atPath: .any, toPath: .any) }.thenReturn(()) self.when { _ = try $0.replaceItem( diff --git a/_SharedTestUtilities/MockMediaDecoder.swift b/_SharedTestUtilities/MockMediaDecoder.swift new file mode 100644 index 0000000000..0d45fb1db5 --- /dev/null +++ b/_SharedTestUtilities/MockMediaDecoder.swift @@ -0,0 +1,36 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import ImageIO + +@testable import SessionUtilitiesKit + +class MockMediaDecoder: Mock, MediaDecoderType { + var defaultImageOptions: CFDictionary { mock() } + + func defaultThumbnailOptions(maxDimension: CGFloat) -> CFDictionary { + return mock(args: [maxDimension]) + } + + func source(for url: URL) -> CGImageSource? { return mock(args: [url]) } + func source(for data: Data) -> CGImageSource? { return mock(args: [data]) } +} + +extension Mock where T == MediaDecoderType { + func defaultInitialSetup() { + let options: CFDictionary = [ + kCGImageSourceShouldCache: false, + kCGImageSourceShouldCacheImmediately: false + ] as CFDictionary + + self.when { $0.defaultImageOptions }.thenReturn(options) + self.when { $0.defaultThumbnailOptions(maxDimension: .any) }.thenReturn(options) + + self + .when { $0.source(for: URL.any) } + .thenReturn(CGImageSourceCreateWithData(TestConstants.validImageData as CFData, options)) + self + .when { $0.source(for: Data.any) } + .thenReturn(CGImageSourceCreateWithData(TestConstants.validImageData as CFData, options)) + } +} diff --git a/_SharedTestUtilities/Mocked.swift b/_SharedTestUtilities/Mocked.swift index 4d4aa57415..8458834245 100644 --- a/_SharedTestUtilities/Mocked.swift +++ b/_SharedTestUtilities/Mocked.swift @@ -34,6 +34,7 @@ extension Dictionary: Mocked { static var mock: Self { [:] } } extension Array: Mocked { static var mock: Self { [] } } extension Set: Mocked { static var mock: Self { [] } } extension Float: Mocked { static var mock: Float { 0 } } +extension CGFloat: Mocked { static var mock: CGFloat { 0 } } extension Double: Mocked { static var mock: Double { 0 } } extension String: Mocked { static var mock: String { "" } } extension Data: Mocked { static var mock: Data { Data() } } diff --git a/_SharedTestUtilities/TestConstants.swift b/_SharedTestUtilities/TestConstants.swift index 0bd76aa6b7..3a51bb1487 100644 --- a/_SharedTestUtilities/TestConstants.swift +++ b/_SharedTestUtilities/TestConstants.swift @@ -1,6 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import Foundation +import UIKit enum TestConstants { // Test keys (from here https://github.com/jagerman/session-pysogs/blob/docs/contrib/auth-example.py) @@ -16,24 +16,17 @@ enum TestConstants { static let serverPublicKey: String = "c3b3c6f32f0ab5a57f853cc4f30f5da7fda5624b0c77b3fb0829de562ada081d" static let invalidImageData: Data = Data([1, 2, 3]) - static let validImageData: Data = Data(hex: "ffd8ffe000104a46494600010100004800480000ffe1008045" + - "78696600004d4d002a000000080005011200030000000100010000011a0005000000010000004a011b000500000001" + - "0000005201280003000000010002000087690004000000010000005a00000000000000480000000100000048000000" + - "010002a00200040000000100000001a0030004000000010000000100000000ffed003850686f746f73686f7020332e" + - "30003842494d04040000000000003842494d0425000000000010d41d8cd98f00b204e9800998ecf8427effc0001108" + - "0001000103011100021101031101ffc4001f0000010501010101010100000000000000000102030405060708090a0b" + - "ffc400b5100002010303020403050504040000017d01020300041105122131410613516107227114328191a1082342" + - "b1c11552d1f02433627282090a161718191a25262728292a3435363738393a434445464748494a535455565758595a" + - "636465666768696a737475767778797a838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6" + - "b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae1e2e3e4e5e6e7e8e9eaf1f2f3f4f5f6f7f8f9faffc4001f01" + - "00030101010101010101010000000000000102030405060708090a0bffc400b5110002010204040304070504040001" + - "0277000102031104052131061241510761711322328108144291a1b1c109233352f0156272d10a162434e125f11718" + - "191a262728292a35363738393a434445464748494a535455565758595a636465666768696a737475767778797a8283" + - "8485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5" + - "d6d7d8d9dae2e3e4e5e6e7e8e9eaf2f3f4f5f6f7f8f9faffdb00430001010101010101010101010101010101010101" + - "010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101ffdb" + - "0043010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101" + - "0101010101010101010101010101010101010101ffdd00040001ffda000c03010002110311003f00fefe2803ffd9") + static let validImageData: Data = { + let size = CGSize(width: 1, height: 1) + UIGraphicsBeginImageContext(size) + defer { UIGraphicsEndImageContext() } + + UIColor.red.setFill() + UIRectFill(CGRect(origin: .zero, size: size)) + + let image: UIImage = UIGraphicsGetImageFromCurrentImageContext()! + return image.jpegData(compressionQuality: 1.0)! + }() } public enum TestError: Error, Equatable {