From fbca2cae81dded1361472e627f29bc4fae61d0ee Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:14:51 +0000 Subject: [PATCH] core, ui: resize images in the core for sharper previews and images (#7941) * WIP: Haskell impl of resizeImageToStrSize * WIP: add FFI export wrapper * WIP: switch ios resizeImageToStrSizeSync to backend * WIP: fix resizeImageToStrSizeSync * WIP: add JNI wrappers * Add non-base64 resize and cWriteImage * WIP: add chat_write_image * fix resizeToStr * fixes * fix syntax, raise jpeg quality back * add chatWriteImage * Fix cfArgs in tests * Use jpeg-turbo encoder * add nix pins * bump jpeg-turbo * add CI deps * add encoding funs to android lib * bump jpeg-turbo for 20.04 fix * jpeg-turbo fix for 8.10 * android fixes * Bump jpeg-turbo * set static flag for jpeg-turbo * Fix nix for aarch64/android * Fix android C api * Fix resize in android * Bump hs-libjpeg-turbo * target correct override * override android lib from android pkgs * add macos build tweaks * bump hs-jpeg-turbo for 8.10 fixes * more build fixes * make ios compile * Always use pkgconfig for libjpeg-turbo * WIP: debug libjpeg in ubuntu pkg-config CI * Add pkg-config to dockerfiles * Add libturbojpeg to dockerfiles * Use -dev version of libturbojpeg in dockerfiles * Install pkg-config in windows CI * Disable flag for custom libturbojpeg linking options Workarounds relocation error on x86_64-linux with gcc: /usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/13/../../../x86_64-linux-gnu/libturbojpeg.a(turbojpeg.c.o): relocation R_X86_64_TPOFF32 against `errStr' can not be used when making a shared object; recompile with -fPIC /usr/bin/ld: failed to set dynamic section sizes: bad value * WIP: debug libjpeg in windows pkg-config CI * WIP: debug libjpeg package in windows CI * core: image resize module and buffer FFI * ci: link libjpeg-turbo statically on all platforms * ui: resize images in the core * core: rename image resize functions to chat_resize_image and chat_resize_image_uri * ci: restore whitespace in build.yml * ci: build libjpeg-turbo 3.1.4.1 from a submodule * core: hs-jpeg-turbo from github.com/simplex-chat * nodejs: resize images * python: resize images * core: hs-jpeg-turbo from gitlab.com, the GitHub import lacks LFS objects * core: generate /img previews * ci: link libjpeg-turbo on Windows without pkg-config * ci: build libjpeg-turbo on Windows with the GHC toolchain * docs: plan for image resizing in the core * docs: update image resizing plan * ios: prevent crash when wallpaper image cannot be resized * android, desktop: raise an error when an image cannot be resized * docs: update spec references for image resizing * docker: build the CLI image from a clone without submodules * docs: apply review fixes to image resizing plan * docker: build the CLI from a git worktree with the submodule present * docs: apply second review to image resizing plan * docs: point image resizing plan at the rewritten commit * core: choose image format by transparency in chat_resize_image * nodejs: resize images to the app sizes * python: resize images to the app sizes * nodejs, python: describe the core modules as used for image resizing * docs: add license notices of the image libraries * ci: describe the avatar method without attributing it to the apps * ci, ios, nix: remove the libjpeg-turbo build outside cabal * python: name resize_image in the core module text * nodejs, python: describe resizing to the app image sizes * core: refuse images larger than the supported size * core: resize to JPEG images with a side above 65500 pixels * docs: record image resizing decisions for the bundled libjpeg-turbo * core, docs: build libjpeg-turbo and stb from the fork submodules * docs: record the fork pins in the image resizing plan * core: resize images to at most 4320 pixels per side, refuse above 16384 * docs: record the image side limits in the image resizing plan * core: pin the rebased hs-jpeg-turbo fork * core: pin the hs-jpeg-turbo fork without package.yaml * docs: plan scaled JPEG decoding and EXIF orientation of images * docs: copy the JuicyPixels-stbir licence of the pinned fork * core: pin the hs-jpeg-turbo fork with scaled JPEG decoding * core: decode JPEG at the display size, up to 65500 pixels per side * core: rotate JPEG images by their EXIF orientation * docs: refer the image plan to the JPEG decoding code * docs: record the approved image processing decisions * core: limit JPEG decoding memory to 1024 MB outside iOS and Android * docs: name JPEG decoding in the libjpeg-turbo notice * docs: plan image resizing from files and the decisions of 26 Oct * core: scale images in one Catmull-Rom pass with sharpening * docs: plan image sizes at a fixed quality * core: resize images at a fixed quality to fill the size limit * core: resize images from a file path or a file descriptor * ios, android, desktop: resize picked images from their files * core: encode resized images with mozjpeg * tests: check descriptor closing by the file it refers to * core: raise JPEG quality only for a full-size image below 97% of the limit * ci: name the mozjpeg submodule in the Windows cleanup comments * docs: refer the image plan to the current code * docs: refer the image plan to the code merged with master * core: export every image resizing function from each library * core: close the file descriptor when its handle cannot be created * docs: count the C sources of mozjpeg with the arithmetic decoder * core: use the reviewed hs-jpeg-turbo and JuicyPixels-stbir forks * ci: build the armv7a Android core library on request * core: round image heights half up in the size search * ios: describe the file image resizing functions in SimpleX.h * ci: pick, preview and send images in the Claude app tests * core: decode PNG images with libspng and refuse other formats * core: encode PNG with libspng, keeping the smaller filter choice * ci: pick, preview and send images in the scripted app smoke tests * flake: build the PNG decoder package in nix builds * ios: remove the copy of a picked image after sending it * core: refuse image files larger than the decode memory limit * core: scale a JPEG image to its largest size once per resize * core: bisect JPEG quality without a step function * core: reduce the memory used to sharpen and to decode PNG images * android, desktop: pass the image format to the core as an integer * android, desktop: fuse the preview conditions of picked media * android, desktop, ci: check that the core resized the smoke test images * python: remove the docstrings of the explicit image resizing functions * docs: add the second review fixes to the image plan * ci: tap the iOS attachment button by its accessibility label * core: keep the colour profiles of resized images * docs: add the colour profiles to the image plan * ci: wait up to 30 s for the new group screens in the iOS smoke test * ci: pick the Android smoke test images from their own photo folders * ci: tap the iOS photo picker by position, as its tree is not shown * ci: pick the test card and the logo by their iOS picker positions * core: update JuicyPixels-stbir with the stb_image_resize licence terms * core: write a fixed Display P3 profile for Display P3 images only * core: do not keep the input image in memory for its colour profile * core: read the JPEG segments once for the orientation and the profile * core: read an image file into one buffer of its size * docs: add the third review fixes to the image plan * core: resize images with Display P3 or no profile, not profile bytes * ci: check the pixel sizes of the images sent in the iOS smoke test * core: update hs-jpeg-turbo with PNG chunk limits and encoder fixes * docs: list Compact ICC Profiles, libspng and miniz in the dependencies * android, desktop: reword the picked media preview comment * docs: refer the image plan to the code merged with master * ci: render the iOS snapshots before the images case * ci: dismiss the snapshot tests' camera alert before the iOS images case * core: update hs-jpeg-turbo with tests and the JuicyPixels-spng synopsis * core: pin hs-jpeg-turbo and JuicyPixels-stbir in simplex-chat repositories * update ios core library * core, ui: accept only JPEG and PNG image data * core: update JuicyPixels-stbir * docs: plan for the iOS copy of picked images --------- Co-authored-by: IC Rainbow Co-authored-by: Evgeny Poberezkin Co-authored-by: Alexandre Esteves --- .github/workflows/android-emu.yml | 38 + .github/workflows/build.yml | 14 +- .github/workflows/desktop-linux.yml | 6 + .github/workflows/ios-sim.yml | 10 +- .../Chat/ChatItem/MarkdownFileView.swift | 1 + .../Chat/ComposeMessage/ComposeView.swift | 36 +- .../Shared/Views/Helpers/ImagePicker.swift | 9 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 +- apps/ios/SimpleXChat/ImageUtils.swift | 68 +- apps/ios/SimpleXChat/SimpleX.h | 12 + apps/ios/spec/architecture.md | 14 +- .../simplex/common/platform/Images.android.kt | 50 +- .../common/views/chat/ComposeView.android.kt | 7 +- .../src/commonMain/cpp/android/simplex-api.c | 28 + .../src/commonMain/cpp/desktop/simplex-api.c | 34 + .../chat/simplex/common/model/CryptoFile.kt | 6 +- .../chat/simplex/common/platform/Core.kt | 13 + .../chat/simplex/common/platform/Images.kt | 33 +- .../simplex/common/views/chat/ComposeView.kt | 20 +- .../views/chat/item/MarkdownFileView.kt | 15 +- .../simplex/common/views/helpers/Utils.kt | 29 +- .../simplex/common/platform/Images.desktop.kt | 44 +- apps/multiplatform/spec/architecture.md | 21 +- apps/multiplatform/spec/client/compose.md | 2 +- apps/multiplatform/spec/services/files.md | 12 +- cabal.project | 11 + docs/dependencies/HASKELL.md | 25 +- docs/dependencies/README.md | 10 + .../apps/compact-icc-profiles/license | 116 +++ .../licences/apps/libspng/LICENSE | 25 + .../licences/apps/libspng/LICENSE-libpng | 32 + docs/dependencies/licences/apps/miniz/LICENSE | 22 + .../licences/apps/mozjpeg/LICENSE.md | 135 +++ .../licences/apps/mozjpeg/README.ijg | 258 +++++ .../licences/apps/stb_image_resize/LICENSE | 37 + .../haskell/JuicyPixels-3.3.8/LICENSE | 30 + .../JuicyPixels-jpeg-turbo-0.1.0.0/LICENSE | 27 + .../haskell/JuicyPixels-spng-0.1.0.0/LICENSE | 26 + .../haskell/JuicyPixels-stbir-0.1.0.0/LICENSE | 31 + .../haskell/jpeg-turbo-0.1.0.0/LICENSE | 27 + flake.nix | 10 + libsimplex.dll.def | 8 +- packages/simplex-chat-nodejs/README.md | 2 +- packages/simplex-chat-nodejs/cpp/simplex.cc | 139 ++- packages/simplex-chat-nodejs/cpp/simplex.h | 6 + .../docs/Namespace.core.md | 6 +- .../docs/core.Class.ChatAPIError.md | 8 +- .../docs/core.Class.ChatInitError.md | 8 +- ...MigrationError.Interface.ErrorMigration.md | 8 +- ...rationError.Interface.ErrorNotADatabase.md | 6 +- ...ore.DBMigrationError.Interface.ErrorSQL.md | 8 +- ...tionError.Interface.InvalidConfirmation.md | 4 +- ...grationError.Interface.InvalidQueueSize.md | 4 +- .../core.DBMigrationError.TypeAlias.Tag.md | 2 +- .../docs/core.Enumeration.ImageSize.md | 35 + .../core.Enumeration.MigrationConfirmation.md | 10 +- .../docs/core.Function.chatResizeImage.md | 31 + .../docs/core.Function.chatResizeImageUri.md | 27 + .../docs/core.Function.resizeImage.md | 73 ++ .../docs/core.Interface.APIResult.md | 6 +- .../docs/core.Interface.CryptoArgs.md | 6 +- .../docs/core.Interface.UpMigration.md | 6 +- .../core.MTRError.Interface.MTREDifferent.md | 6 +- .../core.MTRError.Interface.MTRENoDown.md | 6 +- .../docs/core.MTRError.TypeAlias.Tag.md | 2 +- ...re.MigrationError.Interface.MEDowngrade.md | 6 +- ...core.MigrationError.Interface.MEUpgrade.md | 6 +- ...MigrationError.Interface.MigrationError.md | 6 +- .../docs/core.MigrationError.TypeAlias.Tag.md | 2 +- .../docs/core.TypeAlias.DBMigrationError.md | 2 +- .../docs/core.TypeAlias.MTRError.md | 2 +- .../docs/core.TypeAlias.MigrationError.md | 2 +- packages/simplex-chat-nodejs/src/core.ts | 46 + packages/simplex-chat-nodejs/src/index.ts | 2 +- packages/simplex-chat-nodejs/src/simplex.d.ts | 2 + packages/simplex-chat-nodejs/src/simplex.js | 2 +- .../simplex-chat-nodejs/tests/core.test.ts | 96 +- .../simplex-chat-nodejs/tests/loader.test.ts | 2 +- .../src/simplex_chat/_native.py | 33 +- .../src/simplex_chat/core.py | 84 +- .../tests/test_core_resize_image.py | 121 +++ plans/2026-10-23-core-image-resizing.md | 920 ++++++++++++++++++ scripts/ci/README.md | 1 + scripts/ci/android/README.md | 7 +- scripts/ci/android/build-core.sh | 7 +- scripts/ci/android/claude-test.md | 15 +- scripts/ci/android/smoke-test.sh | 61 +- scripts/ci/desktop-linux/README.md | 4 +- scripts/ci/desktop-linux/backend.sh | 2 +- scripts/ci/desktop-linux/claude-scope | 2 +- scripts/ci/desktop-linux/claude-test.md | 14 +- scripts/ci/desktop-linux/smoke-test.sh | 71 +- scripts/ci/ios-sim/README.md | 4 +- scripts/ci/ios-sim/avatar.swift | 2 +- scripts/ci/ios-sim/claude-prompt.md | 1 + scripts/ci/ios-sim/claude-scope | 4 +- scripts/ci/ios-sim/device-test-plan.md | 13 +- scripts/ci/ios-sim/smoke-test.sh | 95 +- scripts/desktop/build-lib-windows.sh | 4 +- scripts/nix/sha256map.nix | 2 + .../simplex-chat-reproduce-builds-android.sh | 1 + simplex-chat.cabal | 14 +- src/Simplex/Chat/Image.hs | 407 ++++++++ src/Simplex/Chat/Image/DisplayP3-v4.icc | Bin 0 -> 480 bytes src/Simplex/Chat/Library/Commands.hs | 16 +- src/Simplex/Chat/Mobile.hs | 12 + src/Simplex/Chat/Mobile/File.hs | 69 +- src/Simplex/Chat/Types.hs | 5 +- tests/ChatTests/Files.hs | 33 +- tests/ChatTests/Groups.hs | 8 +- tests/ChatTests/Profiles.hs | 2 +- tests/ChatTests/Utils.hs | 2 +- tests/ImageTests.hs | 458 +++++++++ tests/MobileTests.hs | 126 ++- tests/Test.hs | 2 + tests/fixtures/logo-large-rgba.png | Bin 0 -> 28676 bytes .../preview-issue1-400x200-sharpened.png | Bin 0 -> 27785 bytes tests/fixtures/preview-issue1.png | Bin 0 -> 82731 bytes 118 files changed, 4227 insertions(+), 348 deletions(-) create mode 100644 docs/dependencies/licences/apps/compact-icc-profiles/license create mode 100644 docs/dependencies/licences/apps/libspng/LICENSE create mode 100644 docs/dependencies/licences/apps/libspng/LICENSE-libpng create mode 100644 docs/dependencies/licences/apps/miniz/LICENSE create mode 100644 docs/dependencies/licences/apps/mozjpeg/LICENSE.md create mode 100644 docs/dependencies/licences/apps/mozjpeg/README.ijg create mode 100644 docs/dependencies/licences/apps/stb_image_resize/LICENSE create mode 100644 docs/dependencies/licences/haskell/JuicyPixels-3.3.8/LICENSE create mode 100644 docs/dependencies/licences/haskell/JuicyPixels-jpeg-turbo-0.1.0.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/JuicyPixels-spng-0.1.0.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/JuicyPixels-stbir-0.1.0.0/LICENSE create mode 100644 docs/dependencies/licences/haskell/jpeg-turbo-0.1.0.0/LICENSE create mode 100644 packages/simplex-chat-nodejs/docs/core.Enumeration.ImageSize.md create mode 100644 packages/simplex-chat-nodejs/docs/core.Function.chatResizeImage.md create mode 100644 packages/simplex-chat-nodejs/docs/core.Function.chatResizeImageUri.md create mode 100644 packages/simplex-chat-nodejs/docs/core.Function.resizeImage.md create mode 100644 packages/simplex-chat-python/tests/test_core_resize_image.py create mode 100644 plans/2026-10-23-core-image-resizing.md create mode 100644 src/Simplex/Chat/Image.hs create mode 100644 src/Simplex/Chat/Image/DisplayP3-v4.icc create mode 100644 tests/ImageTests.hs create mode 100644 tests/fixtures/logo-large-rgba.png create mode 100644 tests/fixtures/preview-issue1-400x200-sharpened.png create mode 100644 tests/fixtures/preview-issue1.png diff --git a/.github/workflows/android-emu.yml b/.github/workflows/android-emu.yml index 33ded0dcc2..7617693b2b 100644 --- a/.github/workflows/android-emu.yml +++ b/.github/workflows/android-emu.yml @@ -15,6 +15,10 @@ on: description: Also test the latest released app, to tell a branch's failure from the runner's type: boolean default: false + armv7a: + description: Also build the core library for 32-bit Arm (armeabi-v7a) + type: boolean + default: false permissions: contents: read @@ -41,6 +45,7 @@ jobs: run-claude: ${{ steps.jobs.outputs.claude }} run-backend: ${{ steps.jobs.outputs.backend }} run-release: ${{ steps.jobs.outputs.release }} + run-armv7a: ${{ steps.jobs.outputs.armv7a }} claude-backend: ${{ steps.scope.outputs.backend }} core: ${{ steps.names.outputs.core }} core-run: ${{ steps.core.outputs.run-id }} @@ -53,10 +58,12 @@ jobs: CLAUDE: ${{ inputs.claude }} BACKEND: ${{ inputs.backend }} RELEASE: ${{ inputs.release }} + ARMV7A: ${{ inputs.armv7a }} run: | echo "claude=$CLAUDE" | tee -a "$GITHUB_OUTPUT" echo "backend=$BACKEND" | tee -a "$GITHUB_OUTPUT" echo "release=$RELEASE" | tee -a "$GITHUB_OUTPUT" + echo "armv7a=$ARMV7A" | tee -a "$GITHUB_OUTPUT" - name: Checkout code uses: actions/checkout@v6 @@ -173,6 +180,29 @@ jobs: retention-days: 30 if-no-files-found: error + core-armv7a: + name: core library for Android armv7a + needs: plan + if: needs.plan.outputs.run-armv7a == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 150 + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + filter: tree:0 + + - name: Show runner + run: | + lsb_release -ds + nproc + free -h + df -h / + + - name: Build core library + run: scripts/ci/android/build-core.sh ~/core/android-core-armeabi-v7a.tar.gz armv7a + app: name: Android app in the emulator needs: [plan, core, backend] @@ -364,6 +394,14 @@ jobs: - name: Boot emulator run: scripts/ci/android/emulator.sh start "$RUNNER_TEMP/claude-test/screens" + - name: Add test images + run: | + adb push tests/fixtures/test.jpg /sdcard/Pictures/test-card.jpg + adb push tests/fixtures/logo-large-rgba.png /sdcard/Pictures/simplex-logo.png + for image in test-card.jpg simplex-logo.png; do + adb shell am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE -d "file:///sdcard/Pictures/$image" + done + - name: Reach the test backend if: steps.backend.outcome == 'success' run: scripts/ci/android/emulator.sh reverse 5223 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c67f15ed46..47c13dc463 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1499,8 +1499,8 @@ jobs: if: steps.restore-build.outcome == 'success' && steps.restore-build.outputs.cache-matched-key == '' run: echo "::warning title=Cache miss::${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}" - # rm -rf dist-newstyle/src/{direct-sq,simplexmq}* is here because of the bug in cabal's dependency which prevents second build from finishing - # (simplexmq is removed because cabal cannot delete its read-only git submodule pack files - blst, libbbs - on Windows) + # rm -rf dist-newstyle/src/{direct-sq,simplexmq,hs-jpeg-t,JuicyPixe}* is here because of the bug in cabal's dependency which prevents second build from finishing + # (simplexmq, hs-jpeg-turbo and JuicyPixels-stbir are removed because cabal cannot delete their read-only git submodule pack files - blst, libbbs, mozjpeg, stb - on Windows) - name: Build CLI id: windows_cli_build shell: msys2 {0} @@ -1516,10 +1516,10 @@ jobs: echo " extra-lib-dirs: $openssl_windows_style_path" >> cabal.project.local scripts/ci/prune-old-builds.sh dist-newstyle - rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* + rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* dist-newstyle/src/hs-jpeg-t* dist-newstyle/src/JuicyPixe* sed -i "s/, unix /--, unix /" simplex-chat.cabal cabal build -j --enable-tests ${{ env.ghc_options }} - rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* + rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* dist-newstyle/src/hs-jpeg-t* dist-newstyle/src/JuicyPixe* path=$(cabal list-bin simplex-chat | tail -n 1) echo "bin_path=$path" >> $GITHUB_OUTPUT echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT @@ -1642,7 +1642,7 @@ jobs: scripts/desktop/build-lib-windows.sh cd apps/multiplatform ./gradlew -Psimplex.assets.dir=../../assets packageMsi - rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* + rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* dist-newstyle/src/hs-jpeg-t* dist-newstyle/src/JuicyPixe* path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g') echo "package_path=$path" >> $GITHUB_OUTPUT echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT @@ -1684,9 +1684,9 @@ jobs: export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo) scripts/desktop/prepare-openssl-windows.sh scripts/ci/prune-old-builds.sh dist-newstyle - rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* + rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* dist-newstyle/src/hs-jpeg-t* dist-newstyle/src/JuicyPixe* cabal build -j --enable-tests --ghc-options=-O0 - rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* + rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* dist-newstyle/src/hs-jpeg-t* dist-newstyle/src/JuicyPixe* - name: Save -O0 build directory to cache if: ${{ !cancelled() && steps.build-o0.outcome == 'success' && steps.restore-build-o0.outputs.cache-hit != 'true' }} diff --git a/.github/workflows/desktop-linux.yml b/.github/workflows/desktop-linux.yml index eff5767152..21c51967ad 100644 --- a/.github/workflows/desktop-linux.yml +++ b/.github/workflows/desktop-linux.yml @@ -407,6 +407,12 @@ jobs: - name: Start the app run: scripts/ci/desktop-linux/smoke-test.sh "$RUNNER_TEMP/app/simplex" "$RUNNER_TEMP/claude-test/screens" start + - name: Add test images + run: | + mkdir -p "$RUNNER_TEMP/claude-test/images" + cp tests/fixtures/test.jpg "$RUNNER_TEMP/claude-test/images/test-card.jpg" + cp tests/fixtures/logo-large-rgba.png "$RUNNER_TEMP/claude-test/images/simplex-logo.png" + # Claude's Bash is limited to single ui commands by the guard hook in claude-test.sh. Claude Code's credential # scrub stays off: on Linux it forces its whole Bash sandbox (bubblewrap, socat), which ui isn't set up for. - name: Run Claude diff --git a/.github/workflows/ios-sim.yml b/.github/workflows/ios-sim.yml index 1fe3fe5791..7e5740b5b5 100644 --- a/.github/workflows/ios-sim.yml +++ b/.github/workflows/ios-sim.yml @@ -266,7 +266,7 @@ jobs: run: scripts/ci/ios-sim/install-axe.sh - name: Run smoke test - run: scripts/ci/ios-sim/smoke-test.sh "$RUNNER_TEMP/DerivedData/Build/Products/Debug-iphonesimulator/SimpleX.app" "$RUNNER_TEMP/smoke-test" + run: scripts/ci/ios-sim/smoke-test.sh "$RUNNER_TEMP/DerivedData/Build/Products/Debug-iphonesimulator/SimpleX.app" "$RUNNER_TEMP/smoke-test" images - name: Stop test backend if: ${{ !cancelled() && env.START_BACKEND == 'true' }} @@ -354,6 +354,14 @@ jobs: - name: Run smoke test run: scripts/ci/ios-sim/smoke-test.sh "$RUNNER_TEMP/SimpleX.app" "$RUNNER_TEMP/claude-test/smoke-test" + - name: Add test images + run: | + images=(tests/fixtures/test.jpg tests/fixtures/logo-large-rgba.png) + if sips -r 90 -s format heic tests/fixtures/test.jpg --out "$RUNNER_TEMP/test-card-portrait.heic"; then + images+=("$RUNNER_TEMP/test-card-portrait.heic") + fi + xcrun simctl addmedia "$SIM_UDID" "${images[@]}" + - name: Install Claude Code run: | npm install -g "@anthropic-ai/claude-code@$CLAUDE_CODE_VERSION" diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift index aa7e827d6b..0d482882f1 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift @@ -112,6 +112,7 @@ private func imagesWithinBudget() -> Modifier { guard parts.count == 2, parts[0].hasSuffix(";base64"), let data = Data(base64Encoded: String(parts[1])), + data.starts(with: [0xFF, 0xD8, 0xFF]) || data.starts(with: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), let size = UIImage(data: data)?.size, size.width <= MAX_MARKDOWN_IMAGE_DIMENSION, size.height <= MAX_MARKDOWN_IMAGE_DIMENSION, diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index fbac41cf40..f1c0bee3c1 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -299,17 +299,40 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview { // Spec: spec/client/compose.md#UploadContent enum UploadContent: Equatable { case simpleImage(image: UIImage) + case imageFile(image: UIImage, url: URL) case animatedImage(image: UIImage) case video(image: UIImage, url: URL, duration: Int) var uiImage: UIImage { switch self { case let .simpleImage(image): return image + case let .imageFile(image, _): return image case let .animatedImage(image): return image case let .video(image, _, _): return image } } + func imagePreview() async -> String? { + if case let .imageFile(_, url) = self, let preview = resizeImageFileToStrSize(url, maxDataSize: 14000) { + return preview + } + return await resizeImageToStrSize(uiImage, maxDataSize: 14000) + } + + static func loadImageFileFromURL(url: URL) -> UploadContent? { + let content = loadFromURL(url: url) + guard case let .simpleImage(image)? = content else { return content } + let tempUrl = URL(fileURLWithPath: generateNewFileName(getTempFilesDirectory().path + "/" + "image", url.pathExtension, fullPath: true)) + do { + try FileManager.default.copyItem(at: url, to: tempUrl) + } catch let err { + logger.error("UploadContent copyItem error: \(err.localizedDescription)") + return content + } + _ = ChatModel.shared.filesToDelete.insert(tempUrl) + return .imageFile(image: image, url: tempUrl) + } + static func loadFromURL(url: URL) -> UploadContent? { do { let data = try Data(contentsOf: url) @@ -649,7 +672,7 @@ struct ComposeView: View { Task { var media: [(String, UploadContent)] = [] for content in selected { - if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) { + if let img = await content.imagePreview() { media.append((img, content)) await MainActor.run { composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: media)) @@ -1252,7 +1275,7 @@ struct ComposeView: View { // Spec: spec/client/compose.md#addMediaContent private func addMediaContent(_ content: UploadContent) async { - if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) { + if let img = await content.imagePreview() { var newMedia: [(String, UploadContent?)] = [] if case var .mediaPreviews(media) = composeState.preview { media.append((img, content)) @@ -1572,6 +1595,8 @@ struct ComposeView: View { return switch uploadContent { case let .simpleImage(image): (saveImage(image), .image(text: text, image: previewImage)) + case let .imageFile(image, url): + (imageCryptoFile(url, image), .image(text: text, image: previewImage)) case let .animatedImage(image): (saveAnimImage(image), .image(text: text, image: previewImage)) case let .video(_, url, duration): @@ -1581,6 +1606,13 @@ struct ComposeView: View { } } + func imageCryptoFile(_ url: URL, _ image: UIImage) -> CryptoFile? { + let file = saveImageFile(url) ?? saveImage(image) + removeFile(url) + ChatModel.shared.filesToDelete.remove(url) + return file + } + func updateMessage(_ ei: ChatItem, live: Bool) async -> ChatItem? { if let oldMsgContent = ei.content.msgContent { do { diff --git a/apps/ios/Shared/Views/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift index d7525027e0..8250229c87 100644 --- a/apps/ios/Shared/Views/Helpers/ImagePicker.swift +++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift @@ -78,9 +78,9 @@ struct LibraryMediaListPicker: UIViewControllerRepresentable { private func loadImageData(_ p: NSItemProvider) async -> UploadContent? { await withCheckedContinuation { cont in - loadFileURL(p, type: UTType.data) { url in + loadFileURL(p, type: imageFileType(p)) { url in if let url = url { - let img = UploadContent.loadFromURL(url: url) + let img = UploadContent.loadImageFileFromURL(url: url) cont.resume(returning: img) } else { cont.resume(returning: nil) @@ -89,6 +89,11 @@ struct LibraryMediaListPicker: UIViewControllerRepresentable { } } + private func imageFileType(_ p: NSItemProvider) -> UTType { + let original = p.hasItemConformingToTypeIdentifier(UTType.png.identifier) || p.hasItemConformingToTypeIdentifier(UTType.gif.identifier) + return original || !p.hasItemConformingToTypeIdentifier(UTType.jpeg.identifier) ? UTType.data : UTType.jpeg + } + private func loadImage(_ p: NSItemProvider) async -> UIImage? { await withCheckedContinuation { cont in p.loadObject(ofClass: UIImage.self) { obj, err in diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 5615066c88..49b3aec57f 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -193,8 +193,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.15-99xMnav7Ew2J13DE5sZcgh-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.15-99xMnav7Ew2J13DE5sZcgh-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.15-99xMnav7Ew2J13DE5sZcgh.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.15-99xMnav7Ew2J13DE5sZcgh.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -600,8 +600,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.15-99xMnav7Ew2J13DE5sZcgh-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.15-99xMnav7Ew2J13DE5sZcgh-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.15-99xMnav7Ew2J13DE5sZcgh.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.15-99xMnav7Ew2J13DE5sZcgh.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -788,8 +788,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.15-99xMnav7Ew2J13DE5sZcgh-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.15-99xMnav7Ew2J13DE5sZcgh.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -876,8 +876,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.15-99xMnav7Ew2J13DE5sZcgh-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.15-99xMnav7Ew2J13DE5sZcgh.a */, ); path = Libraries; sourceTree = ""; diff --git a/apps/ios/SimpleXChat/ImageUtils.swift b/apps/ios/SimpleXChat/ImageUtils.swift index 2d36a7a53a..a035039b12 100644 --- a/apps/ios/SimpleXChat/ImageUtils.swift +++ b/apps/ios/SimpleXChat/ImageUtils.swift @@ -72,6 +72,19 @@ public func saveImage(_ uiImage: UIImage) -> CryptoFile? { return nil } +public func saveImageFile(_ url: URL) -> CryptoFile? { + var cPath = url.path.cString(using: .utf8)! + guard let data = Data(base64Encoded: fromCString(chat_resize_image_file(&cPath, Int32(MAX_IMAGE_SIZE), -1)!)), !data.isEmpty else { return nil } + let ext = data.starts(with: [0x89, 0x50, 0x4E, 0x47]) ? "png" : "jpg" + return saveFile(data, generateNewFileName("IMG", ext), encrypted: privacyEncryptLocalFilesGroupDefault.get()) +} + +public func resizeImageFileToStrSize(_ url: URL, maxDataSize: Int64) -> String? { + var cPath = url.path.cString(using: .utf8)! + let str = fromCString(chat_resize_image_file_uri(&cPath, Int32(maxDataSize))!) + return str.isEmpty ? nil : str +} + public func cropToSquare(_ image: UIImage) -> UIImage { let size = image.size let side = min(size.width, size.height) @@ -86,33 +99,18 @@ public func cropToSquare(_ image: UIImage) -> UIImage { } public func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha: Bool) -> Data? { - var img = image - var data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85) - var dataSize = data?.count ?? 0 - while dataSize != 0 && dataSize > maxDataSize { - let ratio = sqrt(Double(dataSize) / Double(maxDataSize)) - let clippedRatio = min(ratio, 2.0) - img = reduceSize(img, ratio: clippedRatio, hasAlpha: hasAlpha) - data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85) - dataSize = data?.count ?? 0 - } - logger.debug("resizeImageToDataSize final \(dataSize)") + let data = resizeImageWithCore(image, hasAlpha: hasAlpha) { + chat_resize_image($0, $1, Int32(maxDataSize), hasAlpha ? 1 : 0) + }.flatMap { Data(base64Encoded: $0) } + logger.debug("resizeImageToDataSize final \(data?.count ?? 0)") return data } public func resizeImageToStrSizeSync(_ image: UIImage, maxDataSize: Int64) -> String? { - var img = image - let hasAlpha = imageHasAlpha(image) - var str = compressImageStr(img, hasAlpha: hasAlpha) - var dataSize = str?.count ?? 0 - while dataSize != 0 && dataSize > maxDataSize { - let ratio = sqrt(Double(dataSize) / Double(maxDataSize)) - let clippedRatio = min(ratio, 2.0) - img = reduceSize(img, ratio: clippedRatio, hasAlpha: hasAlpha) - str = compressImageStr(img, hasAlpha: hasAlpha) - dataSize = str?.count ?? 0 + let str = resizeImageWithCore(image, hasAlpha: imageHasAlpha(image)) { + chat_resize_image_uri($0, $1, Int32(maxDataSize)) } - logger.debug("resizeImageToStrSize final \(dataSize)") + logger.debug("resizeImageToStrSize final \(str?.count ?? 0)") return str } @@ -120,22 +118,12 @@ public func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) async -> resizeImageToStrSizeSync(image, maxDataSize: maxDataSize) } -public func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85, hasAlpha: Bool) -> String? { -// // Heavy workload to verify if UI gets blocked by the call -// for i in 0..<100 { -// print(image.jpegData(compressionQuality: Double(i) / 100)?.count ?? 0, terminator: ", ") -// } - let ext = hasAlpha ? "png" : "jpg" - if let data = hasAlpha ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) { - return "data:image/\(ext);base64,\(data.base64EncodedString())" - } - return nil -} - -private func reduceSize(_ image: UIImage, ratio: CGFloat, hasAlpha: Bool) -> UIImage { - let newSize = CGSize(width: floor(image.size.width / ratio), height: floor(image.size.height / ratio)) - let bounds = CGRect(origin: .zero, size: newSize) - return resizeImage(image, newBounds: bounds, drawIn: bounds, hasAlpha: hasAlpha) +private func resizeImageWithCore(_ image: UIImage, hasAlpha: Bool, _ resize: (UnsafeMutablePointer, Int32) -> UnsafeMutablePointer?) -> String? { + let bounds = CGRect(origin: .zero, size: image.size) + let img = image.imageOrientation == .up ? image : resizeImage(image, newBounds: bounds, drawIn: bounds, hasAlpha: hasAlpha) + guard var data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85) else { return nil } + let str = data.withUnsafeMutableBytes { fromCString(resize($0.baseAddress!.assumingMemoryBound(to: CChar.self), Int32($0.count))!) } + return str.isEmpty ? nil : str } public func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect, hasAlpha: Bool) -> UIImage { @@ -255,9 +243,9 @@ public func saveWallpaperFile(url: URL) -> String? { public func saveWallpaperFile(image: UIImage) -> String? { let hasAlpha = imageHasAlpha(image) let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", hasAlpha ? "png" : "jpg", fullPath: true)) - let dataResized = resizeImageToDataSize(image, maxDataSize: 5_000_000, hasAlpha: hasAlpha) + guard let dataResized = resizeImageToDataSize(image, maxDataSize: 5_000_000, hasAlpha: hasAlpha) else { return nil } do { - try dataResized!.write(to: destFile) + try dataResized.write(to: destFile) return destFile.lastPathComponent } catch { logger.error("FileUtils.saveWallpaperFile error: \(error.localizedDescription)") diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index 543dc258e4..43edb2fc15 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -33,6 +33,18 @@ extern int chat_json_length(char *str); extern char *chat_encrypt_media(chat_ctrl ctl, char *key, char *frame, int len); extern char *chat_decrypt_media(char *key, char *frame, int len); +// chat_resize_image returns base64 of at most maxSize bytes of PNG (png > 0), JPEG (png == 0), or PNG for an image with transparency, otherwise JPEG (png < 0), or an empty string +extern char *chat_resize_image(char *data, int len, int maxSize, int png); + +// chat_resize_image_uri returns a data URI of at most maxSize characters, or an empty string +extern char *chat_resize_image_uri(char *data, int len, int maxSize); + +// chat_resize_image_file is chat_resize_image with the image read from the file at path +extern char *chat_resize_image_file(char *path, int maxSize, int png); + +// chat_resize_image_file_uri is chat_resize_image_uri with the image read from the file at path +extern char *chat_resize_image_file_uri(char *path, int maxSize); + // chat_write_file returns null-terminated string with JSON of WriteFileResult extern char *chat_write_file(chat_ctrl ctl, char *path, char *data, int len); diff --git a/apps/ios/spec/architecture.md b/apps/ios/spec/architecture.md index 9ab3eb1fd2..87f30b2a48 100644 --- a/apps/ios/spec/architecture.md +++ b/apps/ios/spec/architecture.md @@ -58,15 +58,15 @@ The app follows a strict layered model where each layer communicates only with i | State | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L337) | `ChatModel`, `ItemsModel`, `Chat` classes | L337, L74, L1271 | | API | [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift#L93) | FFI bridge functions | L93 | | API | [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift#L15) | `ChatCommand`, `ChatResponse`, `ChatEvent` enums | L15, L649, L1055 | -| FFI | [`SimpleXChat/SimpleX.h`](../SimpleXChat/SimpleX.h#L1-L49) | C header declaring Haskell exports | | +| FFI | [`SimpleXChat/SimpleX.h`](../SimpleXChat/SimpleX.h#L1-L63) | C header declaring Haskell exports | | | FFI | [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift#L27) | `APIResult`, `ChatError`, `ChatCmdProtocol` | L27, L699, L17 | | Core | `../../src/Simplex/Chat/Controller.hs` | Haskell command processor — see `processCommand` in `Controller.hs` | | --- -## [2. FFI Bridge](../SimpleXChat/SimpleX.h#L1-L49) +## [2. FFI Bridge](../SimpleXChat/SimpleX.h#L1-L63) -### [C Functions (SimpleX.h)](../SimpleXChat/SimpleX.h#L1-L49) +### [C Functions (SimpleX.h)](../SimpleXChat/SimpleX.h#L1-L63) The Haskell core exposes these C functions, declared in `SimpleXChat/SimpleX.h`: @@ -97,6 +97,12 @@ char *chat_write_file(chat_ctrl ctl, char *path, char *data, int len); char *chat_read_file(char *path, char *key, char *nonce); char *chat_encrypt_file(chat_ctrl ctl, char *fromPath, char *toPath); char *chat_decrypt_file(char *fromPath, char *key, char *nonce, char *toPath); + +// Image resizing +char *chat_resize_image(char *data, int len, int maxSize, int png); +char *chat_resize_image_uri(char *data, int len, int maxSize); +char *chat_resize_image_file(char *path, int maxSize, int png); +char *chat_resize_image_file_uri(char *path, int maxSize); ``` ### [Swift Bridge Functions (SimpleXAPI.swift)](../Shared/Model/SimpleXAPI.swift#L93-L221) @@ -327,7 +333,7 @@ Chat relays are SMP servers that forward messages to channel subscribers. They a | App state | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L337) | L337 | | API types | [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift#L15) | L15 | | Shared types | [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift#L27) | L27 | -| C header | [`SimpleXChat/SimpleX.h`](../SimpleXChat/SimpleX.h#L1-L49) | | +| C header | [`SimpleXChat/SimpleX.h`](../SimpleXChat/SimpleX.h#L1-L63) | | | NSE | [`SimpleX NSE/NotificationService.swift`](../SimpleX%20NSE/NotificationService.swift#L1-L1228) | | | Haskell core | `../../src/Simplex/Chat/Controller.hs` — see `processCommand` in `Controller.hs` | | | Chat protocol (x-events, message envelopes) | `../../src/Simplex/Chat/Protocol.hs` | | diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt index df2e6ccadc..90c7cdd930 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt @@ -13,6 +13,7 @@ import androidx.core.graphics.scale import boofcv.android.ConvertBitmap import boofcv.struct.image.GrayU8 import chat.simplex.common.R +import chat.simplex.common.helpers.toUri import chat.simplex.common.views.helpers.decodeSampledBitmapFromByteArray import chat.simplex.common.views.helpers.errorBitmap import chat.simplex.common.views.helpers.getFileName @@ -20,7 +21,6 @@ import java.io.ByteArrayOutputStream import java.io.InputStream import java.net.URI import kotlin.math.min -import kotlin.math.sqrt private const val MAX_IMAGE_DIMENSION = 4320 @@ -50,20 +50,6 @@ actual fun decodeBase64Image(base64: String, width: Int): ImageBitmap? = null } -actual fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String { - var img = image - var str = compressImageStr(img) - while (str.length > maxDataSize) { - val ratio = sqrt(str.length.toDouble() / maxDataSize.toDouble()) - val clippedRatio = min(ratio, 2.0) - val width = (img.width.toDouble() / clippedRatio).toInt() - val height = img.height * width / img.width - img = Bitmap.createScaledBitmap(img.asAndroidBitmap(), width, height, true).asImageBitmap() - str = compressImageStr(img) - } - return str -} - // Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery actual fun cropToSquare(image: ImageBitmap): ImageBitmap { var xOffset = 0 @@ -87,32 +73,12 @@ fun Bitmap.clipToCircle(): Bitmap { return circle } -actual fun compressImageStr(bitmap: ImageBitmap): String { - val usePng = bitmap.hasAlpha() - val ext = if (usePng) "png" else "jpg" - return "data:image/$ext;base64," + Base64.encodeToString(compressImageData(bitmap, usePng).toByteArray(), Base64.NO_WRAP) -} - actual fun compressImageData(bitmap: ImageBitmap, usePng: Boolean): ByteArrayOutputStream { val stream = ByteArrayOutputStream() bitmap.asAndroidBitmap().compress(if (!usePng) Bitmap.CompressFormat.JPEG else Bitmap.CompressFormat.PNG, 85, stream) return stream } -actual fun resizeImageToDataSize(image: ImageBitmap, usePng: Boolean, maxDataSize: Long): ByteArrayOutputStream { - var img = image - var stream = compressImageData(img, usePng) - while (stream.size() > maxDataSize) { - val ratio = sqrt(stream.size().toDouble() / maxDataSize.toDouble()) - val clippedRatio = min(ratio, 2.0) - val width = (img.width.toDouble() / clippedRatio).toInt() - val height = img.height * width / img.width - img = Bitmap.createScaledBitmap(img.asAndroidBitmap(), width, height, true).asImageBitmap() - stream = compressImageData(img, usePng) - } - return stream -} - actual fun GrayU8.toImageBitmap(): ImageBitmap = ConvertBitmap.grayToBitmap(this, Bitmap.Config.RGB_565).asImageBitmap() actual fun ImageBitmap.hasAlpha(): Boolean = hasAlpha @@ -142,3 +108,17 @@ actual fun isAnimImage(uri: URI, drawable: Any?): Boolean { actual fun loadImageBitmap(inputStream: InputStream): ImageBitmap = BitmapFactory.decodeStream(inputStream).asImageBitmap() + +actual fun resizeImageFileUri(uri: URI, maxSize: Int): String = + imageFileDescriptor(uri)?.let { chatResizeImageFdUri(it, maxSize) } ?: "" + +actual fun resizeImageFileData(uri: URI, maxSize: Int): String = + imageFileDescriptor(uri)?.let { chatResizeImageFd(it, maxSize, -1) } ?: "" + +private fun imageFileDescriptor(uri: URI): Int? = + try { + androidAppContext.contentResolver.openFileDescriptor(uri.toUri(), "r")?.detachFd() + } catch (e: Exception) { + Log.e(TAG, "imageFileDescriptor error: ${e.stackTraceToString()}") + null + } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeView.android.kt index 95438d3d0e..d046f834f0 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeView.android.kt @@ -6,7 +6,6 @@ import android.content.pm.PackageManager import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.runtime.* -import androidx.compose.ui.graphics.ImageBitmap import androidx.core.content.ContextCompat import chat.simplex.common.helpers.toURI import chat.simplex.common.platform.* @@ -23,9 +22,9 @@ actual fun AttachmentSelection( ) { val cameraLauncher = rememberCameraLauncher { uri: Uri? -> if (uri != null) { - val bitmap: ImageBitmap? = getBitmapFromUri(uri.toURI()) - if (bitmap != null) { - val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000) + val imagePreview = resizeImageFileToStrSize(uri.toURI(), maxDataSize = 14000) + ?: getBitmapFromUri(uri.toURI())?.let { resizeImageToStrSize(it, maxDataSize = 14000) } + if (imagePreview != null) { composeState.value = composeState.value.copy(preview = ComposePreview.MediaPreview(listOf(imagePreview), listOf(UploadContent.SimpleImage(uri.toURI())))) } } diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index f66fd82473..353ed95fd1 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -83,6 +83,10 @@ extern char *chat_write_file(chat_ctrl ctrl, const char *path, char *ptr, int le extern char *chat_read_file(const char *path, const char *key, const char *nonce); extern char *chat_encrypt_file(chat_ctrl ctrl, const char *from_path, const char *to_path); extern char *chat_decrypt_file(const char *from_path, const char *key, const char *nonce, const char *to_path); +extern char *chat_resize_image_uri(char *ptr, int length, int max_size); +extern char *chat_resize_image(char *ptr, int length, int max_size, int png); +extern char *chat_resize_image_fd_uri(int fd, int max_size); +extern char *chat_resize_image_fd(int fd, int max_size, int png); JNIEXPORT jobjectArray JNICALL Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) { @@ -273,3 +277,27 @@ Java_chat_simplex_common_platform_CoreKt_chatDecryptFile(JNIEnv *env, jclass cla (*env)->ReleaseStringUTFChars(env, to_path, _to_path); return res; } + +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatResizeImageUri(JNIEnv *env, jclass clazz, jobject buffer, jint maxSize) { + jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer); + jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer); + return decode_and_free(env, chat_resize_image_uri(buff, capacity, maxSize)); +} + +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatResizeImage(JNIEnv *env, jclass clazz, jobject buffer, jint maxSize, jint png) { + jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer); + jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer); + return decode_and_free(env, chat_resize_image(buff, capacity, maxSize, png)); +} + +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatResizeImageFdUri(JNIEnv *env, jclass clazz, jint fd, jint maxSize) { + return decode_and_free(env, chat_resize_image_fd_uri(fd, maxSize)); +} + +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatResizeImageFd(JNIEnv *env, jclass clazz, jint fd, jint maxSize, jint png) { + return decode_and_free(env, chat_resize_image_fd(fd, maxSize, png)); +} diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index fddeb16204..2594c1245e 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -46,6 +46,10 @@ extern char *chat_write_file(chat_ctrl ctrl, const char *path, char *ptr, int le extern char *chat_read_file(const char *path, const char *key, const char *nonce); extern char *chat_encrypt_file(chat_ctrl ctrl, const char *from_path, const char *to_path); extern char *chat_decrypt_file(const char *from_path, const char *key, const char *nonce, const char *to_path); +extern char *chat_resize_image_uri(char *ptr, int length, int max_size); +extern char *chat_resize_image(char *ptr, int length, int max_size, int png); +extern char *chat_resize_image_file_uri(const char *path, int max_size); +extern char *chat_resize_image_file(const char *path, int max_size, int png); // As a reference: https://stackoverflow.com/a/60002045 jstring decode_to_utf8_string(JNIEnv *env, char *string) { @@ -274,3 +278,33 @@ Java_chat_simplex_common_platform_CoreKt_chatDecryptFile(JNIEnv *env, jclass cla (*env)->ReleaseStringUTFChars(env, to_path, _to_path); return res; } + +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatResizeImageUri(JNIEnv *env, jclass clazz, jobject buffer, jint maxSize) { + jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer); + jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer); + return decode_to_utf8_string(env, chat_resize_image_uri(buff, capacity, maxSize)); +} + +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatResizeImage(JNIEnv *env, jclass clazz, jobject buffer, jint maxSize, jint png) { + jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer); + jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer); + return decode_to_utf8_string(env, chat_resize_image(buff, capacity, maxSize, png)); +} + +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatResizeImageFileUri(JNIEnv *env, jclass clazz, jstring path, jint maxSize) { + const char *_path = encode_to_utf8_chars(env, path); + jstring res = decode_to_utf8_string(env, chat_resize_image_file_uri(_path, maxSize)); + (*env)->ReleaseStringUTFChars(env, path, _path); + return res; +} + +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatResizeImageFile(JNIEnv *env, jclass clazz, jstring path, jint maxSize, jint png) { + const char *_path = encode_to_utf8_chars(env, path); + jstring res = decode_to_utf8_string(env, chat_resize_image_file(_path, maxSize, png)); + (*env)->ReleaseStringUTFChars(env, path, _path); + return res; +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt index 60f5c9e2ca..dbe7e50a0b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt @@ -2,7 +2,6 @@ package chat.simplex.common.model import chat.simplex.common.platform.* import kotlinx.serialization.* -import java.nio.ByteBuffer @Serializable sealed class WriteFileResult { @@ -23,10 +22,7 @@ sealed class WriteFileResult { // Spec: spec/services/files.md#writeCryptoFile fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs { val ctrl = ChatController.getChatCtrl() ?: throw Exception("Controller is not initialized") - val buffer = ByteBuffer.allocateDirect(data.size) - buffer.put(data) - buffer.rewind() - val str = chatWriteFile(ctrl, path, buffer) + val str = chatWriteFile(ctrl, path, data.toDirectBuffer()) return when (val d = json.decodeFromString(WriteFileResult.serializer(), str)) { is WriteFileResult.Result -> d.cryptoArgs is WriteFileResult.Error -> throw Exception(d.writeError) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 9d1c0b8769..f34c15af93 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -39,6 +39,12 @@ external fun chatWriteFile(ctrl: ChatCtrl, path: String, buffer: ByteBuffer): St external fun chatReadFile(path: String, key: String, nonce: String): Array external fun chatEncryptFile(ctrl: ChatCtrl, fromPath: String, toPath: String): String external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String +external fun chatResizeImageUri(buffer: ByteBuffer, maxSize: Int): String +external fun chatResizeImage(buffer: ByteBuffer, maxSize: Int, png: Int): String +external fun chatResizeImageFileUri(path: String, maxSize: Int): String +external fun chatResizeImageFile(path: String, maxSize: Int, png: Int): String +external fun chatResizeImageFdUri(fd: Int, maxSize: Int): String +external fun chatResizeImageFd(fd: Int, maxSize: Int, png: Int): String // the code in core's canonical form (prefix and 20 characters, no separators), or null when core rejects it. // The check character is verified by core via FFI, not re-implemented here, so the two cannot disagree. @@ -47,6 +53,13 @@ fun parseBadgeCode(s: String): String? { return if (canonical.isEmpty()) null else canonical } +fun ByteArray.toDirectBuffer(): ByteBuffer { + val buffer = ByteBuffer.allocateDirect(size) + buffer.put(this) + buffer.rewind() + return buffer +} + val chatModel: ChatModel get() = chatController.chatModel diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt index 47cc034bfb..addfc17e73 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt @@ -13,13 +13,11 @@ import kotlinx.coroutines.withContext import java.io.ByteArrayOutputStream import java.io.InputStream import java.net.URI +import java.util.Base64 expect fun base64ToBitmap(base64ImageString: String): ImageBitmap expect fun decodeBase64Image(base64: String, width: Int): ImageBitmap? -expect fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String -expect fun resizeImageToDataSize(image: ImageBitmap, usePng: Boolean, maxDataSize: Long): ByteArrayOutputStream expect fun cropToSquare(image: ImageBitmap): ImageBitmap -expect fun compressImageStr(bitmap: ImageBitmap): String expect fun compressImageData(bitmap: ImageBitmap, usePng: Boolean): ByteArrayOutputStream expect fun GrayU8.toImageBitmap(): ImageBitmap @@ -33,6 +31,35 @@ expect fun isAnimImage(uri: URI, drawable: Any?): Boolean expect fun loadImageBitmap(inputStream: InputStream): ImageBitmap +expect fun resizeImageFileUri(uri: URI, maxSize: Int): String +expect fun resizeImageFileData(uri: URI, maxSize: Int): String + +fun resizeImageFileToStrSize(uri: URI, maxDataSize: Long): String? = + resizeImageFileUri(uri, maxDataSize.toInt()).ifEmpty { + Log.d(TAG, "resizeImageFileToStrSize: core returned no image") + null + } + +fun resizeImageFileToDataSize(uri: URI, maxDataSize: Long): ByteArray? = + resizeImageFileData(uri, maxDataSize.toInt()).ifEmpty { + Log.d(TAG, "resizeImageFileToDataSize: core returned no image") + null + }?.let { Base64.getDecoder().decode(it) } + +fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String { + val str = chatResizeImageUri(compressImageData(image, image.hasAlpha()).toByteArray().toDirectBuffer(), maxDataSize.toInt()) + if (str.isEmpty()) throw Exception("Image resize failed") + return str +} + +fun resizeImageToDataSize(image: ImageBitmap, usePng: Boolean, maxDataSize: Long): ByteArrayOutputStream { + val str = chatResizeImage(compressImageData(image, usePng).toByteArray().toDirectBuffer(), maxDataSize.toInt(), if (usePng) 1 else 0) + if (str.isEmpty()) throw Exception("Image resize failed") + val stream = ByteArrayOutputStream() + stream.write(Base64.getDecoder().decode(str)) + return stream +} + @Composable fun Base64AsyncImage( base64ImageString: String, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 736bb919e5..b98e1e683f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle @@ -362,43 +361,42 @@ suspend fun MutableState.processPickedMedia(uris: List, text: val content = ArrayList() val imagesPreview = ArrayList() uris.forEach { uri -> - var bitmap: ImageBitmap? + var preview: String? = null val uploadContent: UploadContent? = when { isImage(uri) -> { // Image val drawable = getDrawableFromUri(uri) // Do not show alert in case it's already shown from the function above - bitmap = getBitmapFromUri(uri, withAlertOnException = !AlertManager.shared.hasAlertsShown()) + fun bitmapPreview() = getBitmapFromUri(uri, withAlertOnException = !AlertManager.shared.hasAlertsShown())?.let { resizeImageToStrSize(it, maxDataSize = 14000) } if (isAnimImage(uri, drawable)) { // It's a gif or webp val fileSize = getFileSize(uri) if (fileSize != null && fileSize <= maxFileSize) { + preview = bitmapPreview() UploadContent.AnimatedImage(uri) } else { - bitmap = null showLargeFileAlert(fileSize ?: 0, value.sendIncognito, chatModel.currentUser.value?.profile) null } - } else if (bitmap != null) { - UploadContent.SimpleImage(uri) } else { - null + preview = resizeImageFileToStrSize(uri, maxDataSize = 14000) ?: bitmapPreview() + UploadContent.SimpleImage(uri) } } else -> { // Video val res = getBitmapFromVideo(uri, withAlertOnException = true) - bitmap = res.preview + preview = res.preview?.let { resizeImageToStrSize(it, maxDataSize = 14000) } val durationMs = res.duration UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0) } } // content and imagesPreview must stay index-aligned and equal-length: both consumers // (ComposeImageView and sendMessageAsync) cross-index one list by the other's index. - // Only pair them when a preview bitmap exists; otherwise skip the media entirely. - if (bitmap != null && uploadContent != null) { + // Only pair them when a preview exists; otherwise skip the media entirely. + if (preview != null && uploadContent != null) { content.add(uploadContent) - imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000)) + imagesPreview.add(preview) } else if (uploadContent is UploadContent.Video && !AlertManager.shared.hasAlertsShown()) { // A corrupted/undecodable video can yield a null preview frame without throwing, so // getBitmapFromVideo shows no alert. Skip it (other picked media still send) and tell diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt index 15758a7296..ecc38bc681 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt @@ -51,8 +51,13 @@ import java.io.File import java.net.URI import java.nio.ByteBuffer import java.nio.charset.CodingErrorAction +import java.util.Base64 private const val DATA_IMAGE = "data:image/" +private val IMAGE_SIGNATURES = listOf( + listOf(0xFF, 0xD8, 0xFF), + listOf(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) +) private const val MAX_MARKDOWN_VIEW_SIZE: Long = 1_048_576 private const val MAX_MARKDOWN_LINE_LENGTH = 15_000 private const val MAX_MARKDOWN_DEPTH = 32 @@ -281,7 +286,15 @@ private class DataImageTransformer : ImageTransformer { private fun imageWithinBudget(link: String, width: Int): ImageBitmap? = link.takeIf { it.startsWith(DATA_IMAGE) } - ?.let { decodeBase64Image(it.substringAfter("base64,"), width) } + ?.substringAfter("base64,") + ?.takeIf(::hasImageSignature) + ?.let { decodeBase64Image(it, width) } ?.takeIf { it.width.toLong() * it.height <= pixels } ?.also { pixels -= it.width.toLong() * it.height } } + +private fun hasImageSignature(base64: String): Boolean { + val header = runCatching { Base64.getDecoder().decode(base64.take(12)) }.getOrNull() ?: return false + val bytes = header.map { it.toInt() and 0xFF } + return IMAGE_SIGNATURES.any { bytes.take(it.size) == it } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 81a64cb527..622d0f958f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -172,20 +172,14 @@ fun getThemeFromUri(uri: URI, withAlertOnException: Boolean = true): ThemeOverri } fun saveImage(uri: URI): CryptoFile? { - val bitmap = getBitmapFromUri(uri) ?: return null - return saveImage(bitmap) -} - -fun saveImage(image: ImageBitmap): CryptoFile? { return try { val encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get() - val ext = if (image.hasAlpha()) "png" else "jpg" - val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE) + val (dataResized, ext) = resizeImageFromUri(uri) ?: return null val destFileName = generateNewFileName("IMG", ext, File(getAppFilePath(""))) val destFile = File(getAppFilePath(destFileName)) if (encrypted) { try { - val args = writeCryptoFile(destFile.absolutePath, dataResized.toByteArray()) + val args = writeCryptoFile(destFile.absolutePath, dataResized) CryptoFile(destFileName, args) } catch (e: Exception) { Log.e(TAG, "Unable to write crypto file: " + e.stackTraceToString()) @@ -194,7 +188,7 @@ fun saveImage(image: ImageBitmap): CryptoFile? { } } else { val output = FileOutputStream(destFile) - dataResized.writeTo(output) + output.write(dataResized) output.flush() output.close() CryptoFile.plain(destFileName) @@ -206,14 +200,12 @@ fun saveImage(image: ImageBitmap): CryptoFile? { } fun desktopSaveImageInTmp(uri: URI): CryptoFile? { - val image = getBitmapFromUri(uri) ?: return null return try { - val ext = if (image.hasAlpha()) "png" else "jpg" - val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE) + val (dataResized, ext) = resizeImageFromUri(uri) ?: return null val destFileName = generateNewFileName("IMG", ext, tmpDir) val destFile = File(tmpDir, destFileName) val output = FileOutputStream(destFile) - dataResized.writeTo(output) + output.write(dataResized) output.flush() output.close() CryptoFile.plain(destFile.absolutePath) @@ -223,6 +215,17 @@ fun desktopSaveImageInTmp(uri: URI): CryptoFile? { } } +private fun resizeImageFromUri(uri: URI): Pair? { + val data = resizeImageFileToDataSize(uri, MAX_IMAGE_SIZE) + if (data != null) { + val ext = if (data.firstOrNull() == 0x89.toByte()) "png" else "jpg" + return data to ext + } + val image = getBitmapFromUri(uri) ?: return null + val ext = if (image.hasAlpha()) "png" else "jpg" + return resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE).toByteArray() to ext +} + fun saveAnimImage(uri: URI): CryptoFile? { return try { val encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt index 925698154e..1813748529 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt @@ -17,7 +17,6 @@ import java.net.URI import java.util.* import javax.imageio.* import javax.imageio.stream.MemoryCacheImageOutputStream -import kotlin.math.sqrt private fun errorBitmap(): ImageBitmap = ImageIO.read(ByteArrayInputStream(Base64.getMimeDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg=="))).toComposeImageBitmap() @@ -63,33 +62,6 @@ actual fun decodeBase64Image(base64: String, width: Int): ImageBitmap? = null } -actual fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String { - var img = image - var str = compressImageStr(img) - while (str.length > maxDataSize) { - val ratio = sqrt(str.length.toDouble() / maxDataSize.toDouble()) - val clippedRatio = kotlin.math.min(ratio, 2.0) - val width = (img.width.toDouble() / clippedRatio).toInt() - val height = img.height * width / img.width - img = img.scale(width, height) - str = compressImageStr(img) - } - return str -} -actual fun resizeImageToDataSize(image: ImageBitmap, usePng: Boolean, maxDataSize: Long): ByteArrayOutputStream { - var img = image - var stream = compressImageData(img, usePng) - while (stream.size() > maxDataSize) { - val ratio = sqrt(stream.size().toDouble() / maxDataSize.toDouble()) - val clippedRatio = kotlin.math.min(ratio, 2.0) - val width = (img.width.toDouble() / clippedRatio).toInt() - val height = img.height * width / img.width - img = img.scale(width, height) - stream = compressImageData(img, usePng) - } - return stream -} - actual fun cropToSquare(image: ImageBitmap): ImageBitmap { var xOffset = 0 var yOffset = 0 @@ -111,18 +83,6 @@ actual fun cropToSquare(image: ImageBitmap): ImageBitmap { return croppedImage } -actual fun compressImageStr(bitmap: ImageBitmap): String { - val usePng = bitmap.hasAlpha() - val ext = if (usePng) "png" else "jpg" - return try { - val encoded = Base64.getEncoder().encodeToString(compressImageData(bitmap, usePng).toByteArray()) - "data:image/$ext;base64,$encoded" - } catch (e: Exception) { - Log.e(TAG, "resizeImageToStrSize error: $e") - throw e - } -} - actual fun compressImageData(bitmap: ImageBitmap, usePng: Boolean): ByteArrayOutputStream { val writer = ImageIO.getImageWritersByFormatName(if (usePng) "png" else "jpg").next() val writeParam = writer.defaultWriteParam @@ -213,6 +173,10 @@ actual fun isAnimImage(uri: URI, drawable: Any?): Boolean { actual fun loadImageBitmap(inputStream: InputStream): ImageBitmap = Image.makeFromEncoded(inputStream.readBytes()).toComposeImageBitmap() +actual fun resizeImageFileUri(uri: URI, maxSize: Int): String = chatResizeImageFileUri(uri.toFile().absolutePath, maxSize) + +actual fun resizeImageFileData(uri: URI, maxSize: Int): String = chatResizeImageFile(uri.toFile().absolutePath, maxSize, -1) + // https://stackoverflow.com/a/68926993 fun BufferedImage.rotate(angle: Double): BufferedImage { val sin = Math.abs(Math.sin(Math.toRadians(angle))) diff --git a/apps/multiplatform/spec/architecture.md b/apps/multiplatform/spec/architecture.md index cdc43a794a..e0d74bba22 100644 --- a/apps/multiplatform/spec/architecture.md +++ b/apps/multiplatform/spec/architecture.md @@ -139,13 +139,20 @@ All JNI declarations reside in [`Core.kt`](../common/src/commonMain/kotlin/chat/ | 11 | [`chatParseUri()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L33) | `external fun chatParseUri(str: String, safe: Int): String` | 33 | Parse SimpleX connection URI | | 12 | [`chatPasswordHash()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L34) | `external fun chatPasswordHash(pwd: String, salt: String): String` | 34 | Hash password with salt | | 13 | [`chatValidName()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L35) | `external fun chatValidName(name: String): String` | 35 | Validate/sanitize display name | -| 14 | [`chatJsonLength()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L36) | `external fun chatJsonLength(str: String): Int` | 36 | Get JSON-encoded string length | -| 15 | [`chatWriteFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L37) | `external fun chatWriteFile(ctrl: ChatCtrl, path: String, buffer: ByteBuffer): String` | 37 | Write encrypted file via core | -| 16 | [`chatReadFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L38) | `external fun chatReadFile(path: String, key: String, nonce: String): Array` | 38 | Read and decrypt file | -| 17 | [`chatEncryptFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L39) | `external fun chatEncryptFile(ctrl: ChatCtrl, fromPath: String, toPath: String): String` | 39 | Encrypt file on disk | -| 18 | [`chatDecryptFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L40) | `external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String` | 40 | Decrypt file on disk | +| 14 | [`chatParseBadgeCode()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L36) | `external fun chatParseBadgeCode(code: String): String` | 36 | Canonical form of a badge code, or an empty string | +| 15 | [`chatJsonLength()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L37) | `external fun chatJsonLength(str: String): Int` | 37 | Get JSON-encoded string length | +| 16 | [`chatWriteFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L38) | `external fun chatWriteFile(ctrl: ChatCtrl, path: String, buffer: ByteBuffer): String` | 38 | Write encrypted file via core | +| 17 | [`chatReadFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L39) | `external fun chatReadFile(path: String, key: String, nonce: String): Array` | 39 | Read and decrypt file | +| 18 | [`chatEncryptFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L40) | `external fun chatEncryptFile(ctrl: ChatCtrl, fromPath: String, toPath: String): String` | 40 | Encrypt file on disk | +| 19 | [`chatDecryptFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L41) | `external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String` | 41 | Decrypt file on disk | +| 20 | [`chatResizeImageUri()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L42) | `external fun chatResizeImageUri(buffer: ByteBuffer, maxSize: Int): String` | 42 | Resize encoded image to a data URI of at most `maxSize` characters | +| 21 | [`chatResizeImage()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L43) | `external fun chatResizeImage(buffer: ByteBuffer, maxSize: Int, png: Int): String` | 43 | Resize encoded image to base64 of PNG or JPEG data of at most `maxSize` bytes | +| 22 | [`chatResizeImageFileUri()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L44) | `external fun chatResizeImageFileUri(path: String, maxSize: Int): String` | 44 | Desktop: as `chatResizeImageUri`, from the file at `path` | +| 23 | [`chatResizeImageFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L45) | `external fun chatResizeImageFile(path: String, maxSize: Int, png: Int): String` | 45 | Desktop: as `chatResizeImage`, from the file at `path`; `png` −1 selects the format by transparency | +| 24 | [`chatResizeImageFdUri()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L46) | `external fun chatResizeImageFdUri(fd: Int, maxSize: Int): String` | 46 | Android: as `chatResizeImageUri`, from the file descriptor `fd`, which core closes | +| 25 | [`chatResizeImageFd()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L47) | `external fun chatResizeImageFd(fd: Int, maxSize: Int, png: Int): String` | 47 | Android: as `chatResizeImage`, from the file descriptor `fd`, which core closes | -**Total: 18 external native functions** (the `ChatCtrl` type alias at [line 23](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L23) is `Long`, representing the Haskell-side controller pointer). +**Total: 25 external native functions** (the `ChatCtrl` type alias at [line 23](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L23) is `Long`, representing the Haskell-side controller pointer). @@ -338,7 +345,7 @@ Examples from platform files: | `expect class VideoPlayer` | [`VideoPlayer.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt#L25) | 25 | | `expect class RecorderNative` | [`RecAndPlay.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt#L17) | 17 | | `expect val cryptor: CryptorInterface` | [`Cryptor.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt#L9) | 9 | -| `expect fun base64ToBitmap(base64ImageString: String): ImageBitmap` | [`Images.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt#L17) | 17 | +| `expect fun base64ToBitmap(base64ImageString: String): ImageBitmap` | [`Images.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt#L18) | 18 | | `expect fun getWakeLock(timeout: Long): (() -> Unit)` | [`SimplexService.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/SimplexService.kt#L3) | 3 | | `expect class GlobalExceptionsHandler` | [`UI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt#L24) | 24 | | `expect fun UriHandler.sendEmail(subject: String, body: CharSequence)` | [`Share.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt#L7) | 7 | diff --git a/apps/multiplatform/spec/client/compose.md b/apps/multiplatform/spec/client/compose.md index e7f879f5b0..9ec51252f8 100644 --- a/apps/multiplatform/spec/client/compose.md +++ b/apps/multiplatform/spec/client/compose.md @@ -315,7 +315,7 @@ Bottom sheet (`ModalBottomSheetLayout`) presenting attachment type options: **`processPickedMedia`** (line ~300): 1. For each URI, determines type (image, animated image, video). -2. Images: Gets bitmap, creates `SimpleImage` or `AnimatedImage` upload content. +2. Images: the preview of a non-animated image is made by core from the file (`resizeImageFileToStrSize`); otherwise, or when core returns no preview, from the bitmap; creates `SimpleImage` or `AnimatedImage` upload content. 3. Videos: Extracts thumbnail and duration, creates `Video` upload content. 4. Generates base64 preview thumbnails (max 14KB). 5. Sets `ComposePreview.MediaPreview` with thumbnails and content list. diff --git a/apps/multiplatform/spec/services/files.md b/apps/multiplatform/spec/services/files.md index 329e37dbb1..68550ca010 100644 --- a/apps/multiplatform/spec/services/files.md +++ b/apps/multiplatform/spec/services/files.md @@ -56,7 +56,7 @@ FileProtocol.LOCAL -> MAX_FILE_SIZE_LOCAL ## 3. CryptoFile -[`CryptoFile.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt) (62 lines) +[`CryptoFile.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt) (58 lines) Provides encrypted file I/O backed by the chat core's native cryptography (via JNI/JNA calls to `chatWriteFile`, `chatReadFile`, `chatEncryptFile`, `chatDecryptFile`). @@ -78,10 +78,10 @@ sealed class WriteFileResult { | Function | Line | Signature | Description | |---|---|---|---| -| `writeCryptoFile` | [L24](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt#L24) | `(path: String, data: ByteArray): CryptoFileArgs` | Writes data to an encrypted file via a direct `ByteBuffer`. Returns the generated key and nonce. Requires initialized `ChatController`. | -| `readCryptoFile` | [L36](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt#L36) | `(path: String, cryptoArgs: CryptoFileArgs): ByteArray` | Reads and decrypts a file given its key and nonce. Returns the plaintext bytes. Throws on error (status != 0). | -| `encryptCryptoFile` | [L47](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt#L47) | `(fromPath: String, toPath: String): CryptoFileArgs` | Encrypts an existing plaintext file to a new encrypted file. Returns the generated key and nonce. | -| `decryptCryptoFile` | [L57](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt#L57) | `(fromPath: String, cryptoArgs: CryptoFileArgs, toPath: String)` | Decrypts an encrypted file to a plaintext output file. Throws on non-empty error string. | +| `writeCryptoFile` | [L23](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt#L23) | `(path: String, data: ByteArray): CryptoFileArgs` | Writes data to an encrypted file via a direct `ByteBuffer`. Returns the generated key and nonce. Requires initialized `ChatController`. | +| `readCryptoFile` | [L32](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt#L32) | `(path: String, cryptoArgs: CryptoFileArgs): ByteArray` | Reads and decrypts a file given its key and nonce. Returns the plaintext bytes. Throws on error (status != 0). | +| `encryptCryptoFile` | [L43](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt#L43) | `(fromPath: String, toPath: String): CryptoFileArgs` | Encrypts an existing plaintext file to a new encrypted file. Returns the generated key and nonce. | +| `decryptCryptoFile` | [L53](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt#L53) | `(fromPath: String, cryptoArgs: CryptoFileArgs, toPath: String)` | Decrypts an encrypted file to a plaintext output file. Throws on non-empty error string. | All functions delegate to native C library functions through the chat core JNI bridge. @@ -205,7 +205,7 @@ When `auto = true`, relay approval alerts are suppressed (the file is silently r | File | Path | Lines | Description | |---|---|---|---| -| `CryptoFile.kt` | [`common/src/commonMain/.../model/CryptoFile.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt) | 62 | Encrypted file read/write via native crypto | +| `CryptoFile.kt` | [`common/src/commonMain/.../model/CryptoFile.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt) | 58 | Encrypted file read/write via native crypto | | `Files.kt` | [`common/src/commonMain/.../platform/Files.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt) | 191 | Common file path declarations, theme I/O, file helpers | | `Files.android.kt` | [`common/src/androidMain/.../platform/Files.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt) | 79 | Android file path implementations | | `Files.desktop.kt` | [`common/src/desktopMain/.../platform/Files.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt) | 116 | Desktop file path implementations | diff --git a/cabal.project b/cabal.project index ec0215f2ef..a3e36ce771 100644 --- a/cabal.project +++ b/cabal.project @@ -72,3 +72,14 @@ source-repository-package location: https://github.com/simplex-chat/wai.git tag: 63667cb577d09c967ddcd945e42c324872dfeb7d subdir: warp + +source-repository-package + type: git + location: https://github.com/simplex-chat/hs-jpeg-turbo.git + tag: d0a4d954038ee5bcb5c4dc9372a6fb0b05fed77c + subdir: JuicyPixels-jpeg-turbo JuicyPixels-spng jpeg-turbo + +source-repository-package + type: git + location: https://github.com/simplex-chat/JuicyPixels-stbir.git + tag: e0090e14f6348a33b53c11b377cb69df766b02b4 diff --git a/docs/dependencies/HASKELL.md b/docs/dependencies/HASKELL.md index e6b180e33b..ad0337f170 100644 --- a/docs/dependencies/HASKELL.md +++ b/docs/dependencies/HASKELL.md @@ -8,10 +8,10 @@ Bold-faced **`package-name`**s denote standard libraries bundled with `ghc-9.6.3 | --- | --- | --- | --- | --- | | **`base`** | [`4.18.1.0`](http://hackage.haskell.org/package/base-4.18.1.0) | [`BSD-3-Clause`](http://hackage.haskell.org/package/base-4.18.1.0/src/LICENSE) | Basic libraries | *(core library)* | | `base64-bytestring` | [`1.2.1.0`](http://hackage.haskell.org/package/base64-bytestring-1.2.1.0) | [`BSD-3-Clause`](./licences/haskell/base64-bytestring-1.2.1.0/LICENSE) | Fast base64 encoding and decoding for ByteStrings | `simplexmq`, `websockets` | -| **`bytestring`** | [`0.11.5.2`](http://hackage.haskell.org/package/bytestring-0.11.5.2) | [`BSD-3-Clause`](http://hackage.haskell.org/package/bytestring-0.11.5.2/src/LICENSE) | Fast, compact, strict and lazy byte strings with a list interface | `SHA`, `aeson`, `appar`, `asn1-encoding`, `asn1-parse`, `asn1-types`, `attoparsec`, `base64-bytestring`, `binary`, `bitvec`, `blaze-builder`, `blaze-textual`, `bytestring-builder`, `case-insensitive`, `cereal`, `conduit`, `conduit-extra`, `cryptohash-md5`, `cryptohash-sha1`, `crypton`, `crypton-x509`, `crypton-x509-store`, `crypton-x509-validation`, `cryptostore`, `digest`, `direct-sqlcipher`, `email-validate`, `entropy`, `fast-logger`, `file-embed`, `filepath`, `hashable`, `http-types`, `http2`, `integer-conversion`, `iproute`, `libyaml`, `memory`, `monad-logger`, `mono-traversable`, `network`, `network-byte-order`, `network-transport`, `network-udp`, `parsec`, `pem`, `random`, `scientific`, `simplexmq`, `socks`, `sqlcipher-simple`, `streaming-commons`, `strict`, `terminal`, `text`, `text-short`, `tls`, `typed-process`, `unix`, `unix-time`, `unliftio`, `uuid`, `uuid-types`, `vector-algorithms`, `websockets`, `yaml`, `zip`, `zlib`, `zstd` | +| **`bytestring`** | [`0.11.5.2`](http://hackage.haskell.org/package/bytestring-0.11.5.2) | [`BSD-3-Clause`](http://hackage.haskell.org/package/bytestring-0.11.5.2/src/LICENSE) | Fast, compact, strict and lazy byte strings with a list interface | `JuicyPixels`, `JuicyPixels-jpeg-turbo`, `JuicyPixels-spng`, `SHA`, `aeson`, `appar`, `asn1-encoding`, `asn1-parse`, `asn1-types`, `attoparsec`, `base64-bytestring`, `binary`, `bitvec`, `blaze-builder`, `blaze-textual`, `bytestring-builder`, `case-insensitive`, `cereal`, `conduit`, `conduit-extra`, `cryptohash-md5`, `cryptohash-sha1`, `crypton`, `crypton-x509`, `crypton-x509-store`, `crypton-x509-validation`, `cryptostore`, `digest`, `direct-sqlcipher`, `email-validate`, `entropy`, `fast-logger`, `file-embed`, `filepath`, `hashable`, `http-types`, `http2`, `integer-conversion`, `iproute`, `jpeg-turbo`, `libyaml`, `memory`, `monad-logger`, `mono-traversable`, `network`, `network-byte-order`, `network-transport`, `network-udp`, `parsec`, `pem`, `random`, `scientific`, `simplexmq`, `socks`, `sqlcipher-simple`, `streaming-commons`, `strict`, `terminal`, `text`, `text-short`, `tls`, `typed-process`, `unix`, `unix-time`, `unliftio`, `uuid`, `uuid-types`, `vector-algorithms`, `websockets`, `yaml`, `zip`, `zlib`, `zstd` | | `composition` | [`1.0.2.2`](http://hackage.haskell.org/package/composition-1.0.2.2) | [`BSD-3-Clause`](./licences/haskell/composition-1.0.2.2/LICENSE) | Combinators for unorthodox function composition | `simplexmq` | | `constraints` | [`0.14`](http://hackage.haskell.org/package/constraints-0.14) | [`BSD-2-Clause`](./licences/haskell/constraints-0.14/LICENSE) | Constraint manipulation | `simplexmq` | -| **`containers`** | [`0.6.7`](http://hackage.haskell.org/package/containers-0.6.7) | [`BSD-3-Clause`](http://hackage.haskell.org/package/containers-0.6.7/src/LICENSE) | Assorted concrete container types | `QuickCheck`, `aeson`, `attoparsec`, `bifunctors`, `binary`, `cereal`, `comonad`, `crypton-x509`, `crypton-x509-store`, `crypton-x509-validation`, `data-default-instances-containers`, `hashable`, `http2`, `indexed-traversable`, `iproute`, `mono-traversable`, `network-udp`, `resourcet`, `scientific`, `semialign`, `semigroupoids`, `simplexmq`, `sqlcipher-simple`, `th-abstraction`, `websockets`, `witherable`, `yaml`, `zip` | +| **`containers`** | [`0.6.7`](http://hackage.haskell.org/package/containers-0.6.7) | [`BSD-3-Clause`](http://hackage.haskell.org/package/containers-0.6.7/src/LICENSE) | Assorted concrete container types | `JuicyPixels`, `QuickCheck`, `aeson`, `attoparsec`, `bifunctors`, `binary`, `cereal`, `comonad`, `crypton-x509`, `crypton-x509-store`, `crypton-x509-validation`, `data-default-instances-containers`, `hashable`, `http2`, `indexed-traversable`, `iproute`, `mono-traversable`, `network-udp`, `resourcet`, `scientific`, `semialign`, `semigroupoids`, `simplexmq`, `sqlcipher-simple`, `th-abstraction`, `websockets`, `witherable`, `yaml`, `zip` | | `crypton` | [`0.34`](http://hackage.haskell.org/package/crypton-0.34) | [`BSD-3-Clause`](./licences/haskell/crypton-0.34/LICENSE) | Cryptography Primitives sink | `crypton-x509`, `crypton-x509-store`, `crypton-x509-validation`, `cryptostore`, `simplexmq`, `tls` | | `uuid` | [`1.3.15`](http://hackage.haskell.org/package/uuid-1.3.15) | [`BSD-3-Clause`](./licences/haskell/uuid-1.3.15/LICENSE) | For creating, comparing, parsing and printing Universally Unique Identifiers | | | **`directory`** | [`1.3.8.1`](http://hackage.haskell.org/package/directory-1.3.8.1) | [`BSD-3-Clause`](http://hackage.haskell.org/package/directory-1.3.8.1/src/LICENSE) | Platform-agnostic library for filesystem operations | `conduit`, `conduit-extra`, `crypton-x509-store`, `easy-file`, `fast-logger`, `file-embed`, `network`, `process`, `simplexmq`, `streaming-commons`, `temporary`, `unliftio`, `yaml`, `zip` | @@ -22,9 +22,13 @@ Bold-faced **`package-name`**s denote standard libraries bundled with `ghc-9.6.3 | `file-embed` | [`0.0.15.0`](http://hackage.haskell.org/package/file-embed-0.0.15.0) | [`BSD-2-Clause`](./licences/haskell/file-embed-0.0.15.0/LICENSE) | Use Template Haskell to embed file contents directly. | | | `http-types` | [`0.12.4`](http://hackage.haskell.org/package/http-types-0.12.4) | [`BSD-3-Clause`](./licences/haskell/http-types-0.12.4/LICENSE) | Generic HTTP types for Haskell (for both client and server code). | `http2`, `simplexmq` | | `http2` | [`5.0.0`](http://hackage.haskell.org/package/http2-5.0.0) | [`BSD-3-Clause`](./licences/haskell/http2-5.0.0/LICENSE) | HTTP/2 library | `simplexmq` | +| `JuicyPixels` | [`3.3.8`](http://hackage.haskell.org/package/JuicyPixels-3.3.8) | [`BSD-3-Clause`](./licences/haskell/JuicyPixels-3.3.8/LICENSE) | Picture loading/serialization (in png, jpeg, bitmap, gif, tga, tiff and radiance) | `JuicyPixels-jpeg-turbo`, `JuicyPixels-spng`, `JuicyPixels-stbir` | +| `JuicyPixels-jpeg-turbo` | [`0.1.0.0`](https://github.com/simplex-chat/hs-jpeg-turbo/tree/d0a4d954038ee5bcb5c4dc9372a6fb0b05fed77c/JuicyPixels-jpeg-turbo) | [`BSD-3-Clause`](./licences/haskell/JuicyPixels-jpeg-turbo-0.1.0.0/LICENSE) | JuicyPixels wrappers for mozjpeg | | +| `JuicyPixels-spng` | [`0.1.0.0`](https://github.com/simplex-chat/hs-jpeg-turbo/tree/d0a4d954038ee5bcb5c4dc9372a6fb0b05fed77c/JuicyPixels-spng) | [`BSD-3-Clause AND BSD-2-Clause AND libpng-2.0 AND MIT`](./licences/haskell/JuicyPixels-spng-0.1.0.0/LICENSE) | JuicyPixels wrappers for libspng | | +| `JuicyPixels-stbir` | [`0.1.0.0`](https://github.com/simplex-chat/JuicyPixels-stbir/tree/e0090e14f6348a33b53c11b377cb69df766b02b4) | [`BSD-3-Clause AND (MIT OR Unlicense)`](./licences/haskell/JuicyPixels-stbir-0.1.0.0/LICENSE) | Scale JuicyPixels images with stb_image_resize | | | `email-validate` | [`2.3.2.19`](http://hackage.haskell.org/package/email-validate-2.3.2.19) | [`BSD-3-Clause`](./licences/haskell/email-validate-2.3.2.19/LICENSE) | Email address validation | | | `memory` | [`0.18.0`](http://hackage.haskell.org/package/memory-0.18.0) | [`BSD-3-Clause`](./licences/haskell/memory-0.18.0/LICENSE) | memory and related abstraction stuff | `asn1-types`, `crypton`, `crypton-x509`, `crypton-x509-validation`, `cryptostore`, `pem`, `simplexmq`, `tls` | -| **`mtl`** | [`2.3.1`](http://hackage.haskell.org/package/mtl-2.3.1) | [`BSD-3-Clause`](http://hackage.haskell.org/package/mtl-2.3.1/src/LICENSE) | Monad classes for transformers, using functional dependencies | `conduit`, `constraints`, `crypton-x509-store`, `crypton-x509-validation`, `exceptions`, `monad-logger`, `parsec`, `random`, `resourcet`, `simple-logger`, `simplexmq`, `tls`, `yaml`, `zip` | +| **`mtl`** | [`2.3.1`](http://hackage.haskell.org/package/mtl-2.3.1) | [`BSD-3-Clause`](http://hackage.haskell.org/package/mtl-2.3.1/src/LICENSE) | Monad classes for transformers, using functional dependencies | `JuicyPixels`, `conduit`, `constraints`, `crypton-x509-store`, `crypton-x509-validation`, `exceptions`, `monad-logger`, `parsec`, `random`, `resourcet`, `simple-logger`, `simplexmq`, `tls`, `yaml`, `zip` | | `unliftio` | [`0.2.25.0`](http://hackage.haskell.org/package/unliftio-0.2.25.0) | [`MIT`](./licences/haskell/unliftio-0.2.25.0/LICENSE) | The MonadUnliftIO typeclass for unlifting monads to IO (batteries included) | `http2`, `simplexmq`, `time-manager` | | `unliftio-core` | [`0.2.1.0`](http://hackage.haskell.org/package/unliftio-core-0.2.1.0) | [`MIT`](./licences/haskell/unliftio-core-0.2.1.0/LICENSE) | The MonadUnliftIO typeclass for unlifting monads to IO | `conduit`, `conduit-extra`, `monad-logger`, `resourcet`, `simplexmq`, `typed-process`, `unliftio` | | `ansi-terminal` | [`1.0`](http://hackage.haskell.org/package/ansi-terminal-1.0) | [`BSD-3-Clause`](./licences/haskell/ansi-terminal-1.0/LICENSE) | Simple ANSI terminal support | `prettyprinter-ansi-terminal` | @@ -48,6 +52,7 @@ Bold-faced **`package-name`**s denote standard libraries bundled with `ghc-9.6.3 | `tls` | [`1.9.0`](http://hackage.haskell.org/package/tls-1.9.0) | [`BSD-3-Clause`](./licences/haskell/tls-1.9.0/LICENSE) | TLS/SSL protocol native implementation (Server and Client) | `simplexmq` | | `terminal` | [`0.2.0.0`](http://hackage.haskell.org/package/terminal-0.2.0.0) | [`BSD-3-Clause`](./licences/haskell/terminal-0.2.0.0/LICENSE) | Portable terminal interaction library | | | `attoparsec` | [`0.14.4`](http://hackage.haskell.org/package/attoparsec-0.14.4) | [`BSD-3-Clause`](./licences/haskell/attoparsec-0.14.4/LICENSE) | Fast combinator parsing for bytestrings and text | `conduit-extra`, `email-validate`, `ini`, `simplexmq`, `sqlcipher-simple`, `websockets`, `yaml` | +| `vector` | [`0.13.1.0`](http://hackage.haskell.org/package/vector-0.13.1.0) | [`BSD-3-Clause`](./licences/haskell/vector-0.13.1.0/LICENSE) | Efficient Arrays | `JuicyPixels`, `JuicyPixels-jpeg-turbo`, `JuicyPixels-spng`, `JuicyPixels-stbir`, `aeson`, `bitvec`, `blaze-textual`, `conduit`, `indexed-traversable-instances`, `mono-traversable`, `semialign`, `vector-algorithms`, `witherable`, `yaml` | | `websockets` | [`0.12.7.3`](http://hackage.haskell.org/package/websockets-0.12.7.3) | [`BSD-3-Clause`](./licences/haskell/websockets-0.12.7.3/LICENCE) | A sensible and clean way to write WebSocket-capable servers in Haskell. | `simplexmq` | | `zip` | [`2.0.0`](http://hackage.haskell.org/package/zip-2.0.0) | [`BSD-3-Clause`](./licences/haskell/zip-2.0.0/LICENSE.md) | Operations on zip archives | | @@ -62,7 +67,7 @@ Bold-faced **`package-name`**s denote standard libraries bundled with `ghc-9.6.3 | `StateVar` | [`1.2.2`](http://hackage.haskell.org/package/StateVar-1.2.2) | [`BSD-3-Clause`](./licences/haskell/StateVar-1.2.2/LICENSE) | State variables | `contravariant` | | **`array`** | [`0.5.5.0`](http://hackage.haskell.org/package/array-0.5.5.0) | [`BSD-3-Clause`](http://hackage.haskell.org/package/array-0.5.5.0/src/LICENSE) | Mutable and immutable arrays | `SHA`, `attoparsec`, `binary`, `cereal`, `containers`, `deepseq`, `fast-logger`, `http-types`, `http2`, `indexed-traversable`, `integer-logarithms`, `stm`, `streaming-commons`, `text` | | `bifunctors` | [`5.6.1`](http://hackage.haskell.org/package/bifunctors-5.6.1) | [`BSD-3-Clause`](./licences/haskell/bifunctors-5.6.1/LICENSE) | Bifunctors | `semigroupoids` | -| **`binary`** | [`0.8.9.1`](http://hackage.haskell.org/package/binary-0.8.9.1) | [`BSD-3-Clause`](http://hackage.haskell.org/package/binary-0.8.9.1/src/LICENSE) | Binary serialisation for Haskell values using lazy ByteStrings | `SHA`, `constraints`, `network-transport`, `scientific`, `strict`, `text`, `text-short`, `these`, `unix-time`, `uuid`, `uuid-types`, `websockets` | +| **`binary`** | [`0.8.9.1`](http://hackage.haskell.org/package/binary-0.8.9.1) | [`BSD-3-Clause`](http://hackage.haskell.org/package/binary-0.8.9.1/src/LICENSE) | Binary serialisation for Haskell values using lazy ByteStrings | `JuicyPixels`, `SHA`, `constraints`, `network-transport`, `scientific`, `strict`, `text`, `text-short`, `these`, `unix-time`, `uuid`, `uuid-types`, `websockets` | | `blaze-builder` | [`0.4.2.3`](http://hackage.haskell.org/package/blaze-builder-0.4.2.3) | [`BSD-3-Clause`](./licences/haskell/blaze-builder-0.4.2.3/LICENSE) | Efficient buffered output. | `blaze-textual`, `sqlcipher-simple` | | `blaze-textual` | [`0.2.3.1`](http://hackage.haskell.org/package/blaze-textual-0.2.3.1) | [`BSD-3-Clause`](./licences/haskell/blaze-textual-0.2.3.1/LICENSE) | Fast rendering of common datatypes | `sqlcipher-simple` | | `boring` | [`0.2.1`](http://hackage.haskell.org/package/boring-0.2.1) | [`BSD-3-Clause`](./licences/haskell/boring-0.2.1/LICENSE) | Boring and Absurd types | `constraints` | @@ -86,11 +91,11 @@ Bold-faced **`package-name`**s denote standard libraries bundled with `ghc-9.6.3 | `cryptostore` | [`0.3.0.1`](http://hackage.haskell.org/package/cryptostore-0.3.0.1) | [`BSD-3-Clause`](./licences/haskell/cryptostore-0.3.0.1/LICENSE) | Serialization of cryptographic data types | `simplexmq` | | `case-insensitive` | [`1.2.1.0`](http://hackage.haskell.org/package/case-insensitive-1.2.1.0) | [`BSD-3-Clause`](./licences/haskell/case-insensitive-1.2.1.0/LICENSE) | Case insensitive string comparison | `http-types`, `http2`, `simplexmq`, `websockets`, `zip` | | `uuid-types` | [`1.0.5.1`](http://hackage.haskell.org/package/uuid-types-1.0.5.1) | [`BSD-3-Clause`](./licences/haskell/uuid-types-1.0.5.1/LICENSE) | Type definitions for Universally Unique Identifiers | `aeson`, `uuid` | -| **`deepseq`** | [`1.4.8.1`](http://hackage.haskell.org/package/deepseq-1.4.8.1) | [`BSD-3-Clause`](http://hackage.haskell.org/package/deepseq-1.4.8.1/src/LICENSE) | Deep evaluation of data structures | `Only`, `QuickCheck`, `aeson`, `attoparsec`, `bitvec`, `blaze-builder`, `bytestring`, `bytestring-builder`, `case-insensitive`, `constraints`, `containers`, `crypton`, `data-fix`, `dlist`, `filepath`, `hashable`, `hourglass`, `memory`, `network`, `network-transport`, `network-uri`, `pretty`, `primitive`, `process`, `psqueues`, `random`, `safe-exceptions`, `scientific`, `splitmix`, `strict`, `tagged`, `text`, `text-short`, `these`, `time`, `time-compat`, `unliftio`, `unordered-containers`, `uuid-types`, `vector`, `zstd` | +| **`deepseq`** | [`1.4.8.1`](http://hackage.haskell.org/package/deepseq-1.4.8.1) | [`BSD-3-Clause`](http://hackage.haskell.org/package/deepseq-1.4.8.1/src/LICENSE) | Deep evaluation of data structures | `JuicyPixels`, `Only`, `QuickCheck`, `aeson`, `attoparsec`, `bitvec`, `blaze-builder`, `bytestring`, `bytestring-builder`, `case-insensitive`, `constraints`, `containers`, `crypton`, `data-fix`, `dlist`, `filepath`, `hashable`, `hourglass`, `memory`, `network`, `network-transport`, `network-uri`, `pretty`, `primitive`, `process`, `psqueues`, `random`, `safe-exceptions`, `scientific`, `splitmix`, `strict`, `tagged`, `text`, `text-short`, `these`, `time`, `time-compat`, `unliftio`, `unordered-containers`, `uuid-types`, `vector`, `zstd` | | `digest` | [`0.0.1.7`](http://hackage.haskell.org/package/digest-0.0.1.7) | [`BSD-2-Clause`](./licences/haskell/digest-0.0.1.7/LICENSE) | Various hashes for bytestrings; CRC32 and Adler32 for now. | `zip` | | `dlist` | [`1.0`](http://hackage.haskell.org/package/dlist-1.0) | [`BSD-3-Clause`](./licences/haskell/dlist-1.0/license.md) | Difference lists | `aeson`, `data-default-instances-dlist`, `zip` | | `distributive` | [`0.6.2.1`](http://hackage.haskell.org/package/distributive-0.6.2.1) | [`BSD-3-Clause`](./licences/haskell/distributive-0.6.2.1/LICENSE) | Distributive functors -- Dual to Traversable | `comonad`, `semigroupoids` | -| `data-default-class` | [`0.1.2.0`](http://hackage.haskell.org/package/data-default-class-0.1.2.0) | [`BSD-3-Clause`](./licences/haskell/data-default-class-0.1.2.0/LICENSE) | A class for types with a default value | `crypton-x509-validation`, `data-default`, `data-default-instances-containers`, `data-default-instances-dlist`, `data-default-instances-old-locale`, `tls` | +| `data-default-class` | [`0.1.2.0`](http://hackage.haskell.org/package/data-default-class-0.1.2.0) | [`BSD-3-Clause`](./licences/haskell/data-default-class-0.1.2.0/LICENSE) | A class for types with a default value | `JuicyPixels-stbir`, `crypton-x509-validation`, `data-default`, `data-default-instances-containers`, `data-default-instances-dlist`, `data-default-instances-old-locale`, `tls` | | `data-default-instances-containers` | [`0.0.1`](http://hackage.haskell.org/package/data-default-instances-containers-0.0.1) | [`BSD-3-Clause`](./licences/haskell/data-default-instances-containers-0.0.1/LICENSE) | Default instances for types in containers | `data-default` | | `data-default-instances-dlist` | [`0.0.1`](http://hackage.haskell.org/package/data-default-instances-dlist-0.0.1) | [`BSD-3-Clause`](./licences/haskell/data-default-instances-dlist-0.0.1/LICENSE) | Default instances for types in dlist | `data-default` | | `data-default-instances-old-locale` | [`0.0.1`](http://hackage.haskell.org/package/data-default-instances-old-locale-0.0.1) | [`BSD-3-Clause`](./licences/haskell/data-default-instances-old-locale-0.0.1/LICENSE) | Default instances for types in old-locale | `data-default` | @@ -130,13 +135,14 @@ Bold-faced **`package-name`**s denote standard libraries bundled with `ghc-9.6.3 | `pem` | [`0.2.4`](http://hackage.haskell.org/package/pem-0.2.4) | [`BSD-3-Clause`](./licences/haskell/pem-0.2.4/LICENSE) | Privacy Enhanced Mail (PEM) format reader and writer. | `crypton-x509`, `crypton-x509-store`, `crypton-x509-validation`, `cryptostore` | | `appar` | [`0.1.8`](http://hackage.haskell.org/package/appar-0.1.8) | [`BSD-3-Clause`](./licences/haskell/appar-0.1.8/LICENSE) | A simple applicative parser | `iproute` | | **`pretty`** | [`1.1.3.6`](http://hackage.haskell.org/package/pretty-1.1.3.6) | [`BSD-3-Clause`](http://hackage.haskell.org/package/pretty-1.1.3.6/src/LICENSE) | Pretty-printing library | `template-haskell` | -| `primitive` | [`0.9.0.0`](http://hackage.haskell.org/package/primitive-0.9.0.0) | [`BSD-3-Clause`](./licences/haskell/primitive-0.9.0.0/LICENSE) | Primitive memory-related operations | `aeson`, `bitvec`, `conduit`, `conduit-extra`, `integer-conversion`, `resourcet`, `scientific`, `vector`, `vector-algorithms` | +| `primitive` | [`0.9.0.0`](http://hackage.haskell.org/package/primitive-0.9.0.0) | [`BSD-3-Clause`](./licences/haskell/primitive-0.9.0.0/LICENSE) | Primitive memory-related operations | `JuicyPixels`, `aeson`, `bitvec`, `conduit`, `conduit-extra`, `integer-conversion`, `resourcet`, `scientific`, `vector`, `vector-algorithms` | | `iproute` | [`1.7.12`](http://hackage.haskell.org/package/iproute-1.7.12) | [`BSD-3-Clause`](./licences/haskell/iproute-1.7.12/LICENSE) | IP Routing Table | `network-udp`, `simplexmq` | | `prettyprinter` | [`1.7.1`](http://hackage.haskell.org/package/prettyprinter-1.7.1) | [`BSD-2-Clause`](./licences/haskell/prettyprinter-1.7.1/LICENSE.md) | A modern, easy to use, well-documented, extensible pretty-printer. | `optparse-applicative`, `prettyprinter-ansi-terminal`, `terminal` | | `prettyprinter-ansi-terminal` | [`1.1.3`](http://hackage.haskell.org/package/prettyprinter-ansi-terminal-1.1.3) | [`BSD-2-Clause`](./licences/haskell/prettyprinter-ansi-terminal-1.1.3/LICENSE.md) | ANSI terminal backend for the »prettyprinter« package. | `optparse-applicative` | | `psqueues` | [`0.2.8.0`](http://hackage.haskell.org/package/psqueues-0.2.8.0) | [`BSD-3-Clause`](./licences/haskell/psqueues-0.2.8.0/LICENSE) | Pure priority search queues | `network-control` | | `resourcet` | [`1.3.0`](http://hackage.haskell.org/package/resourcet-1.3.0) | [`BSD-3-Clause`](./licences/haskell/resourcet-1.3.0/LICENSE) | Deterministic allocation and freeing of scarce resources. | `conduit`, `conduit-extra`, `libyaml`, `monad-logger`, `yaml`, `zip` | | `iso8601-time` | [`0.1.5`](http://hackage.haskell.org/package/iso8601-time-0.1.5) | [`MIT`](http://hackage.haskell.org/package/iso8601-time-0.1.5) | Convert to/from the ISO 8601 time format | `simplexmq` | +| `jpeg-turbo` | [`0.1.0.0`](https://github.com/simplex-chat/hs-jpeg-turbo/tree/d0a4d954038ee5bcb5c4dc9372a6fb0b05fed77c/jpeg-turbo) | [`BSD-3-Clause AND IJG AND Zlib`](./licences/haskell/jpeg-turbo-0.1.0.0/LICENSE) | FFI bindings to mozjpeg | `JuicyPixels-jpeg-turbo` | | `safe-exceptions` | [`0.1.7.4`](http://hackage.haskell.org/package/safe-exceptions-0.1.7.4) | [`MIT`](./licences/haskell/safe-exceptions-0.1.7.4/LICENSE) | Safe, consistent, and easy exception handling | `unliftio` | | `semigroupoids` | [`6.0.0.1`](http://hackage.haskell.org/package/semigroupoids-6.0.0.1) | [`BSD-2-Clause`](./licences/haskell/semigroupoids-6.0.0.1/LICENSE) | Semigroupoids: Category sans id | `semialign` | | `semialign` | [`1.3`](http://hackage.haskell.org/package/semialign-1.3) | [`BSD-3-Clause`](./licences/haskell/semialign-1.3/LICENSE) | Align and Zip type-classes from the common Semialign ancestor. | `aeson` | @@ -158,7 +164,7 @@ Bold-faced **`package-name`**s denote standard libraries bundled with `ghc-9.6.3 | `time-compat` | [`1.9.6.1`](http://hackage.haskell.org/package/time-compat-1.9.6.1) | [`BSD-3-Clause`](./licences/haskell/time-compat-1.9.6.1/LICENSE) | Compatibility package for time | `aeson`, `text-iso8601` | | `time-manager` | [`0.0.1`](http://hackage.haskell.org/package/time-manager-0.0.1) | [`MIT`](./licences/haskell/time-manager-0.0.1/LICENSE) | Scalable timer | `http2`, `simplexmq` | | `temporary` | [`1.3`](http://hackage.haskell.org/package/temporary-1.3) | [`BSD-3-Clause`](./licences/haskell/temporary-1.3/LICENSE) | Portable temporary file and directory support | `simplexmq` | -| **`transformers`** | [`0.6.1.0`](http://hackage.haskell.org/package/transformers-0.6.1.0) | [`BSD-3-Clause`](http://hackage.haskell.org/package/transformers-0.6.1.0/src/LICENSE) | Concrete functor and monad transformers | `QuickCheck`, `StateVar`, `attoparsec`, `bifunctors`, `boring`, `comonad`, `conduit`, `conduit-extra`, `constraints`, `contravariant`, `crypton-x509`, `distributive`, `exceptions`, `indexed-traversable`, `monad-control`, `monad-logger`, `mono-traversable`, `mtl`, `network-transport`, `optparse-applicative`, `primitive`, `resourcet`, `safe-exceptions`, `semialign`, `semigroupoids`, `simplexmq`, `sqlcipher-simple`, `streaming-commons`, `strict`, `tagged`, `temporary`, `terminal`, `tls`, `transformers-base`, `transformers-compat`, `typed-process`, `unliftio`, `unliftio-core`, `witherable`, `yaml`, `zip` | +| **`transformers`** | [`0.6.1.0`](http://hackage.haskell.org/package/transformers-0.6.1.0) | [`BSD-3-Clause`](http://hackage.haskell.org/package/transformers-0.6.1.0/src/LICENSE) | Concrete functor and monad transformers | `JuicyPixels`, `JuicyPixels-jpeg-turbo`, `JuicyPixels-spng`, `QuickCheck`, `StateVar`, `attoparsec`, `bifunctors`, `boring`, `comonad`, `conduit`, `conduit-extra`, `constraints`, `contravariant`, `crypton-x509`, `distributive`, `exceptions`, `indexed-traversable`, `monad-control`, `monad-logger`, `mono-traversable`, `mtl`, `network-transport`, `optparse-applicative`, `primitive`, `resourcet`, `safe-exceptions`, `semialign`, `semigroupoids`, `simplexmq`, `sqlcipher-simple`, `streaming-commons`, `strict`, `tagged`, `temporary`, `terminal`, `tls`, `transformers-base`, `transformers-compat`, `typed-process`, `unliftio`, `unliftio-core`, `witherable`, `yaml`, `zip` | | `transformers-base` | [`0.4.6`](http://hackage.haskell.org/package/transformers-base-0.4.6) | [`BSD-3-Clause`](./licences/haskell/transformers-base-0.4.6/LICENSE) | Lift computations from the bottom of a transformer stack | `lifted-base`, `monad-control`, `monad-logger`, `zip` | | `transformers-compat` | [`0.7.2`](http://hackage.haskell.org/package/transformers-compat-0.7.2) | [`BSD-3-Clause`](./licences/haskell/transformers-compat-0.7.2/LICENSE) | A small compatibility shim for the transformers library | `comonad`, `monad-control`, `monad-logger`, `optparse-applicative`, `semigroupoids`, `transformers-base` | | `attoparsec` | [`0.14.4`](http://hackage.haskell.org/package/attoparsec-0.14.4) | [`BSD-3-Clause`](./licences/haskell/attoparsec-0.14.4/LICENSE) | Fast combinator parsing for bytestrings and text | `attoparsec` | @@ -167,11 +173,10 @@ Bold-faced **`package-name`**s denote standard libraries bundled with `ghc-9.6.3 | `type-equality` | [`1`](http://hackage.haskell.org/package/type-equality-1) | [`BSD-3-Clause`](./licences/haskell/type-equality-1/LICENSE) | Data.Type.Equality compat package | `constraints` | | `typed-process` | [`0.2.11.1`](http://hackage.haskell.org/package/typed-process-0.2.11.1) | [`MIT`](./licences/haskell/typed-process-0.2.11.1/LICENSE) | Run external processes, with strong typing of streams | `conduit-extra` | | **`unix`** | [`2.8.1.0`](http://hackage.haskell.org/package/unix-2.8.1.0) | [`BSD-3-Clause`](http://hackage.haskell.org/package/unix-2.8.1.0/src/LICENSE) | POSIX functionality | `conduit`, `directory`, `easy-file`, `entropy`, `process`, `streaming-commons`, `temporary`, `unix-compat`, `unliftio`, `zip` | -| `vector` | [`0.13.1.0`](http://hackage.haskell.org/package/vector-0.13.1.0) | [`BSD-3-Clause`](./licences/haskell/vector-0.13.1.0/LICENSE) | Efficient Arrays | `aeson`, `bitvec`, `blaze-textual`, `conduit`, `indexed-traversable-instances`, `mono-traversable`, `semialign`, `vector-algorithms`, `witherable`, `yaml` | | `vector-algorithms` | [`0.9.0.1`](http://hackage.haskell.org/package/vector-algorithms-0.9.0.1) | [`BSD-3-Clause`](./licences/haskell/vector-algorithms-0.9.0.1/LICENSE) | Efficient algorithms for vector arrays | `mono-traversable` | | `vector-stream` | [`0.1.0.0`](http://hackage.haskell.org/package/vector-stream-0.1.0.0) | [`BSD-3-Clause`](./licences/haskell/vector-stream-0.1.0.0/LICENSE) | Efficient Streams | `vector` | | `witherable` | [`0.4.2`](http://hackage.haskell.org/package/witherable-0.4.2) | [`BSD-3-Clause`](./licences/haskell/witherable-0.4.2/LICENSE) | filterable traversable | `aeson` | | `yaml` | [`0.11.11.2`](http://hackage.haskell.org/package/yaml-0.11.11.2) | [`BSD-3-Clause`](./licences/haskell/yaml-0.11.11.2/LICENSE) | Support for parsing and rendering YAML documents. | `simplexmq` | -| `zlib` | [`0.6.3.0`](http://hackage.haskell.org/package/zlib-0.6.3.0) | [`BSD-3-Clause`](./licences/haskell/zlib-0.6.3.0/LICENSE) | Compression and decompression in the gzip and zlib formats | `streaming-commons` | +| `zlib` | [`0.6.3.0`](http://hackage.haskell.org/package/zlib-0.6.3.0) | [`BSD-3-Clause`](./licences/haskell/zlib-0.6.3.0/LICENSE) | Compression and decompression in the gzip and zlib formats | `JuicyPixels`, `streaming-commons` | | `zstd` | [`0.1.3.0`](http://hackage.haskell.org/package/zstd-0.1.3.0) | [`BSD-3-Clause`](./licences/haskell/zstd-0.1.3.0/LICENSE) | Haskell bindings to the Zstandard compression algorithm | `simplexmq` | diff --git a/docs/dependencies/README.md b/docs/dependencies/README.md index bec369c126..bfe3edce8d 100644 --- a/docs/dependencies/README.md +++ b/docs/dependencies/README.md @@ -1,7 +1,17 @@ # SimpleX Chat and SimpleX servers dependencies +[Compact ICC Profiles](https://github.com/saucecontrol/Compact-ICC-Profiles): Display P3 colour profile of resized images ([CC0 1.0](./licences/apps/compact-icc-profiles/license)) + +[libspng](https://github.com/randy408/libspng): PNG decoding and encoding of resized images ([BSD-2-clause](./licences/apps/libspng/LICENSE) and [libpng-2.0](./licences/apps/libspng/LICENSE-libpng)) + +[miniz](https://github.com/richgel999/miniz): zlib compression and decompression for libspng ([MIT](./licences/apps/miniz/LICENSE)) + +[mozjpeg](https://github.com/mozilla/mozjpeg): JPEG decoding and encoding of resized images ([IJG and BSD-3-clause](./licences/apps/mozjpeg/LICENSE.md)). This software is based in part on the work of the Independent JPEG Group. + [SQLCipher](https://github.com/sqlcipher/sqlcipher): Extension of [SQLite](https://sqlite.org) with encryption ([BSD-style](./licences/apps/sqlcipher/LICENSE.md)) +[stb_image_resize](https://github.com/nothings/stb): image scaling ([MIT or public domain](./licences/apps/stb_image_resize/LICENSE)) + [vlc](https://github.com/videolan/vlc): VLC media player library ([LGPLv2](./licences/apps/vlc/COPYING.LIB)) [WebRTC](https://webrtc.googlesource.com/src/): RTC for calls ([BSD-3-clause](./licences/apps/webrtc/LICENSE)) diff --git a/docs/dependencies/licences/apps/compact-icc-profiles/license b/docs/dependencies/licences/apps/compact-icc-profiles/license new file mode 100644 index 0000000000..550e190e5c --- /dev/null +++ b/docs/dependencies/licences/apps/compact-icc-profiles/license @@ -0,0 +1,116 @@ +CC0 1.0 Universal + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator and +subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for the +purpose of contributing to a commons of creative, cultural and scientific +works ("Commons") that the public can reliably and without fear of later +claims of infringement build upon, modify, incorporate in other works, reuse +and redistribute as freely as possible in any form whatsoever and for any +purposes, including without limitation commercial purposes. These owners may +contribute to the Commons to promote the ideal of a free culture and the +further production of creative, cultural and scientific works, or to gain +reputation or greater distribution for their Work in part through the use and +efforts of others. + +For these and/or other purposes and motivations, and without any expectation +of additional consideration or compensation, the person associating CC0 with a +Work (the "Affirmer"), to the extent that he or she is an owner of Copyright +and Related Rights in the Work, voluntarily elects to apply CC0 to the Work +and publicly distribute the Work under its terms, with knowledge of his or her +Copyright and Related Rights in the Work and the meaning and intended legal +effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not limited +to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; + + ii. moral rights retained by the original author(s) and/or performer(s); + + iii. publicity and privacy rights pertaining to a person's image or likeness + depicted in a Work; + + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + + v. rights protecting the extraction, dissemination, use and reuse of data in + a Work; + + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and + + vii. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, +applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and +unconditionally waives, abandons, and surrenders all of Affirmer's Copyright +and Related Rights and associated claims and causes of action, whether now +known or unknown (including existing as well as future claims and causes of +action), in the Work (i) in all territories worldwide, (ii) for the maximum +duration provided by applicable law or treaty (including future time +extensions), (iii) in any current or future medium and for any number of +copies, and (iv) for any purpose whatsoever, including without limitation +commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes +the Waiver for the benefit of each member of the public at large and to the +detriment of Affirmer's heirs and successors, fully intending that such Waiver +shall not be subject to revocation, rescission, cancellation, termination, or +any other legal or equitable action to disrupt the quiet enjoyment of the Work +by the public as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be +judged legally invalid or ineffective under applicable law, then the Waiver +shall be preserved to the maximum extent permitted taking into account +Affirmer's express Statement of Purpose. In addition, to the extent the Waiver +is so judged Affirmer hereby grants to each affected person a royalty-free, +non transferable, non sublicensable, non exclusive, irrevocable and +unconditional license to exercise Affirmer's Copyright and Related Rights in +the Work (i) in all territories worldwide, (ii) for the maximum duration +provided by applicable law or treaty (including future time extensions), (iii) +in any current or future medium and for any number of copies, and (iv) for any +purpose whatsoever, including without limitation commercial, advertising or +promotional purposes (the "License"). The License shall be deemed effective as +of the date CC0 was applied by Affirmer to the Work. Should any part of the +License for any reason be judged legally invalid or ineffective under +applicable law, such partial invalidity or ineffectiveness shall not +invalidate the remainder of the License, and in such case Affirmer hereby +affirms that he or she will not (i) exercise any of his or her remaining +Copyright and Related Rights in the Work or (ii) assert any associated claims +and causes of action with respect to the Work, in either case contrary to +Affirmer's express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + + b. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or otherwise, + including without limitation warranties of title, merchantability, fitness + for a particular purpose, non infringement, or the absence of latent or + other defects, accuracy, or the present or absence of errors, whether or not + discoverable, all to the greatest extent permissible under applicable law. + + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without limitation + any person's Copyright and Related Rights in the Work. Further, Affirmer + disclaims responsibility for obtaining any necessary consents, permissions + or other rights required for any use of the Work. + + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this + CC0 or use of the Work. + +For more information, please see +http://creativecommons.org/publicdomain/zero/1.0/ \ No newline at end of file diff --git a/docs/dependencies/licences/apps/libspng/LICENSE b/docs/dependencies/licences/apps/libspng/LICENSE new file mode 100644 index 0000000000..05234f0ea3 --- /dev/null +++ b/docs/dependencies/licences/apps/libspng/LICENSE @@ -0,0 +1,25 @@ +BSD 2-Clause License + +Copyright (c) 2018-2023, Randy +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. + +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. diff --git a/docs/dependencies/licences/apps/libspng/LICENSE-libpng b/docs/dependencies/licences/apps/libspng/LICENSE-libpng new file mode 100644 index 0000000000..bbee490c74 --- /dev/null +++ b/docs/dependencies/licences/apps/libspng/LICENSE-libpng @@ -0,0 +1,32 @@ +PNG Reference Library License version 2 + +Copyright (c) 1995-2019 The PNG Reference Library Authors. +Copyright (c) 2018-2019 Cosmin Truta. +Copyright (c) 2000-2002, 2004, 2006-2018 Glenn Randers-Pehrson. +Copyright (c) 1996-1997 Andreas Dilger. +Copyright (c) 1995-1996 Guy Eric Schalnat, Group 42, Inc. + +The software is supplied "as is", without warranty of any kind, +express or implied, including, without limitation, the warranties +of merchantability, fitness for a particular purpose, title, and +non-infringement. In no event shall the Copyright owners, or +anyone distributing the software, be liable for any damages or +other liability, whether in contract, tort or otherwise, arising +from, out of, or in connection with the software, or the use or +other dealings in the software, even if advised of the possibility +of such damage. + +Permission is hereby granted to use, copy, modify, and distribute +this software, or portions hereof, for any purpose, without fee, +subject to the following restrictions: + + 1. The origin of this software must not be misrepresented; you + must not claim that you wrote the original software. If you + use this software in a product, an acknowledgment in the product + documentation would be appreciated, but is not required. + + 2. Altered source versions must be plainly marked as such, and must + not be misrepresented as being the original software. + + 3. This Copyright notice may not be removed or altered from any + source or altered source distribution. diff --git a/docs/dependencies/licences/apps/miniz/LICENSE b/docs/dependencies/licences/apps/miniz/LICENSE new file mode 100644 index 0000000000..1982f4bb8f --- /dev/null +++ b/docs/dependencies/licences/apps/miniz/LICENSE @@ -0,0 +1,22 @@ +Copyright 2013-2014 RAD Game Tools and Valve Software +Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC + +All Rights Reserved. + +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. diff --git a/docs/dependencies/licences/apps/mozjpeg/LICENSE.md b/docs/dependencies/licences/apps/mozjpeg/LICENSE.md new file mode 100644 index 0000000000..a785258bcd --- /dev/null +++ b/docs/dependencies/licences/apps/mozjpeg/LICENSE.md @@ -0,0 +1,135 @@ +libjpeg-turbo Licenses +====================== + +libjpeg-turbo is covered by two compatible BSD-style open source licenses: + +- The IJG (Independent JPEG Group) License, which is listed in + [README.ijg](README.ijg) + + This license applies to the libjpeg API library and associated programs, + including any code inherited from libjpeg and any modifications to that + code. Note that the libjpeg-turbo SIMD source code bears the + [zlib License](https://opensource.org/licenses/Zlib), but in the context of + the overall libjpeg API library, the terms of the zlib License are subsumed + by the terms of the IJG License. + +- The Modified (3-clause) BSD License, which is listed below + + This license applies to the TurboJPEG API library and associated programs, as + well as the build system. Note that the TurboJPEG API library wraps the + libjpeg API library, so in the context of the overall TurboJPEG API library, + both the terms of the IJG License and the terms of the Modified (3-clause) + BSD License apply. + + +Complying with the libjpeg-turbo Licenses +========================================= + +This section provides a roll-up of the libjpeg-turbo licensing terms, to the +best of our understanding. This is not a license in and of itself. It is +intended solely for clarification. + +1. If you are distributing a modified version of the libjpeg-turbo source, + then: + + 1. You cannot alter or remove any existing copyright or license notices + from the source. + + **Origin** + - Clause 1 of the IJG License + - Clause 1 of the Modified BSD License + - Clauses 1 and 3 of the zlib License + + 2. You must add your own copyright notice to the header of each source + file you modified, so others can tell that you modified that file. (If + there is not an existing copyright header in that file, then you can + simply add a notice stating that you modified the file.) + + **Origin** + - Clause 1 of the IJG License + - Clause 2 of the zlib License + + 3. You must include the IJG README file, and you must not alter any of the + copyright or license text in that file. + + **Origin** + - Clause 1 of the IJG License + +2. If you are distributing only libjpeg-turbo binaries without the source, or + if you are distributing an application that statically links with + libjpeg-turbo, then: + + 1. Your product documentation must include a message stating: + + This software is based in part on the work of the Independent JPEG + Group. + + **Origin** + - Clause 2 of the IJG license + + 2. If your binary distribution includes or uses the TurboJPEG API, then + your product documentation must include the text of the Modified BSD + License (see below.) + + **Origin** + - Clause 2 of the Modified BSD License + +3. You cannot use the name of the IJG or The libjpeg-turbo Project or the + contributors thereof in advertising, publicity, etc. + + **Origin** + - IJG License + - Clause 3 of the Modified BSD License + +4. The IJG and The libjpeg-turbo Project do not warrant libjpeg-turbo to be + free of defects, nor do we accept any liability for undesirable + consequences resulting from your use of the software. + + **Origin** + - IJG License + - Modified BSD License + - zlib License + + +The Modified (3-clause) BSD License +=================================== + +Copyright (C)2009-2024 D. R. Commander. All Rights Reserved.
+Copyright (C)2015 Viktor Szathmáry. All Rights Reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +- Neither the name of the libjpeg-turbo Project 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 HOLDERS 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. + + +Why Two Licenses? +================= + +The zlib License could have been used instead of the Modified (3-clause) BSD +License, and since the IJG License effectively subsumes the distribution +conditions of the zlib License, this would have effectively placed +libjpeg-turbo binary distributions under the IJG License. However, the IJG +License specifically refers to the Independent JPEG Group and does not extend +attribution and endorsement protections to other entities. Thus, it was +desirable to choose a license that granted us the same protections for new code +that were granted to the IJG for code derived from their software. diff --git a/docs/dependencies/licences/apps/mozjpeg/README.ijg b/docs/dependencies/licences/apps/mozjpeg/README.ijg new file mode 100644 index 0000000000..1be35958cf --- /dev/null +++ b/docs/dependencies/licences/apps/mozjpeg/README.ijg @@ -0,0 +1,258 @@ +libjpeg-turbo note: This file has been modified by The libjpeg-turbo Project +to include only information relevant to libjpeg-turbo, to wordsmith certain +sections, and to remove impolitic language that existed in the libjpeg v8 +README. It is included only for reference. Please see README.md for +information specific to libjpeg-turbo. + + +The Independent JPEG Group's JPEG software +========================================== + +This distribution contains a release of the Independent JPEG Group's free JPEG +software. You are welcome to redistribute this software and to use it for any +purpose, subject to the conditions under LEGAL ISSUES, below. + +This software is the work of Tom Lane, Guido Vollbeding, Philip Gladstone, +Bill Allombert, Jim Boucher, Lee Crocker, Bob Friesenhahn, Ben Jackson, +Julian Minguillon, Luis Ortiz, George Phillips, Davide Rossi, Ge' Weijers, +and other members of the Independent JPEG Group. + +IJG is not affiliated with the ISO/IEC JTC1/SC29/WG1 standards committee +(also known as JPEG, together with ITU-T SG16). + + +DOCUMENTATION ROADMAP +===================== + +This file contains the following sections: + +OVERVIEW General description of JPEG and the IJG software. +LEGAL ISSUES Copyright, lack of warranty, terms of distribution. +REFERENCES Where to learn more about JPEG. +ARCHIVE LOCATIONS Where to find newer versions of this software. +FILE FORMAT WARS Software *not* to get. +TO DO Plans for future IJG releases. + +Other documentation files in the distribution are: + +User documentation: + usage.txt Usage instructions for cjpeg, djpeg, jpegtran, + rdjpgcom, and wrjpgcom. + *.1 Unix-style man pages for programs (same info as usage.txt). + wizard.txt Advanced usage instructions for JPEG wizards only. + change.log Version-to-version change highlights. +Programmer and internal documentation: + libjpeg.txt How to use the JPEG library in your own programs. + example.c Sample code for calling the JPEG library. + structure.txt Overview of the JPEG library's internal structure. + coderules.txt Coding style rules --- please read if you contribute code. + +Please read at least usage.txt. Some information can also be found in the JPEG +FAQ (Frequently Asked Questions) article. See ARCHIVE LOCATIONS below to find +out where to obtain the FAQ article. + +If you want to understand how the JPEG code works, we suggest reading one or +more of the REFERENCES, then looking at the documentation files (in roughly +the order listed) before diving into the code. + + +OVERVIEW +======== + +This package contains C software to implement JPEG image encoding, decoding, +and transcoding. JPEG (pronounced "jay-peg") is a standardized compression +method for full-color and grayscale images. JPEG's strong suit is compressing +photographic images or other types of images that have smooth color and +brightness transitions between neighboring pixels. Images with sharp lines or +other abrupt features may not compress well with JPEG, and a higher JPEG +quality may have to be used to avoid visible compression artifacts with such +images. + +JPEG is normally lossy, meaning that the output pixels are not necessarily +identical to the input pixels. However, on photographic content and other +"smooth" images, very good compression ratios can be obtained with no visible +compression artifacts, and extremely high compression ratios are possible if +you are willing to sacrifice image quality (by reducing the "quality" setting +in the compressor.) + +This software implements JPEG baseline, extended-sequential, progressive, and +lossless compression processes. Provision is made for supporting all variants +of these processes, although some uncommon parameter settings aren't +implemented yet. We have made no provision for supporting the hierarchical +processes defined in the standard. + +We provide a set of library routines for reading and writing JPEG image files, +plus two sample applications "cjpeg" and "djpeg", which use the library to +perform conversion between JPEG and some other popular image file formats. +The library is intended to be reused in other applications. + +In order to support file conversion and viewing software, we have included +considerable functionality beyond the bare JPEG coding/decoding capability; +for example, the color quantization modules are not strictly part of JPEG +decoding, but they are essential for output to colormapped file formats. These +extra functions can be compiled out of the library if not required for a +particular application. + +We have also included "jpegtran", a utility for lossless transcoding between +different JPEG processes, and "rdjpgcom" and "wrjpgcom", two simple +applications for inserting and extracting textual comments in JFIF files. + +The emphasis in designing this software has been on achieving portability and +flexibility, while also making it fast enough to be useful. In particular, +the software is not intended to be read as a tutorial on JPEG. (See the +REFERENCES section for introductory material.) Rather, it is intended to +be reliable, portable, industrial-strength code. We do not claim to have +achieved that goal in every aspect of the software, but we strive for it. + +We welcome the use of this software as a component of commercial products. +No royalty is required, but we do ask for an acknowledgement in product +documentation, as described under LEGAL ISSUES. + + +LEGAL ISSUES +============ + +In plain English: + +1. We don't promise that this software works. (But if you find any bugs, + please let us know!) +2. You can use this software for whatever you want. You don't have to pay us. +3. You may not pretend that you wrote this software. If you use it in a + program, you must acknowledge somewhere in your documentation that + you've used the IJG code. + +In legalese: + +The authors make NO WARRANTY or representation, either express or implied, +with respect to this software, its quality, accuracy, merchantability, or +fitness for a particular purpose. This software is provided "AS IS", and you, +its user, assume the entire risk as to its quality and accuracy. + +This software is copyright (C) 1991-2020, Thomas G. Lane, Guido Vollbeding. +All Rights Reserved except as specified below. + +Permission is hereby granted to use, copy, modify, and distribute this +software (or portions thereof) for any purpose, without fee, subject to these +conditions: +(1) If any part of the source code for this software is distributed, then this +README file must be included, with this copyright and no-warranty notice +unaltered; and any additions, deletions, or changes to the original files +must be clearly indicated in accompanying documentation. +(2) If only executable code is distributed, then the accompanying +documentation must state that "this software is based in part on the work of +the Independent JPEG Group". +(3) Permission for use of this software is granted only if the user accepts +full responsibility for any undesirable consequences; the authors accept +NO LIABILITY for damages of any kind. + +These conditions apply to any software derived from or based on the IJG code, +not just to the unmodified library. If you use our work, you ought to +acknowledge us. + +Permission is NOT granted for the use of any IJG author's name or company name +in advertising or publicity relating to this software or products derived from +it. This software may be referred to only as "the Independent JPEG Group's +software". + +We specifically permit and encourage the use of this software as the basis of +commercial products, provided that all warranty or liability claims are +assumed by the product vendor. + + +REFERENCES +========== + +We recommend reading one or more of these references before trying to +understand the innards of the JPEG software. + +The best short technical introduction to the JPEG compression algorithm is + Wallace, Gregory K. "The JPEG Still Picture Compression Standard", + Communications of the ACM, April 1991 (vol. 34 no. 4), pp. 30-44. +(Adjacent articles in that issue discuss MPEG motion picture compression, +applications of JPEG, and related topics.) If you don't have the CACM issue +handy, a PDF file containing a revised version of Wallace's article is +available at http://www.ijg.org/files/Wallace.JPEG.pdf. The file (actually +a preprint for an article that appeared in IEEE Trans. Consumer Electronics) +omits the sample images that appeared in CACM, but it includes corrections +and some added material. Note: the Wallace article is copyright ACM and IEEE, +and it may not be used for commercial purposes. + +A somewhat less technical, more leisurely introduction to JPEG can be found in +"The Data Compression Book" by Mark Nelson and Jean-loup Gailly, published by +M&T Books (New York), 2nd ed. 1996, ISBN 1-55851-434-1. This book provides +good explanations and example C code for a multitude of compression methods +including JPEG. It is an excellent source if you are comfortable reading C +code but don't know much about data compression in general. The book's JPEG +sample code is far from industrial-strength, but when you are ready to look +at a full implementation, you've got one here... + +The best currently available description of JPEG is the textbook "JPEG Still +Image Data Compression Standard" by William B. Pennebaker and Joan L. +Mitchell, published by Van Nostrand Reinhold, 1993, ISBN 0-442-01272-1. +Price US$59.95, 638 pp. The book includes the complete text of the ISO JPEG +standards (DIS 10918-1 and draft DIS 10918-2). + +The original JPEG standard is divided into two parts, Part 1 being the actual +specification, while Part 2 covers compliance testing methods. Part 1 is +titled "Digital Compression and Coding of Continuous-tone Still Images, +Part 1: Requirements and guidelines" and has document numbers ISO/IEC IS +10918-1, ITU-T T.81. Part 2 is titled "Digital Compression and Coding of +Continuous-tone Still Images, Part 2: Compliance testing" and has document +numbers ISO/IEC IS 10918-2, ITU-T T.83. + +The JPEG standard does not specify all details of an interchangeable file +format. For the omitted details, we follow the "JFIF" conventions, revision +1.02. JFIF version 1 has been adopted as ISO/IEC 10918-5 (05/2013) and +Recommendation ITU-T T.871 (05/2011): Information technology - Digital +compression and coding of continuous-tone still images: JPEG File Interchange +Format (JFIF). It is available as a free download in PDF file format from +https://www.iso.org/standard/54989.html and http://www.itu.int/rec/T-REC-T.871. +A PDF file of the older JFIF 1.02 specification is available at +http://www.w3.org/Graphics/JPEG/jfif3.pdf. + +The TIFF 6.0 file format specification can be obtained from +http://mirrors.ctan.org/graphics/tiff/TIFF6.ps.gz. The JPEG incorporation +scheme found in the TIFF 6.0 spec of 3-June-92 has a number of serious +problems. IJG does not recommend use of the TIFF 6.0 design (TIFF Compression +tag 6). Instead, we recommend the JPEG design proposed by TIFF Technical Note +#2 (Compression tag 7). Copies of this Note can be obtained from +http://www.ijg.org/files/. It is expected that the next revision +of the TIFF spec will replace the 6.0 JPEG design with the Note's design. +Although IJG's own code does not support TIFF/JPEG, the free libtiff library +uses our library to implement TIFF/JPEG per the Note. + + +ARCHIVE LOCATIONS +================= + +The "official" archive site for this software is www.ijg.org. +The most recent released version can always be found there in +directory "files". + +The JPEG FAQ (Frequently Asked Questions) article is a source of some +general information about JPEG. It is available at +http://www.faqs.org/faqs/jpeg-faq. + + +FILE FORMAT COMPATIBILITY +========================= + +This software implements ITU T.81 | ISO/IEC 10918 with some extensions from +ITU T.871 | ISO/IEC 10918-5 (JPEG File Interchange Format-- see REFERENCES). +Informally, the term "JPEG image" or "JPEG file" most often refers to JFIF or +a subset thereof, but there are other formats containing the name "JPEG" that +are incompatible with the original JPEG standard or with JFIF (for instance, +JPEG 2000 and JPEG XR). This software therefore does not support these +formats. Indeed, one of the original reasons for developing this free software +was to help force convergence on a common, interoperable format standard for +JPEG files. + +JFIF is a minimal or "low end" representation. TIFF/JPEG (TIFF revision 6.0 as +modified by TIFF Technical Note #2) can be used for "high end" applications +that need to record a lot of additional data about an image. + + +TO DO +===== + +Please send bug reports, offers of help, etc. to jpeg-info@jpegclub.org. diff --git a/docs/dependencies/licences/apps/stb_image_resize/LICENSE b/docs/dependencies/licences/apps/stb_image_resize/LICENSE new file mode 100644 index 0000000000..a77ae91f3e --- /dev/null +++ b/docs/dependencies/licences/apps/stb_image_resize/LICENSE @@ -0,0 +1,37 @@ +This software is available under 2 licenses -- choose whichever you prefer. +------------------------------------------------------------------------------ +ALTERNATIVE A - MIT License +Copyright (c) 2017 Sean Barrett +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. +------------------------------------------------------------------------------ +ALTERNATIVE B - Public Domain (www.unlicense.org) +This is free and unencumbered software released into the public domain. +Anyone is free to copy, modify, publish, use, compile, sell, or distribute this +software, either in source code form or as a compiled binary, for any purpose, +commercial or non-commercial, and by any means. +In jurisdictions that recognize copyright laws, the author or authors of this +software dedicate any and all copyright interest in the software to the public +domain. We make this dedication for the benefit of the public at large and to +the detriment of our heirs and successors. We intend this dedication to be an +overt act of relinquishment in perpetuity of all present and future rights to +this software under copyright law. +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 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. diff --git a/docs/dependencies/licences/haskell/JuicyPixels-3.3.8/LICENSE b/docs/dependencies/licences/haskell/JuicyPixels-3.3.8/LICENSE new file mode 100644 index 0000000000..e55c66e90e --- /dev/null +++ b/docs/dependencies/licences/haskell/JuicyPixels-3.3.8/LICENSE @@ -0,0 +1,30 @@ +Copyright (c)2011, Vincent Berthoux + +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 Vincent Berthoux nor the names of other + 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 +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/JuicyPixels-jpeg-turbo-0.1.0.0/LICENSE b/docs/dependencies/licences/haskell/JuicyPixels-jpeg-turbo-0.1.0.0/LICENSE new file mode 100644 index 0000000000..0c6b318ca8 --- /dev/null +++ b/docs/dependencies/licences/haskell/JuicyPixels-jpeg-turbo-0.1.0.0/LICENSE @@ -0,0 +1,27 @@ +Copyright 2024 IC Rainbow +Copyright 2026 SimpleX Chat Ltd + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder 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. diff --git a/docs/dependencies/licences/haskell/JuicyPixels-spng-0.1.0.0/LICENSE b/docs/dependencies/licences/haskell/JuicyPixels-spng-0.1.0.0/LICENSE new file mode 100644 index 0000000000..0283a86be5 --- /dev/null +++ b/docs/dependencies/licences/haskell/JuicyPixels-spng-0.1.0.0/LICENSE @@ -0,0 +1,26 @@ +Copyright 2026 SimpleX Chat Ltd + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder 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. diff --git a/docs/dependencies/licences/haskell/JuicyPixels-stbir-0.1.0.0/LICENSE b/docs/dependencies/licences/haskell/JuicyPixels-stbir-0.1.0.0/LICENSE new file mode 100644 index 0000000000..c1a3ad3a4a --- /dev/null +++ b/docs/dependencies/licences/haskell/JuicyPixels-stbir-0.1.0.0/LICENSE @@ -0,0 +1,31 @@ +Copyright 2014 - 2017 Jorge L Rodriguez, Michael Tolly +Copyright 2026 SimpleX Chat Ltd + +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 Author name here nor the names of other + 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 +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/dependencies/licences/haskell/jpeg-turbo-0.1.0.0/LICENSE b/docs/dependencies/licences/haskell/jpeg-turbo-0.1.0.0/LICENSE new file mode 100644 index 0000000000..0c6b318ca8 --- /dev/null +++ b/docs/dependencies/licences/haskell/jpeg-turbo-0.1.0.0/LICENSE @@ -0,0 +1,27 @@ +Copyright 2024 IC Rainbow +Copyright 2026 SimpleX Chat Ltd + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder 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. diff --git a/flake.nix b/flake.nix index 26ece60cbf..2125c470c5 100644 --- a/flake.nix +++ b/flake.nix @@ -68,6 +68,10 @@ ({ pkgs,lib, ... }: lib.mkIf (pkgs.stdenv.hostPlatform.isAndroid) { packages.simplex-chat.components.library.ghcOptions = [ "-pie" ]; + }) + + ({ lib, ... }: { + packages.JuicyPixels-spng.package.license = lib.mkForce "BSD-3-Clause AND BSD-2-Clause AND MIT"; })] ++ extra-modules; }; in # by defualt we don't need to pass extra-modules. @@ -484,6 +488,12 @@ "chat_badge_keygen" "chat_badge_issue" "chat_write_file" + "chat_resize_image_uri" + "chat_resize_image" + "chat_resize_image_file_uri" + "chat_resize_image_file" + "chat_resize_image_fd_uri" + "chat_resize_image_fd" ]; postInstall = '' set -x diff --git a/libsimplex.dll.def b/libsimplex.dll.def index 79b88dc782..f797de3023 100644 --- a/libsimplex.dll.def +++ b/libsimplex.dll.def @@ -26,4 +26,10 @@ EXPORTS chat_write_file chat_read_file chat_encrypt_file - chat_decrypt_file \ No newline at end of file + chat_decrypt_file + chat_resize_image_uri + chat_resize_image + chat_resize_image_file_uri + chat_resize_image_file + chat_resize_image_fd_uri + chat_resize_image_fd \ No newline at end of file diff --git a/packages/simplex-chat-nodejs/README.md b/packages/simplex-chat-nodejs/README.md index 31338c5142..6593e4f858 100644 --- a/packages/simplex-chat-nodejs/README.md +++ b/packages/simplex-chat-nodejs/README.md @@ -134,7 +134,7 @@ The library docs are [here](./docs/README.md). Library provides these modules: - [bot](./docs/Namespace.bot.md): a simple declarative API to run a chat-bot with a single function call. It automates creating and updating of the bot profile, address and bot commands shown in the app UI. - [api](./docs/Namespace.api.md): an API to send chat commands and receive chat events to/from chat core. You need to use it in bot event handlers, and for any other use cases. -- [core](./docs/Namespace.core.md): a low level API to the core library - the same that is used in desktop clients. You are unlikely to ever need to use this module directly. +- [core](./docs/Namespace.core.md): a low level API to the core library - the same that is used in desktop clients. You are unlikely to ever need to use this module directly, except for image resizing. - [util](./docs/Namespace.util.md): useful functions for chat events and types. diff --git a/packages/simplex-chat-nodejs/cpp/simplex.cc b/packages/simplex-chat-nodejs/cpp/simplex.cc index 6cc2172ee1..50262aca0f 100644 --- a/packages/simplex-chat-nodejs/cpp/simplex.cc +++ b/packages/simplex-chat-nodejs/cpp/simplex.cc @@ -36,6 +36,8 @@ struct Library { chat_read_file_fn chat_read_file = nullptr; chat_encrypt_file_fn chat_encrypt_file = nullptr; chat_decrypt_file_fn chat_decrypt_file = nullptr; + chat_resize_image_fn chat_resize_image = nullptr; + chat_resize_image_uri_fn chat_resize_image_uri = nullptr; }; static Library lib; @@ -136,6 +138,45 @@ static bool RequireLoaded(Env env) { return false; } +template +static bool RequireExport(Env env, F fn, const std::string& name, const std::string& feature) { + if (fn != nullptr) return true; + Error::New(env, "loaded libsimplex does not export " + name + "; " + feature + " needs a newer libsimplex").ThrowAsJavaScriptException(); + return false; +} + +static bool GetInt32Arg(Env env, const Value& value, const std::string& name, int& result) { + Number number = value.As(); + result = number.Int32Value(); + if (static_cast(result) == number.DoubleValue()) return true; + RangeError::New(env, "Expected 32-bit integer " + name).ThrowAsJavaScriptException(); + return false; +} + +struct BufferArg { + char* data; + int len; +}; + +static bool GetBufferArg(Env env, const Value& value, BufferArg& result) { + size_t len; + if (value.IsArrayBuffer()) { + ArrayBuffer ab = value.As(); + result.data = static_cast(ab.Data()); + len = ab.ByteLength(); + } else { + TypedArray view = value.As(); + result.data = static_cast(view.ArrayBuffer().Data()) + view.ByteOffset(); + len = view.ByteLength(); + } + if (len > static_cast(INT_MAX)) { + RangeError::New(env, "Buffer is too large").ThrowAsJavaScriptException(); + return false; + } + result.len = static_cast(len); + return true; +} + Value Load(const CallbackInfo& args) { Env env = args.Env(); if (args.Length() < 1 || !args[0].IsString()) { @@ -173,6 +214,8 @@ Value Load(const CallbackInfo& args) { return env.Undefined(); } Resolve(h, "chat_migrate_init_queue", l.chat_migrate_init_queue); + Resolve(h, "chat_resize_image", l.chat_resize_image); + Resolve(h, "chat_resize_image_uri", l.chat_resize_image_uri); Resolve(h, "hs_thread_done", l.hs_thread_done); lib = l; haskell_init(); @@ -527,10 +570,7 @@ Value ChatMigrateInit(const CallbackInfo& args) { Value ChatMigrateInitQueue(const CallbackInfo& args) { Env env = args.Env(); if (!RequireLoaded(env)) return env.Undefined(); - if (lib.chat_migrate_init_queue == nullptr) { - Error::New(env, "loaded libsimplex does not export chat_migrate_init_queue; queue size needs a newer libsimplex").ThrowAsJavaScriptException(); - return env.Undefined(); - } + if (!RequireExport(env, lib.chat_migrate_init_queue, "chat_migrate_init_queue", "queue size")) return env.Undefined(); if (args.Length() < 4 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString() || !args[3].IsNumber()) { TypeError::New(env, "Expected three string arguments and number").ThrowAsJavaScriptException(); return env.Undefined(); @@ -539,12 +579,8 @@ Value ChatMigrateInitQueue(const CallbackInfo& args) { std::string path = args[0].As().Utf8Value(); std::string key = args[1].As().Utf8Value(); std::string confirm = args[2].As().Utf8Value(); - Number queue_size_arg = args[3].As(); - int queue_size = queue_size_arg.Int32Value(); - if (static_cast(queue_size) != queue_size_arg.DoubleValue()) { - RangeError::New(env, "Expected 32-bit integer queue size").ThrowAsJavaScriptException(); - return env.Undefined(); - } + int queue_size; + if (!GetInt32Arg(env, args[3], "queue size", queue_size)) return env.Undefined(); Function cb; Promise promise = CreatePromiseAndCallback(env, cb); @@ -657,27 +693,14 @@ Value ChatWriteFile(const CallbackInfo& args) { chat_ctrl ctrl = FromChatCtrlBigInt(args[0]); std::string path = args[1].As().Utf8Value(); - char* data; - size_t len; - if (args[2].IsArrayBuffer()) { - ArrayBuffer ab = args[2].As(); - data = static_cast(ab.Data()); - len = ab.ByteLength(); - } else { - TypedArray view = args[2].As(); - data = static_cast(view.ArrayBuffer().Data()) + view.ByteOffset(); - len = view.ByteLength(); - } - if (len > static_cast(INT_MAX)) { - RangeError::New(env, "Buffer is too large").ThrowAsJavaScriptException(); - return env.Undefined(); - } + BufferArg buffer; + if (!GetBufferArg(env, args[2], buffer)) return env.Undefined(); Function cb; Promise promise = CreatePromiseAndCallback(env, cb); - auto execute_fn = [ctrl, path, data, len](ResultAsyncWorker* worker) { - char* c_res = lib.chat_write_file(ctrl, path.c_str(), data, static_cast(len)); + auto execute_fn = [ctrl, path, buffer](ResultAsyncWorker* worker) { + char* c_res = lib.chat_write_file(ctrl, path.c_str(), buffer.data, buffer.len); HandleCResult(worker, c_res, "chat_write_file"); }; @@ -785,6 +808,66 @@ Value ChatDecryptFile(const CallbackInfo& args) { return promise; } +Value ChatResizeImage(const CallbackInfo& args) { + Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); + if (!RequireExport(env, lib.chat_resize_image, "chat_resize_image", "image resizing")) return env.Undefined(); + if (args.Length() < 3 || !(args[0].IsArrayBuffer() || args[0].IsTypedArray()) || !args[1].IsNumber() || !args[2].IsNumber()) { + TypeError::New(env, "Expected ArrayBuffer or Uint8Array, number (maxSize), number (png)").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + BufferArg image; + if (!GetBufferArg(env, args[0], image)) return env.Undefined(); + int max_size; + if (!GetInt32Arg(env, args[1], "maxSize", max_size)) return env.Undefined(); + int png; + if (!GetInt32Arg(env, args[2], "png", png)) return env.Undefined(); + + Function cb; + Promise promise = CreatePromiseAndCallback(env, cb); + + auto execute_fn = [image, max_size, png](ResultAsyncWorker* worker) { + char* c_res = lib.chat_resize_image(image.data, image.len, max_size, png); + HandleCResult(worker, c_res, "chat_resize_image"); + }; + + ResultAsyncWorker* worker = new ResultAsyncWorker(cb, std::move(execute_fn)); + worker->KeepAlive(args[0].As()); + worker->Queue(); + + return promise; +} + +Value ChatResizeImageUri(const CallbackInfo& args) { + Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); + if (!RequireExport(env, lib.chat_resize_image_uri, "chat_resize_image_uri", "image resizing")) return env.Undefined(); + if (args.Length() < 2 || !(args[0].IsArrayBuffer() || args[0].IsTypedArray()) || !args[1].IsNumber()) { + TypeError::New(env, "Expected ArrayBuffer or Uint8Array, number (maxSize)").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + BufferArg image; + if (!GetBufferArg(env, args[0], image)) return env.Undefined(); + int max_size; + if (!GetInt32Arg(env, args[1], "maxSize", max_size)) return env.Undefined(); + + Function cb; + Promise promise = CreatePromiseAndCallback(env, cb); + + auto execute_fn = [image, max_size](ResultAsyncWorker* worker) { + char* c_res = lib.chat_resize_image_uri(image.data, image.len, max_size); + HandleCResult(worker, c_res, "chat_resize_image_uri"); + }; + + ResultAsyncWorker* worker = new ResultAsyncWorker(cb, std::move(execute_fn)); + worker->KeepAlive(args[0].As()); + worker->Queue(); + + return promise; +} + Object Init(Env env, Object exports) { exports.Set("load", Function::New(env, Load)); auto* receivers = new Receivers(); @@ -804,6 +887,8 @@ Object Init(Env env, Object exports) { exports.Set("chat_read_file", Function::New(env, ChatReadFile)); exports.Set("chat_encrypt_file", Function::New(env, ChatEncryptFile)); exports.Set("chat_decrypt_file", Function::New(env, ChatDecryptFile)); + exports.Set("chat_resize_image", Function::New(env, ChatResizeImage)); + exports.Set("chat_resize_image_uri", Function::New(env, ChatResizeImageUri)); return exports; } diff --git a/packages/simplex-chat-nodejs/cpp/simplex.h b/packages/simplex-chat-nodejs/cpp/simplex.h index 7c44313295..525740382d 100644 --- a/packages/simplex-chat-nodejs/cpp/simplex.h +++ b/packages/simplex-chat-nodejs/cpp/simplex.h @@ -35,4 +35,10 @@ typedef char *(*chat_encrypt_file_fn)(chat_ctrl ctrl, const char *fromPath, cons // chat_decrypt_file returns null-terminated string with the error message typedef char *(*chat_decrypt_file_fn)(const char *fromPath, const char *key, const char *nonce, const char *toPath); +// chat_resize_image returns base64 of at most maxSize bytes of PNG (png > 0), JPEG (png == 0), or PNG for an image with transparency, otherwise JPEG (png < 0), or an empty string +typedef char *(*chat_resize_image_fn)(const char *data, int len, int maxSize, int png); + +// chat_resize_image_uri returns a data URI of at most maxSize characters, or an empty string +typedef char *(*chat_resize_image_uri_fn)(const char *data, int len, int maxSize); + #endif /* simplex_h */ \ No newline at end of file diff --git a/packages/simplex-chat-nodejs/docs/Namespace.core.md b/packages/simplex-chat-nodejs/docs/Namespace.core.md index fdf9f8b4dd..6ad3907334 100644 --- a/packages/simplex-chat-nodejs/docs/Namespace.core.md +++ b/packages/simplex-chat-nodejs/docs/Namespace.core.md @@ -7,7 +7,7 @@ # core A low level API to the core library - the same that is used in desktop clients. -You are unlikely to ever need to use this module directly. +You are unlikely to ever need to use this module directly, except for image resizing. ## Namespaces @@ -17,6 +17,7 @@ You are unlikely to ever need to use this module directly. ## Enumerations +- [ImageSize](core.Enumeration.ImageSize.md) - [MigrationConfirmation](core.Enumeration.MigrationConfirmation.md) ## Classes @@ -45,6 +46,9 @@ You are unlikely to ever need to use this module directly. - [chatMigrateInit](core.Function.chatMigrateInit.md) - [chatReadFile](core.Function.chatReadFile.md) - [chatRecvMsgWait](core.Function.chatRecvMsgWait.md) +- [chatResizeImage](core.Function.chatResizeImage.md) +- [chatResizeImageUri](core.Function.chatResizeImageUri.md) - [chatSendCmd](core.Function.chatSendCmd.md) - [chatWriteFile](core.Function.chatWriteFile.md) - [loadLibrary](core.Function.loadLibrary.md) +- [resizeImage](core.Function.resizeImage.md) diff --git a/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md b/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md index 19e6343198..7b011f8b31 100644 --- a/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md +++ b/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md @@ -6,7 +6,7 @@ # Class: ChatAPIError -Defined in: [src/core.ts:117](../core.ts#L117) +Defined in: [src/core.ts:154](../core.ts#L154) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:117](../core.ts#L117) > **new ChatAPIError**(`message`, `chatError?`): `ChatAPIError` -Defined in: [src/core.ts:118](../core.ts#L118) +Defined in: [src/core.ts:155](../core.ts#L155) #### Parameters @@ -44,7 +44,7 @@ Defined in: [src/core.ts:118](../core.ts#L118) > **chatError**: `ChatError` \| `undefined` = `undefined` -Defined in: [src/core.ts:118](../core.ts#L118) +Defined in: [src/core.ts:155](../core.ts#L155) *** @@ -52,7 +52,7 @@ Defined in: [src/core.ts:118](../core.ts#L118) > **message**: `string` -Defined in: [src/core.ts:118](../core.ts#L118) +Defined in: [src/core.ts:155](../core.ts#L155) #### Inherited from diff --git a/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md b/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md index 22fc7607cb..2ada4ed60e 100644 --- a/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md +++ b/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md @@ -6,7 +6,7 @@ # Class: ChatInitError -Defined in: [src/core.ts:141](../core.ts#L141) +Defined in: [src/core.ts:187](../core.ts#L187) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:141](../core.ts#L141) > **new ChatInitError**(`message`, `dbMigrationError`): `ChatInitError` -Defined in: [src/core.ts:142](../core.ts#L142) +Defined in: [src/core.ts:188](../core.ts#L188) #### Parameters @@ -44,7 +44,7 @@ Defined in: [src/core.ts:142](../core.ts#L142) > **dbMigrationError**: [`DBMigrationError`](core.TypeAlias.DBMigrationError.md) -Defined in: [src/core.ts:142](../core.ts#L142) +Defined in: [src/core.ts:188](../core.ts#L188) *** @@ -52,7 +52,7 @@ Defined in: [src/core.ts:142](../core.ts#L142) > **message**: `string` -Defined in: [src/core.ts:142](../core.ts#L142) +Defined in: [src/core.ts:188](../core.ts#L188) #### Inherited from diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md index d894205865..d2e83f6417 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md @@ -6,7 +6,7 @@ # Interface: ErrorMigration -Defined in: [src/core.ts:174](../core.ts#L174) +Defined in: [src/core.ts:220](../core.ts#L220) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:174](../core.ts#L174) > **dbFile**: `string` -Defined in: [src/core.ts:176](../core.ts#L176) +Defined in: [src/core.ts:222](../core.ts#L222) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:176](../core.ts#L176) > **migrationError**: [`MigrationError`](core.TypeAlias.MigrationError.md) -Defined in: [src/core.ts:177](../core.ts#L177) +Defined in: [src/core.ts:223](../core.ts#L223) *** @@ -34,7 +34,7 @@ Defined in: [src/core.ts:177](../core.ts#L177) > **type**: `"errorMigration"` -Defined in: [src/core.ts:175](../core.ts#L175) +Defined in: [src/core.ts:221](../core.ts#L221) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md index 9944389b3b..56191ed10b 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md @@ -6,7 +6,7 @@ # Interface: ErrorNotADatabase -Defined in: [src/core.ts:169](../core.ts#L169) +Defined in: [src/core.ts:215](../core.ts#L215) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:169](../core.ts#L169) > **dbFile**: `string` -Defined in: [src/core.ts:171](../core.ts#L171) +Defined in: [src/core.ts:217](../core.ts#L217) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:171](../core.ts#L171) > **type**: `"errorNotADatabase"` -Defined in: [src/core.ts:170](../core.ts#L170) +Defined in: [src/core.ts:216](../core.ts#L216) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md index 108e5e226a..cd9b581b50 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md @@ -6,7 +6,7 @@ # Interface: ErrorSQL -Defined in: [src/core.ts:180](../core.ts#L180) +Defined in: [src/core.ts:226](../core.ts#L226) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:180](../core.ts#L180) > **dbFile**: `string` -Defined in: [src/core.ts:182](../core.ts#L182) +Defined in: [src/core.ts:228](../core.ts#L228) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:182](../core.ts#L182) > **migrationSQLError**: `string` -Defined in: [src/core.ts:183](../core.ts#L183) +Defined in: [src/core.ts:229](../core.ts#L229) *** @@ -34,7 +34,7 @@ Defined in: [src/core.ts:183](../core.ts#L183) > **type**: `"errorSQL"` -Defined in: [src/core.ts:181](../core.ts#L181) +Defined in: [src/core.ts:227](../core.ts#L227) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md index a1abcdf993..1ca4738f33 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md @@ -6,7 +6,7 @@ # Interface: InvalidConfirmation -Defined in: [src/core.ts:161](../core.ts#L161) +Defined in: [src/core.ts:207](../core.ts#L207) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:161](../core.ts#L161) > **type**: `"invalidConfirmation"` -Defined in: [src/core.ts:162](../core.ts#L162) +Defined in: [src/core.ts:208](../core.ts#L208) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md index f59284b129..52747d7ddf 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md @@ -6,7 +6,7 @@ # Interface: InvalidQueueSize -Defined in: [src/core.ts:165](../core.ts#L165) +Defined in: [src/core.ts:211](../core.ts#L211) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:165](../core.ts#L165) > **type**: `"invalidQueueSize"` -Defined in: [src/core.ts:166](../core.ts#L166) +Defined in: [src/core.ts:212](../core.ts#L212) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md index c83f29a130..9985951796 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md @@ -8,4 +8,4 @@ > **Tag** = `"invalidConfirmation"` \| `"invalidQueueSize"` \| `"errorNotADatabase"` \| `"errorMigration"` \| `"errorSQL"` -Defined in: [src/core.ts:155](../core.ts#L155) +Defined in: [src/core.ts:201](../core.ts#L201) diff --git a/packages/simplex-chat-nodejs/docs/core.Enumeration.ImageSize.md b/packages/simplex-chat-nodejs/docs/core.Enumeration.ImageSize.md new file mode 100644 index 0000000000..eb138bfac9 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Enumeration.ImageSize.md @@ -0,0 +1,35 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / ImageSize + +# Enumeration: ImageSize + +Defined in: [src/core.ts:173](../core.ts#L173) + +App image size limit + +## Enumeration Members + +### Image + +> **Image**: `"image"` + +Defined in: [src/core.ts:174](../core.ts#L174) + +*** + +### MessagePreview + +> **MessagePreview**: `"messagePreview"` + +Defined in: [src/core.ts:176](../core.ts#L176) + +*** + +### ProfilePreview + +> **ProfilePreview**: `"profilePreview"` + +Defined in: [src/core.ts:175](../core.ts#L175) diff --git a/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md b/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md index a0df879c4c..e79730e366 100644 --- a/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md +++ b/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md @@ -6,7 +6,7 @@ # Enumeration: MigrationConfirmation -Defined in: [src/core.ts:126](../core.ts#L126) +Defined in: [src/core.ts:163](../core.ts#L163) Migration confirmation mode @@ -16,7 +16,7 @@ Migration confirmation mode > **Console**: `"console"` -Defined in: [src/core.ts:129](../core.ts#L129) +Defined in: [src/core.ts:166](../core.ts#L166) *** @@ -24,7 +24,7 @@ Defined in: [src/core.ts:129](../core.ts#L129) > **Error**: `"error"` -Defined in: [src/core.ts:130](../core.ts#L130) +Defined in: [src/core.ts:167](../core.ts#L167) *** @@ -32,7 +32,7 @@ Defined in: [src/core.ts:130](../core.ts#L130) > **YesUp**: `"yesUp"` -Defined in: [src/core.ts:127](../core.ts#L127) +Defined in: [src/core.ts:164](../core.ts#L164) *** @@ -40,4 +40,4 @@ Defined in: [src/core.ts:127](../core.ts#L127) > **YesUpDown**: `"yesUpDown"` -Defined in: [src/core.ts:128](../core.ts#L128) +Defined in: [src/core.ts:165](../core.ts#L165) diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatResizeImage.md b/packages/simplex-chat-nodejs/docs/core.Function.chatResizeImage.md new file mode 100644 index 0000000000..14c4ee4590 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatResizeImage.md @@ -0,0 +1,31 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / chatResizeImage + +# Function: chatResizeImage() + +> **chatResizeImage**(`image`, `maxSize`, `format`): `Promise`\<`Buffer`\<`ArrayBufferLike`\>\> + +Defined in: [src/core.ts:122](../core.ts#L122) + +Resize image to JPEG or PNG of at most maxSize bytes + +## Parameters + +### image + +`ArrayBuffer` \| `Uint8Array`\<`ArrayBufferLike`\> + +### maxSize + +`number` + +### format + +`"jpeg"` \| `"png"` + +## Returns + +`Promise`\<`Buffer`\<`ArrayBufferLike`\>\> diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatResizeImageUri.md b/packages/simplex-chat-nodejs/docs/core.Function.chatResizeImageUri.md new file mode 100644 index 0000000000..d9e0873456 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatResizeImageUri.md @@ -0,0 +1,27 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / chatResizeImageUri + +# Function: chatResizeImageUri() + +> **chatResizeImageUri**(`image`, `maxSize`): `Promise`\<`string`\> + +Defined in: [src/core.ts:115](../core.ts#L115) + +Resize image to a data URI of at most maxSize characters + +## Parameters + +### image + +`ArrayBuffer` \| `Uint8Array`\<`ArrayBufferLike`\> + +### maxSize + +`number` + +## Returns + +`Promise`\<`string`\> diff --git a/packages/simplex-chat-nodejs/docs/core.Function.resizeImage.md b/packages/simplex-chat-nodejs/docs/core.Function.resizeImage.md new file mode 100644 index 0000000000..820753caf3 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Function.resizeImage.md @@ -0,0 +1,73 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / resizeImage + +# Function: resizeImage() + +## Call Signature + +> **resizeImage**(`image`, `size`): `Promise`\<`Buffer`\<`ArrayBufferLike`\>\> + +Defined in: [src/core.ts:129](../core.ts#L129) + +Resize image to the app size limit: JPEG or PNG for an image, a data URI for a preview + +### Parameters + +#### image + +`ArrayBuffer` \| `Uint8Array`\<`ArrayBufferLike`\> + +#### size + +[`Image`](core.Enumeration.ImageSize.md#image) + +### Returns + +`Promise`\<`Buffer`\<`ArrayBufferLike`\>\> + +## Call Signature + +> **resizeImage**(`image`, `size`): `Promise`\<`string`\> + +Defined in: [src/core.ts:130](../core.ts#L130) + +Resize image to the app size limit: JPEG or PNG for an image, a data URI for a preview + +### Parameters + +#### image + +`ArrayBuffer` \| `Uint8Array`\<`ArrayBufferLike`\> + +#### size + +[`ProfilePreview`](core.Enumeration.ImageSize.md#profilepreview) \| [`MessagePreview`](core.Enumeration.ImageSize.md#messagepreview) + +### Returns + +`Promise`\<`string`\> + +## Call Signature + +> **resizeImage**(`image`, `size`): `Promise`\<`string` \| `Buffer`\<`ArrayBufferLike`\>\> + +Defined in: [src/core.ts:131](../core.ts#L131) + +Resize image to the app size limit: JPEG or PNG for an image, a data URI for a preview + +### Parameters + +#### image + +`ArrayBuffer` \| `Uint8Array`\<`ArrayBufferLike`\> + +#### size + +[`ImageSize`](core.Enumeration.ImageSize.md) + +### Returns + +`Promise`\<`string` \| `Buffer`\<`ArrayBufferLike`\>\> diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md b/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md index 3c03930c57..780b17d43e 100644 --- a/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md +++ b/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md @@ -6,7 +6,7 @@ # Interface: APIResult\ -Defined in: [src/core.ts:112](../core.ts#L112) +Defined in: [src/core.ts:149](../core.ts#L149) ## Type Parameters @@ -20,7 +20,7 @@ Defined in: [src/core.ts:112](../core.ts#L112) > `optional` **error?**: `ChatError` -Defined in: [src/core.ts:114](../core.ts#L114) +Defined in: [src/core.ts:151](../core.ts#L151) *** @@ -28,4 +28,4 @@ Defined in: [src/core.ts:114](../core.ts#L114) > `optional` **result?**: `R` -Defined in: [src/core.ts:113](../core.ts#L113) +Defined in: [src/core.ts:150](../core.ts#L150) diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md b/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md index 3531bad332..787081bbaa 100644 --- a/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md +++ b/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md @@ -6,7 +6,7 @@ # Interface: CryptoArgs -Defined in: [src/core.ts:136](../core.ts#L136) +Defined in: [src/core.ts:182](../core.ts#L182) File encryption key and nonce @@ -16,7 +16,7 @@ File encryption key and nonce > **fileKey**: `string` -Defined in: [src/core.ts:137](../core.ts#L137) +Defined in: [src/core.ts:183](../core.ts#L183) *** @@ -24,4 +24,4 @@ Defined in: [src/core.ts:137](../core.ts#L137) > **fileNonce**: `string` -Defined in: [src/core.ts:138](../core.ts#L138) +Defined in: [src/core.ts:184](../core.ts#L184) diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md b/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md index 061cb346ac..f701655696 100644 --- a/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md +++ b/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md @@ -6,7 +6,7 @@ # Interface: UpMigration -Defined in: [src/core.ts:215](../core.ts#L215) +Defined in: [src/core.ts:261](../core.ts#L261) ## Properties @@ -14,7 +14,7 @@ Defined in: [src/core.ts:215](../core.ts#L215) > **upName**: `string` -Defined in: [src/core.ts:216](../core.ts#L216) +Defined in: [src/core.ts:262](../core.ts#L262) *** @@ -22,4 +22,4 @@ Defined in: [src/core.ts:216](../core.ts#L216) > **withDown**: `boolean` -Defined in: [src/core.ts:217](../core.ts#L217) +Defined in: [src/core.ts:263](../core.ts#L263) diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md index 406e9a054b..eb9b0cf133 100644 --- a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md @@ -6,7 +6,7 @@ # Interface: MTREDifferent -Defined in: [src/core.ts:236](../core.ts#L236) +Defined in: [src/core.ts:282](../core.ts#L282) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:236](../core.ts#L236) > **downMigrations**: `string`[] -Defined in: [src/core.ts:238](../core.ts#L238) +Defined in: [src/core.ts:284](../core.ts#L284) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:238](../core.ts#L238) > **type**: `"different"` -Defined in: [src/core.ts:237](../core.ts#L237) +Defined in: [src/core.ts:283](../core.ts#L283) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md index 8cdb1141ce..ce89a18e22 100644 --- a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md @@ -6,7 +6,7 @@ # Interface: MTRENoDown -Defined in: [src/core.ts:231](../core.ts#L231) +Defined in: [src/core.ts:277](../core.ts#L277) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:231](../core.ts#L231) > **dbMigrations**: `string`[] -Defined in: [src/core.ts:233](../core.ts#L233) +Defined in: [src/core.ts:279](../core.ts#L279) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:233](../core.ts#L233) > **type**: `"noDown"` -Defined in: [src/core.ts:232](../core.ts#L232) +Defined in: [src/core.ts:278](../core.ts#L278) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md index 5d92b7dc67..40f46eb8a4 100644 --- a/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md @@ -8,4 +8,4 @@ > **Tag** = `"noDown"` \| `"different"` -Defined in: [src/core.ts:225](../core.ts#L225) +Defined in: [src/core.ts:271](../core.ts#L271) diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md index d8dadcf2da..0fa730bcb5 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md @@ -6,7 +6,7 @@ # Interface: MEDowngrade -Defined in: [src/core.ts:204](../core.ts#L204) +Defined in: [src/core.ts:250](../core.ts#L250) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:204](../core.ts#L204) > **downMigrations**: `string`[] -Defined in: [src/core.ts:206](../core.ts#L206) +Defined in: [src/core.ts:252](../core.ts#L252) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:206](../core.ts#L206) > **type**: `"downgrade"` -Defined in: [src/core.ts:205](../core.ts#L205) +Defined in: [src/core.ts:251](../core.ts#L251) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md index 7414f4309d..e788b1ab75 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md @@ -6,7 +6,7 @@ # Interface: MEUpgrade -Defined in: [src/core.ts:199](../core.ts#L199) +Defined in: [src/core.ts:245](../core.ts#L245) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:199](../core.ts#L199) > **type**: `"upgrade"` -Defined in: [src/core.ts:200](../core.ts#L200) +Defined in: [src/core.ts:246](../core.ts#L246) #### Overrides @@ -30,4 +30,4 @@ Defined in: [src/core.ts:200](../core.ts#L200) > **upMigrations**: [`UpMigration`](core.Interface.UpMigration.md)[] -Defined in: [src/core.ts:201](../core.ts#L201) +Defined in: [src/core.ts:247](../core.ts#L247) diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md index b27f2a2f68..ece57de625 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md @@ -6,7 +6,7 @@ # Interface: MigrationError -Defined in: [src/core.ts:209](../core.ts#L209) +Defined in: [src/core.ts:255](../core.ts#L255) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:209](../core.ts#L209) > **mtrError**: [`MTRError`](core.TypeAlias.MTRError.md) -Defined in: [src/core.ts:211](../core.ts#L211) +Defined in: [src/core.ts:257](../core.ts#L257) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:211](../core.ts#L211) > **type**: `"migrationError"` -Defined in: [src/core.ts:210](../core.ts#L210) +Defined in: [src/core.ts:256](../core.ts#L256) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md index a7028e908f..68326fd604 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md @@ -8,4 +8,4 @@ > **Tag** = `"upgrade"` \| `"downgrade"` \| `"migrationError"` -Defined in: [src/core.ts:193](../core.ts#L193) +Defined in: [src/core.ts:239](../core.ts#L239) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md index 58cefef41f..406f4f399b 100644 --- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md @@ -8,4 +8,4 @@ > **DBMigrationError** = [`InvalidConfirmation`](core.DBMigrationError.Interface.InvalidConfirmation.md) \| [`InvalidQueueSize`](core.DBMigrationError.Interface.InvalidQueueSize.md) \| [`ErrorNotADatabase`](core.DBMigrationError.Interface.ErrorNotADatabase.md) \| [`ErrorMigration`](core.DBMigrationError.Interface.ErrorMigration.md) \| [`ErrorSQL`](core.DBMigrationError.Interface.ErrorSQL.md) -Defined in: [src/core.ts:147](../core.ts#L147) +Defined in: [src/core.ts:193](../core.ts#L193) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md index bb6e1b1650..c32c2afeaa 100644 --- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md @@ -8,4 +8,4 @@ > **MTRError** = [`MTRENoDown`](core.MTRError.Interface.MTRENoDown.md) \| [`MTREDifferent`](core.MTRError.Interface.MTREDifferent.md) -Defined in: [src/core.ts:220](../core.ts#L220) +Defined in: [src/core.ts:266](../core.ts#L266) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md index 1945816f6f..96007ffe2b 100644 --- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md @@ -8,4 +8,4 @@ > **MigrationError** = [`MEUpgrade`](core.MigrationError.Interface.MEUpgrade.md) \| [`MEDowngrade`](core.MigrationError.Interface.MEDowngrade.md) \| [`MigrationError`](core.MigrationError.Interface.MigrationError.md) -Defined in: [src/core.ts:187](../core.ts#L187) +Defined in: [src/core.ts:233](../core.ts#L233) diff --git a/packages/simplex-chat-nodejs/src/core.ts b/packages/simplex-chat-nodejs/src/core.ts index 5c246cf5c1..8460dff082 100644 --- a/packages/simplex-chat-nodejs/src/core.ts +++ b/packages/simplex-chat-nodejs/src/core.ts @@ -109,6 +109,43 @@ function cryptoArgsResult(res: string): CryptoArgs { } } +/** + * Resize image to a data URI of at most maxSize characters + */ +export async function chatResizeImageUri(image: ArrayBuffer | Uint8Array, maxSize: number): Promise { + return resizedImage(await simplex.chat_resize_image_uri(image, maxSize)) +} + +/** + * Resize image to JPEG or PNG of at most maxSize bytes + */ +export async function chatResizeImage(image: ArrayBuffer | Uint8Array, maxSize: number, format: "jpeg" | "png"): Promise { + return resizeImageData(image, maxSize, format === "png" ? 1 : 0) +} + +/** + * Resize image to the app size limit: JPEG or PNG for an image, a data URI for a preview + */ +export function resizeImage(image: ArrayBuffer | Uint8Array, size: ImageSize.Image): Promise +export function resizeImage(image: ArrayBuffer | Uint8Array, size: ImageSize.ProfilePreview | ImageSize.MessagePreview): Promise +export function resizeImage(image: ArrayBuffer | Uint8Array, size: ImageSize): Promise +export async function resizeImage(image: ArrayBuffer | Uint8Array, size: ImageSize): Promise { + switch (size) { + case ImageSize.Image: return resizeImageData(image, 261120, -1) + case ImageSize.ProfilePreview: return chatResizeImageUri(image, 12500) + case ImageSize.MessagePreview: return chatResizeImageUri(image, 14000) + } +} + +async function resizeImageData(image: ArrayBuffer | Uint8Array, maxSize: number, png: number): Promise { + return Buffer.from(resizedImage(await simplex.chat_resize_image(image, maxSize, png)), "base64") +} + +function resizedImage(res: string): string { + if (res === "") throw new Error("image cannot be decoded or does not fit maxSize") + return res +} + export interface APIResult { result?: R error?: T.ChatError @@ -130,6 +167,15 @@ export enum MigrationConfirmation { Error = "error" } +/** + * App image size limit + */ +export enum ImageSize { + Image = "image", + ProfilePreview = "profilePreview", + MessagePreview = "messagePreview" +} + /** * File encryption key and nonce */ diff --git a/packages/simplex-chat-nodejs/src/index.ts b/packages/simplex-chat-nodejs/src/index.ts index 80180ae282..851098d4fb 100644 --- a/packages/simplex-chat-nodejs/src/index.ts +++ b/packages/simplex-chat-nodejs/src/index.ts @@ -12,7 +12,7 @@ export * as api from "./api" /** * A low level API to the core library - the same that is used in desktop clients. - * You are unlikely to ever need to use this module directly. + * You are unlikely to ever need to use this module directly, except for image resizing. */ export * as core from "./core" diff --git a/packages/simplex-chat-nodejs/src/simplex.d.ts b/packages/simplex-chat-nodejs/src/simplex.d.ts index 0187556afd..d44e6267e8 100644 --- a/packages/simplex-chat-nodejs/src/simplex.d.ts +++ b/packages/simplex-chat-nodejs/src/simplex.d.ts @@ -10,3 +10,5 @@ export function chat_write_file(ctrl: bigint, path: string, buffer: ArrayBuffer export function chat_read_file(path: string, key: string, nonce: string): Promise export function chat_encrypt_file(ctrl: bigint, fromPath: string, toPath: string): Promise export function chat_decrypt_file(fromPath: string, key: string, nonce: string, toPath: string): Promise +export function chat_resize_image(image: ArrayBuffer | Uint8Array, maxSize: number, png: number): Promise +export function chat_resize_image_uri(image: ArrayBuffer | Uint8Array, maxSize: number): Promise diff --git a/packages/simplex-chat-nodejs/src/simplex.js b/packages/simplex-chat-nodejs/src/simplex.js index 3bdfb528df..9a20cc8f45 100644 --- a/packages/simplex-chat-nodejs/src/simplex.js +++ b/packages/simplex-chat-nodejs/src/simplex.js @@ -1,7 +1,7 @@ // The add-on is downloaded from the libsimplex release, so it is loaded at runtime by core.loadLibrary. const NOT_LOADED = "libsimplex is not loaded, call core.loadLibrary(backend) first" const BINDINGS = ["chat_migrate_init", "chat_migrate_init_queue", "chat_close_store", "chat_send_cmd", "chat_recv_msg_wait", - "chat_write_file", "chat_read_file", "chat_encrypt_file", "chat_decrypt_file"] + "chat_write_file", "chat_read_file", "chat_encrypt_file", "chat_decrypt_file", "chat_resize_image", "chat_resize_image_uri"] let addon diff --git a/packages/simplex-chat-nodejs/tests/core.test.ts b/packages/simplex-chat-nodejs/tests/core.test.ts index d8a87c82f6..7b2190e27c 100644 --- a/packages/simplex-chat-nodejs/tests/core.test.ts +++ b/packages/simplex-chat-nodejs/tests/core.test.ts @@ -1,6 +1,8 @@ import {execFile, spawnSync} from "child_process"; +import * as crypto from "crypto"; import * as fs from "fs"; import * as path from "path"; +import * as zlib from "zlib"; import {core} from "../src/index"; import * as libs from "../src/libs"; import * as simplex from "../src/simplex"; @@ -140,10 +142,102 @@ describe("Core tests", () => { await expect(core.chatEncryptFile(ctrl, path.join(tmpDir, "unknown.txt"), encryptedPath)).rejects.toThrow(); await expect(core.chatDecryptFile(path.join(tmpDir, "unknown.txt"), cryptoArgs, decryptedPath)).rejects.toThrow(); - + await stopAndClose(ctrl); }); + describe("image resizing", () => { + const notImage = Buffer.from("not an image"); + const resizeError = "image cannot be decoded or does not fit maxSize"; + const oldLibraryError = (name: string) => `loaded libsimplex does not export ${name}; image resizing needs a newer libsimplex`; + let imageResizing = false; + + beforeAll(async () => { + const error = await core.chatResizeImageUri(notImage, 14000).then(() => "", (e: Error) => e.message); + imageResizing = error !== oldLibraryError("chat_resize_image_uri"); + }); + + function noisePng(width: number, height: number, alpha = false): Buffer { + const rowLength = width * (alpha ? 4 : 3) + 1; + const rows = crypto.randomBytes(rowLength * height); + for (let y = 0; y < height; y++) rows[y * rowLength] = 0; + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header.set([8, alpha ? 6 : 2, 0, 0, 0], 8); + const chunk = (type: string, data: Buffer) => { + const body = Buffer.concat([Buffer.from(type), data]); + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(zlib.crc32(body)); + return Buffer.concat([length, body, crc]); + }; + const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + return Buffer.concat([signature, chunk("IHDR", header), chunk("IDAT", zlib.deflateSync(rows)), chunk("IEND", Buffer.alloc(0))]); + } + + it("should resize image to data URI", async () => { + const resize = core.chatResizeImageUri(noisePng(256, 256), 14000); + if (!imageResizing) return expect(resize).rejects.toThrow(oldLibraryError("chat_resize_image_uri")); + const uri = await resize; + expect(uri.startsWith("data:image/jpg;base64,")).toBe(true); + expect(uri.length).toBeLessThanOrEqual(14000); + }); + + it("should resize the view of a Uint8Array to JPEG and PNG", async () => { + const image = noisePng(256, 256); + const view = Buffer.concat([Buffer.alloc(3), image]).subarray(3); + const resize = core.chatResizeImage(view, 20000, "jpeg"); + if (!imageResizing) return expect(resize).rejects.toThrow(oldLibraryError("chat_resize_image")); + const jpeg = await resize; + expect(jpeg.subarray(0, 3)).toEqual(Buffer.from([0xff, 0xd8, 0xff])); + expect(jpeg.length).toBeLessThanOrEqual(20000); + const png = await core.chatResizeImage(new Uint8Array(image).buffer, 20000, "png"); + expect(png.subarray(0, 8)).toEqual(image.subarray(0, 8)); + expect(png.length).toBeLessThanOrEqual(20000); + }); + + it("should resize image to each ImageSize", async () => { + const image = noisePng(512, 512); + const resize = core.resizeImage(image, core.ImageSize.Image); + if (!imageResizing) return expect(resize).rejects.toThrow(oldLibraryError("chat_resize_image")); + const jpeg = await resize; + expect(jpeg.subarray(0, 3)).toEqual(Buffer.from([0xff, 0xd8, 0xff])); + expect(jpeg.length).toBeLessThanOrEqual(261120); + const profile = await core.resizeImage(image, core.ImageSize.ProfilePreview); + expect(profile.startsWith("data:image/jpg;base64,")).toBe(true); + expect(profile.length).toBeLessThanOrEqual(12500); + const preview = await core.resizeImage(image, core.ImageSize.MessagePreview); + expect(preview.startsWith("data:image/jpg;base64,")).toBe(true); + expect(preview.length).toBeLessThanOrEqual(14000); + }); + + it("should resize image with transparency to PNG for each ImageSize", async () => { + const image = noisePng(256, 256, true); + const resize = core.resizeImage(image, core.ImageSize.Image); + if (!imageResizing) return expect(resize).rejects.toThrow(oldLibraryError("chat_resize_image")); + const png = await resize; + expect(png.subarray(0, 8)).toEqual(image.subarray(0, 8)); + expect(png.length).toBeLessThanOrEqual(261120); + const profile = await core.resizeImage(image, core.ImageSize.ProfilePreview); + expect(profile.startsWith("data:image/png;base64,")).toBe(true); + expect(profile.length).toBeLessThanOrEqual(12500); + const preview = await core.resizeImage(image, core.ImageSize.MessagePreview); + expect(preview.startsWith("data:image/png;base64,")).toBe(true); + expect(preview.length).toBeLessThanOrEqual(14000); + }); + + it("should reject data that is not an image", async () => { + await expect(core.chatResizeImage(notImage, 20000, "jpeg")).rejects.toThrow(imageResizing ? resizeError : oldLibraryError("chat_resize_image")); + }); + + it("should reject maxSize outside 32-bit integers", async () => { + const error = imageResizing ? "Expected 32-bit integer maxSize" : oldLibraryError("chat_resize_image_uri"); + await expect(core.chatResizeImageUri(noisePng(8, 8), 2 ** 31)).rejects.toThrow(error); + }); + }); + it("should not block the libuv pool while receiving", async () => { const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); const receives = [1, 2, 3, 4].map(() => core.chatRecvMsgWait(ctrl, 500_000)); diff --git a/packages/simplex-chat-nodejs/tests/loader.test.ts b/packages/simplex-chat-nodejs/tests/loader.test.ts index 6a03fe8feb..051f042310 100644 --- a/packages/simplex-chat-nodejs/tests/loader.test.ts +++ b/packages/simplex-chat-nodejs/tests/loader.test.ts @@ -38,7 +38,7 @@ describe("native load", () => { try { simplex[name](); console.log(name + ": no error") } catch (e) { console.log(name + ": " + e.message) } }`) const bindings = ["chat_close_store", "chat_decrypt_file", "chat_encrypt_file", "chat_migrate_init", "chat_migrate_init_queue", - "chat_read_file", "chat_recv_msg_wait", "chat_send_cmd", "chat_write_file"] + "chat_read_file", "chat_recv_msg_wait", "chat_resize_image", "chat_resize_image_uri", "chat_send_cmd", "chat_write_file"] const expected = bindings.map(name => `${name}: libsimplex is not loaded, call core.loadLibrary(backend) first\n`).join("") expect(out).toBe(`${expected}status=0 signal=null`) }) diff --git a/packages/simplex-chat-python/src/simplex_chat/_native.py b/packages/simplex-chat-python/src/simplex_chat/_native.py index 009ec685f4..73a3eb60bc 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_native.py +++ b/packages/simplex-chat-python/src/simplex_chat/_native.py @@ -277,17 +277,30 @@ def lib() -> ctypes.CDLL: return _lib -QUEUE_SIZE_UNSUPPORTED = ( - "loaded libsimplex does not export chat_migrate_init_queue; queue size needs a newer libsimplex" -) +def _optional_export(name: str, feature: str, argtypes: list[Any]) -> Any: + try: + fn = getattr(lib(), name) + except AttributeError as e: + raise RuntimeError( + f"loaded libsimplex does not export {name}; {feature} needs a newer libsimplex" + ) from e + fn.argtypes = argtypes + fn.restype = c_void_p + return fn def migrate_init_queue() -> Any: """`chat_migrate_init_queue`, which older libsimplex releases do not export.""" - try: - fn = lib().chat_migrate_init_queue - except AttributeError as e: - raise RuntimeError(QUEUE_SIZE_UNSUPPORTED) from e - fn.argtypes = [c_char_p, c_char_p, c_char_p, c_int, POINTER(c_void_p)] - fn.restype = c_void_p - return fn + return _optional_export( + "chat_migrate_init_queue", + "queue size", + [c_char_p, c_char_p, c_char_p, c_int, POINTER(c_void_p)], + ) + + +def resize_image() -> Any: + return _optional_export("chat_resize_image", "image resizing", [c_char_p, c_int, c_int, c_int]) + + +def resize_image_uri() -> Any: + return _optional_export("chat_resize_image_uri", "image resizing", [c_char_p, c_int, c_int]) diff --git a/packages/simplex-chat-python/src/simplex_chat/core.py b/packages/simplex-chat-python/src/simplex_chat/core.py index adebde1b56..a2ac7242e0 100644 --- a/packages/simplex-chat-python/src/simplex_chat/core.py +++ b/packages/simplex-chat-python/src/simplex_chat/core.py @@ -1,17 +1,19 @@ -"""Internal typed async wrapper around libsimplex's 8 C ABI functions. +"""Typed async wrapper around libsimplex's C ABI functions. Users interact with `Bot` / `ChatApi`. This module is exposed as -`simplex_chat.core` for tests and the api.ChatApi class only. +`simplex_chat.core` for tests, the api.ChatApi class and image resizing +(`resize_image`, `chat_resize_image`, `chat_resize_image_uri`). """ from __future__ import annotations import asyncio +import base64 import ctypes import json from concurrent.futures import Executor from enum import StrEnum -from typing import Any, TypedDict +from typing import Any, Literal, TypedDict, overload from . import _native from .types import CR, CEvt, T @@ -70,6 +72,12 @@ class MigrationConfirmation(StrEnum): ERROR = "error" +class ImageSize(StrEnum): + IMAGE = "image" + PROFILE_PREVIEW = "profilePreview" + MESSAGE_PREVIEW = "messagePreview" + + class CryptoArgs(TypedDict): # wire-format JSON; camelCase fields fileKey: str fileNonce: str @@ -88,6 +96,12 @@ def _read_and_free(ptr: int | None) -> str: _native.libc().free(ctypes.c_void_p(ptr)) +def _check_c_int(name: str, value: int) -> None: + # ctypes silently wraps ints that do not fit C int. + if ctypes.c_int(value).value != value: + raise ValueError(f"{name} {value} does not fit C int") + + async def chat_send_cmd(ctrl: int, cmd: str) -> CR.ChatResponse: def _call() -> str: ptr = _native.lib().chat_send_cmd(ctrl, cmd.encode("utf-8")) @@ -138,9 +152,8 @@ async def chat_migrate_init( `queue_size` is the size of internal queues; the core default is used when None. """ - # ctypes silently wraps ints that do not fit C int. - if queue_size is not None and ctypes.c_int(queue_size).value != queue_size: - raise ValueError(f"queue_size {queue_size} does not fit C int") + if queue_size is not None: + _check_c_int("queue_size", queue_size) init_queue = _native.migrate_init_queue() if queue_size is not None else None @@ -246,3 +259,62 @@ def _crypto_args_result(raw: str) -> CryptoArgs: if parsed.get("type") == "error": raise RuntimeError(parsed.get("writeError", "unknown write error")) raise RuntimeError(f"unexpected result: {raw[:200]}") + + +async def chat_resize_image_uri(data: bytes, max_size: int) -> str: + _check_c_int("len(data)", len(data)) + _check_c_int("max_size", max_size) + resize = _native.resize_image_uri() + + def _call() -> str: + return _read_and_free(resize(data, len(data), max_size)) + + return _resized_image(await asyncio.to_thread(_call)) + + +async def chat_resize_image( + data: bytes, max_size: int, image_format: Literal["jpeg", "png"] +) -> bytes: + return await _resize_image_data(data, max_size, 1 if image_format == "png" else 0) + + +@overload +async def resize_image(data: bytes, size: Literal[ImageSize.IMAGE]) -> bytes: ... + + +@overload +async def resize_image( + data: bytes, size: Literal[ImageSize.PROFILE_PREVIEW, ImageSize.MESSAGE_PREVIEW] +) -> str: ... + + +@overload +async def resize_image(data: bytes, size: ImageSize) -> bytes | str: ... + + +async def resize_image(data: bytes, size: ImageSize) -> bytes | str: + """Resize image to the app size limit: JPEG or PNG for an image, a data URI for a preview.""" + match size: + case ImageSize.IMAGE: + return await _resize_image_data(data, 261120, -1) + case ImageSize.PROFILE_PREVIEW: + return await chat_resize_image_uri(data, 12500) + case ImageSize.MESSAGE_PREVIEW: + return await chat_resize_image_uri(data, 14000) + + +async def _resize_image_data(data: bytes, max_size: int, png: int) -> bytes: + _check_c_int("len(data)", len(data)) + _check_c_int("max_size", max_size) + resize = _native.resize_image() + + def _call() -> str: + return _read_and_free(resize(data, len(data), max_size, png)) + + return base64.b64decode(_resized_image(await asyncio.to_thread(_call))) + + +def _resized_image(res: str) -> str: + if not res: + raise RuntimeError("image cannot be decoded or does not fit max_size") + return res diff --git a/packages/simplex-chat-python/tests/test_core_resize_image.py b/packages/simplex-chat-python/tests/test_core_resize_image.py new file mode 100644 index 0000000000..e86f8c1ccb --- /dev/null +++ b/packages/simplex-chat-python/tests/test_core_resize_image.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import asyncio +import base64 +from typing import Any + +import pytest + +from simplex_chat import core + +JPEG = b"\xff\xd8\xff\xe0" + + +class FakeLib: + def __init__(self, result: str) -> None: + self.result = result + self.calls: list[tuple[str, tuple[Any, ...]]] = [] + + def _export(self, name: str) -> Any: + lib = self + + class Fn: + argtypes: Any = None + restype: Any = None + + def __call__(self, *args: Any) -> str: + lib.calls.append((name, args)) + return lib.result + + return Fn() + + @property + def chat_resize_image(self) -> Any: + return self._export("chat_resize_image") + + @property + def chat_resize_image_uri(self) -> Any: + return self._export("chat_resize_image_uri") + + +@pytest.fixture +def fake_lib(monkeypatch: pytest.MonkeyPatch): + def install(result: str) -> FakeLib: + lib = FakeLib(result) + monkeypatch.setattr(core._native, "lib", lambda: lib) + monkeypatch.setattr(core, "_read_and_free", lambda ptr: ptr) + return lib + + return install + + +def test_resize_image_uri_returns_the_data_uri(fake_lib): + lib = fake_lib("data:image/jpg;base64,AAAA") + assert asyncio.run(core.chat_resize_image_uri(b"image", 14000)) == "data:image/jpg;base64,AAAA" + assert lib.calls == [("chat_resize_image_uri", (b"image", 5, 14000))] + + +@pytest.mark.parametrize(("image_format", "png"), [("jpeg", 0), ("png", 1)]) +def test_resize_image_passes_the_format_and_decodes_base64(fake_lib, image_format, png): + lib = fake_lib(base64.b64encode(JPEG).decode()) + assert asyncio.run(core.chat_resize_image(b"image", 261120, image_format)) == JPEG + assert lib.calls == [("chat_resize_image", (b"image", 5, 261120, png))] + + +def test_resize_image_leaves_the_format_to_the_core(fake_lib): + lib = fake_lib(base64.b64encode(JPEG).decode()) + assert asyncio.run(core.resize_image(b"image", core.ImageSize.IMAGE)) == JPEG + assert lib.calls == [("chat_resize_image", (b"image", 5, 261120, -1))] + + +@pytest.mark.parametrize( + ("size", "max_size"), + [(core.ImageSize.PROFILE_PREVIEW, 12500), (core.ImageSize.MESSAGE_PREVIEW, 14000)], +) +def test_resize_image_previews_return_the_data_uri(fake_lib, size, max_size): + lib = fake_lib("data:image/png;base64,AAAA") + assert asyncio.run(core.resize_image(b"image", size)) == "data:image/png;base64,AAAA" + assert lib.calls == [("chat_resize_image_uri", (b"image", 5, max_size))] + + +@pytest.mark.parametrize( + "resize", + [ + lambda: core.chat_resize_image_uri(b"image", 14000), + lambda: core.chat_resize_image(b"image", 261120, "jpeg"), + lambda: core.resize_image(b"image", core.ImageSize.IMAGE), + ], +) +def test_empty_result_raises(fake_lib, resize): + fake_lib("") + with pytest.raises(RuntimeError, match="image cannot be decoded or does not fit max_size"): + asyncio.run(resize()) + + +@pytest.mark.parametrize( + ("name", "resize"), + [ + ("chat_resize_image_uri", lambda: core.chat_resize_image_uri(b"image", 14000)), + ("chat_resize_image", lambda: core.chat_resize_image(b"image", 261120, "png")), + ("chat_resize_image", lambda: core.resize_image(b"image", core.ImageSize.IMAGE)), + ( + "chat_resize_image_uri", + lambda: core.resize_image(b"image", core.ImageSize.PROFILE_PREVIEW), + ), + ], +) +def test_old_lib_raises_clear_error(monkeypatch, name, resize): + monkeypatch.setattr(core._native, "lib", lambda: object()) + message = f"loaded libsimplex does not export {name}; image resizing needs a newer libsimplex" + with pytest.raises(RuntimeError, match=message): + asyncio.run(resize()) + + +@pytest.mark.parametrize("max_size", [2**31, -(2**31) - 1]) +def test_max_size_outside_c_int_is_rejected_before_ffi(fake_lib, max_size): + lib = fake_lib("data:image/jpg;base64,AAAA") + with pytest.raises(ValueError, match="does not fit C int"): + asyncio.run(core.chat_resize_image_uri(b"image", max_size)) + with pytest.raises(ValueError, match="does not fit C int"): + asyncio.run(core.chat_resize_image(b"image", max_size, "jpeg")) + assert lib.calls == [] diff --git a/plans/2026-10-23-core-image-resizing.md b/plans/2026-10-23-core-image-resizing.md new file mode 100644 index 0000000000..8e8aeafe32 --- /dev/null +++ b/plans/2026-10-23-core-image-resizing.md @@ -0,0 +1,920 @@ +# Image resizing in the core + +The CLI sends a fixed placeholder as the preview of every image sent with `/img` or `/imgf`, and bots on the Node and Python libraries need their own image resizing code. +Images are resized by the core in one Catmull-Rom pass with sharpening, at a fixed JPEG quality, to the size whose encoding fills 97–100% of the size limit; an image that fits at its largest size is encoded at the highest quality that fits. +JPEG input is decoded by mozjpeg at the smallest scale that covers the display size, and its EXIF orientation is applied; PNG input is decoded by libspng; other formats are refused. +The output has one fixed Display P3 colour profile when the input profile describes Display P3 and the fixed profile is at most a tenth of the size limit, and no profile otherwise. +The core functions are called by the apps in place of platform code, wrapped in both bot libraries and used for `/img` and `/imgf` previews; mozjpeg and libspng are compiled by cabal in every build. + +References are to branch `ab/resize-image` at the commit of this plan, based on master `ec8884f46`. Haskell paths without a directory are in `src/Simplex/Chat/`. + +## Image processing decisions + +The research winner is implemented: mozjpeg, Catmull-Rom and sharpening, at a fixed quality, with the size fitted to the limit. Approved on 26 Oct. + +| ID | Decision | Review answer | +|---|---|---| +| D1 | **Quality bar.** The research quality is accepted as good enough. On the 8 research inputs, previews and 255 KB images from the core score at least the research scores of the D4 search (mean +3.3 and 62.2), each input within 0.5. Checked before review. | Changed on 26 Oct by the user's message: "you are overthinking it, settle for good enough already. what I've seen in previews is good enough, ship it". | +| D2 | **mozjpeg master, encoding through its libjpeg API.** mozjpeg master 08265790 (Jun 2025, libjpeg-turbo 3.0.5 base) replaces libjpeg-turbo, built through cabal from a submodule. A small C encoder calls the libjpeg API; decoding keeps the reviewed TurboJPEG 3 binding. | Confirmed. | +| D3 | **Resize in one pass with Catmull-Rom, then sharpen.** stb_image_resize with Catmull-Rom (a JuicyPixels-stbir option, no fork change), then an unsharp mask, sigma 0.6 px, amount 0.5, on gamma-encoded values. | Confirmed. | +| D4 | **Fixed quality, size fitted to the limit.** mozjpeg 4:2:0 at quality 20 for previews and profile images, 65 for full images. The size is predicted from bytes per pixel, then corrected until the encoding fills 97–100% of the limit. When the full-resolution image fits, quality rises to fill the limit, up to 95. PNG: the same size search, without quality. | Changed on 26 Oct by the user's message: "you are overthinking it, settle for good enough already. what I've seen in previews is good enough, ship it". | +| D5 | **Scaled JPEG decoding (S2).** Header first, decode at full, 1/2, 1/4 or 1/8 scale; output sizes equal the full-decode path. Implemented in `fc06d29d4`. | Confirmed. | +| D6 | **EXIF rotation in the core.** JPEG input is rotated by its EXIF orientation after decoding; outputs carry no metadata. Implemented in `37be23b6b`. | Confirmed. | +| D7 | **Apps pass a file reference to the core.** Apps pass the picked file itself, not decoded or re-encoded pixels: iOS a copy of the photo picker's file in the app's temporary directory, desktop the chosen path, Android a file descriptor of the content URI. The core reads the file, decodes it at the needed scale and applies EXIF rotation. | Confirmed, with the comment "Could we pass file reference instead of bytes? I think iOS can create temporary links to chosen photos, so Haskell/C code just reads it instead of piping bytes via FFI. Was it considered? Same for other platforms." The file reference replaced the file bytes of the reviewed version. | +| D8 | **Limits.** JPEG side 65,500 px, PNG side 16,384 px, 268 MP, progressive-JPEG memory 512 MB on iOS and Android and 1,024 MB elsewhere. | Confirmed, with the comment "Do we need 2gb? I'd limit to 1 if it's possible, but explain". 1,024 MB replaced 2,047 MB. | +| D9 | **32-bit Android uses Neon.** mozjpeg's 32-bit Neon sources are compiled for armv7, tested by a native armv7 CI job on GitHub's ARM64 runners. | Confirmed. | +| D10 | **Balance of time and quality.** Settled by D4: one fixed quality and about 3 encodes per preview, without a quality metric in the core. Time per preview and per full image is measured on desktop and devices and reported in the PR. | Changed on 26 Oct by the user's message: "you are overthinking it, settle for good enough already. what I've seen in previews is good enough, ship it". | +| D11 | **Feature texts state behaviour only.** The TJFLAG_LIMITSCANS doc comment copied from upstream is trimmed to "Limits progressive JPEGs to 500 scans." | Confirmed. | +| D12 | **Out of scope.** Guetzli (a later PR for channels and groups, badge holders only) and other image formats. | Confirmed. | +| D13 | **Approved texts.** README line "JPEG decoding and encoding of resized images"; binding description "JPEG encoding and decoding of JuicyPixels images with mozjpeg"; a TurboJPEG3 module header like TurboJPEG2's. | Option (a), with the comment "to many words - we need what we've seen as the best result, I don't think it's even offered. To spell out the only possible choice: mozjpeg + Catmull-Rom + sharpening (recommended) why it's not here?" The research winner became the first line of this section. | + +Decisions of 26 Oct, after the review: + +| ID | Decision | +|---|---| +| D14 | **No interim search.** The D3 functions and their pinned test are added now; `fitImage` changes once, with D4. | +| D15 | **Transparency in premultiplied alpha.** Images with transparency are resized and sharpened with premultiplied colour, so colours under transparent pixels do not reach visible pixels. | +| D16 | **Exact pinned test on every platform.** stb_image_resize in JuicyPixels-stbir is compiled without floating-point contraction. | +| D17 | **HEIC is converted by iOS before the core.** iOS requests the `public.jpeg` representation of picked items other than PNG and GIF; for `""` the bytes functions are used. | +| D18 | **Sharpening only of downscaled images.** An image kept at its size is not sharpened: on the github input kept at full resolution, sharpening lowered the large-screen SSIMULACRA2 from 92.53 to 89.94. | +| D19 | **PNG decoded by libspng; other formats refused.** PNG input is decoded by libspng 0.7.4 with miniz 3.1.2, bundled in JuicyPixels-spng: the header first, then the pixels, within the side and memory limits. Input other than JPEG and PNG gives `Left`, and `""` from the C API, on which the apps decode it themselves and pass JPEG or PNG bytes. JuicyPixels remains for the image types and the pixel conversions. | +| D20 | **PNG output: Catmull-Rom with sharpening, encoded by libspng.** Each PNG trial is encoded by libspng at zlib level 9 with row filters off and on, and the smaller output is kept; the RGBA source is premultiplied once per image for all trials. Research path, the smaller of two at miniz level 9: 3.19 / 87.72 / 86.73. Resize time of the core on the 8 PNG research inputs, on the development machine: previews 0.57–15.6 s, mean 4.09 s; full images 0.24–20.3 s, mean 4.53 s. | +| D21 | **Nix licence of JuicyPixels-spng.** The nix builds take its licence as "BSD-3-Clause AND BSD-2-Clause AND MIT" (`flake.nix:73-75`). haskell.nix counts a licence as free only when its SPDX list marks it OSI-approved or FSF-libre, which libpng-2.0 is not, and refuses to evaluate the package. The cabal file, the licence files and HASKELL.md keep libpng-2.0. | + +Decisions of 27 Oct, after the second review: + +| ID | Decision | +|---|---| +| D22 | **iOS removes the copy of a sent image.** The copy of a picked image in the temporary directory is removed after the image is saved for sending, and taken out of `filesToDelete`; the copy of an image that is not sent is removed at app termination, as before. | +| D23 | **File input within the memory limit.** The file and file descriptor functions read at most `maxDecodeMemoryMB` MiB + 1 byte and return `""` for a larger input, also from a pipe; the descriptor is closed on every path. | +| D24 | **Decoding and sharpening memory within the limit.** PNG is decoded with half of `maxDecodeMemoryMB`, which leaves room for the premultiplied copy. Sharpening keeps 5 horizontally blurred rows in place of two whole-image `Float` buffers, with the same arithmetic. Maximum RSS for a 4320×4320 RGBA PNG: 323 MB before and 223 MB after for the image data, 163 MB and 162 MB for the preview. | +| D25 | **One fixed Display P3 profile.** No input profile bytes are copied. When the ICC profile of JPEG input (APP2 `ICC_PROFILE` segments) or of PNG input (iCCP) describes Display P3, the JPEG or PNG output has the fixed Display P3 v4 profile of Compact-ICC-Profiles (CC0, 480 bytes), when its length in output units is at most a tenth of the size limit: bytes for image data, base64 characters for a data URI. Otherwise, and for an inconsistent, malformed or other profile, the output has no profile, byte for byte as for an input without one. | + +## Decisions + +Decoding, `Image.hs`: +- `decodeResizable bytes`: the image for resizing and its `ColourProfile` of D25, `CPDisplayP3` or `CPNone`; the image at the display size for JPEG, at full size for PNG; `Left` for other formats. +- JPEG, input starting `FF D8`, decoded by mozjpeg: + 1. `readJpegHeader`; `Left` for: + - a side above 65500 + - a precision other than 8 + - a lossless JPEG + 2. target size: both sides halved, each rounded by `scaledSide` (`:398-399`), while a side exceeds 4320; `Left` when a side is below 2 before a halving + 3. scale: the smallest of `Full`, `Half`, `Quarter` and `Eighth` whose rounded-up sides are at least the target + 4. `decodeJpeg` with: + - `maxPixels`: 2^28; `Left` above it, before the output is allocated + - `maxMemoryMB`: `maxDecodeMemoryMB` + 5. a decode larger than the target: `convertRGB8`, then `STBIR.resize` to the target + 6. the EXIF orientation applied + 7. the input ICC profile, `jpegIccProfile`: + - source: the APP2 segments with the `ICC_PROFILE\0` header before the first SOS, from the segment list that `exifTiff` reads, walked once by `jpegSegments` + - each segment: the sequence number, the segment count, the profile chunk + - profile: the chunks ordered by sequence number and concatenated + - `Nothing` for missing or duplicate sequence numbers, a segment count different from the number of segments, a segment shorter than 14 bytes or an empty profile; the image is decoded as without a profile +- PNG, input starting with the signature `89 50 4E 47 0D 0A 1A 0A`, decoded by libspng with `Spng.decodePng`: + 1. the header (IHDR) first; `Left` for a side above 16384 + 2. `maxMemoryMB`: half of `maxDecodeMemoryMB`, so that the decoded pixels and their premultiplied copy fit `maxDecodeMemoryMB`; `Left` when the decoded pixels exceed it, before they are allocated + 3. `Left` for any libspng error, including short image data, a chunk with a wrong CRC, the last chunk among them, a missing IEND, any other truncation and a zTXt or iTXt chunk that decompresses to more than 512 KiB; a PNG cut within the IEND CRC decodes (libspng) + 4. constructor: `ImageRGBA8` for a colour type with alpha or a `tRNS` chunk, otherwise `ImageRGB8`; 16-bit samples converted to 8 bits + 5. the full image, halved to the display size by `fitImage` + 6. the input ICC profile: the decompressed iCCP profile from `Spng.decodePng`; `Left` for a profile that decompresses to more than 1 MiB +- Other formats: `Left "unsupported image format"`. +- Constructors of a decoded JPEG: + + | JPEG colour space | constructor | + |---|---| + | YCbCr, RGB | `ImageRGB8` | + | grayscale | `ImageY8` | + | CMYK, YCCK | `ImageCMYK8` | + +- `Left` from mozjpeg for: + - any warning, including truncated data and extraneous bytes + - more than 500 scans + - whole-image coefficient buffers above `maxMemoryMB` +- `maxDecodeMemoryMB`, a compile-time constant in MiB: + - the `maxMemoryMB` of mozjpeg + - twice the `maxMemoryMB` of libspng + - the largest input of the file and file descriptor functions +- `maxDecodeMemoryMB` is 512 when `mobileBuild` is defined and 1024 otherwise: + + | build | `simplex-chat.cabal` condition | set in | + |---|---|---| + | iOS and iOS simulator | `flag(swift)` | `flake.nix:559`, `:596`; `scripts/ci/ios-sim/build-core.sh:27` | + | Android aarch64 and armv7a | `os(android)` | the target platforms of `flake.nix:422` and `:344` | + +- EXIF orientation of JPEG input: + - source: the first APP1 segment with the `Exif\0\0` header before the first SOS; IFD0 tag `0x0112` + - a missing tag, another value or malformed EXIF: 1 + + | value | transform | + |---|---| + | 1 | none | + | 2 | horizontal flip | + | 3 | rotation by 180° | + | 4 | vertical flip | + | 5 | transpose | + | 6 | rotation by 90° clockwise | + | 7 | transverse | + | 8 | rotation by 90° counter-clockwise | + + - output: upright pixels without EXIF + +- `ColourProfile` (D25), `colourProfile` of the input ICC profile of either format, `CPNone` without one, forced with `$!` in `decodeResizable`, so that it holds no reference to the input: + - header: data colour space `RGB ` at offset 16, PCS `XYZ ` at offset 20 + - tag table at offset 128: a big-endian count, then entries of signature, offset and size; the table inside the profile + - `rXYZ`, `gXYZ` and `bXYZ`: the first entry of each signature; a size of at least 20, the tag inside the profile, type `XYZ `, 4 reserved bytes, 3 s15Fixed16 values + - Display P3: all 9 values within 2/65536 of those of `displayP3`; the result is `CPDisplayP3` + - anything malformed, missing or different: `CPNone` + - `displayP3`: `profiles/DisplayP3-v4.icc` of Compact-ICC-Profiles at `bdd84663`, 480 bytes, CC0 1.0; its primaries are those of Display P3 (D65 white, Bradford-adapted to D50) within 1/65536 + +Resizing, `Image.hs`: +- `resizeImage format maxSize image profile`: a JPEG or PNG encoding of at most `maxSize` bytes; `profile` a `ColourProfile`. +- `resizeImageDataUri maxSize image profile`: a data URI of at most `maxSize` characters. +- Colour profile (D25), `keptProfile`: + - `displayP3`, written by the encoder for `CPDisplayP3` when its length in output units is at most `maxSize div 10` + - output units: bytes for image data; base64 characters, `4·⌈n/3⌉` for n bytes, for a data URI + - JPEG: `encodeRGB`, APP2 segments after APP0 + - PNG: `encodeSmallerPng`, an iCCP chunk named "ICC Profile", in both encodings + - otherwise no profile, and the output is byte for byte the output without a profile + - format: `imageFormat image` + - prefix: `data:image/jpg;base64,` or `data:image/png;base64,` +- `imageFormat`: + - PNG: an image with a pixel below full opacity, of a constructor with alpha: + - `ImageYA8` + - `ImageYA16` + - `ImageRGBA8` + - `ImageRGBA16` + - JPEG: other images +- Pixels are converted to 8 bits per channel, RGB for JPEG and RGBA for PNG; every `DynamicImage` constructor is accepted. +- Size search (D4), `fitImage` and `fitSize`; L is the limit in the output measure, bytes or data URI characters: + + | step | JPEG | PNG | + |---|---|---| + | size bound | the JPEG and PNG header limits: `Left`, before decoding; an image given to `resizeImage` or `resizeImageDataUri` with a side above 16384 pixels: `Left`, before any encode or resize | the same | + | largest size | the image sides halved while a side exceeds 4320 pixels, rounded as `scaledSide` rounds | the same | + | quality | 20 for data URIs, 65 for image data | | + | trial | `scaleRGB8` from the decoded image to the trial size, encoded at the quality; the image at the largest size scaled once, for its trial and the quality bisection | `scaleRGBA8`; `encodeSmallerPng`: libspng at zlib level 9 with row filters off and on, the smaller kept | + | trial height | `max 1 (floor (w·h_largest/w_largest + 0.5))`, rounded half up as `height_for` of the research | the same | + | first width | from pixels `8·B/bpp0`, with B the target T = √0.97·L in JPEG bytes (for a data URI, 3/4 of the characters after the prefix) and bpp0 0.35 at quality 20, 0.9 at quality 65 | the largest width | + | width from pixels p | `round (√(p·w_largest/h_largest))`, within 1..w_largest | the same | + | accepted | fits L and is at least 0.97·L, or fits at the largest width | the same | + | next width | from pixels `p·(T/size)^(1/α)`; α from the last two encodes, `log (size₂/size₁) / log (p₂/p₁)`, when within 0.4..1.3; otherwise 0.85 | the same | + | repeated width | the midpoint of the largest fitting width and the smallest larger width that does not fit; the search ends when they differ by 1 | the same | + | end | after 8 encodes or the end of the bisection: the largest fitting width | the same | + | no width fits | the smallest tried width halved until the encoding fits: `Left` when a width below 2 is to be halved | the same | + | at the largest width | below 0.97·L: quality bisected in the quality..95; otherwise the encoding itself | the encoding itself | + +- The largest size applies to the image data and to the data URIs: the Android and desktop apps display images and previews only up to 4320 pixels per side (`CIImageView.kt:128`, `base64ToBitmap` in `Images.android.kt:35` and `Images.desktop.kt:42`). +- The quality bisection is ended by an encoding of exactly `maxSize`. +- Metadata of the input is dropped; the colour profile is set by D25. + +Scaling (D3), `Image.hs`: +- `scaleRGB8 image size` and `scaleRGBA8 image size`: the image at `size`. + - the same size: the image itself, not sharpened (D18) + - another size: one stb_image_resize pass from the image, Catmull-Rom, edges clamped, on gamma-encoded values; then the unsharp mask +- `scaleRGBA8` (D15, D20): + 1. colour premultiplied by alpha: `(c·a + 127) div 255`, once per `scaleRGBA8 image`, for every size scaled from it + 2. stb_image_resize with `STBIR_FLAG_ALPHA_PREMULTIPLIED` + 3. the unsharp mask on the four channels + 4. colour divided by alpha: `min 255 ((c·255 + a div 2) div a)`; 0 for `a = 0` +- Unsharp mask, the arithmetic of the research code: + - kernel: Gaussian, sigma 0.6 px, radius `ceiling (3·sigma)` = 2, weights divided by their sum, in `Double` + - a horizontal pass on the samples, then a vertical pass on its result; edges clamped; sums in `Double`, each pass stored as `Float` + - output: `floor (v + 0.5·(v − b) + 0.5)`, clamped to 0..255; `v − b` in `Float` +- Unsharp mask, memory: a ring of 5 horizontally blurred rows (2·radius + 1); before output row y, row y + 2 is blurred into the slot of row y − 3, and row y is computed from the ring. +- `vector` is a direct dependency. + +C API: +``` +chat_resize_image_uri(data, len, maxSize) data URI of at most maxSize characters, or "" +chat_resize_image(data, len, maxSize, png) base64 of at most maxSize bytes of PNG (png > 0), JPEG (png == 0) or the imageFormat choice (png < 0), or "" +``` +- `data`: an encoded JPEG or PNG image, read in place during the call. +- `""` is returned when: + - the input is neither JPEG nor PNG, or it is refused by the header limits, by mozjpeg or by libspng; + - the image exceeds the limit at every size; + - an exception is raised. +- The result is allocated with `malloc` and freed by the caller. +- A chat controller is not required. +- The colour profile is set by D25. +- `png`: 0 or 1 from the apps and the explicit library functions; −1 from the library functions with `ImageSize`. + +C API, file reference (D7): +``` +chat_resize_image_file_uri(path, maxSize) as chat_resize_image_uri, from the file at path +chat_resize_image_file(path, maxSize, png) as chat_resize_image, from the file at path +chat_resize_image_fd_uri(fd, maxSize) as chat_resize_image_uri, from the file descriptor fd +chat_resize_image_fd(fd, maxSize, png) as chat_resize_image, from the file descriptor fd +``` +- C declarations: + ```c + extern char *chat_resize_image_file_uri(char *path, int maxSize); + extern char *chat_resize_image_file(char *path, int maxSize, int png); + extern char *chat_resize_image_fd_uri(int fd, int maxSize); + extern char *chat_resize_image_fd(int fd, int maxSize, int png); + ``` +- `path`: a null-terminated path, decoded as `chat_write_file` decodes its path. +- `fd`: a readable file descriptor; the core reads it and closes it, on success and on failure. +- Input from a file or a file descriptor: + - with a file size: `""` for a size above `maxDecodeMemoryMB` MiB, before reading; otherwise the file is read into one buffer of its size + - without a file size (a pipe): at most `maxDecodeMemoryMB` MiB + 1 byte is read; `""` when that byte is read +- `""` is also returned when the file cannot be read. +- Callers: + + | app | input | functions | + |---|---|---| + | iOS | the photo picker file, `public.jpeg` for items other than PNG and GIF, copied to the app's temporary directory | `chat_resize_image_file_uri`, `chat_resize_image_file` | + | desktop | the chosen path | `chat_resize_image_file_uri`, `chat_resize_image_file` | + | Android | `ParcelFileDescriptor.detachFd()` of the content URI | `chat_resize_image_fd_uri`, `chat_resize_image_fd` | + +- For `""`, the apps use the platform decoding and the functions with bytes, as now. Formats other than JPEG and PNG reach the core only as JPEG or PNG bytes encoded by the apps: + + | app | preview | sent image | animated GIF and WebP | + |---|---|---|---| + | iOS | `UploadContent.imagePreview()`, `ComposeView.swift:315-320`: `resizeImageToStrSize` of the `UIImage` | `ComposeView.swift:1610`: `saveImage` of the `UIImage` | `.animatedImage`, sent as the file (`ComposeView.swift:1600-1601`) | + | Android | `processPickedMedia`, `ComposeView.kt:370`, `:382`: `bitmapPreview`, `getBitmapFromUri` then `resizeImageToStrSize` | `resizeImageFromUri`, `Utils.kt:218-227`: `getBitmapFromUri`, then `resizeImageToDataSize` | `UploadContent.AnimatedImage`, sent as the file (`ComposeView.kt:371-380`) | + | desktop | as Android | as Android | as Android; the image picker offers only GIF, WebP, PNG and JPEG (`Images.desktop.kt:159-166`) | + +- Haskell, `Mobile/File.hs`: + ```haskell + cChatResizeImageFileUri :: CString -> CInt -> IO CString + cChatResizeImageFile :: CString -> CInt -> CInt -> IO CString + cChatResizeImageFdUri :: CInt -> CInt -> IO CString + cChatResizeImageFd :: CInt -> CInt -> CInt -> IO CString + ``` + - path: `readFilePath`, `peekCString`, then `hGetImage` in `withBinaryFile` + - descriptor: `readFd`, `fdToHandle`, then `hGetImage` in `bracket` with `hClose`, by which the handle is closed, also on an exception; the descriptor is closed with `c_close` when `fdToHandle` raises + - `hGetImage`, with the size from `tryIO (hFileSize h)`: + - a size: above `maxDecodeMemoryMB` MiB, `fail`, which `runCatchExceptT` turns into `Left`; otherwise `B.hGet` of exactly that size + - `hFileSize` failed (a pipe): `LB.hGet` of `maxDecodeMemoryMB` MiB + 1 byte, read in chunks of about 32 KB until that size or the end; above `maxDecodeMemoryMB` MiB, `fail`; otherwise `LB.toStrict` + - `cResizeImageUri` and `cResizeImageData`: the data URI and the image data from a read action, for the buffer, path and descriptor functions + - `cResizeImage` takes the read action in place of the buffer; the input is read inside `runCatchExceptT`. +- Exports: + - `Mobile.hs`: `foreign export ccall` of the four functions + - `libsimplex.dll.def` and the Android `-u` list of `flake.nix`: the four functions + +Apps, with function signatures kept and call sites unchanged except iOS `saveWallpaperFile(image:)` (`ImageUtils.swift:246`): +- iOS `resizeImageToDataSize` and `resizeImageToStrSizeSync`: + - an image with orientation other than `.up` is redrawn upright; + - input: UIKit PNG for an image with alpha, otherwise JPEG at quality 0.85; + - alpha: + - `resizeImageToDataSize`: the `hasAlpha` parameter, by which PNG or JPEG output is also selected + - `resizeImageToStrSizeSync`: `imageHasAlpha(image)`, for the input only + - `""` is returned as `nil`; for it `nil` is also returned by `saveImage` (`ImageUtils.swift:68`) and `saveWallpaperFile(image:)` (`:246`). +- Android and desktop, common code: + - input: `compressImageData` bytes, PNG or JPEG at quality 85, in a direct `ByteBuffer`; + - for `""` an exception is raised by both functions, the failure mode of the platform code of master; + - `resizeImageToStrSize`: + - PNG input when the image has alpha; + - the exception is caught in `getLinkPreview` (`LinkPreviews.kt:134`), and no link preview is returned; + - in the other eight callers it is propagated, as on master. + - `resizeImageToDataSize`: + - input and output in the requested format; + - the exception is caught by: + - `saveImage` + - `desktopSaveImageInTmp` + - `ThemeWallpaper.importFromString`, around `saveWallpaperFile` + +Apps, file reference (D7, D17): +- iOS: + - picker, `loadImageData` (`ImagePicker.swift`): the `public.jpeg` representation when the item provider has one and the item is neither PNG nor GIF; otherwise `public.data`, as before + - `UploadContent.imageFile(image:url:)`: a non-animated picked image, with its file copied to the temporary directory and added to `filesToDelete` + - preview, `imagePreview()`: `resizeImageFileToStrSize` for `.imageFile`; for other content or `nil`, `resizeImageToStrSize` + - sending, `imageCryptoFile`: `saveImageFile(url)`; for `nil`, `saveImage(image)`; then the copy is removed and taken out of `filesToDelete` + - a copy of an image that is not sent is removed at app termination, with the other `filesToDelete`, as before + - `SimpleX.h`: `chat_resize_image_file`, `chat_resize_image_file_uri` +- Android and desktop: + - common: `resizeImageFileToStrSize` and `resizeImageFileToDataSize`, on `resizeImageFileUri` and `resizeImageFileData` of each platform: + - Android: `contentResolver.openFileDescriptor(uri, "r").detachFd()`, then `chatResizeImageFdUri` or `chatResizeImageFd` + - desktop: `uri.toFile().absolutePath`, then `chatResizeImageFileUri` or `chatResizeImageFile` + - for `""`: the debug log line `: core returned no image`, and `null` + - `processPickedMedia` and the Android camera: the preview from the file; the bitmap only for an animated image within the file size limit or when the preview is `null`; the media is skipped when it has no preview + - `saveImage(uri)` and `desktopSaveImageInTmp(uri)`, through `resizeImageFromUri`: the core data from the file with `png = -1`, extension `png` for a first byte `0x89`, otherwise `jpg`; for `null`, the bitmap path, as before + - JNI: the descriptor functions in the Android `simplex-api.c`, the path functions in the desktop one + +Bot libraries: +- Node, `core`: + - `resizeImage(image: ArrayBuffer | Uint8Array, size: ImageSize): Promise`, with overloads: `Buffer` for `ImageSize.Image`, `string` for the previews + - `chatResizeImageUri(image: ArrayBuffer | Uint8Array, maxSize: number): Promise` + - `chatResizeImage(image: ArrayBuffer | Uint8Array, maxSize: number, format: "jpeg" | "png"): Promise` +- Python, `simplex_chat.core`: + - `async def resize_image(data: bytes, size: ImageSize) -> bytes | str`, with `typing.overload`: `bytes` for `ImageSize.IMAGE`, `str` for the previews + - `async def chat_resize_image_uri(data: bytes, max_size: int) -> str` + - `async def chat_resize_image(data: bytes, max_size: int, image_format: Literal["jpeg", "png"]) -> bytes` +- `ImageSize`, a string enum of both libraries: + + | value | Node | Python | call | limit | + |---|---|---|---|---| + | `"image"` | `Image` | `IMAGE` | `chat_resize_image`, `png = -1` | 261120 bytes | + | `"profilePreview"` | `ProfilePreview` | `PROFILE_PREVIEW` | `chat_resize_image_uri` | 12500 characters | + | `"messagePreview"` | `MessagePreview` | `MESSAGE_PREVIEW` | `chat_resize_image_uri` | 14000 characters | + +- The limits are literals in `resizeImage` and `resize_image`; `ImageSize` is absent from the C API. +- The explicit functions stay public, for other sizes. +- Doc comments: + - `resizeImage` and `resize_image`: "Resize image to the app size limit: JPEG or PNG for an image, a data URI for a preview", with a final period in Python + - Node `chatResizeImageUri`: "Resize image to a data URI of at most maxSize characters" + - Node `chatResizeImage`: "Resize image to JPEG or PNG of at most maxSize bytes" + - Node `ImageSize`: "App image size limit" + - Python `ImageSize`: none +- The core module texts name image resizing; in Python, with `resize_image`, `chat_resize_image` and `chat_resize_image_uri`. +- The core is called on a worker thread: + - Node: an N-API async worker + - Python: `asyncio.to_thread` +- Errors: + + | condition | Node | Python | + |---|---|---| + | `""` from the core | `Error("image cannot be decoded or does not fit maxSize")` | `RuntimeError("image cannot be decoded or does not fit max_size")` | + | export absent from the loaded libsimplex | `Error("loaded libsimplex does not export ; image resizing needs a newer libsimplex")` | `RuntimeError` with the same text | + | function absent from the loaded Node add-on | `TypeError: addon[name] is not a function` | | + | size limit outside 32-bit integers | `RangeError("Expected 32-bit integer maxSize")` | `ValueError` | + | input above 2^31 − 1 bytes | `RangeError("Buffer is too large")` | `ValueError` | + +- Pinned releases: + - Until a core release built with this change is pinned by `scripts/ci/packages_release.py prepare`, `core-7.1.0.14` is loaded (`packages/simplex-chat-nodejs/src/release.ts:3`, `packages/simplex-chat-python/src/simplex_chat/_version.py:8`), and the export error is raised by the Python wrappers. + - The Node add-on is pinned to `nodejs-addon-308c0960c707` (`release.ts:5`), built without either function: the promises of the Node wrappers are rejected with `TypeError: addon[name] is not a function` (`src/simplex.js:20`). The export error is raised with an add-on built from this branch, as in the tests (`jest.config.js:4`). + - The core and the add-on are pinned together by `prepare` (`packages_release.py:362-365`); for nodejs an error is raised by `prepare` until the add-on release of the current sources is published (`:781-782`). + +`/img` and `/imgf`: +- The preview is generated from the file: read with `CF.readFile` (encrypted files included), decoded, and resized to a data URI of at most 14000 characters, with the colour profile by D25. +- On any failure the fixed placeholder is sent, as before; a GIF, which `/img` accepts by its extension (`Library/Commands.hs:204`), gets the fixed placeholder. + +Native code: +- mozjpeg master `08265790` (libjpeg-turbo 3.0.5 base) is the submodule `jpeg-turbo/cbits/mozjpeg` of simplex-chat/hs-jpeg-turbo; cabal compiles it in the `jpeg-turbo` package of every build: + - 114 C sources on every target + - aarch64: 15 Neon sources + - armv7: `simd/arm/aarch32/jsimd.c`, and 14 Neon sources in the internal library `neon`, compiled with `-mfpu=neon` + - configuration headers `jconfig.h`, `jconfigint.h`, `jversion.h` and `neon-compat.h` in `cbits`, with the target-dependent values taken from compiler macros; `THREAD_LOCAL` empty on 32-bit Android Arm + - the C code paths on x86-64 + - built with cabal and the C compiler only +- JPEG input is decoded by the TurboJPEG API of mozjpeg; JPEG output is encoded by `jpeg_turbo_encode` (`cbits/encode.c`) through the libjpeg API, with mozjpeg's default profile. +- libspng 0.7.4 and miniz 3.1.2 are the submodules `JuicyPixels-spng/cbits/libspng` and `JuicyPixels-spng/cbits/miniz` of simplex-chat/hs-jpeg-turbo; cabal compiles `spng.c`, `miniz.c`, `miniz_tdef.c` and `miniz_tinfl.c` in the `JuicyPixels-spng` package of every build, with `SPNG_USE_MINIZ`. PNG input is decoded and PNG output encoded by libspng, with miniz's inflate and deflate. +- Package sources: + + | package | source | + |---|---| + | hs-jpeg-turbo (`jpeg-turbo`, `JuicyPixels-jpeg-turbo`, `JuicyPixels-spng`) | simplex-chat/hs-jpeg-turbo at `d0a4d95`, the squash merge of simplex-chat/hs-jpeg-turbo#1 | + | JuicyPixels-stbir, with nothings/stb as a submodule | simplex-chat/JuicyPixels-stbir at `e0090e1`, the squash merge of simplex-chat/JuicyPixels-stbir#3 | + | JuicyPixels 3.3.8 | Hackage | + +- The hashes of both repositories in `scripts/nix/sha256map.nix` are computed with submodules, as for simplexmq. +- In Windows builds, `dist-newstyle/src/hs-jpeg-t*` and `dist-newstyle/src/JuicyPixe*` are removed beside `direct-sq*` and `simplexmq*`. + +License notices, in `docs/dependencies`: +- `README.md`: Compact ICC Profiles, libspng, miniz, mozjpeg with the IJG sentence, and stb_image_resize +- `HASKELL.md`: JuicyPixels, JuicyPixels-jpeg-turbo, JuicyPixels-spng and JuicyPixels-stbir as direct dependencies, jpeg-turbo as an indirect one; the five are added to the "Also depended upon by" and "Depended upon by" cells of the packages they depend on +- `licences/`: the license files of each, copied from the pinned commits; libspng's BSD-2-Clause `LICENSE` and the libpng-2.0 notice of `spng.c`, and miniz's MIT `LICENSE`; Compact-ICC-Profiles' CC0 1.0 `license`, for the profile of D25 + +Out of scope: WebP, a separate PR after this one. + +Later, with the picker rework: on iOS, the preview and the image resized in the `loadFileRepresentation` completion handler, from the picker's file, without the copy in the temporary directory; `loadFromURL` reads a picked still image twice and creates a `UIImage` twice. + +## hs-jpeg-turbo fork + +simplex-chat/hs-jpeg-turbo at `d0a4d95`: +- mozjpeg compiled by cabal, as in "Native code"; arithmetic-coded JPEGs are decoded, the arithmetic encoder is not compiled. +- `encodeRGB :: EncodableRGB px => Int -> Maybe ByteString -> Image px -> IO ByteString`: `jpeg_turbo_encode` (`cbits/encode.c`, `MozJPEG.hsc`): `jpeg_set_defaults`, `jpeg_set_quality`, the output in a buffer grown by doubling, its size returned as `size_t`; libjpeg errors and warnings end the encoding with their text; the encoder state is kept in one allocation, and it and the output buffer are freed on every path, after an error too. + - the ICC profile, when given: `jpeg_write_icc_profile`, APP2 segments after APP0; `error` for a length outside 1..16707345 (255 segments of 65519 bytes) + - `error` when the buffer size cannot be doubled +- `Codec.Picture.JPEGTurbo` exports, besides `encodeRGB` and `EncodableRGB`: + - `readJpegHeader :: ByteString -> IO (Either String JpegHeader)` + - `decodeJpeg :: DecodeOptions -> ByteString -> IO (Either String DynamicImage)` + - `JpegHeader`: + - `jpegWidth :: Int` + - `jpegHeight :: Int` + - `jpegSubsamp :: TJSAMP` + - `jpegColorspace :: TJCS` + - `DecodeOptions`: + - `scale :: Scale` + - `maxPixels :: Int` + - `maxMemoryMB :: Int`, 1..2047 + - `data Scale = Full | Half | Quarter | Eighth` +- API: + - header and parameters: `tj3DecompressHeader`, `tj3Get`, `tj3Set`, in the new module `TurboJPEG3` + - decoding: `tjDecompress2` with the scaled width and height +- `readJpegHeader` and `decodeJpeg` give `Left` for: + - an input longer than `CULong` + - any mozjpeg error or warning, including an empty input + - a side below 1 + - a precision other than 8 + - a lossless JPEG + - more than `maxPixels` pixels, checked before the output buffer is allocated +- Decoding parameters: + - stop on the first warning (`TJPARAM_STOPONWARNING`, `TJFLAG_STOPONWARNING`) + - at most 500 scans (`TJFLAG_LIMITSCANS`) + - `TJPARAM_MAXMEMORY`: `maxMemoryMB` +- Output: + + | JPEG colour space | pixel format | bytes per pixel | constructor | + |---|---|---|---| + | YCbCr, RGB | `TJPF_RGB` | 3 | `ImageRGB8` | + | grayscale | `TJPF_GRAY` | 1 | `ImageY8` | + | CMYK | `TJPF_CMYK` | 4 | `ImageCMYK8` | + | YCCK | `TJPF_CMYK`, samples inverted in the output buffer | 4 | `ImageCMYK8` | + +- Sides: the JPEG sides divided by 1, 2, 4 or 8, rounded up. +- The bytes per pixel are TurboJPEG's `tjPixelSize` of the pixel format; the output buffer size is their product with the sides. +- The pixels are written into a pinned buffer that becomes the image data without a copy. +- `readJpegHeader` and `decodeJpeg` run in a bound thread when the RTS is threaded (`runInBoundThread`): the error text, kept by mozjpeg per handle or per OS thread, is read in the thread of the failed call. +- Tests (`JuicyPixels-jpeg-turbo/test/JPTest.hs`), on generated JPEGs: + - header: dimensions, subsampling and colour space of YCbCr, RGB, grayscale, YCCK, CMYK, 4:4:1 and unknown subsampling + - 12-bit and lossless JPEGs: `Left` from both functions + - scales: 37×23, 1001×999 and 65500×1 at the four scales; sides rounded up; a solid colour within 2 per byte + - YCbCr, grayscale, YCCK and CMYK at full scale: within 3 per sample of JuicyPixels `decodeJpeg` + - `encodeRGB` of a gradient at quality 50 and of a texture at qualities 20 and 85: the lengths and FNV-1a hashes of mozjpeg 4.1.5 + - truncation after 2 bytes, at the end of the SOS header, in the middle and by one byte: `Left` + - malformed input: empty, text, PNG bytes, a JPEG without a frame, an SOF width of 0 or 65501: `Left`; width 65500 accepted + - limits: `maxMemoryMB` 1 refuses a progressive 1000×1000 JPEG and decodes the baseline one; `maxPixels` one below the pixel count refused, equal accepted; invalid options refused + - a progressive JPEG from `test/progressive.c` with its AC scan repeated 600 times: `Left` + - ICC profiles of 1, 65519, 65520 and 16707345 bytes: read back by libjpeg's `jpeg_read_icc_profile` (`test/icc.c`), and the JPEG without its APP2 segments equal to the JPEG without a profile; 0 and 16707346 bytes: `error` + - `encodeRGB` of a pixel format without a pixel size (`TJPF_UNKNOWN`): `error` "encodeRGB: unknown pixel format" +- Tests (`jpeg-turbo/test/FFITest.hs`): mozjpeg's `testimgari.jpg` decoded as `testimgint.jpg` at the four scales; `jpeg_turbo_encode` of a 0×1 image: −1, libjpeg's message "Empty JPEG image (DNL not supported)" and no output buffer; `tjPixelSize` and `tjMCUWidth` equal to TurboJPEG's tables (`test/tables.c`) for every named pixel format and subsampling, `Nothing` for the unknown ones. +- CI: + - `.github/scripts/encode.c`: baseline and progressive JPEGs at five qualities, and their decodes at the four scales, compared byte for byte between the cabal and CMake builds + - `armv7-check.sh`, on `ubuntu-24.04-arm`: the cabal sources cross-compiled for armv7 and run natively; Neon selected only with the `linux32` personality; JPEGs and decodes identical with Neon and with the C code paths + - `png-check.sh`: the libspng test images decoded identically by armv7 and aarch64 builds of the `JuicyPixels-spng` C sources + - `coverage.sh`, on Linux x86-64 with GHC 9.6.3: the hpc coverage of `TurboJPEG2`, `TurboJPEG3`, `MozJPEG`, `Codec.Picture.JPEGTurbo` and `Codec.Picture.Spng`, and the gcov line and branch coverage of `cbits/encode.c`, in the job log and summary +- `jpeg-turbo`: `THREAD_LOCAL` empty on 32-bit Android Arm (`cbits/jconfigint.h`). +- `JuicyPixels-spng`, new package, module `Codec.Picture.Spng`, synopsis "JuicyPixels wrappers for libspng", description "PNG encoding and decoding of JuicyPixels images with libspng.": + - `readPngHeader :: ByteString -> IO (Either String PngHeader)` + - `decodePng :: DecodeOptions -> ByteString -> IO (Either String (DynamicImage, Maybe ByteString))`: the image and the decompressed iCCP profile + - `PngHeader`: `pngWidth`, `pngHeight`, `pngBitDepth`, `pngColorType` + - `newtype DecodeOptions = DecodeOptions {maxMemoryMB :: Int}`, at least 1 + - limits: + - sides 16384 (`spng_set_image_limits`) + - `spng_set_chunk_limits` 1 MiB and 64 MiB: `Left` for an iCCP chunk that decompresses to more than 1 MiB and for a zTXt or iTXt chunk that decompresses to more than 512 KiB; the chunk data kept by libspng at most 64 MiB + - decoded pixels `maxMemoryMB` + - the chunks after the image data are read (`spng_decode_chunks`): `Left` for a wrong CRC of the last chunk and a missing IEND + - output: `ImageRGBA8` (`SPNG_FMT_RGBA8`) for a colour type with alpha or a `tRNS` chunk, otherwise `ImageRGB8` (`SPNG_FMT_RGB8`) + - `encodePng :: EncodeOptions -> Image PixelRGBA8 -> IO (Either String ByteString)`: 8-bit truecolour with alpha, to a buffer; the buffer of `spng_get_png_buffer` is taken into the `ByteString` with asynchronous exceptions masked (`mask_`), so that it is freed after one too + - `EncodeOptions`: `compressionLevel :: Int`, 0..9; `filtering :: Bool`, `False` for `SPNG_DISABLE_FILTERING`; `iccProfile :: Maybe ByteString`, an iCCP chunk named "ICC Profile", `Left` for an empty profile + - tests (`test/SpngTest.hs`): 16 PngSuite images equal to JuicyPixels' decodes and to a pinned FNV-1a hash; headers; a generated PNG; short image data, a 65537×65537 header, wrong CRCs, the last one included, a missing IEND, a missing image and truncations: `Left`; the memory limit; encoding: gradients of 7 sizes with and without filters decoded back to the same samples, a pinned FNV-1a hash of the PNGs, invalid options, a 0×0 image, short image data and an empty profile refused; a 1 MiB profile decoded from a generated iCCP chunk and kept by encoding; a profile above 1 MiB: `Left`; 1000 zTXt chunks with broken streams that decompress to 512 KiB: decoded; to 512 KiB + 1 byte: `Left` + +## JuicyPixels-stbir fork + +simplex-chat/JuicyPixels-stbir at `e0090e1`, on the Hackage release 0.1.0.0 (`917cd53`): +- `cbits/stb`: nothings/stb at `1ee679ca` as a submodule, `stb_image_resize.h` v0.97, replacing the bundled v0.95 copy. +- `cbits/stb_image_resize.c`: `STBIR_ASSERT` defined empty. +- `cc-options: -ffp-contract=off` (D16). +- cabal `license`: `BSD-3-Clause AND (MIT OR Unlicense)`, with `cabal-version: 2.2`. +- `LICENSE` and the cabal `copyright` field: "2026 SimpleX Chat Ltd" added. +- `resize`: `error` before calling stb_image_resize for a side below 1, an output or working buffers of 2 GiB or more, a `Region` outside the input, or a `Scale` that maps sample centres outside it (#3). +- Test suite `scale-test` (`test/ScaleTest.hs`): + - a solid colour, RGB8 and RGBA8 with alpha 128, kept in every output pixel, for repeated halvings and for scales k/64, k in 65..127 + - a 1001×999 RGB gradient resized to 320×240 with Catmull-Rom: a pinned FNV-1a hash of the samples + - a 46341×46341 RGBA8 output: refused; 16384×1 to 4320×1 and 1×16384 to 1×4320, RGB8 and RGBA8: accepted +- CI (`.github/workflows/build.yml`): build and test on Linux x86-64 with GHC 9.6.3 and 8.10.7, Linux ARM64, macOS and Windows with GHC 9.6.3. + +## Mechanics + +Core: +- `Image.hs`, new: + - exports (`:9-20`): + - `ImageFormat (IFJpeg, IFPng)` + - `ColourProfile (CPNone, CPDisplayP3)` + - `imageFormat` + - `decodeResizable` + - `resizeImage` + - `resizeImageDataUri` + - `maxDecodeMemoryMB`, also used by `Mobile/File.hs` and `MobileTests` + - `scaleRGB8` + - `scaleRGBA8` + - `ColourProfile` (`:46-47`): `CPNone`, `CPDisplayP3`; `Eq`, `Show` + - `imageFormat` (`:49-60`) + - `decodeResizable` (`:62-70`): `decodeDisplayJpeg` for input starting `FF D8`; `Spng.decodePng` with ``maxDecodeMemoryMB `div` 2`` for input starting with the PNG signature; otherwise `Left`; then `colourProfile` of the input profile, `CPNone` without one, forced with `$!` (`:65`) + - `decodeDisplayJpeg` (`:72-78`): `readJpegHeader`, `displaySize`, `jpegScale`, `decodeJpeg`, `resizeToSize`, `orientImage` with `exifOrientation`, and `jpegIccProfile`; the image and the input profile, as from `Spng.decodePng`; `segments` (`:78`), the one walk of `jpegSegments`, for `exifOrientation` and `jpegIccProfile` + - `displaySize` (`:80-86`): the target size and the number of halvings, rounded by `scaledSide` + - `jpegScale` (`:88-93`): `Full`, `Half`, `Quarter`, `Eighth` for 0, 1, 2, and 3 or more halvings + - `resizeToSize` (`:95-98`): `convertRGB8` and `STBIR.resize` when the decoded size differs from the target + - `exifOrientation` (`:100-122`): the orientation value of IFD0 from the segments, 1 when absent or malformed + - `exifTiff` (`:124-125`): the TIFF data of the first APP1 `Exif` segment + - `jpegIccProfile` (`:127-137`): the APP2 `ICC_PROFILE` segments (`iccSegments`), checked, ordered and concatenated + - `jpegSegments` (`:139-145`): the marker and the data of each segment after SOI and before the first SOS; the list ends at a byte other than `FF`, at fewer than 4 bytes or at SOS + - `orientImage` (`:147-159`): the 8 transforms with `generateImage`, applied with `dynamicPixelMap` + - `colourProfile` (`:161-165`): `CPDisplayP3` when the 9 primaries from `iccPrimaries` are within 2 of those of `displayP3`, otherwise `CPNone` + - `iccPrimaries` (`:167-189`): the header check; the tag table as one `slice`; `tagEntry`, the first entry of a signature, walked without a list; `colorant`, the size, the tag `slice`, the type and the 3 `s15Fixed16` values; numbers as `Integer`, `slice` bounds checked against the profile length + - `displayP3` (`:191-192`): `embedFile "src/Simplex/Chat/Image/DisplayP3-v4.icc"`, as `Operators.hs:70` embeds `PRIVACY.md` + - `ImageOutput` (`:194`): `ImageBytes`, `ImageDataUri` + - `resizeImage` (`:196-197`) + - `resizeImageDataUri` (`:199-200`) + - `fitImage` (`:202-222`): the size bound; the largest size from `displaySize`; the output, the quality, bpp0 and `outputLength` by `ImageOutput`; `keptProfile` (`:210`), `displayP3` for `CPDisplayP3` within a tenth of the limit; `fitSize`; the quality bisection at the largest size below `minFill`; JPEG with `encodeRGB`, the trial at the largest size from `largestRGB` (`scale`, `:215`), shared with the bisection; PNG with `encodeSmallerPng` of `scaleRGBA8` applied once to the image + - `fitSize` (`:224-262`): the size search of D4 + - `minFill = 0.97` (`:264-265`) + - `imageDoesNotFit` (`:267-268`) + - `bisect` (`:270-281`): the JPEG quality bisection, over `Int` + - `encodeSmallerPng` (`:283-286`): `Spng.encodePng` at level 9 without and with row filters, with `keptProfile`, the shorter output; `Left` raised as an exception, as `encodeRGB` raises its errors + - `maxImageSide = 16384` (`:288-289`) + - `maxDisplaySide = 4320` (`:291-292`) + - `maxJpegPixels = 2 ^ 28` (`:294-295`) + - `maxDecodeMemoryMB` (`:297-302`), 512 or 1024 by `mobileBuild` + - `hasSideAbove` (`:304-305`) + - `hasSize` (`:307-308`) + - `scaleRGB8` (`:310-315`) + - `scaleRGBA8` (`:317-325`): the premultiplied image bound once, shared by the returned function + - `catmullRom` (`:327-330`): `STBIR.resize` with Catmull-Rom and the given flags + - `premultiply` (`:332-335`) + - `unpremultiply` (`:337-342`) + - `sharpen` (`:344-369`): the ring `rows` of 5 rows of `Float`; `blurRow`, the horizontal pass of one row into its slot `rowStart`; rows 0 and 1 first, then row y + 2 before output row y; the vertical pass from the ring and `unsharp` into the output + - `convolve` (`:371-377`): the sum of the weighted samples at the 5 offsets, in `Double`, in order; monadic, for the samples and for the ring + - `sharpenOffsets` (`:379-382`) + - `sharpenWeights` (`:384-387`) + - `sharpenRadius` (`:389-390`) + - `sharpenAmount = 0.5` (`:392-393`) + - `sharpenSigma = 0.6` (`:395-396`) + - `scaledSide` (`:398-399`) + - `toDataUri` (`:401-407`), with the approved comment at `:405` +- `src/Simplex/Chat/Image/DisplayP3-v4.icc`, new: the profile of D25, the upstream file unchanged (git blob `4ee40fb5`) +- `Mobile/File.hs`: + - exported at `:13-18`: + - `cChatResizeImageUri` (`:153-154`) + - `cChatResizeImage` (`:156-157`) + - `cChatResizeImageFileUri` (`:159-160`) + - `cChatResizeImageFile` (`:162-163`) + - `cChatResizeImageFdUri` (`:165-166`) + - `cChatResizeImageFd` (`:168-169`) + - `readFilePath` (`:171-174`): `withBinaryFile`, then `hGetImage` + - `readFd` (`:176-177`): `c_close` (`:55`) of the descriptor when `fdToHandle` raises; `hGetImage` in `bracket` with `hClose` + - `hGetImage` (`:179-190`): with `tryIO (hFileSize h)`, `fail` for a size above `maxDecodeMemoryMB * 1048576`, otherwise `B.hGet` of the size; when `hFileSize` fails, at most `maxDecodeMemoryMB * 1048576 + 1` bytes with `LB.hGet` and `fail` above `maxDecodeMemoryMB * 1048576`; `tooLarge`, the one `fail` + - `cResizeImageUri` (`:192-193`) + - `cResizeImageData` (`:195-202`): the format by `compare png 0`, `imageFormat` for `LT` + - `cResizeImage` (`:204-207`): + - the read action + - `decodeResizable`, in place of `decodeImage` + - the resize function applied to the image and its `ColourProfile` + - `runCatchExceptT` + - `newCStringFromLazyBS` +- `Mobile.hs:167-177`: `foreign export ccall` of the six functions. +- `Library/Commands.hs`: + - `maxImagePreviewSize = 14000` (`:178-179`) + - `imagePreview :: CryptoFile -> IO ImageData` (`:231-237`): `decodeResizable`, in place of `decodeImage` (`:236`), and `resizeImageDataUri` with the profile (`:237`) + - `SendImage` (`:3555`): the preview from `imagePreview` (`:3562`) +- `simplex-chat.cabal`: + - `extra-source-files`: `src/Simplex/Chat/Image/DisplayP3-v4.icc` (`:21`), as `PRIVACY.md` + - library: `if flag(swift) || os(android)`, `cpp-options: -DmobileBuild` (`:419-420`) + - module `Simplex.Chat.Image` (`:54`) + - library dependencies: + - `JuicyPixels` (`:391`) + - `JuicyPixels-jpeg-turbo` (`:392`) + - `JuicyPixels-spng` (`:393`) + - `JuicyPixels-stbir` (`:394`) + - `vector` (`:413`) + - test suite: + - module `ImageTests` (`:730`) + - dependencies `JuicyPixels` and `JuicyPixels-jpeg-turbo` (`:823-824`) +- `libsimplex.dll.def:30-35`, `flake.nix:491-496`: the export names. +- `flake.nix:73-75`: the nix licence of JuicyPixels-spng (D21). + +iOS: +- `apps/ios/SimpleXChat/ImageUtils.swift`: + - `saveImageFile` (`:75-80`) + - `resizeImageFileToStrSize` (`:82-86`) + - `resizeImageToDataSize` (`:101-107`) + - `resizeImageToStrSizeSync` (`:109-115`) + - `resizeImageWithCore` (`:121-127`), private + - `saveWallpaperFile(image:)` (`:246`): `guard let` in place of a force-unwrap + - `compressImageStr` and `reduceSize` removed +- `apps/ios/SimpleXChat/SimpleX.h:36-46`: the four declarations, each with its approved comment. +- `apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift`: + - `UploadContent.imageFile` (`:302`) + - `imagePreview()` (`:315-320`) + - `loadImageFileFromURL` (`:322-334`) + - previews (`:675`, `:1278`) + - sending (`:1598-1599`), with `imageCryptoFile` (`:1609-1614`): the saved image, then `removeFile` of the copy and its removal from `ChatModel.shared.filesToDelete` +- `apps/ios/Shared/Views/Helpers/ImagePicker.swift`: + - `loadImageData` (`:79-90`) + - `imageFileType` (`:92-95`) +- `apps/ios/spec/architecture.md`: + - `:101-105`: FFI list + - `:61, 67, 69, 336`: header range `SimpleX.h#L1-L63` + +Android and desktop, `apps/multiplatform/common/src/`: +- `commonMain/.../platform/Images.kt`: + - `expect` of `resizeImageFileUri` and `resizeImageFileData` (`:34-35`) + - `resizeImageFileToStrSize` (`:37-41`), with the debug log line `resizeImageFileToStrSize: core returned no image` for `""` + - `resizeImageFileToDataSize` (`:43-47`), with the debug log line `resizeImageFileToDataSize: core returned no image` for `""` + - `resizeImageToStrSize` (`:49-53`) + - `resizeImageToDataSize` (`:55-61`): `png` 1 or 0 by `usePng` + - the `expect` declarations of `resizeImageToStrSize`, `resizeImageToDataSize` and `compressImageStr` removed +- `androidMain/.../platform/Images.android.kt`: + - `resizeImageFileUri` (`:112-113`) + - `resizeImageFileData` (`:115-116`) + - `imageFileDescriptor` (`:118-124`) +- `desktopMain/.../platform/Images.desktop.kt`: + - `resizeImageFileUri` (`:176`) + - `resizeImageFileData` (`:178`) +- In both platform files, the three `actual` functions of master are removed. +- `commonMain/.../platform/Core.kt`: + - `chatResizeImageUri` (`:42`) + - `chatResizeImage` (`:43`), with `png: Int` as the file and descriptor functions + - `chatResizeImageFileUri`, `chatResizeImageFile`, `chatResizeImageFdUri`, `chatResizeImageFd` (`:44-47`) + - `ByteArray.toDirectBuffer` (`:56-61`) +- `commonMain/.../model/CryptoFile.kt:25`: `toDirectBuffer` is used by `writeCryptoFile`. +- `commonMain/.../views/helpers/Utils.kt`: + - `saveImage(uri)` (`:174-200`) + - `desktopSaveImageInTmp` (`:202-216`) + - `resizeImageFromUri` (`:218-227`) +- `commonMain/.../views/chat/ComposeView.kt`: `processPickedMedia` (`:359`), one `preview` per URI (`:364`), `bitmapPreview` (`:370`), the preview from the file (`:382`), the media added when it has a preview (`:397`). +- `androidMain/.../views/chat/ComposeView.android.kt`: the camera preview (`:23-31`). +- `commonMain/cpp/android/simplex-api.c`: declarations (`:86-89`), JNI functions (`:282`, `:289`, `:296`, `:301`) with `decode_and_free`; `png` as `jint`. +- `commonMain/cpp/desktop/simplex-api.c`: declarations (`:49-52`), JNI functions (`:283`, `:290`, `:297`, `:305`) with `decode_to_utf8_string`; `png` as `jint`. +- `apps/multiplatform/spec/architecture.md`: + - `:142-155`: JNI table, with a `chatParseBadgeCode` row, the rows of the file and descriptor functions, and the total of 25 + - `:348`: `base64ToBitmap` line +- `apps/multiplatform/spec/services/files.md:59, 81-84, 208`: `CryptoFile.kt` lines. +- `apps/multiplatform/spec/client/compose.md:318`: the image previews of `processPickedMedia`. + +Node, `packages/simplex-chat-nodejs/`: +- `cpp/simplex.h:38-42`: function types, with the approved comments. +- `cpp/simplex.cc`: + - `Library` fields (`:39-40`), resolved as optional symbols (`:217-218`) + - helpers, with their other users: + - `RequireExport` (`:141-146`): `ChatMigrateInitQueue` (`:573`) + - `GetInt32Arg` (`:148-154`): `ChatMigrateInitQueue` (`:583`) + - `BufferArg` (`:156-159`) and `GetBufferArg` (`:161-178`): `ChatWriteFile` (`:696-697`) + - with `ResultAsyncWorker` and `KeepAlive` of the input: + - `ChatResizeImage` (`:811-840`) + - `ChatResizeImageUri` (`:842-869`) + - exports (`:890-891`) +- `src/simplex.d.ts:13-14`, `src/simplex.js:3-4`: bindings. +- `src/core.ts`: + - `chatResizeImageUri` (`:112-117`) + - `chatResizeImage` (`:119-124`) + - `resizeImage` (`:126-138`): the doc comment, three overloads and the implementation + - `resizeImageData` (`:140-142`), private + - `resizedImage` (`:144-147`), private + - `ImageSize` (`:170-177`), with its doc comment +- `docs/`, by `npm run docs`: + - new: + - `core.Enumeration.ImageSize.md` + - `core.Function.chatResizeImage.md` + - `core.Function.chatResizeImageUri.md` + - `core.Function.resizeImage.md` + - `Namespace.core.md`: the lists and the module text + - other pages: updated source line references +- Texts: + - `README.md:137`: `- [core](./docs/Namespace.core.md): a low level API to the core library - the same that is used in desktop clients. You are unlikely to ever need to use this module directly, except for image resizing.` + - `src/index.ts:15`: ` * You are unlikely to ever need to use this module directly, except for image resizing.` + - `src/core.ts:127`, `resizeImage`: ` * Resize image to the app size limit: JPEG or PNG for an image, a data URI for a preview` + - `src/core.ts:171`, `ImageSize`: ` * App image size limit` + +Python, `packages/simplex-chat-python/src/simplex_chat/`: +- `_native.py`: + - `_optional_export` (`:280-289`), also used by `migrate_init_queue` (`:292-298`) + - `resize_image` (`:301-302`) + - `resize_image_uri` (`:305-306`) +- `core.py`: + - module docstring (`:1-6`): + ``` + """Typed async wrapper around libsimplex's C ABI functions. + + Users interact with `Bot` / `ChatApi`. This module is exposed as + `simplex_chat.core` for tests, the api.ChatApi class and image resizing + (`resize_image`, `chat_resize_image`, `chat_resize_image_uri`). + """ + ``` + - `ImageSize` (`:75-78`) + - `_check_c_int` (`:99-102`), also used by `chat_migrate_init` (`:156`) + - `chat_resize_image_uri` (`:264-272`), without a docstring + - `chat_resize_image` (`:275-278`), without a docstring + - `resize_image` (`:281-303`): three overloads and the implementation, with `match`; docstring (`:296`): `"""Resize image to the app size limit: JPEG or PNG for an image, a data URI for a preview."""` + - `_resize_image_data` (`:306-314`) + - `_resized_image` (`:317-320`) + +Build: +- `cabal.project`, `source-repository-package`: + - hs-jpeg-turbo (`:76-80`): simplex-chat/hs-jpeg-turbo at `d0a4d95`, subdirectories `JuicyPixels-jpeg-turbo`, `JuicyPixels-spng` and `jpeg-turbo` + - JuicyPixels-stbir (`:82-85`): simplex-chat/JuicyPixels-stbir at `e0090e1` +- `scripts/nix/sha256map.nix:12-13`: the hashes of both repositories, computed with submodules. +- `dist-newstyle/src/hs-jpeg-t*` and `dist-newstyle/src/JuicyPixe*` in the Windows removals: + - `.github/workflows/build.yml:1519, 1522, 1645, 1687, 1689`, with the comment `:1502-1503` + - `scripts/desktop/build-lib-windows.sh:43`, with the comment `:41-42` +- `scripts/simplex-chat-reproduce-builds-android.sh:103`: submodules are updated after checkout. + +CI, comments of the avatar scripts: +- `scripts/ci/ios-sim/avatar.swift:1`: `// Makes a group avatar:` +- `scripts/ci/desktop-linux/backend.sh:70`: `# Prints a profile image data URI: JPEG at quality 85, scaled down by the square root` + +CI, the armv7a Android core library, on request (D9): +- `.github/workflows/android-emu.yml`: + - input `armv7a` (`:18-21`), passed by the `plan` job as `run-armv7a` (`:48`, `:61`, `:66`) + - job `core-armv7a` (`:183-204`): `build-core.sh ~/core/android-core-armeabi-v7a.tar.gz armv7a` on `ubuntu-24.04`, built only +- `scripts/ci/android/build-core.sh:13-14`: the architecture argument, `aarch64` by default or `armv7a`, in the Hydra job names `-android:lib:simplex-chat` and `-android:lib:support` +- `scripts/ci/README.md:158`, `scripts/ci/android/README.md:7`, `:21`: the input and the job. + +CI, scripted app smoke tests, in every app workflow run: +- `tests/fixtures/test.jpg` and `logo-large-rgba.png` picked, previewed and sent in a local group; for each image: + - the compose preview + - a new `IMG_*.jpg` or `IMG_*.png` file; on iOS, also its pixel size + - no new log line of the app's own resizing during the send + + | app | steps | images | log line of the app's own resizing | + |---|---|---|---| + | Android | `scripts/ci/android/smoke-test.sh:263-317`, from `:355` | `/sdcard/Pictures/test-card/test-card.jpg` and `/sdcard/Pictures/simplex-logo/simplex-logo.png`, one folder each, with a media scan broadcast (`add_images`, `:263-269`); in Google Photos' "Select photos": the folder by its name, its photo ("Photo taken on …"), then "Done" (or "Add") | `core returned no image` (`core_returned_no_image`, `:283-285`), a check at `:308` | + | desktop | `scripts/ci/desktop-linux/smoke-test.sh:320-379`, from `:438` | the fixture paths typed in the file dialog | `core returned no image` (`core_returned_no_image`, `:346-348`), a check at `:370` | + | iOS | `scripts/ci/ios-sim/smoke-test.sh:107-171`, from `:216-224` with `images`, after `snapshots.sh` (`:211-214`): the app's chats, files and hints are shown in the snapshot renders; first "Don’t Allow" in the camera access alert that the scanner views of the snapshot tests leave (`:217`) | local file encryption off for the images case (`disable_file_encryption`, `:125-129`): `privacyEncryptLocalFiles` false in the app-group defaults while the app is stopped, as `ui layout` sets `oneHandUI`, because the app stores sent images encrypted by default and `sips` reads only plain files; both images added to the photo library with one `xcrun simctl addmedia`, which lists the test card first and the logo second; the attachment button by its label "Attachements", "Choose from library"; the photo picker, absent from the accessibility tree, by position (`:21-25`): the test card or the logo, then the ✓ button; the pixel size of the newest sent file by `sips` (`sent_size`, `:119-123`, checked at `:168`): 1280x720 for the test card and 1630x466 for the logo, the sizes the core keeps at the 261120-byte limit | `resizeImageToStrSize final` and `resizeImageToDataSize final` (`app_resized_images`, `:147-149`), `fail` at `:169` | + +- `.github/workflows/ios-sim.yml:269`: the app job's smoke test with `images`. +- `scripts/ci/android/README.md:32`, `scripts/ci/desktop-linux/README.md:27`, `scripts/ci/ios-sim/README.md:3`: the image steps. + +CI, Claude-driven app tests (run with `claude=true`, which needs the `app-sim-claude` environment): +- images: `tests/fixtures/test.jpg` and `tests/fixtures/logo-large-rgba.png`, added before Claude starts by the step "Add test images": + + | app | where | HEIC | + |---|---|---| + | Android (`android-emu.yml`) | `/sdcard/Pictures/test-card.jpg` and `simplex-logo.png`, with a media scan broadcast | | + | desktop (`desktop-linux.yml`) | `images/` in Claude's working directory | | + | iOS (`ios-sim.yml`) | the photo library, with `xcrun simctl addmedia` | `test.jpg` turned to portrait by `sips`, when `sips` writes HEIC | + +- steps: pick the JPEG, then the PNG with transparency (and on iOS the HEIC); check the compose preview; send; open the sent image + - Android: `scripts/ci/android/claude-test.md`, part D + - desktop: `scripts/ci/desktop-linux/claude-test.md`, part I, after the channel parts, in a new group + - iOS: `scripts/ci/ios-sim/device-test-plan.md`, cases I1-I4, after the channel cases, named in `claude-scope` with `max-turns: 390`; the photo picker in `claude-prompt.md` +- `scripts/ci/desktop-linux/claude-scope`: `max-turns: 330`, read by `claude-test.sh` for Android and desktop + +License notices, `docs/dependencies/`: +- `README.md:3`: + `[Compact ICC Profiles](https://github.com/saucecontrol/Compact-ICC-Profiles): Display P3 colour profile of resized images ([CC0 1.0](./licences/apps/compact-icc-profiles/license))` +- `README.md:5`: + `[libspng](https://github.com/randy408/libspng): PNG decoding and encoding of resized images ([BSD-2-clause](./licences/apps/libspng/LICENSE) and [libpng-2.0](./licences/apps/libspng/LICENSE-libpng))` +- `README.md:7`: + `[miniz](https://github.com/richgel999/miniz): zlib compression and decompression for libspng ([MIT](./licences/apps/miniz/LICENSE))` +- `README.md:9`: + `[mozjpeg](https://github.com/mozilla/mozjpeg): JPEG decoding and encoding of resized images ([IJG and BSD-3-clause](./licences/apps/mozjpeg/LICENSE.md)). This software is based in part on the work of the Independent JPEG Group.` +- `README.md:13`: + `[stb_image_resize](https://github.com/nothings/stb): image scaling ([MIT or public domain](./licences/apps/stb_image_resize/LICENSE))` +- `licences/apps/mozjpeg/LICENSE.md` and `README.ijg`: copies from mozjpeg `08265790`, the submodule of simplex-chat/hs-jpeg-turbo. +- `licences/apps/stb_image_resize/LICENSE`: `cbits/stb/stb_image_resize.h:2596-2632` of simplex-chat/JuicyPixels-stbir at `e0090e1`. +- `licences/apps/libspng/LICENSE`: libspng's `LICENSE` (BSD-2-Clause); `licences/apps/libspng/LICENSE-libpng`: `spng/spng.c:6149-6180` (libpng-2.0), without the comment markers; from libspng `fb768002`, the submodule of simplex-chat/hs-jpeg-turbo at `d0a4d95`. +- `licences/apps/miniz/LICENSE`: miniz's `LICENSE` (MIT), from miniz 3.1.2 (`77d0dce8`), the submodule of simplex-chat/hs-jpeg-turbo at `d0a4d95`. +- `licences/apps/compact-icc-profiles/license`: the `license` (CC0 1.0) of saucecontrol/Compact-ICC-Profiles at `bdd84663`, the source of `DisplayP3-v4.icc`. +- `HASKELL.md`: + - direct dependencies (`:25-28`): + - `JuicyPixels` 3.3.8, Hackage + - `JuicyPixels-jpeg-turbo` 0.1.0.0, simplex-chat/hs-jpeg-turbo at `d0a4d95` + - `JuicyPixels-spng` 0.1.0.0, simplex-chat/hs-jpeg-turbo at `d0a4d95`, `BSD-3-Clause AND BSD-2-Clause AND libpng-2.0 AND MIT`, "JuicyPixels wrappers for libspng": the description column holds each package's synopsis + - `JuicyPixels-stbir` 0.1.0.0, simplex-chat/JuicyPixels-stbir at `e0090e1`, `BSD-3-Clause AND (MIT OR Unlicense)` + - direct dependency (`:55`): `vector` 0.13.1.0, moved from the indirect dependencies + - indirect dependency (`:145`): `jpeg-turbo` 0.1.0.0, simplex-chat/hs-jpeg-turbo at `d0a4d95`, `BSD-3-Clause AND IJG AND Zlib` + - each of the five added to the dependents of the packages it depends on, among: + - `bytestring` (`:11`) + - `containers` (`:14`) + - `mtl` (`:31`) + - `vector` (`:55`) + - `binary` (`:70`) + - `deepseq` (`:94`) + - `data-default-class` (`:98`) + - `primitive` (`:138`) + - `transformers` (`:167`) + - `zlib` (`:180`) +- `licences/haskell/-/LICENSE`: the `LICENSE` of each of the five packages, from Hackage and from the pinned commits; the JuicyPixels-spng and JuicyPixels-stbir copies with the line `Copyright 2026 SimpleX Chat Ltd`. + +## Tests + +`tests/ImageTests.hs`, group "Image resize" (`tests/Test.hs:98`): +- Same aspect ratio, in every comparison below: `|w·h' − h·w'| ≤ w' + h'` (`:457-458`). +- Without a colour profile, `CPNone`, in every `resizeImage` and `resizeImageDataUri` call below. +- data URI (`:32-35`), limit 14000; each result is decoded and compared by aspect ratio with the input: + - `test.jpg` (1280×720): `data:image/jpg;base64,` + - `logo-large-rgba.png` (1630×466, transparent pixels): `data:image/png;base64,` + - `preview-issue1.png` (1200×600, opaque): `data:image/jpg;base64,` +- 16-bit images (`:36`), 128×128: a JPEG URI for RGB16; for RGBA16 with half opacity, a PNG URI decoded as RGBA8. +- image data (`:37-40`), limit 10000: + - JPEG from `test.jpg`: starts `FF D8`, width below 1280, same aspect ratio + - PNG from `logo-large-rgba.png`: RGBA8, width below 1630, same aspect ratio + - 1×1 image with limit 10: `Left` for JPEG and PNG +- size bound (`:41`), transparent images, limit 100000, for PNG, JPEG and the data URI: + - 16384×4 and 4×16384: `Right` + - 16385×4 and 4×16385: `Left` +- display halving (`:42`), opaque and transparent images, `imageFormat`; the image data with limit 261120 and the data URIs with limits 12500 and 14000, each decoded: + + | input | output | + |---|---| + | 8640×8 | 4320×4 | + | 8×8640 | 4×4320 | + | 4320×4 | 4320×4 | + +- fill (`:43`, `:138-147`): `test.jpg` and `preview-issue1.png`, the data URI with limit 14000 and the JPEG data with limit 20000: 97–100% of the limit +- quality at the image size (`:44`, `:149-157`): a 64×48 gradient with a limit midway between its quality-65 and quality-95 encodings: at most the limit, larger than the quality-65 encoding, 64×48 +- image preview (`:45-49`): + - `test.jpg`: JPEG data URI of at most 14000 characters + - an encrypted copy of `test.jpg`: the same + - `test.pdf`: `fixedImagePreview` +- JPEG display size (`:51`, `:172-186`), blank W×16 JPEGs from `encodeRGB` and the same with the sides swapped, `decodeResizable`, then the image data with limit 261120, decoded: + - 4320, 4321, 8640, 8641: the size of the full-decode path, JuicyPixels `decodeImage` then `resizeImage` + - expected sizes: + + | W | output | + |---|---| + | 4320 | 4320×16 | + | 4321 | 2160×8 | + | 8640 | 4320×8 | + | 8641 | 4320×8 | + | 17280 | 4320×4 | + | 17281 | 4320×4 | + | 17282 | 4320×4 | + | 34561 | 4320×2 | + | 65500 | 4094×1 | + +- JPEG colour spaces (`:52`, `:188-196`), 64×48, `decodeResizable`: + + | input | result | + |---|---| + | progressive YCbCr, `encodeRGB` | `ImageRGB8` | + | baseline YCbCr, JuicyPixels `encodeJpegAtQuality` | `ImageRGB8` | + | grayscale, JuicyPixels `Pixel8` encoding | `ImageY8` | + | YCCK, JuicyPixels `PixelCMYK8` encoding | `ImageCMYK8` | + +- JPEG refusals (`:53`, `:198-213`), a baseline 64×48 JPEG changed as below, `decodeResizable`, `Left`: + - precision 12 written into the SOF + - SOF marker `FF C3` (lossless) + - a side of 65501 written into the SOF + - 16385×16384 written into the SOF + - the input shortened by one byte + - a byte inserted before the SOF marker +- EXIF orientation (`:54`, `:215-257`): a 64×32 JPEG with four solid quadrants and an APP1 Exif segment, little-endian for odd and big-endian for even values; `decodeResizable`: + - values 1–8: the sides swapped for 5–8; the quadrant colours placed by the transform; the resized JPEG without `Exif` + - an IFD0 offset beyond the segment: as value 1 +- PNG decoding (`:55-57`), `decodeResizable`: + - `logo-large-rgba.png` (`:259-269`): `ImageRGBA8` with the samples of JuicyPixels `decodeImage`, and `imageFormat` PNG; `preview-issue1.png`: `ImageRGB8` with the samples of JuicyPixels `decodeImage`; both without a colour profile + - refusals (`:271-304`): + + | input | `Left` | + |---|---| + | `pngShortImageData`: the 2048×1 grayscale PNG from JuicyPixels `encodePng` with its header set to 2048×2048 | `IDAT stream too short` | + | `pngSideAbove16384`: the 1×1 grayscale PNG with its header set to 65537×65537 | `image width exceeds user limit` | + | a 16×16 GIF and a 16×16 BMP from JuicyPixels | `unsupported image format` | + +- scaling (`:58-61`): + - research code (`:321-327`): `scaleRGB8` of `preview-issue1.png` to 400×200 equals `preview-issue1-400x200-sharpened.png` pixel for pixel; the fixture is the output of the research code (`pvsf`, resize `catmullrom/g/0.5@0.6/single`) for the same decoded pixels + - the same size (`:329-334`): `scaleRGB8` and `scaleRGBA8` of 64×48 images to 64×48 return the input samples + - transparency (`:336-346`): a 64×64 opaque red disc on transparent pixels alternately green and blue, `scaleRGBA8` to 40×40: every pixel with alpha above 0 is red; some pixels have alpha below 255 +- colour profiles (`:62-67`, `:348-449`), the 64×48 `gradient` and `transparentGradient`; the `ColourProfile` of an output read back by `decodeResizable` (`resizedProfile`, `uriProfile`): + - fixtures built in the test: + - ICC (`:432-443`): `iccProfile`, a 228-byte profile with a given colour space, PCS `XYZ ` and the tags `rXYZ`, `gXYZ` and `bXYZ` of given primaries; `p3Profile`, with the values of Apple's Display P3 profile (`p3Primaries`), bY 1/65536 from `displayP3` + - JPEG (`:312-316`): APP2 segments inserted after SOI by `withIccProfile`, each `ICC_PROFILE\0`, the sequence number, the segment count and the chunk (`iccChunk`) + - PNG (`:295-310`): an iCCP chunk named `test` inserted after IHDR by `pngWithIccProfile`, the profile in a stored zlib block with its Adler-32 (`zlibStored`), the chunk CRC by `crc32` (`pngChunk`) + - Display P3 JPEG (`:348-360`): `p3Profile` in two segments, the second first: decoded as `CPDisplayP3`; with rX 2/65536 above: `CPDisplayP3`; the JPEG image data with limit 261120 contains the bytes of `src/Simplex/Chat/Image/DisplayP3-v4.icc`, read by the test (`displayP3File`); the JPEG and PNG image data with limit 261120 read back as `CPDisplayP3` + - Display P3 PNG (`:362-372`): decoded as `CPDisplayP3`; the JPEG and PNG image data with limit 261120 and the PNG data URI with limit 14000 read back as `CPDisplayP3` + - other profiles (`:374-392`), in one JPEG segment: decoded as `CPNone`; the JPEG data with limit 261120 equal to that of the JPEG without a profile, which is also decoded as `CPNone`: + - sRGB primaries + - rX 3/65536 above Display P3 + - colour space `GRAY` with the Display P3 tags + - tag count 9: the table ends past the profile + - `bXYZ` offset 209: the tag ends past the profile + - above a tenth of the limit (`:394-405`), `CPDisplayP3`, whose `displayP3` has 480 bytes: + - image data, JPEG and PNG: limit 4799 gives the output of `CPNone`, byte for byte; limit 4800 keeps the profile + - data URI: limit 6399 gives the data URI of `CPNone` (640 base64 characters); limit 6400 keeps the profile + - inconsistent JPEG profiles (`:407-421`), `p3Profile` split after 100 bytes: one of two segments; a repeated sequence number; segment counts 2 and 3; sequence number 0; an empty chunk; a 13-byte segment: decoded as `CPNone` + +`tests/MobileTests.hs`, "Images" (`:103-110`), with buffers from `mallocBytes`: +- `cChatResizeImageUri` on `test.jpg`, limit 14000 (`:368-375`): `data:image/jpg;base64,`, at most 14000 characters; on `"not an image"`: `""`. +- `cChatResizeImage` on `test.jpg`, limit 100000 (`:377-385`): base64 decoded to at most 100000 bytes, starting `FF D8` for `png = 0` and with the PNG signature for `png = 1`. +- `cChatResizeImage` with `png = -1`, limit 100000 (`:387-395`): `FF D8` for `test.jpg`; the PNG signature for `logo-large-rgba.png`. +- `cChatResizeImage` with `png = -1`, limit 261120, on a blank 20000×16 JPEG from `encodeRGB` (`:397-401`): `FF D8`. +- an input above the limit (`oversizedJpegFile`, `:450-455`): `test.jpg` extended with zeros to `maxDecodeMemoryMB` MiB + 1 byte by `hSetFileSize`, a sparse file; resized as `test.jpg` is when no limit applies +- file path (`:403-412`): + - `cChatResizeImageFileUri` on `test.jpg`, limit 14000: `data:image/jpg;base64,`, at most 14000 characters + - `cChatResizeImageFile` with `png = -1`, limit 100000, on `logo-large-rgba.png`: the PNG signature + - a missing file: `""` + - `cChatResizeImageFileUri` on the input above the limit: `""` +- file descriptor (`:414-448`), opened with `GHC.IO.FD.openFile`; closed afterwards: `fdStat` fails or names another file + - `cChatResizeImageFdUri` on `test.jpg`, limit 14000: `data:image/jpg;base64,`, at most 14000 characters; closed afterwards + - `cChatResizeImageFdUri` on the read end of a pipe (`pipeFd`, `createPipeFd`), with `test.jpg` written by another thread, limit 14000: `data:image/jpg;base64,`; closed afterwards + - `cChatResizeImageFd` on `test.pdf`: `""`; closed afterwards + - `cChatResizeImageFdUri` on the `tests/fixtures` directory, opened with `c_open`: `""`; closed afterwards + - descriptors 1000000 and −1, not open: `""` + - `cChatResizeImageFdUri` on the input above the limit: `""`; closed afterwards +- malformed PNG (`:457-465`): `pngShortImageData` and `pngSideAbove16384` from `ImageTests`: `""` from `cChatResizeImageUri` with limit 14000 and from `cChatResizeImage` with `png = -1` and limit 261120 + +`tests/ChatTests/Files.hs`, "send image with generated preview" (`:47`, `:179-192`): +- `/img @bob ./tests/fixtures/test.jpg`: the received item has a JPEG data URI preview of at most 14000 characters, read with `APIGetChat` (`lastImagePreview`, `:194-200`). + +`packages/simplex-chat-nodejs/tests/core.test.ts`, "image resizing" (`:149-239`), with a generated noise PNG: +- data URI, limit 14000: `data:image/jpg;base64,`, at most 14000 characters +- a `Uint8Array` view at offset 3, limit 20000: JPEG starting `FF D8 FF`; PNG from an `ArrayBuffer` with the PNG signature; both at most 20000 bytes +- `resizeImage` with each `ImageSize`: + + | input | `Image`, at most 261120 bytes | `ProfilePreview`, at most 12500 characters | `MessagePreview`, at most 14000 characters | + |---|---|---|---| + | 512×512, opaque (`:201-214`) | JPEG starting `FF D8 FF` | `data:image/jpg;base64,` | `data:image/jpg;base64,` | + | 256×256, random alpha (`:216-229`) | PNG signature | `data:image/png;base64,` | `data:image/png;base64,` | + +- `"not an image"`: the resize error +- `maxSize` 2^31: `Expected 32-bit integer maxSize` +- With a libsimplex lacking the exports, the export error is expected in each test (`:155-158`). + +`packages/simplex-chat-nodejs/tests/loader.test.ts:39-42`: `chat_resize_image` and `chat_resize_image_uri` are added to the add-on exports called before `load`; from each, `libsimplex is not loaded, call core.loadLibrary(backend) first` is expected (`RequireLoaded`, `cpp/simplex.cc:135`). + +`packages/simplex-chat-python/tests/test_core_resize_image.py`, with a fake library: +- `chat_resize_image_uri`: the export is called with `(data, len(data), max_size)`; the data URI is returned (`:52`) +- `chat_resize_image`: `png` is 0 for `"jpeg"` and 1 for `"png"`; the result is base64-decoded (`:59`) +- `resize_image` with `ImageSize.IMAGE`: `chat_resize_image` is called with limit 261120 and `png = -1`; the result is base64-decoded (`:65`) +- `resize_image` with `PROFILE_PREVIEW` and `MESSAGE_PREVIEW`: `chat_resize_image_uri` is called with limits 12500 and 14000; the data URI is returned (`:75`) +- errors: + + | condition | raised | raised by | + |---|---|---| + | `""` from the export (`:89`) | `RuntimeError` | both functions, `resize_image` with `IMAGE` | + | absent export (`:107`) | `RuntimeError` with the export text | both functions, `resize_image` with `IMAGE` and `PROFILE_PREVIEW` | + | `max_size` 2^31 or −2^31 − 1 (`:115`) | `ValueError`, before the call | both functions | diff --git a/scripts/ci/README.md b/scripts/ci/README.md index e6d645ff17..50fad152fb 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -155,6 +155,7 @@ Pushes start none of the app workflows: a run is started by hand, on any branch, | `claude` | scripted test, then Claude | scripted test, then Claude | scripted test, then Claude | | `backend` | also the test backend | also the test backend | also the test backend | | `release` | — | also the latest release's onboarding, to tell a branch's failure from the runner's | also the latest release's onboarding, to tell a branch's failure from the runner's | +| `armv7a` | — | — | also the core library for 32-bit Arm (armeabi-v7a), built only | In the Actions tab: `ios-sim`, `desktop-linux` or `android-emu` → Run workflow, choose the branch and tick the inputs. With the GitHub CLI, each input is a field: diff --git a/scripts/ci/android/README.md b/scripts/ci/android/README.md index ceac2a1b08..5b76b6d081 100644 --- a/scripts/ci/android/README.md +++ b/scripts/ci/android/README.md @@ -4,7 +4,7 @@ ## Running it -The workflow runs only when started by hand, on any branch: in the Actions tab, `android-emu` → Run workflow, or `gh workflow run android-emu.yml --ref ` with `-f claude=true`, `-f backend=true` or `-f release=true`. [CI on GitHub's runners](../README.md) lists the inputs. A newer run on the same branch cancels the older one. +The workflow runs only when started by hand, on any branch: in the Actions tab, `android-emu` → Run workflow, or `gh workflow run android-emu.yml --ref ` with `-f claude=true`, `-f backend=true`, `-f release=true` or `-f armv7a=true`. [CI on GitHub's runners](../README.md) lists the inputs. A newer run on the same branch cancels the older one. Every run builds the core library, and the backend when it's asked for, from the branch's own Haskell code. It reuses an earlier build only when the sources are unchanged, as an artifact of the same branch named after a hash of its sources (30 days): for the core `src/**`, `cabal.project`, `simplex-chat.cabal`, `PRIVACY.md`, the Nix files (`flake.nix`, `flake.lock`, `scripts/nix/**`) and `build-core.sh`; the backend is desktop-linux's, under its name and hash, so a backend either workflow built is reused by both. The workflow saves no Actions caches, which this repository evicts within hours and shares with `build.yml`. @@ -18,6 +18,7 @@ Every run builds the core library, and the backend when it's asked for, from the | `claude` (opt-in) | after `app` passed: its APK in a new emulator at the first screen, then Claude Code drives it | about 5 min, Claude 2.5 | | `backend` (opt-in) | desktop-linux's `build-backend.sh` | about 30 min, skipped when reused | | `release` (opt-in) | the latest release's arm64 APK, its signer checked against SimpleX's key, onboarding only | about 4 min | +| `core-armv7a` (opt-in) | `build-core.sh` with `armv7a`: `lib:simplex-chat` and `lib:support` for armv7a-android, with GHC 8.10.7; the app and the emulator use only arm64 | not measured yet | Results are artifacts: `android-smoke-test` (numbered screenshots, the accessibility tree and the text of each, `steps.log`, `checks.txt`, logcat as `app.log`, `emulator.log`), `android-release-smoke-test`, `android-claude-test`, and the backend's logs; `android-app`, the APK for manual tests and the Claude job, is kept 14 days. The job summaries list the checks and the Claude usage. @@ -28,7 +29,7 @@ A Google APIs x86_64 system image of API 34 (revision 14) with KVM, on the x86_6 ## Scripts - `ui`: the UI driver, with desktop's command names: text and positions from uiautomator's accessibility tree, taps and keys with adb. `ui` alone prints its commands: start (with app settings), shot, text, tree, find, wait, click, click-text, scroll, type, key, layout (the `OneHandUI` preference and a restart), relaunch, text-size, do, stop. `screen-tree.py` reads the tree; `prefs.py` writes the settings, which only a debug build accepts (`run-as`). -- `smoke-test.sh [start|onboarding|layouts|backend]`: the scripted cases; the default is onboarding, a local group and both layouts. Onboarding on Android includes the app's instant notifications alert and the system's battery dialog. `backend` also adds the test SMP server under Your servers and checks that it passes Test servers. +- `smoke-test.sh [start|onboarding|layouts|backend]`: the scripted cases; the default is onboarding, a local group, both layouts, and `tests/fixtures/test.jpg` and `logo-large-rgba.png` pushed to Pictures, picked in the system's picker, resized by the core, previewed and sent in the group. Onboarding on Android includes the app's instant notifications alert and the system's battery dialog. `backend` also adds the test SMP server under Your servers and checks that it passes Test servers. - `install-sdk.sh`, `emulator.sh`: the emulator, above. - `build-core.sh`, `install-core.sh`, `release-apk.sh`: builds and the release. - `claude-test.md` (the prompt) and `claude-scope` (whether it uses the test backend): the Claude job, which runs desktop-linux's `claude-test.sh`, `claude-guard.py` and `claude-usage.py`. @@ -39,7 +40,7 @@ Desktop-linux's `backend.sh` starts it on the runner as it does for desktop: [it ## The Claude job -It needs the GitHub environment `app-sim-claude` with the secret `CLAUDE_CODE_OAUTH_TOKEN`, a Claude subscription token. Which branches may use it is decided only by that environment's branch rule in the repository settings: on a branch the rule doesn't allow, a requested Claude run fails at the environment check. It runs once the app job has passed, on that job's APK in a new emulator, one per branch at a time. The token goes only to the step that runs `claude`; `claude-test.sh` passes it to the `claude` process alone, and every `ui` call unsets it too; it never reaches the emulator. The job starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since this prompt doesn't use the backend. The job installs Claude Code at the version the other app workflows pin, and runs `claude-haiku-5-5` with desktop's harness: `--max-turns 60`, `--max-budget-usd 3`, Bash limited to single `ui` commands by `claude-guard.py`, file tools limited to the session's directory, and its output checked for the token before anything is printed or uploaded. It passes when Claude's final message ends with `CLAUDE-TEST: PASS`. +It needs the GitHub environment `app-sim-claude` with the secret `CLAUDE_CODE_OAUTH_TOKEN`, a Claude subscription token. Which branches may use it is decided only by that environment's branch rule in the repository settings: on a branch the rule doesn't allow, a requested Claude run fails at the environment check. It runs once the app job has passed, on that job's APK in a new emulator, one per branch at a time. Before Claude starts, the job pushes two images from `tests/fixtures` to the emulator's Pictures, for the prompt's image part. The token goes only to the step that runs `claude`; `claude-test.sh` passes it to the `claude` process alone, and every `ui` call unsets it too; it never reaches the emulator. The job starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since this prompt doesn't use the backend. The job installs Claude Code at the version the other app workflows pin, and runs `claude-haiku-5-5` with desktop's harness: `--max-turns 90`, `--max-budget-usd 3`, Bash limited to single `ui` commands by `claude-guard.py`, file tools limited to the session's directory, and its output checked for the token before anything is printed or uploaded. It passes when Claude's final message ends with `CLAUDE-TEST: PASS`. ## Known limits diff --git a/scripts/ci/android/build-core.sh b/scripts/ci/android/build-core.sh index 36587ffe36..e23538bbc8 100755 --- a/scripts/ci/android/build-core.sh +++ b/scripts/ci/android/build-core.sh @@ -1,8 +1,8 @@ #!/bin/bash -# Builds the arm64 Android core (libsimplex.so with the libraries it needs, and libsupport.so) with Nix, as +# Builds the aarch64 or armv7a Android core (libsimplex.so with the libraries it needs, and libsupport.so) with Nix, as # scripts/android/build-android.sh does, and packs them as scripts/android/download-libs.sh lays them out. -# Usage: scripts/ci/android/build-core.sh +# Usage: scripts/ci/android/build-core.sh [aarch64|armv7a] # Installs the Nix version that build-android.sh pins, in single-user mode, with the zw3rk Hydra's cache, which # builds these jobs for master: without it, the toolchain and every dependency would be compiled too. @@ -10,7 +10,8 @@ set -euo pipefail nix_ver=nix-2.22.0 nix_hash=4fed7db867186c01ce2a2077da4a6950ed16232efbf78d0cd19700cff80559f9 -jobs=(".#hydraJobs.x86_64-linux.aarch64-android:lib:simplex-chat" ".#hydraJobs.x86_64-linux.aarch64-android:lib:support") +arch=${2:-aarch64} +jobs=(".#hydraJobs.x86_64-linux.$arch-android:lib:simplex-chat" ".#hydraJobs.x86_64-linux.$arch-android:lib:support") install_nix() { local tmp diff --git a/scripts/ci/android/claude-test.md b/scripts/ci/android/claude-test.md index ae16750545..bf7e231b87 100644 --- a/scripts/ci/android/claude-test.md +++ b/scripts/ci/android/claude-test.md @@ -1,4 +1,4 @@ -# Test: onboarding and the chat list in the SimpleX Chat Android app +# Test: onboarding, the chat list and sending images in the SimpleX Chat Android app You are testing the SimpleX Chat Android app, which runs in an Android emulator on this machine. Drive it only with the `ui` helper, through Bash, one command per call: `ui [args]`. Anything else is refused; don't look for ways around that, and if something blocks the test, report it. The commands are listed at the end. @@ -9,6 +9,7 @@ Your working directory is where your output goes. `ui shot ` saves `screen - The app is already running, on its first screen, with a new, empty database: never run `ui start` or `ui stop`. Begin with `ui shot start` to see it. - `ui type` types into the field that has the focus: tap a field before typing into it. `ui key BACK` is Android's back button. - Besides the chats, the chat list shows the app's own cards as list items until they are closed: "New 1-time link / Paste link / Scan" (once there is a conversation), "Bottom bar / Top bar", and "Invest in SimpleX Chat!". They are part of the list, not something drawn over it: leave them open. +- The emulator's Pictures folder has two images for part D: `test-card.jpg`, a TV test card 1280×720 (colour bars, a large circle, "1080" at the top and "TVB" at the bottom), and `simplex-logo.png`, the dark blue and turquoise SimpleX logo on a transparent background. ## The test @@ -38,7 +39,15 @@ Your working directory is where your output goes. `ui shot ` saves `screen - the search field is in the top half of the screen (y under 1200), and "Add list" is just below it; - nothing is cut off, overlapping or garbled. "Start new chat" is now a floating button at the bottom right, drawn over whatever the list shows there: note what it covers, but that alone is not a failure. -You have at most 60 turns, and the whole test fits in about 25 if you chain steps whose outcome you can predict with `ui do` and look at the screen after each chain. For example: +**D. Sending images in "seeded notes".** + +1. Click "seeded notes" to open the group. Click "Attach" (left of the message field), then "Image". Android opens a picker that is not part of the app (the photo picker, or Files): find `test-card.jpg` (under Pictures, Images or Recent; its thumbnail shows the test card) and choose it, then confirm the choice if the picker has a button for it ("Add", "Select", "Done"). +2. Back in the app, the compose area shows the image above the message field before it is sent. Take a shot. Check: the preview shows the whole test card, upright, not stretched. +3. Click "Send Message". Check: the chat shows the sent test card. Click the image: it opens full screen, and "1080" and "TVB" are sharp and readable. Take a shot, then `ui key BACK`. +4. Repeat 1 to 3 with `simplex-logo.png`. Check: the preview and the sent image show the whole logo, and the area around its letters shows the chat's background, not black or grey. +5. Check that the chat shows both images after `ui relaunch` and opening "seeded notes" again. + +You have at most 90 turns, and the whole test fits in about 45 if you chain steps whose outcome you can predict with `ui do` and look at the screen after each chain. For example: - `ui do click-text "Get started" -- click-text "Enter profile name" 30 -- type citester -- shot profile` - `ui do click-text "Create profile" -- wait Continue 60 -- shot your-network` @@ -49,6 +58,6 @@ You have at most 60 turns, and the whole test fits in about 25 if you chain step ## Report -Write `report.md` in your working directory: for each of A, B and C, each check you ran as PASS or FAIL with its evidence, and anything else unexpected. End your final message with one line: `CLAUDE-TEST: PASS` if every check you ran passed, otherwise `CLAUDE-TEST: FAIL` followed by the checks that failed. +Write `report.md` in your working directory: for each of A, B, C and D, each check you ran as PASS or FAIL with its evidence, and anything else unexpected. End your final message with one line: `CLAUDE-TEST: PASS` if every check you ran passed, otherwise `CLAUDE-TEST: FAIL` followed by the checks that failed. ## The ui helper diff --git a/scripts/ci/android/smoke-test.sh b/scripts/ci/android/smoke-test.sh index 61017047d1..fee7bd9cc6 100755 --- a/scripts/ci/android/smoke-test.sh +++ b/scripts/ci/android/smoke-test.sh @@ -1,7 +1,8 @@ #!/bin/bash # Installs the APK in the booted emulator (emulator.sh) and runs the scripted UI cases with the ui helper: onboarding to -# the chat list, a local group, then the chat list with the reachable toolbar (the default) and with the top bar. +# the chat list, a local group, then the chat list with the reachable toolbar (the default) and with the top bar, then +# tests/fixtures/test.jpg and logo-large-rgba.png picked in the system's image picker and sent in the group. # Usage: scripts/ci/android/smoke-test.sh [start|onboarding|layouts|backend] # With "start" it stops at the first screen, for an agent to go on from; with "onboarding", at the chat list. "backend" # also adds the test SMP server (NAMES_SERVER, from backend.sh, reached through emulator.sh reverse) under Your servers @@ -27,6 +28,7 @@ package=chat.simplex.app local_chat="seeded notes" search_field="Search or paste SimpleX link" ui=$(dirname "$0")/ui +fixtures=$(dirname "$0")/../../../tests/fixtures checks=$UI_OUT/checks.txt mkdir -p "$UI_OUT" : > "$checks" @@ -258,6 +260,62 @@ check_layout() { fi } +add_images() { + adb push "$fixtures/logo-large-rgba.png" /sdcard/Pictures/simplex-logo/simplex-logo.png + adb push "$fixtures/test.jpg" /sdcard/Pictures/test-card/test-card.jpg + for image in simplex-logo/simplex-logo.png test-card/test-card.jpg; do + adb shell am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE -d "file:///sdcard/Pictures/$image" > /dev/null + done +} + +sent_files() { + adb shell run-as "$package" ls files/app_files 2> /dev/null | grep -c "^IMG_.*\.$1\$" || true +} + +sent_file() { + local deadline=$((SECONDS + 30)) + until [ "$(sent_files "$1")" -gt "$2" ]; do + [ "$SECONDS" -lt "$deadline" ] || return 1 + sleep 1 + done +} + +core_returned_no_image() { + grep -c "core returned no image" "$UI_OUT/app.log" || true +} + +send_image() { + local folder=$1 ext=$2 sent no_image + sent=$(sent_files "$ext") + no_image=$(core_returned_no_image) + "$ui" click-text Attach 10 + "$ui" click-text Image 10 + sleep 3 + "$ui" shot "picker-$ext" + "$ui" click-text "$folder" 30 || fail "no $folder folder in the image picker" + sleep 2 + "$ui" shot "picker-folder-$ext" + "$ui" click-text Photo 30 || fail "no photo in the $folder folder of the image picker" + if ! "$ui" wait "Cancel image preview" 10; then + "$ui" shot "picker-selected-$ext" + "$ui" click-text Done 5 || "$ui" click-text Add 5 || true + fi + check "$ext: the compose area shows the picked image" "$ui" wait "Cancel image preview" 30 + "$ui" shot "preview-$ext" + "$ui" click-text "Send Message" 10 + check "$ext: the image is sent as a .$ext file" sent_file "$ext" "$sent" + "$ui" shot "sent-$ext" + check "$ext: the core resized the image" [ "$(core_returned_no_image)" -eq "$no_image" ] +} + +send_images() { + add_images + "$ui" click-text "$local_chat" 10 + "$ui" wait Attach 10 + send_image test-card jpg + send_image simplex-logo png +} + # private routing to unknown servers only: the preset servers that would proxy to the test SMP server can't reach the # runner's 127.0.0.1; developer tools at the debug level put the app's debug log in logcat started=$SECONDS @@ -294,6 +352,7 @@ if [ "$cases" != onboarding ]; then check_layout reachable "$ui" layout top check_layout top + send_images fi check "the app is running and logged no crash" app_running diff --git a/scripts/ci/desktop-linux/README.md b/scripts/ci/desktop-linux/README.md index 3b12c342a9..dfecf046e4 100644 --- a/scripts/ci/desktop-linux/README.md +++ b/scripts/ci/desktop-linux/README.md @@ -24,7 +24,7 @@ Queueing for a runner adds from under a minute to 20 minutes. Results are artifa ## Scripts - `ui`: the UI driver. Compose for Desktop has no accessibility tree on Linux, so it finds text on screenshots by OCR (tesseract) and acts with xdotool on an Xvfb display. `ui` alone prints its commands: start (with app settings), shot, text, find, wait, click, right-click, click-text, scroll, type, key, avatar (a profile image's colour), ink (a text's height), do (several in a row), stop. `screen-text.py`, `screen-colour.py` and `screen-ink.py` are its helpers. -- `smoke-test.sh [start|onboarding|setup|backend]`: the scripted cases; the default is onboarding and both layouts. `setup` also creates a local group and makes the test SMP server the app's only names server; `backend` does that, connects to the seeded name, and checks both layouts. +- `smoke-test.sh [start|onboarding|setup|backend]`: the scripted cases; the default is onboarding, a local group, both layouts, and `tests/fixtures/test.jpg` and `logo-large-rgba.png` picked in the file dialog, resized by the core, previewed and sent in the group. `setup` also creates a local group and makes the test SMP server the app's only names server; `backend` does that, connects to the seeded name, and checks both layouts. - `backend.sh start ` / `stop `: the test backend, below. - `claude-test.sh`, `claude-test.md` (the prompt), `claude-scope` (whether the prompt uses the test backend, and optionally the session's turns and budget), `claude-guard.py`, `claude-usage.py`: the Claude job. - `build-core.sh`, `install-core.sh`, `build-backend.sh`, `earlier-run.sh`, `release-app.sh`, `test-summary.py`: builds, reuse and reports. @@ -53,7 +53,7 @@ It writes the directory's address to `/directory-link` and the SMP server's ## The Claude job -It needs the GitHub environment `app-sim-claude` with the secret `CLAUDE_CODE_OAUTH_TOKEN`, a Claude subscription token. Which branches may use it is decided only by that environment's branch rule in the repository settings: on a branch the rule doesn't allow, a requested Claude run fails at the environment check. It runs once the app job has passed, on that job's distributable, one per branch at a time. The token goes only to the step that runs `claude`; `claude-test.sh` passes it to the `claude` process alone, and every `ui` call unsets it too. The job starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since this prompt doesn't use the backend. The job installs Claude Code at the version the iOS workflow pins too, and runs `claude-haiku-5-5` with the turns and budget `claude-scope` sets (`--max-turns 60` and `--max-budget-usd 3` unless it sets `max-turns:` or `max-budget-usd:`) and the iOS sessions' other flags (`--restricted`, `--permission-prompts none`, `--effort high`); the job's time limit is 120 minutes. Its Bash runs only single `ui` commands (`claude-guard.py`, a PreToolUse hook: Claude Code runs read-only commands without asking in every permission mode). Before anything the session wrote is printed or uploaded, `claude-test.sh` checks it for the token; if it's there, the output, transcript, Claude Code's stderr and the guard log are deleted and the job fails. It passes when Claude's final message ends with `CLAUDE-TEST: PASS`; `report.md`, the transcript, the guard's log and `claude-usage.json` are in `desktop-claude-test`. +It needs the GitHub environment `app-sim-claude` with the secret `CLAUDE_CODE_OAUTH_TOKEN`, a Claude subscription token. Which branches may use it is decided only by that environment's branch rule in the repository settings: on a branch the rule doesn't allow, a requested Claude run fails at the environment check. It runs once the app job has passed, on that job's distributable, one per branch at a time. Before Claude starts, the job copies two images from `tests/fixtures` to `images` in Claude's working directory, for the prompt's image part. The token goes only to the step that runs `claude`; `claude-test.sh` passes it to the `claude` process alone, and every `ui` call unsets it too. The job starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since this prompt doesn't use the backend. The job installs Claude Code at the version the iOS workflow pins too, and runs `claude-haiku-5-5` with the turns and budget `claude-scope` sets (`--max-turns 60` and `--max-budget-usd 3` unless it sets `max-turns:` or `max-budget-usd:`) and the iOS sessions' other flags (`--restricted`, `--permission-prompts none`, `--effort high`); the job's time limit is 120 minutes. Its Bash runs only single `ui` commands (`claude-guard.py`, a PreToolUse hook: Claude Code runs read-only commands without asking in every permission mode). Before anything the session wrote is printed or uploaded, `claude-test.sh` checks it for the token; if it's there, the output, transcript, Claude Code's stderr and the guard log are deleted and the job fails. It passes when Claude's final message ends with `CLAUDE-TEST: PASS`; `report.md`, the transcript, the guard's log and `claude-usage.json` are in `desktop-claude-test`. ## Cost diff --git a/scripts/ci/desktop-linux/backend.sh b/scripts/ci/desktop-linux/backend.sh index 842b3c890e..1580d6c871 100755 --- a/scripts/ci/desktop-linux/backend.sh +++ b/scripts/ci/desktop-linux/backend.sh @@ -67,7 +67,7 @@ db_query() { sqlite3 "$work/$1_chat.db" "$2" } -# Prints a profile image data URI made as the apps make one: JPEG at quality 85, scaled down by the square root +# Prints a profile image data URI: JPEG at quality 85, scaled down by the square root # of its excess, at most halved per step, until it is at most max_image_size characters. The picture fills the # whole square, a gradient between two shades of one colour with a large white letter; noise keeps JPEG from # compressing it much, so the image ends up near the limit. diff --git a/scripts/ci/desktop-linux/claude-scope b/scripts/ci/desktop-linux/claude-scope index 548ea0c988..b7fd26fca6 100644 --- a/scripts/ci/desktop-linux/claude-scope +++ b/scripts/ci/desktop-linux/claude-scope @@ -2,5 +2,5 @@ # backend: yes when the prompt uses the test backend; a run that asks for Claude must then start it too. # max-turns and max-budget-usd (optional) bound the session; claude-test.sh uses 60 and 3 when they're absent. backend: no -max-turns: 300 +max-turns: 330 max-budget-usd: 5 diff --git a/scripts/ci/desktop-linux/claude-test.md b/scripts/ci/desktop-linux/claude-test.md index b2c3e99798..f22ff6cdad 100644 --- a/scripts/ci/desktop-linux/claude-test.md +++ b/scripts/ci/desktop-linux/claude-test.md @@ -1,4 +1,4 @@ -# Test: channel message signing and channel moderators in the SimpleX Chat desktop app +# Test: channel message signing, channel moderators and sending images in the SimpleX Chat desktop app You are testing the SimpleX Chat desktop app, which runs on a virtual display on this machine. Drive it only with the `ui` helper, through Bash, one command per call: `ui [args]`. Anything else is refused; don't look for ways around that, and if something blocks the test, report it. The commands are listed at the end. @@ -9,6 +9,7 @@ Your working directory is where your output goes. `ui shot ` saves `screen - The app is already running, on its first screen, with a new, empty database: never run `ui start` or `ui stop`. Begin with `ui shot start` to see it. - After onboarding, the chat list is in the left pane (x 1 to 389) and chats open in the right pane. An empty chat list shows only its toolbar, with the profile picture at about 27,730 and a pencil (New chat) at about 363,730: the search field "Search or paste SimpleX link" appears once there is a chat. The toolbar is at the bottom of the left pane. - The channel uses the app's preset chat relays, on the internet. Creating it and joining it wait for them: wait up to 120 s there (`ui wait "" 120`). +- The folder `images` in your working directory has two images for part I: `test-card.jpg`, a TV test card 1280×720 (colour bars, a large circle, "1080" at the top and "TVB" at the bottom), and `simplex-logo.png`, the dark blue and turquoise SimpleX logo on a transparent background. You can Read them to compare with what the app shows. ## How to do things in the app @@ -57,7 +58,14 @@ The parts build on each other: run them in this order. cisub must join only afte 9. (B6) Right-click "contributor post" → "Moderate" → "Delete" in the alert "Delete member message?". Check: the message shows "moderated by cimod" in its place. 10. (B7) Channel info → "Subscribers" → "cisub" → "Block for all" → "Block for all" in the alert "Block subscriber for all?". Check: cisub's info now offers "Unblock for all". Press Escape until the chat shows. Check: the chat shows "you blocked cisub". -You have at most 300 turns, and the whole test fits in about 150 if you chain steps whose outcome you can predict with `ui do` and look at the screen after each chain. For example: +**I. Sending images (as citester).** + +1. (I1) Switch to citester, and press Escape until the chat list shows. Click the pencil (about 363,730), then "Create group". Click the name field, about 40 px below the text "Enter group name", type `image notes`, and click "Create group". If "Skip inviting members" shows, click it. Check: the group "image notes" is open in the right pane. +2. (I2) Click the attachment button, the paperclip icon at the left end of the message field at the bottom of the right pane, then "Image". A file dialog opens: click its "File Name" field, type the absolute path of `images/test-card.jpg` in your working directory, and press Return. Back in the app, the compose area shows the image above the message field before it is sent. Take a shot. Check: the preview shows the whole test card, upright, not stretched. +3. (I3) Press Return or click the send button at the right end of the message field. Check: the chat shows the sent test card. Click the image: it opens larger, and "1080" and "TVB" are sharp and readable. Take a shot, then close the image with Escape. +4. (I4) Repeat I2 and I3 with `images/simplex-logo.png`. Check: the preview and the sent image show the whole logo, and the area around its letters shows the chat's background, not black or grey. + +You have at most 330 turns, and the whole test fits in about 170 if you chain steps whose outcome you can predict with `ui do` and look at the screen after each chain. For example: - `ui do click-text "Create your profile" -- wait "Enter profile name" -- click-text "Enter profile name" -- type citester -- shot profile` - `ui do click 363 730 -- click-text "Create public channel" 10 -- wait "Channel name" -- shot new-channel` @@ -68,6 +76,6 @@ You have at most 300 turns, and the whole test fits in about 150 if you chain st ## Report -Write `report.md` in your working directory as you go, so a session that is cut short still leaves its results: one row per step (S1 to S3, A1 to A5, J1, J2, A6, B1 to B3, J3 to J5, B4 to B7), each check you ran as PASS or FAIL with its evidence (the text OCR read, screenshot names), and anything else unexpected. End your final message with one line: `CLAUDE-TEST: PASS` if every check you ran passed, otherwise `CLAUDE-TEST: FAIL` followed by the steps that failed. +Write `report.md` in your working directory as you go, so a session that is cut short still leaves its results: one row per step (S1 to S3, A1 to A5, J1, J2, A6, B1 to B3, J3 to J5, B4 to B7, I1 to I4), each check you ran as PASS or FAIL with its evidence (the text OCR read, screenshot names), and anything else unexpected. End your final message with one line: `CLAUDE-TEST: PASS` if every check you ran passed, otherwise `CLAUDE-TEST: FAIL` followed by the steps that failed. ## The ui helper diff --git a/scripts/ci/desktop-linux/smoke-test.sh b/scripts/ci/desktop-linux/smoke-test.sh index a3561865d9..08d9242c23 100755 --- a/scripts/ci/desktop-linux/smoke-test.sh +++ b/scripts/ci/desktop-linux/smoke-test.sh @@ -1,7 +1,8 @@ #!/bin/bash # Starts the desktop app on a virtual display and runs the scripted UI cases with the ui helper: onboarding to the -# chat list, a local group, then the chat list with the toolbar at the bottom (the default) and at the top. +# chat list, a local group, then the chat list with the toolbar at the bottom (the default) and at the top, then +# tests/fixtures/test.jpg and logo-large-rgba.png picked in the file dialog and sent in the group. # Usage: scripts/ci/desktop-linux/smoke-test.sh [start|onboarding|layouts|setup|backend] # With "start" it stops at the first screen, for an agent to go on from; with "onboarding", at the chat list. # "setup" creates the group, makes the test SMP server (backend.sh) the one that resolves SimpleX names, and stops; @@ -31,6 +32,12 @@ ui=$(dirname "$0")/ui checks=$UI_OUT/checks.txt # switches are at the right of the left pane, where Settings open switch_x=335 +fixtures=$(cd "$(dirname "$0")/../../../tests/fixtures" && pwd) +state=${UI_STATE:-${RUNNER_TEMP:-/tmp}/ui} +compose_y=730 +paperclip_x=415 +message_x=700 +compose_area=976x200+390+500 mkdir -p "$UI_OUT" : > "$checks" if [ -n "${GITHUB_ENV:-}" ]; then @@ -310,6 +317,67 @@ connect_by_name() { back_to_chat_list || fail "no chat list after the name's alert" } +sent_files() { + find "$state/data" -name "IMG_*.$1" | wc -l +} + +sent_file() { + local deadline=$((SECONDS + 30)) + until [ "$(sent_files "$1")" -gt "$2" ]; do + [ "$SECONDS" -lt "$deadline" ] || return 1 + sleep 1 + done +} + +latest_shot() { + find "$UI_OUT" -maxdepth 1 -name "[0-9][0-9]-$1.png" | sort | tail -n 1 +} + +compose_changed() { + local changed + convert "$(latest_shot "$1")" -crop "$compose_area" +repage "$UI_OUT/compose-$1.png" + convert "$(latest_shot "$2")" -crop "$compose_area" +repage "$UI_OUT/compose-$2.png" + changed=$(compare -metric AE -fuzz 10% "$UI_OUT/compose-$1.png" "$UI_OUT/compose-$2.png" null: 2>&1 || true) + changed=${changed%% *} + echo "pixels changed above the message field: $changed" + [ "${changed%%.*}" -gt 2000 ] +} + +core_returned_no_image() { + grep -c "core returned no image" "$UI_OUT/app.log" || true +} + +send_image() { + local file=$1 ext=$2 sent no_image x y + sent=$(sent_files "$ext") + no_image=$(core_returned_no_image) + "$ui" shot "before-$ext" + "$ui" click "$paperclip_x" "$compose_y" + "$ui" click-text Image 10 + "$ui" wait "File Name" 20 + "$ui" shot "file-dialog-$ext" + read -r x y _ < <("$ui" find "File Name") + "$ui" click $((x + 250)) "$y" + "$ui" type "$fixtures/$file" + "$ui" key Return + sleep 3 + "$ui" shot "preview-$ext" + check "$ext: the compose area shows the picked image" compose_changed "before-$ext" "preview-$ext" + "$ui" click "$message_x" "$compose_y" + "$ui" key Return + check "$ext: the image is sent as a .$ext file" sent_file "$ext" "$sent" + "$ui" shot "sent-$ext" + check "$ext: the core resized the image" [ "$(core_returned_no_image)" -eq "$no_image" ] +} + +send_images() { + local x y + read -r x y < <(left_pane_position "$local_chat") || fail "no \"$local_chat\" in the chat list" + "$ui" click "$x" "$y" + send_image test.jpg jpg + send_image logo-large-rgba.png png +} + # private routing to unknown servers only: the preset servers that would proxy name lookups can't reach the test SMP # server on 127.0.0.1; developer tools at the debug level put the app's debug log in app.log "$ui" start "$app_dir" NetworkSMPProxyMode=Unknown DeveloperTools=true LogLevel=DEBUG @@ -367,6 +435,7 @@ fi check_layout bottom use_top_toolbar check_layout top +send_images if grep -q '^FAIL' "$checks"; then fail "the smoke test failed checks: $(grep -c '^FAIL' "$checks") of $(grep -cE '^(PASS|FAIL)' "$checks")" diff --git a/scripts/ci/ios-sim/README.md b/scripts/ci/ios-sim/README.md index 23d2e6c95a..a16aded2e9 100644 --- a/scripts/ci/ios-sim/README.md +++ b/scripts/ci/ios-sim/README.md @@ -1,6 +1,6 @@ # iOS app in the simulator on GitHub's runners -`.github/workflows/ios-sim.yml` builds the core library and the iOS app from the branch's code on a macOS runner, starts the app in an iPhone 17 simulator, and drives it through onboarding to the chat list with a scripted smoke test. On request it also runs a Claude session that follows a device test plan, and a test backend (relay, directory, names server, contacts) for tests that need servers and peers. +`.github/workflows/ios-sim.yml` builds the core library and the iOS app from the branch's code on a macOS runner, starts the app in an iPhone 17 simulator, and drives it through onboarding to the chat list with a scripted smoke test; the app job's smoke test (`images`) then turns off the encryption of local files, creates a group and sends `tests/fixtures/test.jpg` and `logo-large-rgba.png`, each added to the photo library, picked, resized by the core, previewed and sent, and checks the pixel size of each sent file with `sips`. On request it also runs a Claude session that follows a device test plan, and a test backend (relay, directory, names server, contacts) for tests that need servers and peers. ## Running it @@ -22,7 +22,7 @@ The macOS jobs run on `macos-26`, the image with Xcode 26 and the iPhone 17 simu ## The Claude session -It runs in its own job, `claude`, once the app job has passed: the job installs that job's build (the artifact `ios-sim-app`, kept 14 days) in a new simulator, runs the smoke test to reach the sessions' starting state, then one session per layout listed in `claude-scope`, with Claude Code installed from npm at the version the desktop workflow pins too. Only this job references the environment `app-sim-claude`, which holds the secret `CLAUDE_CODE_OAUTH_TOKEN` (a Claude subscription token) and admits the branches its rule allows; one runs per branch at a time. Only the Claude step gets the token, and `claude-test.sh` passes it to the `claude` processes alone. The job checks out without persisting its GitHub token and installs a pinned AXe (`install-axe.sh`), since it holds the Claude token. It starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since the smoke plan doesn't use the backend. +It runs in its own job, `claude`, once the app job has passed: the job installs that job's build (the artifact `ios-sim-app`, kept 14 days) in a new simulator, runs the smoke test to reach the sessions' starting state, adds the plan's images to the photo library (`tests/fixtures/test.jpg`, `tests/fixtures/logo-large-rgba.png`, and a HEIC of the first turned to portrait when `sips` can write HEIC), then one session per layout listed in `claude-scope`, with Claude Code installed from npm at the version the desktop workflow pins too. Only this job references the environment `app-sim-claude`, which holds the secret `CLAUDE_CODE_OAUTH_TOKEN` (a Claude subscription token) and admits the branches its rule allows; one runs per branch at a time. Only the Claude step gets the token, and `claude-test.sh` passes it to the `claude` processes alone. The job checks out without persisting its GitHub token and installs a pinned AXe (`install-axe.sh`), since it holds the Claude token. It starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since the smoke plan doesn't use the backend. Each session's final message ends with `CLAUDE-TEST: PASS`, or `CLAUDE-TEST: FAIL` and the failed cases; the step fails unless every session passes. diff --git a/scripts/ci/ios-sim/avatar.swift b/scripts/ci/ios-sim/avatar.swift index e1b0925335..56575a8917 100644 --- a/scripts/ci/ios-sim/avatar.swift +++ b/scripts/ci/ios-sim/avatar.swift @@ -1,4 +1,4 @@ -// Makes a group avatar the way the app makes profile images (resizeImageToStrSize in apps/ios/SimpleXChat/ImageUtils.swift): +// Makes a group avatar: // a JPEG at quality 0.85 drawn at scale 1, shrunk by sqrt(size / 12500), at most halved per step, // until its data URI is at most 12500 characters. The picture is synthetic and photo-like: a gradient, // discs and noise, with more noise for a higher detail. diff --git a/scripts/ci/ios-sim/claude-prompt.md b/scripts/ci/ios-sim/claude-prompt.md index 73d00b2ac7..30729e0ea2 100644 --- a/scripts/ci/ios-sim/claude-prompt.md +++ b/scripts/ci/ios-sim/claude-prompt.md @@ -21,6 +21,7 @@ You are testing the SimpleX Chat iOS app in an iPhone 17 simulator (iOS 26.5, sc - **Joining** (the app's normal flow, from `NewChatView.swift`; an earlier session took its first step for a failure). Tapping a group or channel the profile does not know yet, from a link or a list, shows an alert with its name (and subscriber count for a channel) and "Cancel" / "Open channel" or "Open group": that is the start of joining, not a sign that the entry is known. "Open …" opens the chat, not yet joined, with "Tap Join channel" in it and a "Join channel" / "Join group" bar at the bottom; tapping that bar joins (a channel then shows "you are subscriber"; a group may stay "connecting"). A contact works the same way: "Open new chat", then the chat's connect button. An entry already opened, joined or connecting shows the same kind of alert, plus your role ("You are a subscriber", "You are a member") once joined. So each case that joins uses the entry named for it under "This session", which no earlier case has touched. Opened and joined chats appear in the chat list, and the profile is no longer "without conversations". - **A second profile:** open the picker, "Your chat profiles", add a profile; a new profile has no conversations. Switch back to the first profile when done. - **Text size:** `ui text-size accessibility-large`, and back to `ui text-size medium` afterwards. Only one phone size is available. +- **The photo picker** ("Choose from library" in the "Attach" dialog) is iOS's, not the app's: its photos and its "Cancel" and "Add" buttons are in the tree. Photos can share a label: tap the one you mean with `ui tap-xy` at its centre, judged from a screenshot. - **If the app gets stuck**, `ui relaunch` restarts it (search text is cleared, everything else stays). - Waiting: prefer `ui wait ` over repeated trees; `ui wait` returns at once when the text is already shown. diff --git a/scripts/ci/ios-sim/claude-scope b/scripts/ci/ios-sim/claude-scope index 4571871501..6f40d96a67 100644 --- a/scripts/ci/ios-sim/claude-scope +++ b/scripts/ci/ios-sim/claude-scope @@ -5,6 +5,6 @@ backend: no model: claude-haiku-5-5 layouts: reachable -cases-reachable: S2, S3, A1, A2, A3, A4, A5, J1, J2, A6, B1, B2, B3, J3, J4, J5, B4, B5, B6, B7 -max-turns: 350 +cases-reachable: S2, S3, A1, A2, A3, A4, A5, J1, J2, A6, B1, B2, B3, J3, J4, J5, B4, B5, B6, B7, I1-I4 +max-turns: 390 max-budget-usd: 5 diff --git a/scripts/ci/ios-sim/device-test-plan.md b/scripts/ci/ios-sim/device-test-plan.md index 6bfe81d9ad..270d8f1058 100644 --- a/scripts/ci/ios-sim/device-test-plan.md +++ b/scripts/ci/ios-sim/device-test-plan.md @@ -1,4 +1,4 @@ -# Device test plan: channel message signing and channel moderators +# Device test plan: channel message signing, channel moderators and sending images The app as the scripted smoke test leaves it: the profile "citester", without conversations. The cases run in the order of the tables and build on each other: one channel, "ci signing", owned by citester, and two more profiles in this app, "cimod" and "cisub", that join it by its link. Do not reorder them: cisub must join only after cimod has become a moderator (B2), so that cimod learns about cisub. @@ -35,3 +35,14 @@ The app as the scripted smoke test leaves it: the profile "citester", without co | B5 | Open "cisub" in that list | cisub's info offers "Block for all" (do not tap it yet) | | B6 | Back in the chat, long-press "contributor post" → "Moderate" → "Delete member message?" → "Delete" | The message shows "moderated by cimod" in its place | | B7 | Channel info → "Subscribers" → "cisub" → "Block for all" → "Block subscriber for all?" → "Block for all" | cisub's info now offers "Unblock for all"; the chat shows "you blocked cisub" | + +## Sending images + +These cases run after the channel cases: first switch to citester and go back to its chat list. The photo library has, besides the simulator's own photos, the images of these cases: a TV test card 1280×720 (JPEG: colour bars, a large circle, "1080" at the top and "TVB" at the bottom), the dark blue and turquoise SimpleX logo on a transparent background (PNG), and, when the runner could make it, the same test card turned by 90° to portrait (HEIC). In the photo picker, identify each by its thumbnail. The paperclip button left of the message field opens the "Attach" dialog; the send button is the arrow at the right end of the field; find their labels with `ui tree`. + +| ID | Do | Expect | +|----|----|--------| +| I1 | Tap "Compose", then "Create group"; type `image notes` in the field "Enter group name…", check the value, tap "Create group", then "Skip" if it shows; open the group's chat if it is not open | The chat of "image notes", with the message field and the paperclip button enabled | +| I2 | Tap the paperclip button, "Choose from library", the test card's thumbnail, then the picker's "Add" button; `ui shot I2-preview`; tap the send button; tap the sent image; `ui shot I2-full`; close the image | Before sending, the compose area shows the whole test card, upright and not stretched; after sending, the chat shows it; opened, it fills the screen width and "1080" and "TVB" are sharp and readable | +| I3 | As I2, with the SimpleX logo | The preview and the sent image show the whole logo; the area around its letters shows the chat's background, not black or grey | +| I4 | As I2, with the portrait test card; skipped when the library has no portrait test card | The preview and the sent image show the test card upright in portrait, as the picker's thumbnail does | diff --git a/scripts/ci/ios-sim/smoke-test.sh b/scripts/ci/ios-sim/smoke-test.sh index 9df68585cb..5e6682b2cc 100755 --- a/scripts/ci/ios-sim/smoke-test.sh +++ b/scripts/ci/ios-sim/smoke-test.sh @@ -4,7 +4,9 @@ # chat list with the one-hand UI (the default), then the chat list again with the top bar. It leaves the app in the one-hand UI. # For an app in the derived data of a build, it then renders the previews of the SimpleX Snapshots tests to the snapshots # folder of the output dir (snapshots.sh). -# Usage: scripts/ci/ios-sim/smoke-test.sh +# With "images" it then turns off the encryption of local files, creates a group and sends tests/fixtures/test.jpg and +# logo-large-rgba.png picked in the photo library, and checks the pixel sizes of the sent files. +# Usage: scripts/ci/ios-sim/smoke-test.sh [images] # Each step saves a screenshot and an accessibility tree to the output dir and prints the tree to the log. # Later steps of the job get SIM_UDID and UI_OUT, with the simulator still running. @@ -12,8 +14,15 @@ set -euo pipefail app=$1 export UI_OUT=$2 +cases=${3:-} bundle_id=chat.simplex.app ui=$(dirname "$0")/ui +fixtures=$(cd "$(dirname "$0")/../../../tests/fixtures" && pwd) +test_card_x=66 +logo_x=200 +photo_y=412 +picker_done_x=363 +picker_done_y=144 mkdir -p "$UI_OUT" axe --version @@ -87,6 +96,80 @@ expect_chat_list() { echo "in the $1 layout the chat list shows the onboarding pages and the toolbar" } +tap_element() { + local line x y w h + line=$("$ui" tree | grep -iE "^$1 \"$2\"" | tail -n 1) + [ -n "$line" ] || return 1 + read -r x y w h < <(sed -E 's/.* at (-?[0-9]+),(-?[0-9]+) size ([0-9]+)x([0-9]+).*/\1 \2 \3 \4/' <<< "$line") + "$ui" tap-xy $((x + w / 2)) $((y + h / 2)) +} + +sent_files() { + find "$(xcrun simctl get_app_container "$SIM_UDID" "$bundle_id" group.chat.simplex.app)" "$(xcrun simctl get_app_container "$SIM_UDID" "$bundle_id" data)" -name "IMG_*.$1" 2> /dev/null | wc -l +} + +sent_file() { + local deadline=$((SECONDS + 30)) + until [ "$(sent_files "$1")" -gt "$2" ]; do + [ "$SECONDS" -lt "$deadline" ] || return 1 + sleep 2 + done +} + +sent_size() { + local file + file=$(find "$(xcrun simctl get_app_container "$SIM_UDID" "$bundle_id" group.chat.simplex.app)" "$(xcrun simctl get_app_container "$SIM_UDID" "$bundle_id" data)" -name "IMG_*.$1" -exec ls -t {} + 2> /dev/null | sed -n 1p) + sips -g pixelWidth -g pixelHeight "$file" | awk '/pixelWidth/ {w = $2} /pixelHeight/ {h = $2} END {print w "x" h}' +} + +disable_file_encryption() { + xcrun simctl terminate "$SIM_UDID" "$bundle_id" 2> /dev/null || true + xcrun simctl spawn "$SIM_UDID" defaults write "$(xcrun simctl get_app_container "$SIM_UDID" "$bundle_id" group.chat.simplex.app)/Library/Preferences/group.chat.simplex.app" privacyEncryptLocalFiles -bool false + "$ui" relaunch +} + +fail() { + echo "error: $*" + exit 1 +} + +create_group() { + "$ui" tap Compose + "$ui" tap "Create group" 30 + "$ui" tap-value "Enter group name…" 30 + "$ui" type "image notes" + "$ui" shot new-group + tap_element Button "Create group" || fail "no \"Create group\" button" + "$ui" tap Skip 10 || true + "$ui" shot group-created +} + +app_resized_images() { + grep -cE "resizeImageTo(Str|Data)Size final" "$UI_OUT/app.log" || true +} + +send_image() { + local ext=$1 photo_x=$2 size=$3 sent resized + sent=$(sent_files "$ext") + resized=$(app_resized_images) + tap_element Button "Attachements" || fail "no attachment button" + "$ui" tap "Choose from library" + sleep 3 + "$ui" shot "picker-$ext" + "$ui" tap-xy "$photo_x" "$photo_y" + "$ui" shot "picker-selected-$ext" + "$ui" tap-xy "$picker_done_x" "$picker_done_y" + sleep 3 + "$ui" shot "preview-$ext" + "$ui" find multiply || fail "$ext: no image preview in the compose area" + tap_element Button "[^\"]*arrow[^\"]*" || fail "no send button" + sent_file "$ext" "$sent" || fail "$ext: no .$ext file sent" + "$ui" shot "sent-$ext" + [ "$(sent_size "$ext")" = "$size" ] || fail "$ext: the sent image is $(sent_size "$ext"), not $size" + [ "$(app_resized_images)" -eq "$resized" ] || fail "$ext: the app resized the image in place of the core" + echo "$ext: the picked image was previewed and sent as a $size .$ext file" +} + xcrun simctl install "$SIM_UDID" "$app" xcrun simctl launch "$SIM_UDID" "$bundle_id" @@ -129,3 +212,13 @@ products=$(dirname "$app") if [[ "$products" == */Build/Products/Debug-iphonesimulator ]]; then "$(dirname "$0")/snapshots.sh" "${products%/Build/Products/Debug-iphonesimulator}" "$UI_OUT/snapshots" fi + +if [ "$cases" = images ]; then + "$ui" tap "Don’t Allow" 10 || true + disable_file_encryption + xcrun simctl addmedia "$SIM_UDID" "$fixtures/test.jpg" "$fixtures/logo-large-rgba.png" + create_group + send_image jpg "$test_card_x" 1280x720 + send_image png "$logo_x" 1630x466 + echo "smoke test passed: images" +fi diff --git a/scripts/desktop/build-lib-windows.sh b/scripts/desktop/build-lib-windows.sh index ad62b2d209..48598702cb 100755 --- a/scripts/desktop/build-lib-windows.sh +++ b/scripts/desktop/build-lib-windows.sh @@ -39,8 +39,8 @@ scripts/desktop/prepare-openssl-windows.sh openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-3.0.15 | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g') rm -rf $BUILD_DIR 2>/dev/null || true # Existence of these directories produces build error: cabal's bug -# (simplexmq is removed because cabal cannot delete its read-only git submodule pack files - blst, libbbs - on Windows) -rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* 2>/dev/null || true +# (simplexmq, hs-jpeg-turbo and JuicyPixels-stbir are removed because cabal cannot delete their read-only git submodule pack files - blst, libbbs, mozjpeg, stb - on Windows) +rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* dist-newstyle/src/hs-jpeg-t* dist-newstyle/src/JuicyPixe* 2>/dev/null || true rm cabal.project.local 2>/dev/null || true echo "ignore-project: False" >> cabal.project.local echo "package direct-sqlcipher" >> cabal.project.local diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index eb4119d190..862be9d806 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -9,4 +9,6 @@ "https://github.com/simplex-chat/zip.git"."2eff156c3aac389e35d38bf10a52733d7061640a" = "052vahd5d4lxnazjrb6l60i261aycn2js7jhzafyb72n15ns4r6p"; "https://github.com/yesodweb/wai.git"."ec5e017d896a78e787a5acea62b37a4e677dec2e" = "1ckcpmpjfy9jiqrb52q20lj7ln4hmq9v2jk6kpkf3m68c1m9c2bx"; "https://github.com/simplex-chat/wai.git"."63667cb577d09c967ddcd945e42c324872dfeb7d" = "1nh4j3fnxbpkdhz1sfgpfhb7j0371apbmx1kv7aqa2zrdxc8gmzb"; + "https://github.com/simplex-chat/hs-jpeg-turbo.git"."d0a4d954038ee5bcb5c4dc9372a6fb0b05fed77c" = "0b6gl3m05hdaq8ph1nd9b9807k7cdlmwrxvj0rhxv4lp1rkm57rg"; + "https://github.com/simplex-chat/JuicyPixels-stbir.git"."e0090e14f6348a33b53c11b377cb69df766b02b4" = "0wj8i9wylpas6i7bbyw1vl11vxxy4qz3vx3z9cplzy1nfp914h0g"; } diff --git a/scripts/simplex-chat-reproduce-builds-android.sh b/scripts/simplex-chat-reproduce-builds-android.sh index 45bf2a6936..dec507d996 100755 --- a/scripts/simplex-chat-reproduce-builds-android.sh +++ b/scripts/simplex-chat-reproduce-builds-android.sh @@ -100,6 +100,7 @@ checkout_git() { git -C "$git_dir" reset --hard git -C "$git_dir" clean -dfx git -C "$git_dir" checkout "$tag" + git -C "$git_dir" submodule update --init --recursive } check_apk() { diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 8b2512a170..d954c6fba0 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -18,6 +18,7 @@ extra-source-files: README.md PRIVACY.md cabal.project + src/Simplex/Chat/Image/DisplayP3-v4.icc flag swift description: Enable swift JSON format @@ -50,6 +51,7 @@ library Simplex.Chat.Controller Simplex.Chat.Delivery Simplex.Chat.Files + Simplex.Chat.Image Simplex.Chat.Library.Commands Simplex.Chat.Library.Internal Simplex.Chat.Library.Subscriber @@ -386,6 +388,10 @@ library , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 + , JuicyPixels ==3.3.* + , JuicyPixels-jpeg-turbo ==0.1.* + , JuicyPixels-spng ==0.1.* + , JuicyPixels-stbir ==0.1.* , memory ==0.18.* , mtl >=2.3.1 && <3.0 , network >=3.1.2.7 && <3.2 @@ -404,11 +410,14 @@ library , unliftio-core ==0.2.* , uri-bytestring >=0.3.3.1 && <0.4 , uuid ==1.3.* + , vector ==0.13.* , zip ==2.2.* , zstd ==0.1.3.* default-language: Haskell2010 if flag(swift) cpp-options: -DswiftJSON + if flag(swift) || os(android) + cpp-options: -DmobileBuild if flag(client_postgres) build-depends: postgresql-libpq >=0.10.0.0 @@ -718,6 +727,7 @@ test-suite simplex-chat-test ChatTests.Names ChatTests.Profiles ChatTests.Utils + ImageTests JSONFixtures JSONTests MarkdownTests @@ -810,7 +820,9 @@ test-suite simplex-chat-test -- add -fhpc to ghc-options below to run tests with coverage ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -Werror=name-shadowing -threaded -rtsopts "-with-rtsopts=-N" build-depends: - QuickCheck ==2.14.* + JuicyPixels ==3.3.* + , JuicyPixels-jpeg-turbo ==0.1.* + , QuickCheck ==2.14.* , aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 , async ==2.2.* diff --git a/src/Simplex/Chat/Image.hs b/src/Simplex/Chat/Image.hs new file mode 100644 index 0000000000..5a7a864494 --- /dev/null +++ b/src/Simplex/Chat/Image.hs @@ -0,0 +1,407 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TupleSections #-} + +module Simplex.Chat.Image + ( ImageFormat (..), + ColourProfile (..), + imageFormat, + decodeResizable, + resizeImage, + resizeImageDataUri, + maxDecodeMemoryMB, + scaleRGB8, + scaleRGBA8, + ) +where + +import Codec.Picture (DynamicImage (..), Image (..), Pixel (..), PixelRGB8, PixelRGBA8 (..), convertRGB8, convertRGBA8, dynamicMap, dynamicPixelMap, generateImage, pixelMap) +import Codec.Picture.JPEGTurbo (DecodeOptions (DecodeOptions), JpegHeader (..), Scale (..), decodeJpeg, encodeRGB, readJpegHeader) +import qualified Codec.Picture.STBIR as STBIR +import qualified Codec.Picture.Spng as Spng +import Codec.Picture.Types (pixelFoldMap) +import Control.Monad (forM_, guard, when, (<=<)) +import Control.Monad.Except (ExceptT (..), runExceptT, throwError) +import Data.ByteString (ByteString) +import qualified Data.ByteString as B +import qualified Data.ByteString.Base64.Lazy as LB64 +import qualified Data.ByteString.Lazy.Char8 as LB +import Data.FileEmbed (embedFile) +import Data.Int (Int32, Int64) +import Data.List (maximumBy, minimumBy, sortOn) +import Data.Maybe (fromMaybe, listToMaybe) +import Data.Monoid (All (..)) +import Data.Ord (comparing) +import qualified Data.Vector.Storable as VS +import qualified Data.Vector.Storable.Mutable as VSM +import Data.Word (Word8) +import GHC.Float (double2Float, float2Double) + +data ImageFormat = IFJpeg | IFPng + +data ColourProfile = CPNone | CPDisplayP3 + deriving (Eq, Show) + +imageFormat :: DynamicImage -> ImageFormat +imageFormat = \case + ImageYA8 img -> alphaFormat img + ImageYA16 img -> alphaFormat img + ImageRGBA8 img -> alphaFormat img + ImageRGBA16 img -> alphaFormat img + _ -> IFJpeg + where + alphaFormat :: (Pixel a, Eq (PixelBaseComponent a), Bounded (PixelBaseComponent a)) => Image a -> ImageFormat + alphaFormat img + | getAll $ pixelFoldMap (All . (maxBound ==) . pixelOpacity) img = IFJpeg + | otherwise = IFPng + +decodeResizable :: ByteString -> IO (Either String (DynamicImage, ColourProfile)) +decodeResizable s = runExceptT $ do + (img, profile) <- decode + pure . (img,) $! maybe CPNone colourProfile profile + where + decode + | "\xFF\xD8" `B.isPrefixOf` s = decodeDisplayJpeg s + | "\x89PNG\r\n\x1a\n" `B.isPrefixOf` s = ExceptT $ Spng.decodePng (Spng.DecodeOptions $ maxDecodeMemoryMB `div` 2) s + | otherwise = throwError "unsupported image format" + +decodeDisplayJpeg :: ByteString -> ExceptT String IO (DynamicImage, Maybe ByteString) +decodeDisplayJpeg s = do + JpegHeader {jpegWidth, jpegHeight} <- ExceptT $ readJpegHeader s + (halvings, size) <- maybe (throwError "image does not fit") pure $ displaySize jpegWidth jpegHeight + (,jpegIccProfile segments) . orientImage (exifOrientation segments) . resizeToSize size <$> ExceptT (decodeJpeg (DecodeOptions (jpegScale halvings) maxJpegPixels maxDecodeMemoryMB) s) + where + segments = jpegSegments $ B.drop 2 s + +displaySize :: Int -> Int -> Maybe (Int, (Int, Int)) +displaySize = go 0 + where + go n w h + | w <= maxDisplaySide && h <= maxDisplaySide = Just (n, (w, h)) + | w < 2 || h < 2 = Nothing + | otherwise = go (n + 1) (scaledSide 2 w) (scaledSide 2 h) + +jpegScale :: Int -> Scale +jpegScale = \case + 0 -> Full + 1 -> Half + 2 -> Quarter + _ -> Eighth + +resizeToSize :: (Int, Int) -> DynamicImage -> DynamicImage +resizeToSize size@(w, h) img + | dynamicMap (hasSize size) img = img + | otherwise = ImageRGB8 $ STBIR.resize STBIR.defaultOptions w h $ convertRGB8 img + +exifOrientation :: [(Word8, ByteString)] -> Int +exifOrientation segments = fromMaybe 1 $ do + tiff <- exifTiff segments + order <- case B.take 2 tiff of + "II" -> Just B.reverse + "MM" -> Just id + _ -> Nothing + let field n i + | i < 0 || B.length bytes /= n = Nothing + | otherwise = Just $ B.foldl' (\a b -> a * 256 + fromIntegral b) 0 $ order bytes + where + bytes = B.take n $ B.drop i tiff + u16 = field 2 + ifd <- field 4 4 + entries <- u16 ifd + listToMaybe + [ v + | e <- [ifd + 2 + 12 * k | k <- [0 .. entries - 1]], + u16 e == Just 0x0112, + u16 (e + 2) == Just 3, + Just v <- [u16 (e + 8)], + v >= 1 && v <= 8 + ] + +exifTiff :: [(Word8, ByteString)] -> Maybe ByteString +exifTiff segments = listToMaybe [B.drop 6 segment | (0xE1, segment) <- segments, "Exif\0\0" `B.isPrefixOf` segment] + +jpegIccProfile :: [(Word8, ByteString)] -> Maybe ByteString +jpegIccProfile segments = do + chunks <- sortOn (\(n, _, _) -> n) <$> mapM iccChunk iccSegments + let profile = B.concat [chunk | (_, _, chunk) <- chunks] + guard $ [n | (n, _, _) <- chunks] == [1 .. length chunks] && all (\(_, count, _) -> count == length chunks) chunks && not (B.null profile) + pure profile + where + iccSegments = [segment | (0xE2, segment) <- segments, "ICC_PROFILE\0" `B.isPrefixOf` segment] + iccChunk segment + | B.length segment < 14 = Nothing + | otherwise = Just (fromIntegral $ B.index segment 12, fromIntegral $ B.index segment 13, B.drop 14 segment) + +jpegSegments :: ByteString -> [(Word8, ByteString)] +jpegSegments s + | B.length s < 4 || B.index s 0 /= 0xFF || marker == 0xDA = [] + | otherwise = (marker, B.take (len - 2) $ B.drop 4 s) : jpegSegments (B.drop (2 + len) s) + where + marker = B.index s 1 + len = fromIntegral (B.index s 2) * 256 + fromIntegral (B.index s 3) + +orientImage :: Int -> DynamicImage -> DynamicImage +orientImage orientation = dynamicPixelMap orient + where + orient :: Pixel a => Image a -> Image a + orient img@Image {imageWidth = w, imageHeight = h} = case orientation of + 2 -> generateImage (\x y -> pixelAt img (w - 1 - x) y) w h + 3 -> generateImage (\x y -> pixelAt img (w - 1 - x) (h - 1 - y)) w h + 4 -> generateImage (\x y -> pixelAt img x (h - 1 - y)) w h + 5 -> generateImage (\x y -> pixelAt img y x) h w + 6 -> generateImage (\x y -> pixelAt img y (h - 1 - x)) h w + 7 -> generateImage (\x y -> pixelAt img (w - 1 - y) (h - 1 - x)) h w + 8 -> generateImage (\x y -> pixelAt img (w - 1 - y) x) h w + _ -> img + +colourProfile :: ByteString -> ColourProfile +colourProfile profile = fromMaybe CPNone $ do + primaries <- iccPrimaries profile + p3Primaries <- iccPrimaries displayP3 + CPDisplayP3 <$ guard (and $ zipWith (\v v' -> abs (v - v') <= 2) primaries p3Primaries) + +iccPrimaries :: ByteString -> Maybe [Integer] +iccPrimaries profile = do + guard $ slice 16 4 == Just "RGB " && slice 20 4 == Just "XYZ " + count <- unsigned <$> slice 128 4 + table <- slice 132 (12 * count) + concat <$> mapM (colorant <=< tagEntry table) ["rXYZ", "gXYZ", "bXYZ"] + where + tagEntry entries sig + | B.null entries = Nothing + | B.take 4 entries == sig = Just $ B.take 12 entries + | otherwise = tagEntry (B.drop 12 entries) sig + colorant entry = do + let offset = unsigned $ B.take 4 $ B.drop 4 entry + size = unsigned $ B.drop 8 entry + guard $ size >= 20 + tag <- slice offset size + guard $ "XYZ " `B.isPrefixOf` tag + pure [s15Fixed16 $ B.take 4 $ B.drop i tag | i <- [8, 12, 16]] + slice i n + | i + n > toInteger (B.length profile) = Nothing + | otherwise = Just $ B.take (fromInteger n) $ B.drop (fromInteger i) profile + unsigned = B.foldl' (\a b -> a * 256 + toInteger b) 0 + s15Fixed16 = toInteger . (fromInteger :: Integer -> Int32) . unsigned + +displayP3 :: ByteString +displayP3 = $(embedFile "src/Simplex/Chat/Image/DisplayP3-v4.icc") + +data ImageOutput = ImageBytes | ImageDataUri + +resizeImage :: ImageFormat -> Int64 -> DynamicImage -> ColourProfile -> IO (Either String LB.ByteString) +resizeImage = fitImage ImageBytes + +resizeImageDataUri :: Int64 -> DynamicImage -> ColourProfile -> IO (Either String LB.ByteString) +resizeImageDataUri maxSize img = fitImage ImageDataUri (imageFormat img) maxSize img + +fitImage :: ImageOutput -> ImageFormat -> Int64 -> DynamicImage -> ColourProfile -> IO (Either String LB.ByteString) +fitImage output format maxSize img profile + | dynamicMap (hasSideAbove maxImageSide) img = pure imageDoesNotFit + | otherwise = maybe (pure imageDoesNotFit) (fitLargest . snd) $ dynamicMap (\i -> displaySize (imageWidth i) (imageHeight i)) img + where + (out, quality, bitsPerPixel, jpegBytes, outputLength) = case output of + ImageBytes -> (id, 65, 0.9, id, id) + ImageDataUri -> (toDataUri format, 20, 0.35, \target -> 0.75 * max 0 (target - fromIntegral (LB.length $ toDataUri format "")), \n -> 4 * ((n + 2) `div` 3)) + keptProfile = displayP3 <$ guard (profile == CPDisplayP3 && fromIntegral (outputLength $ B.length displayP3) <= maxSize `div` 10) + fitLargest largest = case format of + IFJpeg -> do + let rgb = convertRGB8 img + largestRGB = scaleRGB8 rgb largest + scale size = if size == largest then largestRGB else scaleRGB8 rgb size + jpeg q = fmap (out . LB.fromStrict) . encodeRGB q keptProfile + fitSize maxSize (Just $ \target -> 8 * jpegBytes target / bitsPerPixel) largest (jpeg quality . scale) >>= \case + Right (size, r) | size == largest && fromIntegral (LB.length r) < minFill * fromIntegral maxSize -> Right <$> bisect maxSize (`jpeg` largestRGB) (quality, r) 96 + r -> pure $ snd <$> r + IFPng -> do + let png = fmap (out . LB.fromStrict) . encodeSmallerPng keptProfile + fmap snd <$> fitSize maxSize Nothing largest (png . scaleRGBA8 (convertRGBA8 img)) + +fitSize :: Int64 -> Maybe (Double -> Double) -> (Int, Int) -> ((Int, Int) -> IO LB.ByteString) -> IO (Either String ((Int, Int), LB.ByteString)) +fitSize maxSize firstPixels (lw, lh) encode = search [] $ maybe lw (widthFor . ($ target)) firstPixels + where + search tried w = do + r <- encode $ sizeOf w + let tried' = (w, r) : tried + if fits r && (size r >= minFill * limit || w == lw) + then pure $ Right (sizeOf w, r) + else maybe (largestFit tried') (search tried') $ nextWidth tried' w r + nextWidth tried w r + | length tried >= 8 = Nothing + | predicted `notElem` map fst tried = Just predicted + | hi - lo > 1 = Just $ (lo + hi) `div` 2 + | otherwise = Nothing + where + predicted = widthFor $ pixels w * (target / size r) ** (1 / slope tried) + lo = maximum $ 0 : map fst (filter (fits . snd) tried) + hi = minimum $ lw + 1 : [x | (x, r') <- tried, not (fits r'), x > lo] + slope ((w2, r2) : (w1, r1) : _) + | w1 /= w2 && size r1 /= size r2 && a >= 0.4 && a <= 1.3 = a + where + a = log (size r2 / size r1) / log (pixels w2 / pixels w1) + slope _ = 0.85 + largestFit tried = case filter (fits . snd) tried of + [] -> shrink $ minimum $ map fst tried + fitting -> let (w, r) = maximumBy (comparing fst) fitting in pure $ Right (sizeOf w, r) + shrink w + | w < 2 = pure imageDoesNotFit + | otherwise = do + let w' = w `div` 2 + r <- encode $ sizeOf w' + if fits r then pure $ Right (sizeOf w', r) else shrink w' + widthFor p = max 1 $ min lw $ round $ sqrt (p * fromIntegral lw / fromIntegral lh) + sizeOf w = (w, max 1 $ floor (fromIntegral (w * lh) / fromIntegral lw + 0.5 :: Double)) + pixels w = let (w', h') = sizeOf w in fromIntegral (w' * h') + fits r = LB.length r <= maxSize + size = fromIntegral . LB.length + limit = fromIntegral maxSize + target = sqrt minFill * limit + +minFill :: Double +minFill = 0.97 + +imageDoesNotFit :: Either String a +imageDoesNotFit = Left "image does not fit" + +bisect :: Int64 -> (Int -> IO LB.ByteString) -> (Int, LB.ByteString) -> Int -> IO LB.ByteString +bisect maxSize encode = go + where + go (good, r) bad + | bad - good <= 1 = pure r + | otherwise = do + let m = (good + bad) `div` 2 + r' <- encode m + case compare (LB.length r') maxSize of + LT -> go (m, r') bad + EQ -> pure r' + GT -> go (good, r) m + +encodeSmallerPng :: Maybe ByteString -> Image PixelRGBA8 -> IO ByteString +encodeSmallerPng profile img = minimumBy (comparing B.length) <$> mapM encode [False, True] + where + encode filtering = either fail pure =<< Spng.encodePng (Spng.EncodeOptions 9 filtering profile) img + +maxImageSide :: Int +maxImageSide = 16384 + +maxDisplaySide :: Int +maxDisplaySide = 4320 + +maxJpegPixels :: Int +maxJpegPixels = 2 ^ (28 :: Int) + +maxDecodeMemoryMB :: Int +#if defined(mobileBuild) +maxDecodeMemoryMB = 512 +#else +maxDecodeMemoryMB = 1024 +#endif + +hasSideAbove :: Int -> Image a -> Bool +hasSideAbove side img = imageWidth img > side || imageHeight img > side + +hasSize :: (Int, Int) -> Image a -> Bool +hasSize (w, h) img = imageWidth img == w && imageHeight img == h + +scaleRGB8 :: Image PixelRGB8 -> (Int, Int) -> Image PixelRGB8 +scaleRGB8 img size + | hasSize size img = img + | otherwise = scaled {imageData = sharpen 3 size $ imageData scaled} + where + scaled = catmullRom [] size img + +scaleRGBA8 :: Image PixelRGBA8 -> (Int, Int) -> Image PixelRGBA8 +scaleRGBA8 img = scale + where + premultiplied = pixelMap premultiply img + scale size + | hasSize size img = img + | otherwise = pixelMap unpremultiply $ scaled {imageData = sharpen 4 size $ imageData scaled} + where + scaled = catmullRom [STBIR.flag_ALPHA_PREMULTIPLIED] size premultiplied + +catmullRom :: STBIR.STBIRPixel a => [STBIR.Flag] -> (Int, Int) -> Image a -> Image a +catmullRom flags (w, h) = STBIR.resize options w h + where + options = STBIR.defaultOptions {STBIR.flags = flags, STBIR.filterHorizontal = STBIR.FILTER_CATMULLROM, STBIR.filterVertical = STBIR.FILTER_CATMULLROM} + +premultiply :: PixelRGBA8 -> PixelRGBA8 +premultiply (PixelRGBA8 r g b a) = PixelRGBA8 (multiply r) (multiply g) (multiply b) a + where + multiply c = fromIntegral ((fromIntegral c * fromIntegral a + 127) `div` 255 :: Int) + +unpremultiply :: PixelRGBA8 -> PixelRGBA8 +unpremultiply (PixelRGBA8 r g b a) = PixelRGBA8 (divide r) (divide g) (divide b) a + where + divide c + | a == 0 = 0 + | otherwise = fromIntegral (min 255 $ (fromIntegral c * 255 + fromIntegral a `div` 2) `div` fromIntegral a :: Int) + +sharpen :: Int -> (Int, Int) -> VS.Vector Word8 -> VS.Vector Word8 +sharpen channels (w, h) samples = VS.create $ do + rows <- VSM.unsafeNew $ VS.length sharpenOffsets * stride + out <- VSM.unsafeNew $ VS.length samples + let blurRow r = forM_ [0 .. w - 1] $ \p -> do + let offsets + | p >= sharpenRadius && p < w - sharpenRadius = interior + | otherwise = VS.map (\t -> (max 0 (min (w - 1) (p + t)) - p) * channels) sharpenOffsets + forM_ [0 .. channels - 1] $ \c -> do + let i = (r * w + p) * channels + c + v <- convolve (\o -> pure $ fromIntegral $ VS.unsafeIndex samples $ i + o) offsets + VSM.unsafeWrite rows (rowStart r + p * channels + c) $ double2Float v + forM_ [0 .. min h sharpenRadius - 1] blurRow + forM_ [0 .. h - 1] $ \y -> do + when (y + sharpenRadius < h) $ blurRow $ y + sharpenRadius + let offsets = VS.map (\t -> rowStart $ max 0 $ min (h - 1) $ y + t) sharpenOffsets + forM_ [0 .. stride - 1] $ \x -> do + b <- convolve (\o -> float2Double <$> VSM.unsafeRead rows (o + x)) offsets + let i = y * stride + x + VSM.unsafeWrite out i $ unsharp (VS.unsafeIndex samples i) $ double2Float b + pure out + where + stride = channels * w + rowStart r = (r `mod` VS.length sharpenOffsets) * stride + interior = VS.map (* channels) sharpenOffsets + unsharp v b = fromIntegral (max 0 $ min 255 $ floor (fromIntegral v + sharpenAmount * float2Double (fromIntegral v - b) + 0.5) :: Int) + +convolve :: Monad m => (Int -> m Double) -> VS.Vector Int -> m Double +convolve sample offsets = go 0 0 + where + go t s + | t == VS.length sharpenWeights = pure s + | otherwise = sample (VS.unsafeIndex offsets t) >>= \v -> go (t + 1) (s + VS.unsafeIndex sharpenWeights t * v) +{-# INLINE convolve #-} + +sharpenOffsets :: VS.Vector Int +sharpenOffsets = VS.enumFromTo (-radius) radius + where + radius = ceiling (3 * sharpenSigma) + +sharpenWeights :: VS.Vector Double +sharpenWeights = VS.map (/ VS.sum weights) weights + where + weights = VS.map (\t -> let d = fromIntegral t in exp (negate d * d / (2 * sharpenSigma * sharpenSigma))) sharpenOffsets + +sharpenRadius :: Int +sharpenRadius = VS.last sharpenOffsets + +sharpenAmount :: Double +sharpenAmount = 0.5 + +sharpenSigma :: Double +sharpenSigma = 0.6 + +scaledSide :: Float -> Int -> Int +scaledSide scale side = round $ fromIntegral side / scale + +toDataUri :: ImageFormat -> LB.ByteString -> LB.ByteString +toDataUri format body = "data:image/" <> mime <> ";base64," <> LB64.encode body + where + mime = case format of + -- iOS and Android apps decode only the "jpg" mime type + IFJpeg -> "jpg" + IFPng -> "png" diff --git a/src/Simplex/Chat/Image/DisplayP3-v4.icc b/src/Simplex/Chat/Image/DisplayP3-v4.icc new file mode 100644 index 0000000000000000000000000000000000000000..4ee40fb5eeb6dfde851c989ecc7c3ebd496ba9fc GIT binary patch literal 480 zcmZQzV0@61oLkJIz`&53S5g$@?xYYA8KuDfh=G|wgn^TRk3lvuxwybL*e!$s2#OQS zi<3)=azJbZ_;!tf5yZQ!n~|88a@gGJT=tTe2q0Uh2f`=AsvaGgv8FxDFxXJ0wCA(q~?VN18FuOmSHGn2w*S(cE7`S#K#561z7zDW(7*6agNGyVcFEh|KrnF23hEK~G7zT$N$` literal 0 HcmV?d00001 diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 6737ac1368..5c49395d7a 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -71,6 +71,7 @@ import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) import Simplex.Chat.Files +import Simplex.Chat.Image (decodeResizable, resizeImageDataUri) import Simplex.Chat.Markdown import Simplex.Chat.Messages import Simplex.Chat.Messages.Batch (BatchMode, encodeBatchElement) @@ -174,6 +175,9 @@ maxImageSize = 261120 * 2 -- auto-receive on mobiles maxProfileImageSize :: Int maxProfileImageSize = 12500 +maxImagePreviewSize :: Int64 +maxImagePreviewSize = 14000 + checkProfileImageSize :: Maybe ImageData -> CM () checkProfileImageSize = mapM_ $ \(ImageData t) -> let size = T.length t @@ -224,6 +228,14 @@ readProfileImageFile path = liftIO (loadImageData path) >>= either throwCmdError fixedImagePreview :: ImageData fixedImagePreview = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg==" +imagePreview :: CryptoFile -> IO ImageData +imagePreview f = either (const fixedImagePreview) (ImageData . safeDecodeUtf8 . LB.toStrict) <$> (runExceptT preview `catchAll` (pure . Left . show)) + where + preview = do + s <- withExceptT show $ CF.readFile f + (img, profile) <- ExceptT $ decodeResizable $ LB.toStrict s + ExceptT $ resizeImageDataUri maxImagePreviewSize img profile + imageFilePrefix :: String imageFilePrefix = "IMG_" @@ -3547,8 +3559,8 @@ processChatCommand cxt nm = \case unless (any (`isSuffixOf` map toLower fPath) imageExtensions) $ throwChatError CEFileImageType {filePath} fileSize <- getFileSize filePath unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath} - -- TODO include file description for preview - processChatCommand cxt nm $ APISendMessages sendRef False Nothing False [composedMessage (Just f) (MCImage "" fixedImagePreview)] + preview <- liftIO $ imagePreview (f {filePath} :: CryptoFile) + processChatCommand cxt nm $ APISendMessages sendRef False Nothing False [composedMessage (Just f) (MCImage "" preview)] ForwardFile chatName fileId -> forwardFile chatName fileId SendFile ForwardImage chatName fileId -> forwardFile chatName fileId SendImage SendFileDescription _chatName _f -> throwCmdError "TODO" diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 94b4f013ea..1c27feda35 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -164,6 +164,18 @@ foreign export ccall "chat_encrypt_file" cChatEncryptFile :: StablePtr ChatContr foreign export ccall "chat_decrypt_file" cChatDecryptFile :: CString -> CString -> CString -> CString -> IO CString +foreign export ccall "chat_resize_image_uri" cChatResizeImageUri :: Ptr Word8 -> CInt -> CInt -> IO CString + +foreign export ccall "chat_resize_image" cChatResizeImage :: Ptr Word8 -> CInt -> CInt -> CInt -> IO CString + +foreign export ccall "chat_resize_image_file_uri" cChatResizeImageFileUri :: CString -> CInt -> IO CString + +foreign export ccall "chat_resize_image_file" cChatResizeImageFile :: CString -> CInt -> CInt -> IO CString + +foreign export ccall "chat_resize_image_fd_uri" cChatResizeImageFdUri :: CInt -> CInt -> IO CString + +foreign export ccall "chat_resize_image_fd" cChatResizeImageFd :: CInt -> CInt -> CInt -> IO CString + -- | check / migrate database and initialize chat controller on success -- For postgres first param is schema prefix, second param is database connection string. cChatMigrateInit :: CString -> CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString diff --git a/src/Simplex/Chat/Mobile/File.hs b/src/Simplex/Chat/Mobile/File.hs index afbb1bc8c9..5f70dbbcb3 100644 --- a/src/Simplex/Chat/Mobile/File.hs +++ b/src/Simplex/Chat/Mobile/File.hs @@ -10,6 +10,12 @@ module Simplex.Chat.Mobile.File cChatReadFile, cChatEncryptFile, cChatDecryptFile, + cChatResizeImageUri, + cChatResizeImage, + cChatResizeImageFileUri, + cChatResizeImageFile, + cChatResizeImageFdUri, + cChatResizeImageFd, WriteFileResult (..), ReadFileResult (..), chatWriteFile, @@ -17,6 +23,7 @@ module Simplex.Chat.Mobile.File ) where +import Codec.Picture (DynamicImage) import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class @@ -24,6 +31,7 @@ import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import Data.ByteString (ByteString) import qualified Data.ByteString as B +import qualified Data.ByteString.Base64.Lazy as LB64 import qualified Data.ByteString.Lazy as LB import qualified Data.ByteString.Lazy.Char8 as LB' import Data.Char (chr) @@ -34,7 +42,9 @@ import Foreign.Marshal.Alloc (mallocBytes) import Foreign.Ptr import Foreign.StablePtr import Foreign.Storable (poke, pokeByteOff) +import GHC.IO.Handle.FD (fdToHandle) import Simplex.Chat.Controller (ChatController (..)) +import Simplex.Chat.Image (ColourProfile, ImageFormat (..), decodeResizable, imageFormat, maxDecodeMemoryMB, resizeImage, resizeImageDataUri) import Simplex.Chat.Mobile.Shared import Simplex.Chat.Util (chunkSize, encryptFile) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..), CryptoFileHandle, FTCryptoError (..)) @@ -42,7 +52,8 @@ import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Util (catchAll) -import UnliftIO (Handle, IOMode (..), atomically, withFile) +import System.Posix.Internals (c_close) +import UnliftIO (Handle, IOMode (..), atomically, bracket, hClose, hFileSize, onException, tryIO, withBinaryFile, withFile) data WriteFileResult = WFResult {cryptoArgs :: CryptoFileArgs} @@ -139,6 +150,62 @@ chatDecryptFile fromPath keyStr nonceStr toPath = fromLeft "" <$> runCatchExcept liftIO $ B.hPut w ch when (size' > 0) $ decryptChunks r w size' +cChatResizeImageUri :: Ptr Word8 -> CInt -> CInt -> IO CString +cChatResizeImageUri ptr len = cResizeImageUri $ getByteString ptr len + +cChatResizeImage :: Ptr Word8 -> CInt -> CInt -> CInt -> IO CString +cChatResizeImage ptr len = cResizeImageData $ getByteString ptr len + +cChatResizeImageFileUri :: CString -> CInt -> IO CString +cChatResizeImageFileUri = cResizeImageUri . readFilePath + +cChatResizeImageFile :: CString -> CInt -> CInt -> IO CString +cChatResizeImageFile = cResizeImageData . readFilePath + +cChatResizeImageFdUri :: CInt -> CInt -> IO CString +cChatResizeImageFdUri = cResizeImageUri . readFd + +cChatResizeImageFd :: CInt -> CInt -> CInt -> IO CString +cChatResizeImageFd = cResizeImageData . readFd + +readFilePath :: CString -> IO ByteString +readFilePath cPath = do + path <- peekCString cPath + withBinaryFile path ReadMode hGetImage + +readFd :: CInt -> IO ByteString +readFd fd = bracket (fdToHandle fd `onException` c_close fd) hClose hGetImage + +hGetImage :: Handle -> IO ByteString +hGetImage h = + tryIO (hFileSize h) >>= \case + Right size + | size > fromIntegral maxSize -> tooLarge + | otherwise -> B.hGet h $ fromIntegral size + Left _ -> do + s <- LB.hGet h $ maxSize + 1 + if LB.length s > fromIntegral maxSize then tooLarge else pure $ LB.toStrict s + where + maxSize = maxDecodeMemoryMB * 1048576 + tooLarge = fail "image file is too large" + +cResizeImageUri :: IO ByteString -> CInt -> IO CString +cResizeImageUri input maxSize = cResizeImage id (resizeImageDataUri $ fromIntegral maxSize) input + +cResizeImageData :: IO ByteString -> CInt -> CInt -> IO CString +cResizeImageData input maxSize png = cResizeImage LB64.encode resize input + where + resize img = resizeImage (format img) (fromIntegral maxSize) img + format img = case compare png 0 of + LT -> imageFormat img + EQ -> IFJpeg + GT -> IFPng + +cResizeImage :: (LB.ByteString -> LB.ByteString) -> (DynamicImage -> ColourProfile -> IO (Either String LB.ByteString)) -> IO ByteString -> IO CString +cResizeImage encode resize input = do + r <- runCatchExceptT $ ExceptT . uncurry resize =<< ExceptT (decodeResizable =<< input) + newCStringFromLazyBS $ either (const "") encode r + runCatchExceptT :: ExceptT String IO a -> IO (Either String a) runCatchExceptT action = runExceptT action `catchAll` (pure . Left . show) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 842ad784a5..06bdc55e87 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -950,7 +950,10 @@ instance ToJSON ImageData where safeImageData :: Text -> Text safeImageData t - | "data:" `T.isPrefixOf` t = t + | Just b64 <- T.stripPrefix "data:image/jpg;base64," t <|> T.stripPrefix "data:image/png;base64," t, + Right header <- B64.decode $ encodeUtf8 $ T.take 12 b64, + any (`B.isPrefixOf` header) ["\xFF\xD8\xFF", "\x89PNG\r\n\x1a\n"] = + t | otherwise = "" instance ToField ImageData where toField (ImageData t) = toField t diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 6c5ae61704..aa8a9b5682 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -17,13 +17,20 @@ import Control.Monad.Reader (runReaderT) import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB +import Data.Int (Int64) +import qualified Data.Text as T import Network.HTTP.Types.URI (urlEncode) import Data.Time.Clock (addUTCTime, getCurrentTime, nominalDay) import Simplex.Chat.Badges (BadgeProof, BadgeStatus (..), BadgeType (..), FileSizeLimits (..), ProofPresHeader (..), badgeProof, defaultFileSizeLimits) -import Simplex.Chat.Controller (ChatConfig (..)) +import Simplex.Chat.Controller (ChatCommand (APIGetChat), ChatConfig (..), ChatPagination (CPLast), ChatResponse (CRApiChat)) +import Simplex.Chat.Library.Commands (execChatCommand') import Simplex.Chat.Library.Internal (badgeProofStatus, roundedFDCount) +import Simplex.Chat.Messages (AChat (..), CChatItem (..), Chat (..), ChatItem (..), ChatRef (..), ChatType (..)) +import Simplex.Chat.Messages.CIContent (ciMsgContent) import Simplex.Chat.Mobile.File import Simplex.Chat.Options (ChatOpts (..)) +import Simplex.Chat.Protocol (MsgContent (MCImage)) +import Simplex.Chat.Types (ImageData (..)) import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), XFTPStoreConfig (..)) import Simplex.Messaging.Crypto.BBS (BBSPublicKey, bbsKeyGen) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) @@ -37,6 +44,7 @@ chatFileTests = do describe "messages with files" $ do it "send and receive message with file" runTestMessageWithFile it "send and receive image" testSendImage + it "send image with generated preview" testSendImagePreview it "sender marking chat item deleted cancels file" testSenderMarkItemDeleted it "files folder: send and receive image" testFilesFoldersSendImage it "files folder: sender deleted file" testFilesFoldersImageSndDelete -- TODO add test deleting during upload @@ -168,6 +176,29 @@ testSendImage = fileExists <- doesFileExist testJpg fileExists `shouldBe` True +testSendImagePreview :: HasCallStack => TestParams -> IO () +testSendImagePreview = + testChat2 aliceProfile bobProfile $ + \alice bob -> withXFTPServer alice $ do + connectUsers alice bob + alice ##> "/img @bob ./tests/fixtures/test.jpg" + alice <# "/f @bob ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + alice <## "completed uploading file 1 (test.jpg) for bob" + preview <- lastImagePreview bob 2 + T.length preview `shouldSatisfy` (<= 14000) + preview `shouldSatisfy` ("data:image/jpg;base64," `T.isPrefixOf`) + +lastImagePreview :: TestCC -> Int64 -> IO T.Text +lastImagePreview TestCC {chatController = cc} contactId = do + r <- execChatCommand' (APIGetChat (ChatRef CTDirect contactId Nothing) Nothing (CPLast 1) Nothing) 0 `runReaderT` cc + case r of + Right (CRApiChat _ (AChat _ Chat {chatItems = [CChatItem _ ChatItem {content = itemContent}]}) _) + | Just (MCImage _ (ImageData image)) <- ciMsgContent itemContent -> pure image + _ -> fail $ "no image item: " <> show r + testSenderMarkItemDeleted :: HasCallStack => TestParams -> IO () testSenderMarkItemDeleted = testChat2 aliceProfile bobProfile $ diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 9942726482..1ff60fc1d4 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -10166,7 +10166,7 @@ testChannelLargeProfileFits ps = -- ~14000 chars: profile fits in a singleton batch AND packs -- inline with the forwarded body (exercises the in-body path). - let bigImage = T.pack ("data:image/png;base64," <> replicate 14000 'A') + let bigImage = T.pack ("data:image/png;base64," <> take 14000 ("iVBORw0KGgo" <> repeat 'A')) withCCTransaction bob $ \db -> DB.execute db "UPDATE contact_profiles SET image = ? WHERE display_name = ?" (bigImage, "cath" :: T.Text) @@ -10193,7 +10193,7 @@ testChannelOverflowProfileNotification ps = dan <## "ok" danConnId <- relayConnIdToMember bob "dan" - let bigImage = T.pack ("data:image/png;base64," <> replicate 10000 'A') + let bigImage = T.pack ("data:image/png;base64," <> take 10000 ("iVBORw0KGgo" <> repeat 'A')) longText = replicate 7000 'x' withCCTransaction bob $ \db -> DB.execute db "UPDATE contact_profiles SET image = ? WHERE display_name = ?" (bigImage, "cath" :: T.Text) @@ -10220,8 +10220,8 @@ testChannelMultipleLargeProfiles ps = -- ~14500 chars each: one rides inline with the body, -- the other spills into a standalone overflow batch. - let cathImage = T.pack ("data:image/png;base64," <> replicate 14500 'A') - danImage = T.pack ("data:image/png;base64," <> replicate 14500 'B') + let cathImage = T.pack ("data:image/png;base64," <> take 14500 ("iVBORw0KGgo" <> repeat 'A')) + danImage = T.pack ("data:image/png;base64," <> take 14500 ("iVBORw0KGgo" <> repeat 'B')) withCCTransaction bob $ \db -> do DB.execute db "UPDATE contact_profiles SET image = ? WHERE display_name = ?" (cathImage, "cath" :: T.Text) DB.execute db "UPDATE contact_profiles SET image = ? WHERE display_name = ?" (danImage, "dan" :: T.Text) diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 20134bbc6f..a698de9449 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -1200,7 +1200,7 @@ testSetProfileImageTooLarge = alice <## "profile image updated" -- image over the size limit is rejected; -- the long command wraps in the virtual terminal, so drain echo lines until the error - alice `send` ("/set profile image data:image/png;base64," <> replicate 12500 'A') + alice `send` ("/set profile image data:image/png;base64," <> take 12500 ("iVBORw0KGgo" <> repeat 'A')) let errPrefix = "bad chat command: Profile image is too large" expectError = do l <- getTermLine alice diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index a410d80ad3..57d617befc 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -244,7 +244,7 @@ sndRcvImg pqEnc enabled (cc1, msg, v1) (cc2, v2) = do genProfileImg :: IO ByteString genProfileImg = do g <- C.newRandom - atomically $ B64.encode <$> C.randomBytes lrgLen g + atomically $ B64.encode . ("\x89PNG\r\n\x1a\n" <>) <$> C.randomBytes (lrgLen - 8) g where -- raw bytes that base64-encode to fit maxProfileImageSize when prefixed with "data:image/png;base64," lrgLen = (maxProfileImageSize - imagePrefixLen) * 3 `div` 4 - 1 diff --git a/tests/ImageTests.hs b/tests/ImageTests.hs new file mode 100644 index 0000000000..4bfbd4d5fa --- /dev/null +++ b/tests/ImageTests.hs @@ -0,0 +1,458 @@ +{-# LANGUAGE OverloadedStrings #-} + +module ImageTests where + +import Codec.Picture (DynamicImage (..), Image (..), Pixel8, PixelCMYK8, PixelRGB16 (..), PixelRGB8 (..), PixelRGBA16 (..), PixelRGBA8 (..), convertRGB8, convertRGBA8, decodeImage, dynamicMap, encodeBitmap, encodeGifImage, generateImage, pixelAt) +import Codec.Picture.JPEGTurbo (encodeRGB) +import Codec.Picture.Jpg (encodeDirectJpegAtQualityWithMetadata, encodeJpegAtQuality) +import Codec.Picture.Png (encodePng) +import Codec.Picture.Types (convertImage, extractLumaPlane, pixelFold) +import Control.Monad (forM_, when) +import Control.Monad.Except (runExceptT) +import Data.Bits (complement, shiftR, testBit, xor) +import qualified Data.ByteString as B +import qualified Data.ByteString.Base64.Lazy as LB64 +import qualified Data.ByteString.Lazy.Char8 as LB +import Data.Either (isLeft, isRight) +import qualified Data.Text as T +import Data.Word (Word32) +import Simplex.Chat.Image +import Simplex.Chat.Library.Commands (fixedImagePreview, imagePreview) +import Simplex.Chat.Types (ImageData (..)) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFile (..)) +import qualified Simplex.Messaging.Crypto.File as CF +import System.FilePath (()) +import Test.Hspec +import UnliftIO.STM (atomically) +import UnliftIO.Temporary (withTempDirectory) + +imageTests :: Spec +imageTests = do + describe "data URI" $ do + it "should resize JPEG to JPEG" $ testDataUri "tests/fixtures/test.jpg" "data:image/jpg;base64," + it "should resize PNG with transparency to PNG" $ testDataUri "tests/fixtures/logo-large-rgba.png" "data:image/png;base64," + it "should resize opaque PNG to JPEG" $ testDataUri "tests/fixtures/preview-issue1.png" "data:image/jpg;base64," + it "should convert 16-bit images to 8 bits" testDataUri16Bit + describe "image data" $ do + it "should reduce JPEG size" testResizeJpeg + it "should reduce PNG size" testResizePng + it "should fail when image does not fit" testImageTooLarge + it "should refuse image with a side above 16384 pixels" testImageSideLimit + it "should halve image to at most 4320 pixels per side" testDisplaySide + it "should fill 97-100% of the limit" testFill + it "should raise JPEG quality when the image fits at its size" testFullSizeQuality + describe "image preview" $ do + it "should generate preview of an image file" $ testFilePreview $ CryptoFile "tests/fixtures/test.jpg" Nothing + it "should generate preview of an encrypted image file" testEncryptedFilePreview + it "should keep the fixed preview when the file is not an image" $ + imagePreview (CryptoFile "tests/fixtures/test.pdf" Nothing) `shouldReturn` fixedImagePreview + describe "JPEG decoding" $ do + it "should decode JPEG at the display size" testJpegDisplaySize + it "should decode JPEG colour spaces" testJpegColourSpaces + it "should refuse unsupported and malformed JPEG" testJpegRefused + it "should apply EXIF orientation of JPEG" testJpegOrientation + describe "PNG decoding" $ do + it "should decode PNG as JuicyPixels does" testPngDecoding + it "should refuse malformed PNG and other formats" testPngRefused + describe "scaling" $ do + it "should scale and sharpen as the research code" testScaleResearch + it "should keep an image of the same size" testScaleSameSize + it "should scale transparency in premultiplied alpha" testScaleTransparency + describe "colour profiles" $ do + it "should write the Display P3 profile for Display P3 JPEG" testJpegProfile + it "should write the Display P3 profile for Display P3 PNG" testPngProfile + it "should drop colour profiles other than Display P3" testProfileDropped + it "should drop the Display P3 profile above a tenth of the limit" testProfileAboveLimit + it "should ignore inconsistent JPEG colour profiles" testJpegProfileInconsistent + +testDataUri :: FilePath -> LB.ByteString -> IO () +testDataUri path prefix = do + Right img <- decodeImage <$> B.readFile path + Right uri <- resizeImageDataUri 14000 img CPNone + LB.length uri `shouldSatisfy` (<= 14000) + let (uriPrefix, body) = LB.splitAt (LB.length prefix) uri + uriPrefix `shouldBe` prefix + Right resized <- pure $ decodeImage . LB.toStrict =<< LB64.decode body + imageSize resized `shouldKeepAspect` imageSize img + +testDataUri16Bit :: IO () +testDataUri16Bit = do + let rgb = ImageRGB16 $ generateImage (\x y -> PixelRGB16 (fromIntegral x * 512) (fromIntegral y * 512) 0) 128 128 + rgba = ImageRGBA16 $ generateImage (\x y -> PixelRGBA16 (fromIntegral x * 512) (fromIntegral y * 512) 0 32768) 128 128 + Right jpegUri <- resizeImageDataUri 14000 rgb CPNone + jpegUri `shouldSatisfy` ("data:image/jpg;base64," `LB.isPrefixOf`) + Right pngUri <- resizeImageDataUri 14000 rgba CPNone + Just body <- pure $ LB.stripPrefix "data:image/png;base64," pngUri + Right (ImageRGBA8 _) <- pure $ decodeImage . LB.toStrict =<< LB64.decode body + pure () + +testResizeJpeg :: IO () +testResizeJpeg = do + Right img <- decodeImage <$> B.readFile "tests/fixtures/test.jpg" + Right jpeg <- resizeImage IFJpeg 10000 img CPNone + LB.length jpeg `shouldSatisfy` (<= 10000) + LB.take 2 jpeg `shouldBe` "\xFF\xD8" + Right resized <- pure $ decodeImage $ LB.toStrict jpeg + fst (imageSize resized) `shouldSatisfy` (< 1280) + imageSize resized `shouldKeepAspect` imageSize img + +testResizePng :: IO () +testResizePng = do + Right img <- decodeImage <$> B.readFile "tests/fixtures/logo-large-rgba.png" + Right png <- resizeImage IFPng 10000 img CPNone + LB.length png `shouldSatisfy` (<= 10000) + Right resized@(ImageRGBA8 _) <- pure $ decodeImage $ LB.toStrict png + fst (imageSize resized) `shouldSatisfy` (< 1630) + imageSize resized `shouldKeepAspect` imageSize img + +testImageTooLarge :: IO () +testImageTooLarge = do + let img = ImageRGBA8 $ generateImage (\_ _ -> PixelRGBA8 0 0 0 0) 1 1 + resizeImage IFPng 10 img CPNone >>= (`shouldSatisfy` isLeft) + resizeImage IFJpeg 10 img CPNone >>= (`shouldSatisfy` isLeft) + +testImageSideLimit :: IO () +testImageSideLimit = do + let blank w h = ImageRGBA8 $ generateImage (\_ _ -> PixelRGBA8 0 0 0 0) w h + resizeAll img = sequence [resizeImage IFPng 100000 img CPNone, resizeImage IFJpeg 100000 img CPNone, resizeImageDataUri 100000 img CPNone] + forM_ [blank 16384 4, blank 4 16384] $ \img -> + resizeAll img >>= (`shouldSatisfy` all isRight) + forM_ [blank 16385 4, blank 4 16385] $ \img -> + resizeAll img >>= (`shouldSatisfy` all isLeft) + +testDisplaySide :: IO () +testDisplaySide = + forM_ [((8640, 8), (4320, 4)), ((8, 8640), (4, 4320)), ((4320, 4), (4320, 4))] $ \((w, h), size) -> do + let opaque = ImageRGB8 $ generateImage (\_ _ -> PixelRGB8 0 0 0) w h + transparent = ImageRGBA8 $ generateImage (\_ _ -> PixelRGBA8 0 0 0 0) w h + forM_ [opaque, transparent] $ \img -> do + Right image <- resizeImage (imageFormat img) 261120 img CPNone + decodedSize image `shouldBe` Right size + forM_ [12500, 14000] $ \limit -> do + Right uri <- resizeImageDataUri limit img CPNone + (decodedSize =<< LB64.decode (LB.drop 1 $ LB.dropWhile (/= ',') uri)) `shouldBe` Right size + where + decodedSize = fmap imageSize . decodeImage . LB.toStrict + +testFill :: IO () +testFill = do + forM_ ["tests/fixtures/test.jpg", "tests/fixtures/preview-issue1.png"] $ \path -> do + Right img <- decodeImage <$> B.readFile path + Right uri <- resizeImageDataUri 14000 img CPNone + LB.length uri `shouldSatisfy` filled 14000 + Right image <- resizeImage IFJpeg 20000 img CPNone + LB.length image `shouldSatisfy` filled 20000 + where + filled limit n = n <= limit && fromIntegral n >= 0.97 * (fromIntegral limit :: Double) + +testFullSizeQuality :: IO () +testFullSizeQuality = do + let rgb = generateImage (\x y -> PixelRGB8 (fromIntegral $ x * 4) (fromIntegral $ y * 5) (fromIntegral $ x * y)) 64 48 + q65 <- encodeRGB 65 Nothing rgb + q95 <- encodeRGB 95 Nothing rgb + let limit = fromIntegral $ (B.length q65 + B.length q95) `div` 2 + Right jpeg <- resizeImage IFJpeg limit (ImageRGB8 rgb) CPNone + LB.length jpeg `shouldSatisfy` (\n -> n > fromIntegral (B.length q65) && n <= limit) + imageSize <$> decodeImage (LB.toStrict jpeg) `shouldBe` Right (64, 48) + +testFilePreview :: CryptoFile -> IO () +testFilePreview f = do + ImageData preview <- imagePreview f + T.length preview `shouldSatisfy` (<= 14000) + preview `shouldSatisfy` ("data:image/jpg;base64," `T.isPrefixOf`) + +testEncryptedFilePreview :: IO () +testEncryptedFilePreview = withTempDirectory "tests/tmp" "image-preview" $ \dir -> do + args <- atomically . CF.randomArgs =<< C.newRandom + let f = CryptoFile (dir "test.jpg") (Just args) + Right () <- runExceptT . CF.writeFile f . LB.fromStrict =<< B.readFile "tests/fixtures/test.jpg" + testFilePreview f + +testJpegDisplaySize :: IO () +testJpegDisplaySize = + forM_ sizes $ \(w, (w', h')) -> + forM_ [((w, 16), (w', h')), ((16, w), (h', w'))] $ \((iw, ih), size) -> do + jpeg <- encodeRGB 90 Nothing $ generateImage (\_ _ -> PixelRGB8 128 128 128) iw ih + Right (img, _) <- decodeResizable jpeg + resizedSize img `shouldReturn` size + when (w <= 8641) $ do + Right full <- pure $ decodeImage jpeg + resizedSize full `shouldReturn` size + where + sizes = [(4320, (4320, 16)), (4321, (2160, 8)), (8640, (4320, 8)), (8641, (4320, 8)), (17280, (4320, 4)), (17281, (4320, 4)), (17282, (4320, 4)), (34561, (4320, 2)), (65500, (4094, 1))] + resizedSize img = do + Right image <- resizeImage IFJpeg 261120 img CPNone + either fail (pure . imageSize) $ decodeImage $ LB.toStrict image + +testJpegColourSpaces :: IO () +testJpegColourSpaces = do + let rgb = generateImage (\x y -> PixelRGB8 (fromIntegral $ x * 4) (fromIntegral $ y * 5) 128) 64 48 + progressive <- encodeRGB 90 Nothing rgb + Right (ImageRGB8 _, _) <- decodeResizable progressive + Right (ImageRGB8 _, _) <- decodeResizable $ baselineJpeg rgb + Right (ImageY8 _, _) <- decodeResizable $ LB.toStrict $ encodeDirectJpegAtQualityWithMetadata 90 mempty $ extractLumaPlane rgb + Right (ImageCMYK8 _, _) <- decodeResizable $ LB.toStrict $ encodeDirectJpegAtQualityWithMetadata 90 mempty (convertImage rgb :: Image PixelCMYK8) + pure () + +testJpegRefused :: IO () +testJpegRefused = + forM_ inputs $ \input -> isLeft <$> decodeResizable input `shouldReturn` True + where + jpeg = baselineJpeg $ generateImage (\_ _ -> PixelRGB8 128 128 128) 64 48 + sof = B.length $ fst $ B.breakSubstring "\xFF\xC0" jpeg + patchAt i bytes = B.take i jpeg <> B.pack bytes <> B.drop (i + length bytes) jpeg + sides w h = patchAt (sof + 5) $ concatMap (\v -> [fromIntegral (v `shiftR` 8), fromIntegral v]) [h, w :: Int] + inputs = + [ patchAt (sof + 4) [12], + patchAt (sof + 1) [0xC3], + sides 65501 16, + sides 16385 16384, + B.init jpeg, + B.take sof jpeg <> "\0" <> B.drop sof jpeg + ] + +testJpegOrientation :: IO () +testJpegOrientation = do + jpeg <- encodeRGB 100 Nothing $ generateImage quadrant 64 32 + forM_ orientations $ \(orientation, size, colours) -> do + Right (img, _) <- decodeResizable $ withExif (odd orientation) 8 orientation jpeg + imageSize img `shouldBe` size + quadrants img `shouldBe` colours + Right resized <- resizeImage IFJpeg 261120 img CPNone + "Exif" `B.isInfixOf` LB.toStrict resized `shouldBe` False + Right (img, _) <- decodeResizable $ withExif True 1000 6 jpeg + quadrants img `shouldBe` [red, green, blue, white] + where + quadrant x y = case (x < 32, y < 16) of + (True, True) -> red + (False, True) -> green + (True, False) -> blue + (False, False) -> white + orientations :: [(Int, (Int, Int), [PixelRGB8])] + orientations = + [ (1, (64, 32), [red, green, blue, white]), + (2, (64, 32), [green, red, white, blue]), + (3, (64, 32), [white, blue, green, red]), + (4, (64, 32), [blue, white, red, green]), + (5, (32, 64), [red, blue, green, white]), + (6, (32, 64), [blue, red, white, green]), + (7, (32, 64), [white, green, blue, red]), + (8, (32, 64), [green, white, red, blue]) + ] + red = PixelRGB8 255 0 0 + green = PixelRGB8 0 255 0 + blue = PixelRGB8 0 0 255 + white = PixelRGB8 255 255 255 + quadrants img = + let rgb = convertRGB8 img + (w, h) = (imageWidth rgb, imageHeight rgb) + snap (PixelRGB8 r g b) = PixelRGB8 (level r) (level g) (level b) + level c = if c > 127 then 255 else 0 + in [snap $ pixelAt rgb x y | y <- [h `div` 4, 3 * h `div` 4], x <- [w `div` 4, 3 * w `div` 4]] + withExif :: Bool -> Int -> Int -> B.ByteString -> B.ByteString + withExif littleEndian ifdOffset orientation jpeg = B.take 2 jpeg <> "\xFF\xE1" <> bigEndian 2 (8 + B.length tiff) <> "Exif\0\0" <> tiff <> B.drop 2 jpeg + where + tiff = B.concat [if littleEndian then "II" else "MM", word 2 42, word 4 ifdOffset, word 2 1, word 2 0x0112, word 2 3, word 4 1, word 2 orientation, word 2 0, word 4 0] + word n = (if littleEndian then B.reverse else id) . bigEndian n + +testPngDecoding :: IO () +testPngDecoding = do + transparent <- B.readFile "tests/fixtures/logo-large-rgba.png" + Right (img@(ImageRGBA8 rgba), CPNone) <- decodeResizable transparent + Right reference <- pure $ decodeImage transparent + imageData rgba `shouldBe` imageData (convertRGBA8 reference) + IFPng <- pure $ imageFormat img + opaque <- B.readFile "tests/fixtures/preview-issue1.png" + Right (ImageRGB8 rgb, CPNone) <- decodeResizable opaque + Right reference' <- pure $ decodeImage opaque + imageData rgb `shouldBe` imageData (convertRGB8 reference') + +testPngRefused :: IO () +testPngRefused = + forM_ inputs $ \(input, err) -> + either Just (const Nothing) <$> decodeResizable input `shouldReturn` Just err + where + inputs = + [ (pngShortImageData, "IDAT stream too short"), + (pngSideAbove16384, "image width exceeds user limit"), + (LB.toStrict $ encodeGifImage $ generateImage (\_ _ -> 0) 16 16, "unsupported image format"), + (LB.toStrict $ encodeBitmap $ generateImage (\_ _ -> PixelRGB8 0 0 0) 16 16, "unsupported image format") + ] + +pngShortImageData :: B.ByteString +pngShortImageData = pngWithSides 2048 2048 $ blankPng 2048 1 + +pngSideAbove16384 :: B.ByteString +pngSideAbove16384 = pngWithSides 65537 65537 $ blankPng 1 1 + +blankPng :: Int -> Int -> B.ByteString +blankPng w h = LB.toStrict $ encodePng (generateImage (\_ _ -> 0) w h :: Image Pixel8) + +pngWithSides :: Int -> Int -> B.ByteString -> B.ByteString +pngWithSides w h png = B.take 8 png <> pngChunk "IHDR" (bigEndian 4 w <> bigEndian 4 h <> B.take 5 (B.drop 24 png)) <> B.drop 33 png + +pngWithIccProfile :: B.ByteString -> B.ByteString -> B.ByteString +pngWithIccProfile profile png = B.take 33 png <> pngChunk "iCCP" ("test\0\0" <> zlibStored profile) <> B.drop 33 png + +pngChunk :: B.ByteString -> B.ByteString -> B.ByteString +pngChunk name body = bigEndian 4 (B.length body) <> name <> body <> bigEndian 4 (fromIntegral $ crc32 $ name <> body) + +crc32 :: B.ByteString -> Word32 +crc32 = complement . B.foldl' (\c b -> iterate step (c `xor` fromIntegral b) !! 8) 0xffffffff + where + step c = if testBit c 0 then 0xedb88320 `xor` shiftR c 1 else shiftR c 1 + +zlibStored :: B.ByteString -> B.ByteString +zlibStored s = "\x78\x01\x01" <> B.reverse (bigEndian 2 n) <> B.reverse (bigEndian 2 $ 65535 - n) <> s <> bigEndian 4 (b * 65536 + a) + where + n = B.length s + (a, b) = B.foldl' (\(x, y) w -> let x' = (x + fromIntegral w) `mod` 65521 in (x', (y + x') `mod` 65521)) (1, 0) s + +withIccProfile :: [B.ByteString] -> B.ByteString -> B.ByteString +withIccProfile chunks jpeg = B.take 2 jpeg <> B.concat ["\xFF\xE2" <> bigEndian 2 (14 + B.length c) <> "ICC_PROFILE\0" <> c | c <- chunks] <> B.drop 2 jpeg + +iccChunk :: Int -> Int -> B.ByteString -> B.ByteString +iccChunk n count profile = B.pack [fromIntegral n, fromIntegral count] <> profile + +bigEndian :: Int -> Int -> B.ByteString +bigEndian n v = B.pack [fromIntegral (v `shiftR` (8 * i)) | i <- [n - 1, n - 2 .. 0]] + +testScaleResearch :: IO () +testScaleResearch = do + Right src <- decodeImage <$> B.readFile "tests/fixtures/preview-issue1.png" + Right (ImageRGB8 expected) <- decodeImage <$> B.readFile "tests/fixtures/preview-issue1-400x200-sharpened.png" + let scaled = scaleRGB8 (convertRGB8 src) (400, 200) + imageSize (ImageRGB8 scaled) `shouldBe` (400, 200) + pixelFold (\n x y p -> if p == pixelAt expected x y then n else n + 1) (0 :: Int) scaled `shouldBe` 0 + +testScaleSameSize :: IO () +testScaleSameSize = do + let rgb = generateImage (\x y -> PixelRGB8 (fromIntegral $ x * 4) (fromIntegral $ y * 5) 128) 64 48 + rgba = generateImage (\x y -> PixelRGBA8 (fromIntegral $ x * 4) (fromIntegral $ y * 5) 128 (fromIntegral $ x + y)) 64 48 + imageData (scaleRGB8 rgb (64, 48)) `shouldBe` imageData rgb + imageData (scaleRGBA8 rgba (64, 48)) `shouldBe` imageData rgba + +testScaleTransparency :: IO () +testScaleTransparency = do + let scaled = scaleRGBA8 (generateImage pixel 64 64) (40, 40) + visible = [p | y <- [0 .. 39], x <- [0 .. 39], let p@(PixelRGBA8 _ _ _ a) = pixelAt scaled x y, a > 0] + filter (\(PixelRGBA8 r g b _) -> (r, g, b) /= (255, 0, 0)) visible `shouldBe` [] + any (\(PixelRGBA8 _ _ _ a) -> a < 255) visible `shouldBe` True + where + pixel x y + | (x - 32) * (x - 32) + (y - 32) * (y - 32) < 400 = PixelRGBA8 255 0 0 255 + | even (x + y) = PixelRGBA8 0 255 0 0 + | otherwise = PixelRGBA8 0 0 255 0 + +testJpegProfile :: IO () +testJpegProfile = do + displayP3 <- B.readFile displayP3File + jpeg <- encodeRGB 90 Nothing gradient + let (start, rest) = B.splitAt 100 p3Profile + Right (img, profile) <- decodeResizable $ withIccProfile [iccChunk 2 2 rest, iccChunk 1 2 start] jpeg + profile `shouldBe` CPDisplayP3 + Right (_, profile') <- decodeResizable $ withIccProfile [iccChunk 1 1 $ iccProfile "RGB " $ 33759 + 2 : drop 1 p3Primaries] jpeg + profile' `shouldBe` CPDisplayP3 + Right jpegData <- resizeImage IFJpeg 261120 img profile + LB.toStrict jpegData `shouldSatisfy` (displayP3 `B.isInfixOf`) + Right pngData <- resizeImage IFPng 261120 img profile + forM_ [jpegData, pngData] $ \resized -> resizedProfile resized `shouldReturn` profile + +testPngProfile :: IO () +testPngProfile = do + let png = pngWithIccProfile p3Profile $ LB.toStrict $ encodePng transparentGradient + Right (img, profile) <- decodeResizable png + profile `shouldBe` CPDisplayP3 + forM_ [IFJpeg, IFPng] $ \format -> do + Right resized <- resizeImage format 261120 img profile + resizedProfile resized `shouldReturn` profile + Right uri <- resizeImageDataUri 14000 img profile + uri `shouldSatisfy` ("data:image/png;base64," `LB.isPrefixOf`) + uriProfile uri `shouldReturn` profile + +testProfileDropped :: IO () +testProfileDropped = do + jpeg <- encodeRGB 90 Nothing gradient + Right (img, CPNone) <- decodeResizable jpeg + Right without <- resizeImage IFJpeg 261120 img CPNone + forM_ profiles $ \profile -> do + Right (img', profile') <- decodeResizable $ withIccProfile [iccChunk 1 1 profile] jpeg + profile' `shouldBe` CPNone + resizeImage IFJpeg 261120 img' profile' `shouldReturn` Right without + where + profiles = + [ iccProfile "RGB " srgbPrimaries, + iccProfile "RGB " $ 33759 + 3 : drop 1 p3Primaries, + iccProfile "GRAY" p3Primaries, + patch 128 (bigEndian 4 9) p3Profile, + patch 160 (bigEndian 4 209) p3Profile + ] + srgbPrimaries = [28576, 14581, 912, 25239, 46983, 6361, 9375, 3972, 46787] + patch i bytes profile = B.take i profile <> bytes <> B.drop (i + B.length bytes) profile + +testProfileAboveLimit :: IO () +testProfileAboveLimit = do + let img = ImageRGB8 gradient + forM_ [IFJpeg, IFPng] $ \format -> do + Right without <- resizeImage format 4799 img CPNone + resizeImage format 4799 img CPDisplayP3 `shouldReturn` Right without + Right kept <- resizeImage format 4800 img CPDisplayP3 + resizedProfile kept `shouldReturn` CPDisplayP3 + Right uriWithout <- resizeImageDataUri 6399 img CPNone + resizeImageDataUri 6399 img CPDisplayP3 `shouldReturn` Right uriWithout + Right uri <- resizeImageDataUri 6400 img CPDisplayP3 + uriProfile uri `shouldReturn` CPDisplayP3 + +testJpegProfileInconsistent :: IO () +testJpegProfileInconsistent = do + jpeg <- encodeRGB 90 Nothing gradient + let (start, rest) = B.splitAt 100 p3Profile + forM_ + [ [iccChunk 1 2 start], + [iccChunk 1 2 start, iccChunk 1 2 rest], + [iccChunk 1 2 start, iccChunk 2 3 rest], + [iccChunk 0 1 start], + [iccChunk 1 1 ""], + ["\1"] + ] + $ \chunks -> do + Right (_, profile) <- decodeResizable $ withIccProfile chunks jpeg + profile `shouldBe` CPNone + +gradient :: Image PixelRGB8 +gradient = generateImage (\x y -> PixelRGB8 (fromIntegral $ x * 4) (fromIntegral $ y * 5) 128) 64 48 + +transparentGradient :: Image PixelRGBA8 +transparentGradient = generateImage (\x y -> PixelRGBA8 (fromIntegral $ x * 4) (fromIntegral $ y * 5) 128 (fromIntegral $ 255 - x)) 64 48 + +displayP3File :: FilePath +displayP3File = "src/Simplex/Chat/Image/DisplayP3-v4.icc" + +p3Profile :: B.ByteString +p3Profile = iccProfile "RGB " p3Primaries + +p3Primaries :: [Int] +p3Primaries = [33759, 15807, -69, 19135, 45367, 2745, 10296, 4363, 51385] + +iccProfile :: B.ByteString -> [Int] -> B.ByteString +iccProfile colourSpace primaries = header <> bigEndian 4 3 <> B.concat entries <> B.concat tags + where + header = bigEndian 4 228 <> B.replicate 12 0 <> colourSpace <> "XYZ " <> B.replicate 104 0 + entries = [sig <> bigEndian 4 (168 + 20 * k) <> bigEndian 4 20 | (k, sig) <- zip [0 ..] ["rXYZ", "gXYZ", "bXYZ"]] + tags = ["XYZ \0\0\0\0" <> B.concat (map (bigEndian 4) $ take 3 $ drop (3 * k) primaries) | k <- [0 .. 2]] + +resizedProfile :: LB.ByteString -> IO ColourProfile +resizedProfile = fmap (either (const CPNone) snd) . decodeResizable . LB.toStrict + +uriProfile :: LB.ByteString -> IO ColourProfile +uriProfile = either (const $ pure CPNone) resizedProfile . LB64.decode . LB.drop 1 . LB.dropWhile (/= ',') + +baselineJpeg :: Image PixelRGB8 -> B.ByteString +baselineJpeg = LB.toStrict . encodeJpegAtQuality 90 . convertImage + +imageSize :: DynamicImage -> (Int, Int) +imageSize = dynamicMap (\img -> (imageWidth img, imageHeight img)) + +shouldKeepAspect :: (Int, Int) -> (Int, Int) -> Expectation +shouldKeepAspect (w, h) (w', h') = abs (w * h' - h * w') `shouldSatisfy` (<= w' + h') diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index b93e1c8c8c..4b59e84f74 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -12,6 +12,9 @@ module MobileTests (mobileTests) where import ChatClient import ChatTests.DBUtils import ChatTests.Utils +import Codec.Picture (PixelRGB8 (..), generateImage) +import Codec.Picture.JPEGTurbo (encodeRGB) +import Control.Concurrent (forkIO) import Control.Concurrent.STM import Control.Monad.Except import Data.Aeson (FromJSON) @@ -19,6 +22,7 @@ import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import Data.ByteString (ByteString) import qualified Data.ByteString as B +import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Char8 as BS import Data.ByteString.Internal (create) import qualified Data.ByteString.Lazy.Char8 as LB @@ -31,10 +35,14 @@ import Foreign.Ptr import Foreign.StablePtr import Foreign.Storable (peek) import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding) +import qualified GHC.IO.FD as FD +import GHC.IO.Handle.FD (fdToHandle) +import ImageTests (pngShortImageData, pngSideAbove16384) import JSONFixtures import Simplex.Chat import Simplex.Chat.Badges (BadgeInfo (..), BadgeRequest (..), BadgeType (..), generateMasterKey, verifyCredential) import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatDatabase (..)) +import Simplex.Chat.Image (maxDecodeMemoryMB) import Simplex.Chat.Mobile hiding (error) import Simplex.Chat.Mobile.Badges hiding (error) import Simplex.Chat.Mobile.File @@ -55,7 +63,10 @@ import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import System.Directory (copyFile) import System.FilePath (()) -import System.IO (utf8) +import System.IO (IOMode (..), hClose, hSetFileSize, utf8, withBinaryFile) +import System.IO.Error (tryIOError) +import System.Posix.Internals (c_open, fdStat, o_RDONLY, withFilePath) +import System.Process (createPipeFd) import Test.Hspec hiding (it) mobileTests :: HasCallStack => SpecWith TestParams @@ -89,6 +100,14 @@ mobileTests = do it "should parse and sanitize URI" testChatParseUri describe "Badges" $ do it "should generate key and issue badge via C API, verify credential" testBadgeKeygenIssueCApi + describe "Images" $ do + it "should resize image to data URI via C API" testResizeImageUriCApi + it "should resize image to data size via C API" testResizeImageCApi + it "should choose image format by transparency via C API" testResizeImageAutoFormatCApi + it "should resize JPEG with a side above 16384 via C API" testResizeLargeJpegCApi + it "should resize image from a file path via C API" testResizeImageFileCApi + it "should resize image from a file descriptor via C API" testResizeImageFdCApi + it "should refuse malformed PNG via C API" testResizeMalformedPngCApi noActiveUser :: LB.ByteString noActiveUser = @@ -346,6 +365,111 @@ testBadgeKeygenIssueCApi _ = do cred <- ffiResult =<< (peekCString =<< cChatBadgeIssue =<< newCString (LB.unpack (J.encode req))) verifyCredential publicKey cred `shouldReturn` True +testResizeImageUriCApi :: TestParams -> IO () +testResizeImageUriCApi _ = do + (ptr, len) <- cBuffer =<< B.readFile "./tests/fixtures/test.jpg" + uri <- peekCAString =<< cChatResizeImageUri ptr len 14000 + uri `shouldStartWith` "data:image/jpg;base64," + length uri `shouldSatisfy` (<= 14000) + (ptr', len') <- cBuffer "not an image" + (peekCAString =<< cChatResizeImageUri ptr' len' 14000) `shouldReturn` "" + +testResizeImageCApi :: TestParams -> IO () +testResizeImageCApi _ = do + (ptr, len) <- cBuffer =<< B.readFile "./tests/fixtures/test.jpg" + let resizeToDataSize png = do + Right bytes <- B64.decode . BS.pack <$> (peekCAString =<< cChatResizeImage ptr len 100000 png) + B.length bytes `shouldSatisfy` (<= 100000) + pure bytes + B.take 2 <$> resizeToDataSize 0 `shouldReturn` "\xFF\xD8" + B.take 8 <$> resizeToDataSize 1 `shouldReturn` "\x89PNG\r\n\x1a\n" + +testResizeImageAutoFormatCApi :: TestParams -> IO () +testResizeImageAutoFormatCApi _ = do + B.take 2 <$> resizeWithAutoFormat "./tests/fixtures/test.jpg" `shouldReturn` "\xFF\xD8" + B.take 8 <$> resizeWithAutoFormat "./tests/fixtures/logo-large-rgba.png" `shouldReturn` "\x89PNG\r\n\x1a\n" + where + resizeWithAutoFormat path = do + (ptr, len) <- cBuffer =<< B.readFile path + Right bytes <- B64.decode . BS.pack <$> (peekCAString =<< cChatResizeImage ptr len 100000 (-1)) + pure bytes + +testResizeLargeJpegCApi :: TestParams -> IO () +testResizeLargeJpegCApi _ = do + (ptr, len) <- cBuffer =<< encodeRGB 90 Nothing (generateImage (\_ _ -> PixelRGB8 128 128 128) 20000 16) + Right bytes <- B64.decode . BS.pack <$> (peekCAString =<< cChatResizeImage ptr len 261120 (-1)) + B.take 2 bytes `shouldBe` "\xFF\xD8" + +testResizeImageFileCApi :: TestParams -> IO () +testResizeImageFileCApi ps = do + uri <- peekCAString =<< withCString "./tests/fixtures/test.jpg" (`cChatResizeImageFileUri` 14000) + uri `shouldStartWith` "data:image/jpg;base64," + length uri `shouldSatisfy` (<= 14000) + Right bytes <- B64.decode . BS.pack <$> (peekCAString =<< withCString "./tests/fixtures/logo-large-rgba.png" (\path -> cChatResizeImageFile path 100000 (-1))) + B.take 8 bytes `shouldBe` "\x89PNG\r\n\x1a\n" + (peekCAString =<< withCString "./tests/fixtures/missing.jpg" (`cChatResizeImageFileUri` 14000)) `shouldReturn` "" + oversized <- oversizedJpegFile ps + (peekCAString =<< withCString oversized (`cChatResizeImageFileUri` 14000)) `shouldReturn` "" + +testResizeImageFdCApi :: TestParams -> IO () +testResizeImageFdCApi ps = do + jpeg <- openFd "./tests/fixtures/test.jpg" + jpegFile <- fdStat jpeg + uri <- peekCAString =<< cChatResizeImageFdUri jpeg 14000 + uri `shouldStartWith` "data:image/jpg;base64," + length uri `shouldSatisfy` (<= 14000) + isOpen jpeg jpegFile `shouldReturn` False + piped <- pipeFd =<< B.readFile "./tests/fixtures/test.jpg" + pipedFile <- fdStat piped + pipedUri <- peekCAString =<< cChatResizeImageFdUri piped 14000 + pipedUri `shouldStartWith` "data:image/jpg;base64," + isOpen piped pipedFile `shouldReturn` False + pdf <- openFd "./tests/fixtures/test.pdf" + pdfFile <- fdStat pdf + (peekCAString =<< cChatResizeImageFd pdf 100000 (-1)) `shouldReturn` "" + isOpen pdf pdfFile `shouldReturn` False + dir <- withFilePath "./tests/fixtures" $ \path -> c_open path o_RDONLY 0 + dirFile <- fdStat dir + (peekCAString =<< cChatResizeImageFdUri dir 14000) `shouldReturn` "" + isOpen dir dirFile `shouldReturn` False + (peekCAString =<< cChatResizeImageFdUri 1000000 14000) `shouldReturn` "" + (peekCAString =<< cChatResizeImageFdUri (-1) 14000) `shouldReturn` "" + oversized <- openFd =<< oversizedJpegFile ps + oversizedFile <- fdStat oversized + (peekCAString =<< cChatResizeImageFdUri oversized 14000) `shouldReturn` "" + isOpen oversized oversizedFile `shouldReturn` False + where + openFd path = FD.fdFD . fst <$> FD.openFile path ReadMode False + isOpen fd file = either (const False) (== file) <$> tryIOError (fdStat fd) + pipeFd s = do + (r, w) <- createPipeFd + h <- fdToHandle w + _ <- forkIO $ B.hPut h s >> hClose h + pure r + +oversizedJpegFile :: TestParams -> IO FilePath +oversizedJpegFile ps = do + let path = tmpPath ps "oversized.jpg" + copyFile "./tests/fixtures/test.jpg" path + withBinaryFile path ReadWriteMode (`hSetFileSize` (fromIntegral maxDecodeMemoryMB * 1048576 + 1)) + pure path + +testResizeMalformedPngCApi :: TestParams -> IO () +testResizeMalformedPngCApi _ = do + refused pngShortImageData + refused pngSideAbove16384 + where + refused png = do + (ptr, len) <- cBuffer png + (peekCAString =<< cChatResizeImageUri ptr len 14000) `shouldReturn` "" + (peekCAString =<< cChatResizeImage ptr len 261120 (-1)) `shouldReturn` "" + +cBuffer :: ByteString -> IO (Ptr Word8, CInt) +cBuffer s = do + ptr <- mallocBytes $ B.length s + putByteString ptr s + pure (ptr, fromIntegral $ B.length s) + -- Decode an FFI `BadgeResult` envelope, returning the result or failing on error. ffiResult :: FromJSON r => String -> IO r ffiResult s = case J.eitherDecode (LB.pack s) of diff --git a/tests/Test.hs b/tests/Test.hs index 8874e08211..e90f5fa296 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -22,6 +22,7 @@ import ChatTests.Utils (xdescribe'') import Control.Logger.Simple import Data.Time.Clock.System import BadgeTests +import ImageTests import JSONTests import MarkdownTests import MemberRelationsTests @@ -94,6 +95,7 @@ main = do describe "Message batching" batchingTests describe "Operators" operatorTests describe "Random servers" randomServersTests + describe "Image resize" imageTests #if !defined(dbPostgres) around (tmpTestBracket chatQueryStats agentQueryStats portBases) $ describe "names tests" chatNamesTests around (tmpTestBracket chatQueryStats agentQueryStats portBases) $ describe "SimpleX Directory names" directoryNameTests diff --git a/tests/fixtures/logo-large-rgba.png b/tests/fixtures/logo-large-rgba.png new file mode 100644 index 0000000000000000000000000000000000000000..b42ccaefa183c547c2f7694636e88ae59349d907 GIT binary patch literal 28676 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%0DQc9y6ZxLy0(-CNlwUKv zGt|}mpw|x*YVNb<`zjQu-Mb!mcjuPnZ#h(AHE+&6$?yL7U~Z`L)h!y@)h)Z^i?er+ zm#%ou0+IAHlyZQJ4L{(J@%iK}ALeO&hkbQlkX=>Pls#ryO^#hSSs)`FG;ba@w)n=< z&BUa#-5dc`Dtvp1Mu%Y|*KQa(?mUHzO{h0`jzo~Jh}>xY*NJo6pO=3v7QXe+%Bp`l zo~#uLr22F>NoBMJp>`SIb69Q2s6 zs^0x_TrX{{1@SPItCgh=g;c!_KM8DLPA4xZd6* z$S80Kumc4e{eFFYr-t6`-=43tA)Gy@4f_XtOb+aDLgnz4^h)Rd(!9@qs4g@pVq_MlJ3DuuoH=_ge4h}Q=-&4qUTi57CW!u{BB5;3BO-*y_H#mRV z8S&U*2s81NZA>S#H0oDjfLRU<&#PEg58Bq~ma-&xfjm=TPB$0<1j`HGv5`I&7Ixjk zQcz8=fbH>hd>zcIFK;?VGy0@I>hEU@i>0k~IUV*69<{BUE71z?@Vm6$*z#euSYHDI zo-01m)Wi)Qy^`k)d8_H*plMNq9PMZC17Dr){>l30F{$XnNP#xnnxUa#oXWSv#F?Ce zpBfah#goZiClei{D%e3c-d&#A&($gF-7XgEzHV)+iPAo#Om=$_XJUC`r>yA&v~Y-TI|>U1N%-76O|&p>OP6 z_{W`zSMI%Sfd9R~Auaee-p*%{InE#ZNJ)K3Nx_3uxa@mMVPF6+4>Cue_NW?L*lTI2 z29`bNc|wE{4>hUOL7#8m-kxx$@5(F4wcHJ%*=qE(bvj){2Y5!+)fq(OtN3`Wrg&*% zX@M|O2H)Y^1PVgUpyq{%0DaFxY^kx21_p3A{3)azgmgJNU<7W0=WI#v}@Zy>0kn9mZ?7OJ|*othYOicttKB(|j-K9j07ZHDh1i zEShFQtqzIIOB|j~pMWLg0`14X?@UQC@uAANIj{CJeR|Y<>$F>?H2G@?bJLj=6Z+12 zCkAHua&n%m6V@}~6R8Y)txNP_y#qhWu?>TcmwQ9DDR>I`;sO>cc!#|-Q;xN>3?z6x zx(*(>(s$cIq>O&nPXK(x+oh4W`uavjOKq^ayhk{J#l=nqrG@2A)-S)k`O)6))O4NQ zWjjR|yZ-2r&p7Ww&4IpPNn~6!dwI#oZtNzvIZXTP;xz5m`Ej>A9B%e>_|&ra(I&d9 z!avKyw>f6ec0kCxq^>Rz+IMs`($c5Iqvhd66NE(*4ncg9dZVg5QNW#FP(8aJ9!H=5 zTv~dh)rn`|CGb|~gX>!FtAs8xN-~~LM?kX4K1t7SBfnBR>j)y!*K{7|XhB^V>>KOw z%pUkSF4IREj85eqJO6#H*s9WCxF2XM|NLJ@SPlsN?*!vP$IFyJEXptwE-X9_qK*%^ zBs$b#^+fa)FaNg`@_+G%IdMA@;HYWjMr9wI7KsB2@}D;evO|BGa*c{Wf4Haly(lio zF5}ar_~~e1HWrq*Fbhrwqp@y`;@@Q^cdP^0Quh6vQjq24I)YVslAY;Bhl_Jhy#{*= zL#c-{y!aUL0Z=BT_@C^}bRwp2@7F|--E=TF@!g5p==YE5ovLRQ5s8%?JrC$=4+_dk zvrgLTM3BNnjhYVh6@-MZMa343GH!o?eqsRfk|@J(>+;)!S=ywXh0r|()+GhLH2Al_ zma%%s-wxJp^uwEvD4OrPquFZuXFn<-6i-exp52Zd=X|qD#VfHBcBg}XbWV@4xW7mi zT=^u<_nb+bYwpTU%0%7wlTP`8fg#-OE^JVQeu)|cTjte}wfoVLOK_j7=iKsetKseP?Zu~t#urJhMYBEU^OL)~bZ4Na zcPfU}yu^NGteiSko$V2Cp1A8S=R)08N@Ex?3UzX0$I!@Z*p%LJ{LXKspV80z@Ek^= zbho*_Y3P01fd<8!!kYHB_ouvhm>92HuTj@#zbrdgk}XzEg>_4`5Z`TW?G*Be9ZXVc zE!qPuwj0czX5)eSz!g`SbvhBMD8%K|(X2@(v;PO-ioKb+mlxj!!-wynW^$IZy2QmB z*mpG(n+-S~jxKgrB=NPZVY+% zn^p>(De1R&5%02TzSrR13`rLyn&DH6^`zRFJC=FB`P;tDrS0=D*y$oSXSv3UN3O|9@gL!6 zPf)beKh%6jy&a|e`LP7e)}fKUHX{|?pbN=n0M%9$YH#?ZehFY)GmFY)9GZTFG%Xfi zytnW(U6qi?p}&{jWsPveDL4cBq+}GNU`e>4eo(U@aMDK2Qp^TOh4mn6!0Hw zu&Om!;D6$+Sm3iftSizy$@x=Z>~PVar@`t<(1_3*3ows`zomRQgndav`k2cL-O->T zfmUrXx>$Hk+USs$oh zaoOFhl^iE<$53mF-;-N;$!R(`N~xEX@o8(P>c7=c*Yt_T3axQzlg6LjN97seNC7}G zl3AGyz}E&FOtpqcrd0p#j5%Ie-b_7(wan?l~J(k;abDs8ll&E^` zbRDQb^>8*`Sb|gLb~7W6_B?jp51EvZmX*(beHQHmC4ko%ZwCg453sRmm3+Vh*tJSe zoyQ&T;hyU1x`r(Wt6fHl_@z4aWfgTArCLRr$_6IKp2gGbkD@ep{jOibp=DS8yyA<7 zYT_qMK)P5K1I1R>%u|z@k;MJh{bXq2`yPBNhR>@@Xlo@GCEbleUY~JN8_?szX{4z= zUEzHz{~sT#0GGLS^51)<&ne+}C08z}rLBG61mEmhxOv*cd-@=H>CX5eV83uZa1{+> zpSHg&-fy*%-8tob?B?mFf3(QXEQJxo#HY4&nUBJ|Vm()`yQ6-H-Co6$nm1%+AnbjU z_<>owH|eV3Uo#$wP0dbu%hhA0G0>N%h`51Iw;;w&=xP<51RX)OAnI&ty=TfQIU{5C zzVhktEfLT@7XS!K7?>T`T{g0G#toJA@y@(?Lhi0q^7NO|PKZ`M0Lp<&i(5 zPPjJuUpd&bbA){_e`m|EzT>K0UvuGipE{*FZ#~rN{8&XrQMkI=bkH0=W=-TXCS#Pg zD`rVFYM)wSp&`ygYBV*Fa(?o~%bI3#Kk2<5@#<>F<@L)J@t8Q$+{q7I`HE4N)M2L7 zQQ0&*jQ-;rSvj$41ZE;~j(6*E_f6GD&4L^D$h#g!AKnSu+_ncf3Pd`PEVm*VmE7 zR?@DfadX$W!R<581cY1cHopV@j7E%75cxR8W}8+;0DFYBFdR9=AnI!Rw|4@J1WR7~ zIAM8m>LHfj#-PwaqZ>JMSsl|jM?eyUR)hrcv-avDzP(p#y-wb|1mj_U z5v^dUeDM@@Ot*aSD}jdbma`7a=>4P0zlO+F1`h=Tnbn*GR>%8u(jX@Eg_5YxLAHGE z!Q3-cXCW^baW;hILM2c~*qbo)`HRKW8i^lWW)zFeP`>9cVU>8Vd1G0gG8yZ7F9i|> z&uVCV(3>B5`+gciMd!ft4VOfbgqWnk#msi_d?|Crc*P#?LEM0Kp0=}K5K}AccXCRI zb$LXt*VV6gI<3~Td<)l91Aoi;+=iepuYzS3u>6FYb2W^v>|2XB=Wuv8s7W4U zAl`O)MjAH1p(N8dJJAn-My|>QGq2UaP`(BSiTVFL9EY-mcYWtRF)0N-(L^?Y4Ks!N z*~$fWQ(dHL_5H9)OQVr*!K5xIxbIUw?6*F6RXpv~H^ixPc4w!RHfYTg9*XOnQ!`=tusBBEs)_>HA8#orQ+h32fp}RZ2zD`Y$fc?6 zfsG8__2s8q&pXz8FPl8Q$!m9)kWcCrUpZ}SY@L8-6E$FS*Ioa|r{t7@%!v7NgC!HZTM#iGUVP^p_b3v$A@` zZ9$4O`ptyhV`4vFn_gYGpT`E{oi$7OWH)sPN}LQHIk7Zn3foD38oPlTrNRcL6tp|x zqHDWmc4oK2eqt*!&vJzHTpJcN&KHZ+1XOkN%pbibg9ZXFp=^tuu32KW02&wq85{v55kpLJLQd+h_+sNjB&ErA$mrZoCa!Xg z{Oqc9^27upo8E=zJqNbvkuLjj`ivRA_hBj$cKoT|+XCqP=V-#V$>z7$1vh;f8k^DA zds&`)bM>3wrI{0;Oav7ob;mj5>HM#i`=gZmhcbs3nssEbwU+H@n&A|3i#@?O}-yy6gxW%zS(x=+H{86c0dX9{;H#QW?*F-q2{XGE9Y& zT64c#(f+~33Mh#n7A}BN;oE z4s$Fuvf})g{>=8DC9VD&OLYn-#;>y+NYul@RAXB+VQl%vk77nxh;ZpcNl7`sp1aQ8 zsPXlQ-R7%e06`{wmMP6N2(7m~G+jUdW*;@8rP&o>p%@yISP4k7nGY2of^imOX3al%7+ve354!a?BZ;^{ zg!>d5Ad~vgQp$z@MCHB8YYdR(w&S-2B=MO;6gP=K1pz@7dJf9Dm|%e_VWU4Ol#Vxz z)2XTTr;qPu$!nbQLAZQjVq`Y?K}g090W07?pjX-#K>H3Sdl?2=&pCj4JiTlIRlD2MGecmk_*wxjh zy36DCRRhl*KDgGl_AdG1r3TVw^0_U|YS}W&=4VOy!Vu{I{C_89urF4H>?-RFt{W_Z z(~wOj>e+S62r@pAcVs)kQ?EO}gbcv8$OAu>`$(r-i z6yH#2NGkk|B49|-(Gg_y_4ty(kueOJ+WNxS~E|l z8_Z0A=HgII;+t2MQEN+ZII)?|oTXbY#bdr=>$qD}>ZwJZ;q4*wN7~c0*biUvJzU-# zt+tc(ebc*CvFkygliemb^{BC!UxAdtu+G@eQMCo>=y`gCABUfI^`|7zrh^j~tN6%R zM>Fk^wxa@-BdDi+n`rn>J^yX5ZYne&9ym@nc6Y9P7#R148f*sBDDd3?Q_CPpbqL}G z^BR8R(=cibU=p$D2eul9Y07sQ}HZs#PM&!_Yq8s*?J&qAS?4|k_aCSUdr4cZ=?pwk>cc-lZN`u1sk|@LSXo)AZ4TWY&mSg`noo^x>cE(hXu ze`a7$KtjDV$craY1LcjTX4#I77=mxrHIp)3{(f5(U+s;IO{>+`()9eiJ<&9@VdOVo z=|(CuE){t9KT!jkgnnm)S+?{Y^5gdT4lQj;`G@=6GOU{OSeMd|MWey(xu4_P>7jb19)Cgzlyj@Db_6$;>)fcS zxt*qL$WG`Y^XW# zt=k$nsu;=7{G_P;w$8F+igMTYc7NyY)|-?8QMEVEkATj>4(jtWzlynef7CEPwPmTU zpM8Gp5#GG5tq#e+>oTn|e9R{KAN0itp!%c^0hro`Sw%U{7P|Hfd`N_fzo}@~M&1rq zowCUi^jUnAwhyOd{}}hcOt;_EJyCOezMRH^9{)=(mZGAxnP>r>;KQ8A)DTRCIm5TkEku2O6%2GDHrSTC=hX-sh{r z=sz>dgi%mZk}!(0iJ0p8D$Q&aQNX2L}g(e6EqteocmmCzt+j`{RFIm6eroq9=;B zwmY6na6!=R7AJ96=XE+{(7QuVu42?zr-bsASWC6KkM zrmD*FUHfrz!lq7!mw#r^YNLN>NFeuYeKy6leCpC((nT2=58df%FR!ja&!K80vXPiB9TVUfnA`Wr zD6>YnjnjmUT5Be0PQuB2NEl{XLvv|0ZM-ZLM<$_tOJ%b&Bi1jeRX`Z^A506N%E}Ty z&8@Yw-DRMkF83Tx^ixtv%KCcpy*9-vr=3Ze3^Ti|%mk^XPZQPRw16BUB#sR$Z#C$u z;E+QMOYok5+So>-NfDB$!|6uGC8zmiUYZG;saziB7IwgLP&}RKM*uFZU@9S7q z|5gEhV@Bgw`1T}hI5}B!C$<|DY@X`E8nO}O`9l*EQ~GZKUqn z=DqP-TU+Ar>k1C^PI0*(YSyYJj`0G8;^N|B!O#BwefdZDV^DZI8HDp7RqX)eiTsu; zt-u}=9`GKl+uQfoC=}|W#7B+C2@JRxsiQuQTg$DbN6ws{5VFLyZcfZT0INl|bA^qKbO6WI z-Y!>$*)Kg}?ua3xKG*sM^Gc!j`n7cvmeu@DzHygzfwu_ni@m84Yv*JQEzN=TszkqL zJk%kQnXkah!ijshs3_yv z?iPXarELbs9@+;$@FCI=267zt+);)<18hZWB|Kcudj|#vjRnBUuo5;#06rQ1zqtoG z{QUgw?d_n-#cgwl_`w5mxk}Jb04}R(bPfJHGUDSUA+pBC#&U9@&!Y?)KHBK%>Fdw? z8l(*hSPg-7tbzjP$OAF0g>)q=(p%kP#v*P)E>n zdUJ4#=fmpZR768hjSswlCkWo&6o>ruktcDQt2_OR4=nuJDRarNiP}LH`+%bP!_LP; zuFxNG$-b}5C6|779srVGLXalf<3%7RkrhV@fHD4*GR52L&L#{2S(?3)2`u}xsL$9V z>nZBX;np9#3QEUaZ<(jIqo!`^)+M+l%C!n<&%=5nUY5#d2-uILES0!wRp{;qf>{|d zUYLVySJ$rk%gbk1Gk836-I=Z-`o?dr`8EFfHe)4C1#y4&%X~cl9ZxfMa!W*uqeZ>m0iO|MD3;>?s zlhOJ$$#1JS^Oi;lN#3G=`gHgKa5O#zmhAF1*h$Ul<;av7gq^Q0b_MQlic?a;)3mBx zc2QHwhU68ge^ZZV3~Yia2xs@K@=^Q9>Mr3d_$k~sWhVXgRsK+>c5xz|fYZDWli5|j zd+@c< zDtpp$(@Pa1SKUPjkPNlMgg&qCdxYmVqU)n|`Q6WV{pM^dXU}lWRN$*dH`3)9AkI@voBrjP4;JA$jia-@k|Z`g$BJ%+|YY z#>dCo{jP_jJ=7`aXldm?(|^mzKo-8wZ)yll|n+N4Z?}FD4|P+1bluhz^~Zcgcp` zqyhsE9BHd3Jy3h0cD7F>PpSN2CJO}-6~)?o`yL7wn(m);o&$V*J8BtXOZ;5j!#!5V zRz?!%YaN93*}s~a3hMsSXeM}{j}&Oa>aX01V{a44F~0x$)l~3nGPidBZ)h}2&TPAj zZ7v(EFnsVp$d!W^D)f@=WT#orKwSw;%pixV2TBb-XNht~z03NU3B;$ANAFONjudzb z(PP~C$#vyQN!_ilYf53<5p}F>6XrbW0OepE?Jw3qwZX;3xtI_0z;Mqz(!l4^+VZV& z8^88NW$R!q9jclL$t&(Vn<}Qhf-x-0Lj*vfoVTdBs8soycb1i~>vM1ml@bMX%@2de z^qkWM6}l=MPn}B2XKNjw?Lr=!Hi;o$JbE}LR(P?6-e=)edLQ$_=k~=4f1XO3fX&!X zu$BLBuc!Cw0uE@+T#dyf;jKLWXG%q7r4Noy9q#2#X0m4X7kpmVex_e&*58CkNjlCA zER~@hJMR8e>8Z;uX71?5#WBsF7KiSWRbVfE$#O?j&la&4$L-I^lK+fT?s&eIm|pSl zO`JM;qsQkbHk@6{#rf15%4fI(QRiqcsV!PMVOvE{tCcE7l=)^-k35cQz0-H;?q7CT zbL&CkgT2BY2NvYZvK|C-WcVSr(d{gAlgcoQ$t-a{T5Ca2A@p~>uCZ|A4P(*>tmG=^ z2SZTPVXKJO_0I{^XRQ%GCp+il<2mX+}N%%Qt^FzVrC3D=pycu8kO2McvM>GeVO_WF^Rk0`nrC%RfTUJ=H5bKN=Z zno1@6t}vN|al-e^@w+m6C6Hx@PZIzQWRh9p5N3@hYTf9`zUgAQ^@AsV+o;pxeQLTN z%M`E*i&Q`WWa1>A5sHDGPdn!Up|->7CQdRbrcvfddIJA`$v^Kz!@&F)+6@mda+mOa=KW2&)Jql`r38^kyr*@Aoi$o%<5l@t8 zvtk!sVG~2;;wi8wz~xA7G!#FQY0J)12OENIsu-1LgTNVs)nQ|RkTZqX@(Z>UpfpaT z#7lt#{Ni2vBB=x&;ZtHJv_@pxq~@k)YOZG6xniz^Kp-GlWmLYqgv5%0E zW$cW78~gg+JKbNP-&~FJbsa}O!BLNz)3J}Y-D(hXt?BxSi)lKP0gIS79Uo~(7zH7f5v`^ z=K*ixMi+V6Nw;U~a9VaqR+h{sdxm-V*sn40XEJWkG++(K)_yYMm$mj*jtl}`3NW^~ zcf1<^y`*~K&jbGZLV>#wYUn=~FO?$h|NED}^1+}p|J*xZJ(k5;^RWSCyx7Wk;Z-h)JUnFNpb<8(u{@~x;n8fZX zhRBBgav!x?EGK$010NjXx0o4E1y%lyu@|2tl&A}v=516sDa|kGD>-?lyT_}m^2g^d zP5*eP?9itEsvUDm^IrrjsG1-uI=s`aT5RQ#rsAr2E}fK}ot#o+Q)p2auew4SXM`s_$0;T2E(f#ugokTOf6cGBAjg9RAzvA!vFJ4Z3S8uZy zN@O^_^k3k=_#~=v*`%m#SZRA-hRmIQigfR6&=e7SMYhetRr>Co;8~cyMt(KQcGw85 z&y_kc;4ri^wr8kkIO)G1ru<4IXyv_o(5`#P{LK&mh7$P|lC4HI_kA~hFrAiI{XpvW zbP>9w-kr%mZ2SD0T8_`Tdmqx+aNB=aD%gADw&#@d0nEk@+K} z=VZm(S%Kl*pBzCIvSE4a=%y=SRuGzriz8?q9iJK$`S-Q}X8C@_V6 z7%aC$tf*#=|B%27z51ln*mO>hJ5xS|pnIE5aSSJz_y4n+0~MSDaG&t5F{Q)JSd*8FcfE?<%Pb23&K7l_27#c6x)~dc&4{$ zt4E=Q&k!jQ9^tX;Zv&+!+kxo#)0$_-Jzg0^R%fdd0R4J)8F)Ft?Qy+M0fj;}89m&W zz3Ml<|Kvb;TvO3VBi2Z!)Bj+X!0&BJATP;~buVpq3v3Drc6tW76t{WWUUU=!sUlVi zpA?0OdJ*hRjS8PknVX>Dba@v&J_^y3K^I?vZ9@8COwr|XYkD5cG#*^`TG_G(yUBk_ z5xXhM4Lc_nuBWGGc_c6}O6n<0ev7s8`C*`0oY^??wqd8c=u5TRc46~Lk5!=8#tyyJ zX47~e*{oma@Z@!H;7Fu={wK$%^A`y6gX0y5UG|)rKif-V z>*=xC$HmiE>exdnC3S-y3%iCO93B!{5^T0$FohAPFAx9Ek*Gir;qUihcUKjGD>0g7yzKPCwOp#-+}elCON&W0Kkv5Z-?&x$)b+ zW8(`_k>yG1y#|7NV6Pu}^nfz5Hsp26)^HTxxZlF9Q|<^QvtkCsMDtwi@VOn)lE5iqj_Bna|6Z5$hbUK`Us&Y zDv4(E%O4o4neCMz$k>v4mt@|CmBBDmpl&&me_jr5^>R+yIMPOi`S;IWHmlbY ze2O8>FA~MQ6knuo+wx?%Rw|uFgh=2SB^TXabpLEEwrZ6f#|3O%FgmeYnjR58>>JGT z9zAF_DlsJ%IJ~zyo%1ahI>qibAV~(AU0q$4NRqDaC~o6NRMpXXVYUoedaQOoz9eLT zBR|U}v-UJJs$n~m`wr%0Kh{cd`=g`Lo%XPDAO=E?JMLAk{Mqk#+ql)$uU{~TkdP>^ z8m*j+eD98^91htp2-#cGKicrr2(EMqI!ewyDHd}ey+K%c^y(0~yisC4UM@$i%Jq}L z69?`_88It0! zf7FY8ZFLYinBl)!&SCpnvEkTkeE&z63q=#azO6_4O0yp?&8;29y9W)1G=^-@_bBcz zmq(tzV7juS_LTTkXz=GZ-af*}OUKC?OPx!a}H!#OQt z_%NR51R9m6@k8}Nt?~E9-Z}ae4Rn+h54nir9UL56@fw-lu!1LohLxsw5m>b6$}o;l z-CJ6kGieP2dE2>oE&uMSVSIf`_a9XSFE z(Db(M`HvxBQQoJyo-A+&zc{X`o-Dvcl>k%i$Q`mQc6#))gdlA;elnI4tEqhDIpKKP z-bxyu5wMeXdfZrFdrvXwxaMZ|Xam?`18YIf@0{C2vzRvk9a5?1o^_xlF#^cX7 ziCL6Ua^M`rYHz4#?b~eS#S*_+b$Wcst^7~-Mm=`3k9(tD8wb2tqsjIdZf`t1@t!#2W@yL$31q_EoA#4%QYqy+-BCbPSyJb>l*7`v2_DH`s`m2)V> zYMybK?9JdsG=|bTj$UYT%kN+C@>w0N-tN_0s}Hcnm~vgaCfHfl?`DQ57otoiBCX@H zJ|-6#l@KU_K0Z4+QP<^m_j^hPm(Yo#3>1Z)KChAK*Gw71^ImdJ>CRYo+Xf+BF!f>O z^^5@R;5$z4qn@kSmG_qRu|g7p)-(s>65?-bA6;u~=*ca}71C_IwX>^?k>Z^hJLDd%w7tzcv*Owo2$dLheM~&0w(o$;-EZ2xfr1qY zSlFl6^CWWQA8`Bki@$Yu37i7>kkVn7#2i!Wq}z{wQb3Dwo|!~#;8nhs6%)Dnkmqp^ z7eTlL<{Kb;yJ)`@il84HMw^EgW7guuNY^MzAlEE>Esn#v&3CGMf;O^G7Vz08UN$a) zl=@xWczqdH8GCz6cFfYi@Kf6lmRQ5&7%>MMn=mj-WDahpw3Rz{`rF8ghJmDngoL=b zxV%Jp&`71#(NSd-w|^o_---|V4y}j!8#eVf+9v4 z-42M#A@jgHgndenETY}f??-hH3E0za>8pRL1AgpI&`6W2(!ga7+tw>SRistnG3#f~ zpWApYZn(<(yIvN+tOfLxoStMKtzEe8KeH*=u-jZz!jGy0R)axuk-}8Bc5)#=O8UxS zOZL&pLEh`lc3#koY)=qjDkw7Q{OL+r&+-}rrFGVySgaHW?pyVWFRgD$YVmMyd4M1} z`=a3|J*j0v$2*dpJG*L!$Yc1d-Seu6f27FS3gHonTC9}Lmk*SSd>Kf)@yp>#@^{~5 z42|f~Zm)35WfDH=z7O`H=#EsmzE4YQFB8&E(#toSu{U5%fnkPRJ~>@&Ers7nunm!h zV8k>)IcS@>2x}Q7>9JKWVzG&Ym>4hWU1lb!($$PTa@(K8UhdTYDW!-}erIo5jnfR< zEV?B^;^<#OSUKwoP;ibrgR!|)TN_liFNvRX}^ey>BGnSi0c(yD)`D)e$*@Kwe9MXRJvy9;J>*AMNGea~V4LbL1(a z7$t`#8SR-@+sptEm)*$aOTT)Bit42*rlVLz6jErRPabu{t$ZJ?>R*>)4y_P4WMOtrMAnWQ2HzjXJx^y75}4dWOtUW~GClkF*c z0E3_s;aVJESm1nSV~fX0=c=xNMO|}PdnsY4Sc2n8WVP|ZHAV&X12HfQbnJM^U1?#c zr)DS8Y!zS#W^NJQhbrfn%m1NSOReZR)Uo@b== zwE8LKFW)Ex`Ano=ddEw0wK#syL=@ifl?6$_5l<9MQukRMvAX>gLONDhG*TG#oIney zW<9~^J$O)TQCm6SO!6HMN`cM#^#oXQw!T(A`Q}mCM$xOiMEzrCW@deT{nxLDtm^$H zseIZv%lr%p@)0e$ahAqnnaQ7YG;jMx@508*#3;j0j9GC?7g4ChzwCNhLjN9qc~t6P zZ-?tIbK0quRw}b!$`0A==RV2Y&8~Lz=q-Vl$gJ6ryjEiaIJst&Ukk*5c=Izor?>;> z&qZx5kDr{RoE$3h{>6k4ywh!ZkG86)kR64dx20>Bb}1Kq57u^XbB1(XvpQY8IrwGa zB5~GlV|Dz1bo)7B$*nE|?tem0u-G&hy-UBf{}+wFD*F)^D{nE?T(fZgNt(JpU;Ajr zuOVP}wR?9wq_yYX@@REGdotONRoy=Atv!c2Gua%*L9LRNlBsu_LG_fW6=#YSi1F%q zkPT4o*HZYspd&smvN@~zfU|dC;Hc>}^*89v$tdpNiO>t13cK}!9c6LTG6~af-n>cH zSK6P?d#wcQQViPAsWjgxM|mXq%f;yqm&k9Cpeux;uzao4^U&AG;2CB_f`UyqJwRfr z%nuG;AJ2~eow-J2gNq zdyYlG0AbPO+GctPov&-$o%7^bkmBbHZYg;ff%_!X8L&F^&0`url(w)D-K$r{rQSP< zFciaMO9}h8wuL%3%YFUFleWA2NT0ody~|F4iwz>0hpRN48tVxX{XzpLT*vp zR&>P*yOx;L`s}Xu{`m1DV6DEYe__t=3r-yWd&!$TIx}NeJ2YBv>xeTAI-z8TjQNq< zMI~FF+@a;|GH#Z!MenB<&>w)WR}e`@IX}hS5u!5M5-!M0{i(y73c`KIkZVa%%dSVT)V=0;yqTK>zF|h4 zd^Z^MAD)JL61>wdWk%SB*EecrxQqV)EgW;H?y7@4h_XxmQnEPsYd62G5hL?b>t+!j zm+?v@nIOtAh?RH#8S}=lNJ>gdR!nTj8Ry{WSmi!0CMyg4*t_DBiCpm+VLC_0D?>J5 z{prD}ZXohb3~!1h0-!p)0xZy#2r22FvW<-mtXY-YNaYGaer>eWmOMZItd?XGCv`P` z>{qU#94Jgux1@S5UGU4d+ z5$zTfID)%3CN3+b7*dP-Yb@>XzhnG*e+jZZTM~pMf9a*U7V=_SpWAOs4BAFRZk+g# zx1OKvpCn?wuWz+iC1fVUv9_3P9s&R~AV~dYW9n@+0qt6)*^0lmJzP6>atqp4WaTjl zNOULc@n~bn;?C#)XM%3?=Um{-!As#=*R+)h>JO%`Ub=^4RM3 zXpD|$YTWJ}&)+_odje!9z<0|P-~`0nrTWxu$M8LJ7IQd!MY*uaM3fSTfS}qZ8 znEwgPz9$Y2OkDLw>PX<_Q&ireK-Bzc~%Ez66 zxfBejeXGr3BjMA#4`Jh{@7MPJSe;}SETp|YTCl2{n~K$wn|vX>_l0C@WP!|VtP=^I ztfoJR<1YN0hrR@?y5i(d4*`bhT@t)sm?Bu~rN`9tU7wI!T+o2QrRHD|U@P z4aU!LmGfOz+A)FY%T>;2oRyvt;8A^(Kmb*JSFZ+b3ksW;5&8cnWy(Kj_;a(E5g4woj)|kFkx1 z%Z(KynLj1&T0LJK`s?SUhE(#`&(|TPNBw#-eckfQooZ0-1l1GMe0U;Oe@*k4bjtI-T_ zQ?CeYy5-9@%&2yzt;#gYbK5>ZD9XxK`%&hWN*aSb1Dn{JA)WojBo`Be4mL3Dms{0) zvR^7eU1|JD7<|!;an8?@H3Htj4n%MeeET9p1lWMp2^%k_WOfi0#sdnw51D0O(%s)! zp_3&KI7{jFOX^~A+pqyyBi4wXfDFoACS}hd4BZ#a)H}VlKN`S59~_+iB(hE9MO)6YwOmPl{e!cz5IeCYQaIkMKf+ z5^mq+1GdJA(SCi!f~path7E4uDR9oSfEV2vCSkr=V{|-~Q0oi0WUV3lo2{pNA=mq) zWLMD1O2_SgAIsZ!tlTLuuerb*LR0V-@v?fa8!q^0np=72 zkFCU`{%!vt8=EF}zzd1V1w-3J0XgO>{a)#3go=65K;WYoKpL`LQ=Y$#T93bw0=GB0 zY8!ZUOEp62%7K^$hLso4AqXrOjDyoUJ`$aZB&aGK%eO*20_*)JhwCx7|9J_QR zfN=>1RSa)w>|`MAv+g;5y65ue`JjcbcmHES40=QG9FR}Kc|`HX3Iv`tc% zqOhwP+pwy`*@}M$(5^ag7?Kds9IhmwVK7tMcg?o=W&+r@7~a9m)^;DF1LC@V{W>?d z5o};ln|;&2-m+)_1y(n(BUM-yZ69zPv|~_-eA;?S`!hNs^(icfxWLVW!GcBoT23_^>E;_`WitL$Hx@pHp=mUXo%WN)D1 z<>f0fjA1;V4Fwy9J}|jAIK<&T4!Ou`Ch=K6K-)XfVo;GDHt}LaQ_VO-r}Ri`W7Y_q za1T9ipHOGBsKWBs<|I)-UX~~@gz+RyTXLoM_VzA)sa@q~v;4NW-pu9k7$~hE6BK$0 zr~nZW4C3u|3+jY}HXVdh~q` zNZdl5>D>+{sof6c{n`NoCP{p>O`@G;y}bsFczw_$CvEdDqc;XHCoG5UxpPvysO-tK zPYXw>8?XYmPnM+kw_8I1aIvJpE zD=ocf^b%!g7V7+zpFb^g1^Pf&*MqPWk0)YZGcsPJF_;hEI8y2TsT$ImA+=bNgRB%X zwf6e(rFWhBzalzkScQbem4)Ezqqix`*FtwvhqhE-#fe$%Q+7t&#{*A;rfYo#G3niN zEiEXda3>wY6>CV@=y~QE_Xd6kbMzrsEEPP}nRxI+1B+X`H%~@hwZ%#W*AyVXzzZTT z&lM$Iv3Fu%4(q({;5Uh`BzO>>eW&IHg3a+9FDNHY9Lr#vAto&)8>C4lY%jeglsy3T zK9kHqlH80SQsUxJ&`^yx0?nt%jqFOlnea4hBz*gW@o3cZjl~X2$ES$9yb*>Lyn^}@ zkHU|CBnkouPxak1MV;uXzeZNMx7#W1?6t@8VS0LccNE#PJ!sWvd&ro5xwW;Gme&+9 zu8}K1H=g}fptDSfqM)B-XJ^;a!+f{1uY?H=Kd&MxDmsCP0&xBQd6lQwZ6vH;Xz7z* z`GAveS?(_=-SxIQAxPd0G|u1q#fxw70zPdP>L0(pe{X98VTwtsK5}VjYfhsk(Ny@1 zpOUEPeF#AH#PEAfkSW;cl#9?x6^m8m;q^ENbHlsZ;9_` zK73~Q=k1gk=jGq7l1J=tQH@G4%okH9AwOOL`1)i!)GP2w-r=W+Ihx zQZR6smhd714um7Sb$$4rQ>Lcz@}tt;{7;K}2!3O<}xrivd(n+qh%^ufHB?4Y*XXP0sZT zSpg1?4zF+-V#*`PU`DTiYSU+YjK7Mz^@69y z#3huX6@mPFP1;4PA4nU57zYQ3Kox^V{q#=_npQ;vbYVQY{X8IGL8?7x+uF49df7QR za?s~h9u_>Fl5vx~ihl=)R57vH&QusAPk<#X-^n@7I!+sc9Vjxw78}|kyg8%P4bbXo z_7Ozs9-X{6WGWb%QI4e1D|j0B=#C5%^-_iWicito<0Bcns*x1Z)7!^0as=HsM6VL3TDJfN%F z8enSZD;Ngw-ZPQq<>jiX=Yr|UbwZuF0=WW(1I{Thbr7?94pO5a34BjX)(tBWsm0u4 zz|!_hwlIDpemN~W-3g!&#y122YoA5abRk#Z)~#EC;c6jLH6&rOLV_*#GYopgCtw;M zZ&sy5T=6;1)jVCDlYbtcHv2oP*WPi6n`fEr-zLWIp{#z6r^)I;_6Fk|KL(r4E`K~BwiR#3-H^s zu9DMT`Xo+n?w&Fi2Y)&3q|ne%z?KnZINtkcHKz&E?nlGZ*{b0fVj;;1NdTm4OqX%; z+iGb*@#JB-hw{*-wwX9I99nQkN8>ZY$2HiRPTT?YQvPdk{FKa2xoQNs|Iwq@ZbRaV zidfvvO;oH%TNrE5XL~K|-FQf9wZt+L@n1r*{1%`E8KYIVVyqS%XnE15P|!%Z+b`y- ze66_#iwi*#ikgoNu8_8V9NzOC$uulqFr)vZo<-&$15#QC67iI{=!{#8+Q#GY8A#Qe zH=W+J^9U5ftC@4W@TRtpExpWKZ6C0r1PzOy6K4HZ9W&^7KcbZ;v>}-{X6!f*#s4t! zPf3M@Yn-WQDvn$gZ2UYrZ?s{q>Xu8@h*_nJnCV-46RcrSH+$p5UnSk06myUP*6_IO zyD3&$QWEWG!eh_*6X1RC%Jl?OJ{9x?<4j!#3ZLNL%ROdXPFKL}8F$vjd#){G~a|EjJGSKKevElZZS zL|L=9Jq5Cl+4UiKfgu_mn;&=LDTDAjKxb6D|3@^0bc@7Sma+WhJS5qBRsPQlsJ|sr zZazi3jDGX*@{-i;2l`dUZGSO+&<5nLU)Ev4$Kd^Oz*Ds>88pKZ0Vlfc*ZQvs9!G^9 z&+KdclHLTYDbD%n)9)7~#xLH`PEvegSBxb1Rt*k&^;h=AP^!=1?r-|znmFFV=?KX( zdj05w6OI;I!bdOw_Yi&}J3j|~GY^BR)!Oe814%rH7vKWTzhr?DS+?5Z2=Ca7{&>Hv zR*z-Xg`7o;uAjpC7Vo z+o?RL$9FDcaoO%paUO*IrW^xSW@rTwsJQASp_t|5W!2-kiU%_LOwu(BytaBZfz)Zq z(Id(rm=eQ;Lc_C%0yFiY=BzX--$7!~O;K=I>BI9G;JnA*BZM#G66BgPE}=cAppdie zG&j-g`I7tuO(hIk|0^(SF`oclBR|I8EDMx#fglFMXMX;f7r^{Jz`fE*nOR61c`;8> z7Sr0Z``@ytES0sr{cJ0%e1jkbvyv43HY3A}@uJLiCnqPv3dUf{P!QQ1f|M*vD_kXs{JN5)M#AKnT4dzgF0L`UdD#_5V7@ ziF_@bezPWnsq^!3N@zs*{S<@0?C@noT(95oSkJOLIWXD)`uS!{4Nx6YMtY6~2RH&M zAv0}ZVXo^zKB>LD&JeLT7WB8E(X7xjyiy7D*wlDKU*!taV86IaJS&#MYZ0Q6>RC?M zJkies&hX9lXfcGON2D0>*>7|oQ8sU&Yq=OFjSe%ujd~a+zf_D%`Dn@-k$9uW)2vWb zyQbzT(2g)IAnc@FAc_a$K{*-Xu+I!V5pOcTa9yc<0 zcBf1A_xBS`x!qcQRt)_nr=Iovx?}dx9H@2~-ZN@S**SkcV6d_Z>3-OdZG^XgLLzG! zIfOa>p~X-WU!P#7U;MxL7h?}n0%CW!y=^Lp0JM2}ipSh&jQkZPFg^r;RA}g##yZhX z@y2;!234$gs=cWbBCa=5;)+(gKtGI@P%eqX%OEI{K!3!@f}PVH_HsPC?NJjHTVXY9 zlFZ*OmTStw7(DvkfkK%H!E)Yco0c$KExXK(&X z4G*6a=A4#3P%dX8KMsceVsbU8X;eK60l*L7d19X|OV}cut45S$XjBr(ei-;)LJOwG z8g{ZvC@5r*_W>iGJu=v|z+iYy`49Uq)1EHLc(ELYVq8m&Lg%KZ`*aj6>2uvz;z26Y z$@j@0DwqyQT|sR?w^*|;mOa-17UshB2yl{w6*C$R1L;m_b@1rco>;7l+Mo=lS_E{9 z=}{9!KM0xfQIV0iZr|pxB@U;hrCs3mf4;(uP@kZWudz(B4D1c0Ti*6G z_Y{2|5=Z!^^(%S(;eTf(mTxXeV}rufVRJoE8I5+rofN?Mbisx306MIH;Q%TGhjD)g zYUr0DtqoY!4Mc&SCWpXy4&_wiq9^f&Co=}@3Ec!35He^u7H1X0{(Le+0t1`+lik85 zj(vOwU{)}bD`~w6ES>E1=XzHvUP)+k!XU9t2C(q^dp{oF-*IQy{f;=}E4@CeA8nf< zp6A619S`bTlmnO{@_U{eYXxIMj&HPGU7Ru}vWcoSh6lH!_O38L$*5tmW&a zd~s>_Io=M+r@tJ&WcJ^=kN{b7s?hEJfWWm=k)l!BA&Jy^K$V+eQ%fK~L_;va*PYTo zX~UVJ0+t z2%OUZ+-AISHa-LA!Fll_BgQFx0Zzw@S`WHaLt6d*{iT)8g01Vj7PP^S{Y!g1N9e&> zZkxw=zc`g9)v*6^osr2${CtpR?z^54qI1Wq@`P)!9WOpF6u9j3!;UOgRa$R?$uwVz zy6Hn54tqR%3R?z}tq^|sMzm^s#vOZBZ;H^GPoamvIfn{Hdl>~|N3_6nk~2$WsMx3e z1!B=Gov1~@o3eO zoUslSH+~OX9VipZ6TN{K#j|?EJ2$c0>LCetv>ytlSfOVX8iNyh}B0gy?Ro1=^;Q*lZMPy%LKtHq&L{HqMj+nuUb&37^Z7)H0Ivs^rDRZ=gWu&&TF|HPzvG2%8f4ghpGl zwpz2aqF97wbhRy0(O|^ZulspdHJxtJ+dv%8#mn*54LQ?7`Xn!geSjBqSqT0J+U~u* zZo=v8!~rWH`n|mWLg2r%PIEGd*xW-M`H{L5RJX$;ghj#QPyNM0I#8`Fz{9pdOXC4+ zgC^(H`svfBfq@Ei;Q~oHGlp+N8Dbu^i++BBIB|*duy^74=&t)i;3m3ac*U8e%Hcin zvuHvrl;2}ML%UyS#pmP1V>4BuY4hh#C*@Q)1 z1q@!A5+KBw!ywPJ3+OSQA1BEq+P{&$Lpzx(gzhtRa?L;zylY8g&=wd!q|-AbJmA9p zU77jf-u;~3`B9oDTiM1+|EV}x^)U~k@Vkz0j`bgIqU9VRmBKYl%@gSxAh3)B))cvLc(;Ye~N?#B*h%{zbSbv` z88m|RceP&8Woi{mR`j>Df`a=UT9GwEMcNS(Zkf8diI1S5_P!gmd3@So_phj{gx#n6 zL5o#m_TZy!*2v4LAe0vyU@M1RuS6KIdq8yZAiO|jkeXLG@kU~p#0_?iV#9C&?Q(?} zzLke_uR?AThea7u;P4KGPIA~r!(_Kr#eNW|-JGo{qMnm2=mzr2Yi@B3ETsBXR^Q=F zBg_BZ`o8#FtZ1IJPck1#k~@bl0y@%c?3P0XlKI&pTKWn(V<|H zTUC0?wy7@Y%_k@R7!=q(i(Uhh2wQ@&-do(x6=3Jp=5%yQS9^og6Xd79Z7vE6yDth| z#5uD>oVi)aqL~77(#kXKjwu_^?Jr(;&j9D5V{)|+ijoRy9LNiTP38H=6TD2TcjrCa zbwlre3;YjaQDB>R!&#Q@?f}`y)&1=c$?QQq%eww{zwbObuQCzPEr_Wo9Uc-AG5o@Q z1Al9vjroR*&XfS%&81?Zy}(Di-^@3t9ph+ukE#R1qdvl=h%98e6+MG1rbW(4l8fFD zl*FyXBm*0Cue}q{6WWp!*Bryz#4N5LqXlu5ay+MV^}GI+4pSjO z2h{>o|E9$NN*y94FJV^nMyO~6aI+SCQx#p6Y$zeSE{`uJ*0oL zx5=87+k8%?hQn-W9Wz+6D*I71@qA9Wnl#}GxL@j%q9;%-44i|LqY@28XI|{NL}KS&Wio=w=?)-+84A zRf$iEL!EhjmN_cp(KQ+ca2!jz_an$hiBt(g)-Dp2LxbE z55YDaWmj9z4HViHvz$+PNVgby4R>>}xUjX9uf>T=+a!KEcZ7@M`c;mr3>ctVI<|s> zOD{?h-bvJ4ZN1EKg+yR3GDKxWWatQ^z;SSxi-0|morICu}|YGMfa^% zo5!-T2nYROvoJ%fgekHGUZHSUT!(8O21hdgd+av1Bd~xFX15lnRx?iHLNI|obI-gs zRHd1NOklZz5&BC8y=qBALk@QfJf}WBP2tm<5={u>i-{96MQCUVU?vpk&ME&f#`4Ac zEB0Kz$w#X?WzG{;*T(5>&0cJR>o9SIl-JCl9wMO3=iwC&4mH@hiHV8DfudT{7cdm^ zDAk@%o%P}c6&}pI*KmdmzDcO_YKq-+J9SG|r_^Nly*t|A2z**vf!OM3jg)4py5CWa zG{!dZqbgI0sj(VCr48=i_;Q*8S)DrA6QU<(68pommWaj8;c;V;Jx zRTnL-i~)4#EO%eke<@eXq$?%a;X?+}Fus{Dq3OOj;gy5|D@a4uHib_+>7!+dZ!K4<+^xMyL8jY8y7r6S zeg@(YKC%HAt!|y7m-{XKai88>8An`dM(M+c(D(sUle}j{U0E_=m@fu{?lLwKF#6nl z{z90ju*(o(SWx5Y0H2AhIg@>2i$`19Obh#xfCtJTH@ZBGBES$`4u|25S=E7(mSu?q zK1RU^R(^Z<-I$dH0$b8l({&Cw!Y2@U{~N=9P_33?QqoPzov`NeKqhNIki-M(KM0>+ zBkt~NDvsnq-BJRFqae=tPu!+L$-S%g>2$o|qN(C`aR$ys9^iI=gDdDnXBP1$4*LWd z7Ha9}X@SJJ$iNSlMIO9f&R4Z16~u*P?%~gi&-2E7U4tD3Dog54E%XrrnVUu zfzzL*sWOh!qF$)y9d~*Y!A&Mb=qYmn%yekF>thJNIY`siRWOvHNi?o05#B+=1_hJN zq^@ej%_hh=M<1RJ$*;*&7jB*^_ds^XoJRVu(ej>Qs4r5!Um6NC*vkdc#l%=Oq?V+V zT)eDyM*U+r=!O6&%nN+x3^UYSnF|y%qZIbjS;yn)`;MNe)ibQSD;kqJnYxM{#N?5bEJlb zVN6X;4ZoO2ze>n+<-wrO^7DwEz3~i7rM2W$AV)|&x=S4v{aE<@`ST8N*tJUo1NK+d z^yC>OiqJN>S~@qLxfCPOUYvJ@WaTgwHV#x=BNoOnQ;SL%Rw@uH|ILePSk@cPnkoFH za2SmC6&!|Pm7wRnPY0oqz$dVMf8cWFGF2WpRmYNYR(|H-Tvf4ZVlM95m*5Kxe4M%xv4!jSKxCuV1FXp|9|f8iU@p95P3Gc_|y; zJHj`@J0686@ZGqj*8z%By#cz!^53ZRZu;;?guZnArA&yJ+x16Ea9N%!&}-s))mt(r zaG16l&=FQSx3s#L^s2PwEEQJjl6Jvmx>OLA>7B2v0n##Au>uvuQkomg9GCpE9OKn_ zKeFQ4A9wU$eK6ykxiebv_6nS;GzqSyO*Fl6%hOwKbqDgWMPGpEjgb<~W1Vft^pN_% zzlcEJ`G}_C^lgaFW)_8DqN&fK30KR#gLBT#LhCK9-6)l4^>kOvhMp+XbFi>n|*0rS0?k> zxG|zBe0jO@Ky_SNy&BzBKd( z=#a7;OHWolh914hR`eNid~FfwH#tnKe7^?tlqvY`bg42bOt_j&UzzKE;$DA(f3K*h zNK&i^zP@_b>42-3hTm#WzU8(+laR#&+Ld2FH$b=^I1$jJyj$Gu{R9f_YUB=? z`t2T=KRhIo9XMswu(w}oVE~0fn<`(4;i#<{-lB%jU#i>O+YDDC;Wi?=NDvi zudJ?KfjY0K8Q57wy|Y>JwmE+z`s_<)odkL{)?ISkrF*s<(XZ_8(j~${yl0F-(XX6( z&QKNb-C#+3B?NMfD?*+<;atX1o3|R5|pIb&rq|BaOn{ocOHe=75LQYMgW{P#gjhnFZ^3hb^ z?V>XHc$rbB9Ca1XHX7%YADxs{7fk-S$#kugrs4~tma-ay`hoA+bZ=vEc6ZaSE$pwsWywz zw~tyCpMiCL2EcAU)E?Zn&vt&Z7$Db0^}r6B0vCAV8B;|}HEnx#k&5RRdvlioD=qKT z6y7r(Q8(YW0-iq7=Y2dXHegH_Z>>?06wQApv=8;hO6U_DqRcQAu;0K$jNHF*6$$}` zzGO}e1#mz5aJb6t#ohBx{KM{4J5Qu{V!>|Tu1GSFmTsS6QQ6~ePA690^_CY@5zrJA!%y@F+Zn*m=w0EM%Es~ zE&Uc~*4pH6@%doDQp9c@< zUX}J0GciZpKhKMz{tdIuzH*n}&y6$NZtfe`XPh*F-rj(2H|3@iarni9f?{U&w9X{) z1nSfqi5{OX(QH*K86#}doUr^))mWitU-F;Tfr2yzA!_aoZ-IXqfoE6SI?lb=G&W{Q z{VF`a>$owAO0V%-ZCo6sQdp0 zL|Pa^8Y$@p2?3=;nxVTxh7geM?v#|0?jeUR>F!RE?(TXn@85m@Up%wcykX5O;9TeI z`0mf%=fU~=_e`%-GFmhD%@Ie_rKpAe~l=Cb=!+n(o+QiO$LSM)`y$1pmd^RB7S-}@hU`X*QXzJGUKaT{B+Ydsp> zV$%00T>bCyDD6r4y=HlJ!Q<;7JKVONM1rKcoBH`p;~A{V>~+qL8}#5_!T@F6ot5iV z4h{NnC_=;E+0dB-7gzcH07C32ZK@ zIJfNu3ypGZZj%Qs6a>&;(g_F6YR(rP@B62~wOGWu79SxKxU4+AxacMbyr3 z&s=PgXYzKfPtdGPr%_rSuaZ0^dheTuLKn}%i}~D`MP0`>E|jj*hr+D0aq1Ty^T;N{ zs3)@jbE9<*_gfK347`}HS@)>4%!0jPK~J;{S7t^?z6>e_mgV_)q7cJMQ#FIE6ssW< z^XepQ<^Fl4woM1WNcucxRKfP$p|-$Dc8pJB5mJo#gDgg!>}4&`#Y{IiYrIc|=L3F3ud2Gaa3?&%yJn~* zqy94IUrg1DXI?VfnU}}J*rm8EB!GQ?E{&YlnEnb)6or#EYb<&AJ&2`7eEnmzx(te6 zKL)85b)YcfKtBe?uLMNASxp4@Gmp zFdy$7Y|CGxnK4&S04%2ZXP385BB!R95iJefu66C40dl_#zaQ^ zh=avNg4XH=nA1uu!S+Sa1zXc)vPF7Z>TFtUEW3NA*K^RCuIrvGI=tgf{+BlXi*Zm7SpUno%hVF1DTcB@k$pc$h?M%AcA7`@LAo%;C1 zPJi#saT>;lfxyF)c)nkqYE4_7IGhi*=K|ASlO{)n<%9y{pgp{iHJB}BpgJ~3lR4OE zdSxn?v|bZqQGnmT3!;{-q$GpN0DP4{V<2xzyO=$kR0@)x9~&AwVipb@y4qQAlEXiR z9<9&5lO4>Uwyzjal&`=v15SR+!`aJE0k;ZmYWuApmlymE1y~{&NKzRTcmVVl*4MFk zIGNqBt1g&Z%;=P!G@d*!nmDHm?4%hy41V@F`gpoy^Sd*-qGA81)H|=TYmA$fmocDk zLG}lo;^O8N;8i2z>BC1qp7^KPHrmLtz5f@9l=}8#V$1A%%&F{Gg6#|Nj$f6Lf`rN* zIj`X`{0%2^B>v&w=-$6^h;Vx(yl7&O4Jq+*fRY5dH(n|`Mp&8pPq=Uq%YJ7kC#NkB zKffrWoP-^7!HVo`)~!bQJ?q5_&b{x;I5RkJ!(WeG7^91bn<7EVX>eq=(A|VkQh#ge z93F9Z1JCzyTsw)!IMiBdXK}u!Ek9p^p{KPc`2K$UM8vnBaLy6*F+-tn>T9393~dNT z^hQz?6G)kH0c>y*`}NzEvkI*R_^WW>@B81X_(s=EWMy#?vR=mTm~g)b(T`1yZL%9t z0OslZacj1DY0kxVPYn)k*?aC2EO-Cmr1|D``*Z#VhJK2`iJpMo>4Cwj>qWUmhNFJe zm(bnJhj+J~VSRSz?)CV(b8gRJQgxS%I5@$wpum@>E4xn@%bG5wGC<}wZ$%^#Soz&Z zs}&_xwtY&GMFBP zdME`_gsac^A?|?BS2~Hmxyhg28lsaXldpurkev;+f4YxeHHLV3@nk5xjzjJG24E;) zfRT*tw66v2@+p9b;70SNu8OUZ0{As}O{c(LUa8S&a})G&cA*aRj@AAhdVgUC?7);t zKOdoI@u(Wn(5$E3my+V&oG(`c)N8IABan*rDxil<@$;1e4v?t7XnDR|a^Z0QGwn(P z=KV>1C3>u~^%S%@z*>A&DR71wjES2vvtM3bru4)0u40uXTt87@bVWzA3ZZP2$(qk< z?|7Vx(o^O|l{u5Cji2APE{8Dq@pJ)ByU)j-U>@ zI!tto@;XlaSeSawx7uh>XyYH`Gso~0j8tR7*G71RuP`Rj%2jDIU?an6!TAcK(hr&k*h{5X!2-A(=0}2qhE>Q zJsB%zr?4UMpwalNg9Z2*kwoT?WcDmmuUis!tnv}5sQ9BLDFw&giW;_!uS_j1rSZbi zbA(3HRQJ}w%CT}$^2N6Wd_+X zs5bbc@xiXpA72W)jQ`N5p6P0I)}rE0%H&oVwb6$vN6s_1HR<-X`H(n!bR48~ql6fy z;RouV_PukPk2N{IeGppHgx%ZdN!qn3d3023Xkfe$Y|SY=ou}EgshJ4FzWP%vpLAeh zxbJgi@oxi7Yct-+8Wma(g(S93H*Ls^i%ZD*@dJ5SpHEw}K5zfHScOe}OZvobRYnAd z3H)p)%R~Kc$Z@p!&zR5M0lS6wb|ZoF+DV6B-%0y}9m~i_SGwu&@%+q+*G=n7!^`gw zecj3;ZBNZF9|4n2cK#e7bia^#Bba zy?puXJ|E(^a7cd~KSp5Nc6GgU+~9E+K8KVk(fxF@^01qWDYWTj7x8unosFeoFKH>$ z^?c9CCg717uH*8n3f8<`*4)}Wh60aCP1Zlz(gaD_=PU1@3#0< zYz}&H{v&xyD^Z9X$4R3t&kuD96u!tZm;|K~!xy+<22M$obyv)sq95TzVGSV3q%SC6 zFjiJtl}&@x^=XrBh}s{6g4kNl9HgXp#mzm%^kl_WMr$n0zzTy-lY0pXo%W=ZzM+A$ zGDGxeLBb6A2YfPULFpr|ERD;X!NFfQ=$eeAXO#$1#VN-%kL#BL+PX;T8+jW<5IPL~ z1*6H6HsI~W?Gx7UmoNaujF7dw{J^$ptM27~Gp>uMWr_8|dC6>bTd#vDB3ae@D=@@l zu(YD_IGDJx_|9`3=wxg@$b9&hBzT{$*m3jlSg+F1bOU}u|M@}a@yf3Lq0PV}uj=}a zfI#r&uRl6l<6m4U&%*?^%xNWqmpZ5oFiQ1xvabD!7uI|>r(`6D;-{xW6-6PB=bmJs z4{RdqW!XX!Z@l?n^}+ip4PAi0x#W%Ec3+n3{uy27qSu_8Mf!8VSCzuZO}A;|^`VN# z$3p`)cQ^YZql)&k?O#^o_UTMU5Gs zD>ZV*O}y6o=^l1;q?c_+nACdRbPkT&3br1#%KSN!AKRQW&W4|=$*}$rlMF#($ot8U96ww_h#ju;N^oz#n5z!A*qZhM$$2|y=VKIfZkk^2 zlUvA9^P;wa`yLH$P@}H-Y}wKN!9kw*8XF-l4-ZchR1z=xV0vzg-`)LSay7v|ii=Ku zSXvQY8!xgSuKRu9JYTDGJU@=E!5YbDXJb<@D- zbB!GXkEbZuh4tEJd^Rn0@8yLVr@zSxt>^m?NYQVZW1rLY+!%qgEsZ1$6YI`?S3o7g z6uO_9)AQMgwW^F~$FKtq7IpOVZ~#_o)EjNP#-mZ{=#S6979jz*s{Va=R-4mxxhxdo z9Uf(xUChdG8pTo&stG18+XOZTsmou2Ecg3`b+%&SVb3!&jm*zbN9_hYP$S^ne*pf|5?>0V_ zC}pBEn)i6x*n<&apt2zq(s;D9Zl8Q%9@svNj4B5tuFHH6-nZrnq3?^yN*S89;7o0@ z<~e9ulF!sU+1rj3$fo3N%>Q`6w~0JeD4)h>guqpy+gM#%%7t$T?hPjD?~n=8WsMMS zlq+d$gMR)IFO!oHNTJu~kk!*yaMxo(E`}+MBN<|v77faD_*;nd&hxq_=pu`?74lF1 zy#^dZFy<)L)zNI`T$+VsZgP(eGL>nbQhqR9}d=R$*(XVvvuBg({}04T_;|Tl`+$V zy~ArlPs8mm8-CdGt5Z`CGjg0&JP*6necGYkGE2E^6BiG zl^jcB|MWAKuHKw%1D7AqdQ7q+Ld)Yz@x1%#T12;tN|V8PaPSgOw@KTmEMy1jxj66$ zTscY|@nuzVaU4m^@Qsgr9;=;(EHv@VZ^K^DuV2M~qH+%%SxAwG{a27KB+w*eulBp1!4&wOBR!i|S#m$sLUc730bqU4*U2(~40 zZbHS$>FIB`Qds32=FB($f+eE}U(t?A6>?)OW525Rh}iHAohk1)6f8HRxG7~3%->7Z z$`Q}iiIar6N#VXiV^Kn*K%jtxF(XhSz&m|+7B~8pPr5dZhZk{|d0dQ3SEg6*JlOzr zJy?r*WnhS*g;y|Z&d=1z%dgfQQkT< zla>?ycGT1On0fwz@wGp86vr-oMK&f%WoJs;q;FfD!+}Osm@nMc_6Nbrus^}0`C$~K z;o=}#>px;4q=KX(n%gsPnGE!*IK9gfetv>%M?1RA+aITAIH%WKL_4lwdNsHXR0jtK zH@`f`D36UdL63L|bk8?Vd|Yav5FKE8nC$|y75nzTuqZDKSAp+%euQ%=UqctRw@(wC z>f_nVWpzqH`LE`Hgu8jAc}l#dcH%vS6qD45}B~93d}TSPvWShL6wT4oC~5? zYl5;a&3D^p?x09$r-Ml>ntB%s%-(2yCX6ASH=!-2B#r;i6B)2A(Z=pkM5^~*^^QMCF%U4gAwZ)9n5 zx#h*%BZiz8s>$4TJJ}h;)(*DGgIxoM_#Ugh*>t?dr0zd4s80Mo)a9uu^IOnTApPs? zU!3pM_QdAL^w~0T;69DX7IF=Wv%DECE!EHo7scg=QbGKGS<1gh4ED3jp-4Nc-m8sg z%#bP68xuET%qSUa^2@f?%bO6>0j49{4J9ue6;x01=02%5g)XoZH-R`~Bzt zlxOFCciaq~JHy%^XYSHICz2yxWO=$j|4=meHt-htuzt+G@7>d?%Srp*6qIzF+J1JJ z2-x9E<4PwF&p%=1V}duG-?Y;JPvrz0P3~dOUacKWhCsowC6|*=fMXFB2fWkgt6@i& z!OsTnp*KE{PX)V(kfYT0N3|&aD=eSWSi8qDhNqHkAco|yDsHe$@Q2)BQG=%F5 z4d73OuH?!yKN#?x-Kr>jF6NYZ*_;~lJP8phzqTK{U}M#+Jl*m;W;SX3zvmq5jbm21 z-19sL;w}39oQa$GBLT7s0tE>}l!f^d2TeH{>HWWP7AI_!(4%SP7QW4ELHKbZ#UOS#4 zq)pAKIyL3tUo9o%CW5sgNGQ4EIPgKVm6mTg;5iXM*w`evJ$j};N%jH3lTrAaDlyBC zs-!PWL|Rr>mMNTV`QtM^y%f^1Mc~XIrBlTpZnmb>m-aa8tK zMDAj50g7v^z++f^dPko2vI*pTAycv@H5VflS@%~eiVQvrX(yjH$4eC6c^tMKm=UD& zo;Dot>zqcvxSnPcuNTZ)pGE*#!%p-|!j9WkfH&yzE;x;~`5eoLP`&vZApf|mc&rtC zAZy%!dKl>3xx)bOcXnIhy(Rl9rv3wO)^os#LKe?fJ%NG#(c%HLy(-K1?x5TSqcQV_ z-186b^G?DZiBB>yl@MhUc?A+-rc&E(T{)OVDolnZlXV%8JiY zUuR^D!G{e|a z50MJ3;^HCK%flI6^rv<7e-)zwMVkD_0W8IbEuz93m4K?;#fdDwzhiT{S~_l=7v;ck z-aRIw*D^A5PJ=I`da{eC-Gjfkd~~j(=bjtY_kJdvw>Ww~Z~ag#Qw^}kKid&=S`m+2mqxy4euz*LY01=O+|L{vZyLP8437ive?iql) zC6G&xkPzB=)pBpmnl}1jd!J;gk)JJ1UEj{7%|_4T;O8tiMBSyX-l?{}4hG$`)H!f* zQl2R6xQS?`2<@dkw%h6LXsvDN`di2Q7InBp==7ucNUzJN8LkF#RRw}%3rheG;okGD41am8x55i zQ8`eF*Be2`>~(cFr98Fq-*TpWOb$47@t`7jRW0+I+(&n{;aJ3uU1pGWZ; zXjc~X`c``sZ9riR@WW&)i-2;cpZj#k01W+%^>I}Ye4gSS z9rgKJe7;eb=`}ii`B&h4BP8Q;VQiXENh4X{CB4RcBwg9#OF%WWwRN!=NL+~3nwtTy zYgM?oct7?r=^^!B{n0GW>J*D<$p2dhs^Rve51iQIUk*5GI8JuXS4>-`liCaf5*a@R zKiVd+xkm%(AC+t#Y}Bl#(Yh`h>fPCJR%0>xlf&!2At_0Yf5Y;KADRUIvQA$f_zkdn z@HIH}`3a)2FgfO+X_=md%qlQi0Q>VUKDJ7+s=*^@1BvVG%t2Zn1>ljAzZ9ks6CYGJ z>e3!q^*QQlG((*is^R=F@*zPRiK!5AA}oI z!SN@tM^?%dngO~P3wc)CINbb#3mg}U>&!&Y^ZuBzRd4ku_*_hCq4V918HJgdEg-b9 zRGetNUiLCv+vh}Q#4&^TU|DePy*z2pYz_|^6?#38h+&dQM_I|uIYM+bH&o)IN1wNX z{^y?X)wsM07~fXsF{4P3KOs}Y#|oPxZSgg3o98D|t4Z!5@ydJC6NZ%~a3;#QLFs~~ z0l-OcP7UlHy`-cuw>&<+JU*{#ur@tDuBl;=JhC-Oq_Lmi42y+M$}pHGfqO-S4k2#) zO{~~Ge|hxZQCXF5#BK3pf(LI^AKN)-qkRhm$0oFyw)g?ZmXLq~3Zm2+61OLqx|Iax zol+Hf;mXj6psrhG$Z#O^B1Pv)^v1iqC&sdGU@@)NiU(PB{z0Mq>^vVLbe=8t{2YU9 z*K#*YpucFX7Sx+^@_b5Hzjj%8OE4~d=Q&a&OCv?@cDtqVoUMI=D}<8Ne!rMb7QcE! zZ8F~7$qC3-m6S)40Afq_JmKi$Xf&fEXmEhf?FvX$B^khGTKx9x>Iz+&p0BhOE@L)V zlL+7Flz?>)XOrIxd+Dvj)ZS!?d7j+uWWvIJ{Nrj=x{p1*3YhLf^_8|9bSvPrm=d zy*eEE9$WYqMJ;$HCT+aY;kEWOJy~%F4!dlzOt!dRRmu-voS+0MTU6>4>~X?e7!_4z zULJ)oWe_zD7fD?e1~nC)s9&WV!UW-lm6XgLpF$QYW#3B428GcA3-zqd?4qVlZwXc& zo^OYB4n6(73_9zoVkw0Ql=o&ujG3H=1YKR47uzOc9VR$9LcV zcm#6|ym*Ykb0e=C63nx&76%Gm{+^ykAaa#B}Dl+KWMP+~7KUTEW5T-EabX%o?^(P1Uq2C9RC=Cs?` z^-yv1NWDYsL}FgE^ZlOG)oaXRRWH3(xxzp^9GW;9SvX%A<7K#B?Xin8m~dowbgr3o zTOTw0@7SdHFl7|{L(4vG5+7pQNJ_-_3E(E^0iOah9@7ZUhBO6s4^duKXtChPI{Jjda>7X@@29jWKbWUJXDWiq| zmV0a6gRaUjgpG}Z+iCPK5;Nj(Y$h@g+^hNb9)4ULoFia%cLPe!1g!36XOm|3wjI)q z$78(Fq99u#NHaD}HT4FXVTICpoVgmuYDm&YIMrmU2dmLOSZw@dRv@1dKVs^BaVIi% zeR+9(dA(hN{JW&wW~HePTaObw81{2`S>+VuW;01~5W%#1{WjtL7HS z4j|a7zBAnhWF#A%tG``jJBLblTbjof|B%C3-^nLLqc6r77;GF|TyJUxDlQoVKM&yH z_TkCueIfEycGrIj4AVQxpzj!J*rlg6rlLYJhD5=M!-b*v_Rh1Td{18@>cdg?g)^+| zmG;wSFu|Zo5Hsyfp+F%F+GvQLlDSH& zZr74ArAP0HM=_?P(kXrDXIc6qIxP~7B>gMgha`UbP) zZ^`xRsE1kvYIds2qccg-)-}OlX7PA7{ty2ob)rx{VEMV~)37~Wi7VFvn^KdM704yR z=hQJBGXZ`IqBqCmtveKYygEM4o^!TgH3}r*TsaFk{QS*U4P=qzb2%jhk!t>-8@(`9 z+(5YT)i&=u|H|t{9`r5idCzR64gCeV0svsVotlj?{Wc7}aL(j2dIWN$fK0viwv7hd zcGvSTx|WweuYkNriTNHKpS9_J@gU3dK9$(<9e}qnwv!J1pJ+g=L~@l_d`L4T#ElJi zU;LYRAjfMFiG~4*>UDf9m0>nGYJ(^V#c)=Rhh4;1TZ|bsj9L~a4*t>j4<80%M^P<~ zsHTn;M%S)H|Q2=$yOy>lqAcq8pTBLm)IzOC2Vsm_P5F*fu zN)c%J)#{s`6oke%2qXik)*Yn)e5WB2B4x^g=1?~r0ueT%&QKESfP5nqsC)GX6J|Rv ztBSn}5S0`~hckBdlH+;5drjj_#a=^+YpczIXTX;bVd{~G5&>Nl(zaBZXZy14|ZR4FU7LzaXn?b}~eZmT1AcXuDh_5Ak6(@7%*KCi`TZ+z_kIoVgy$-&2y8{n7m zC{fR}e9SlS4GH-Ju{K36EqEakU2&-LE*dU+jw;Ldud%}xKyHh1;CL8xBSE08+e!&Q z#;cNa=cud2g_973=<&h<1bEmm(ta6_t6T5#ujp@AGJA4l^SW*$)4yqw%BsxxT{&4J zW}g&2GLr}L;35+kpHbM_bRlMulsQ0iI4C;|kqTai%7lfQkwF;+m}KP+9RkNqX6{fL zGn8qRPdcb3!0f+9JS@!8`F|q>O2zC(HB-~mx2b^GJdlg|@_WzX^XXU!1PeN`X6(oz zPCk*>?^?r|Ba{St!HN8P(!E4Bir`z&3-)cgzEO*6`BAqnI4Cs|#^6S1plrC`VX zP?+Q9*^)1!V04&ZyKSUpb#eA{3w8*@H~+h^1{@@$K28QcYOs0>6>=VRha*r^*F~0v|ct-Bp$0{xCZ?vo*P=%9?CX zTDV+HgW_K;{~8zgzMx2-N=ZVP^?QrNbc)_ML!YQu)&M zgc-f%{g1`9enXM7QD7|F`aI(s@W#TNmiRd&JFx&W6a{EbIA2z!O&j8-2c~g_KQtuN z${TzJde~^g|JIz*#oOnN65>iw#&P%F_f*#8T8!*F0V6`rBD@D>zaDk#nSy>_YrO^{ zFs9N$#G(0LqL7Rc^Mn0;#C_4vhsUGscfC3X+DpyNO#s1x9wh2%)ACV#^T8WWVpbc8 z!f?pC9{~ZiQry&rFgQa`FDW#vtV%P^E#ei%))3q8U;5E!!K(ZdUB0e=epQnO$!JGn zhP)R|frE=3BT#slP=sL20}78KpqQ>J?z*jF1H5*4DYTfQKoV7p8=z?l@lwhqInav&8Ku{4QeNX+RcG(ijUXG%!2&aNuhb<6^I=!-A8~#ay>%!@t!nLRy>UWxxJdXb?(%Qq#x!aL7@6=P>;L zYcVjqD8IDnv%hSA)|(0BXQd~Oqd@cpE@~?MWQa!@s-aQ#p%-JVd z+`~VCLld0o+4;9#^Wb3ca7s(VX)_>-Bry{g(_m9l!!s23i&_?!*4NiG*TY&@a7bvk zEQ=8lUct-w^_$9B+W7vVDj*$mRPo&@5BHfmhvXAri`lDfJ-2{in|5Ly2CEueC~`!Z;W&~0}KX@BkWb$y81hEuud+_`c1 z*IRMenb%9|jy~b`pE>omHfuMQng*gY>J?iZljGiP#3!I!t4+?7;C35VV(SW5xskp= zZ9o9mfjOf`v*zju!n%|9tDv1fptZ0AKbql==g1qLN5<10)W&N3I*CAg!TeA zp0AT3rNzHf{u>vU!{_TA{=lcv37R+d=XrcZUyKb;k8~h8?t=Oz{;8j2;RIEj#f&h> zdxAHPvU){N&z`s>41+x|E=+=eL#sNJYLqxi_BOo7*v>AyxwH_tRkgKs#3@6Ubtiuk zj}0s6pb1WT*q$^7BQ?}m!^IpakWbPQBkr{cJ(>+3*hR6vYE~1SUbpXc_w{x3=;*O$ z+n)zN0}PZ}jQ1zIO)gWIWvn*goICq)$#RuZ6Xd|G!Wobk~h&7yTxx zsZ~8IlN14BqiAR6;^IOWTqTkfCTEJl&ZT0#4+z}3KCIezV#!&?2Jh+&hZ$4i6(#fES}lw)^) zpyI1uh$x5)K39CJDb+d}wW%41b$|pxl<=oaf0Z>TK4a85#Aw`kGOqaozkd1!p%|6sdc|X`Fw?uP*LO|-`+^7Nyi312 z2ZtTh9Y8_e6@4;~QEol)@qGRPm;}2+f6V|1HnuhZ?6Ja_|9YHm^mDtl{8!OcX^FPO zHh#;-Z{)U}PT7hg=j$k5jifdWO*s^8~@M-c_a$H6d|^WwphN#ds1 z`7pfTIF14(l&0o$Wz&3Bu#EV+bjHcv(RcSrB1QTS_;9f_OnpY;vYS$rsH)Ml5R3(5 zLZ=)2re=@D#YGTD@=(*F7?nx6gC1Twm_c-K$3sA%88(}Wp##|n&bKF{HZ+)|MKkY$ zL!nnjHO@~51^pQLptv-OhKh*br_{aP>L6UPEK_@3m+aDf-jCESB0k@Mz%@WX_IW^o zCgFCR@}&8;nhAojyMO%rnB4BZ?zS`h^JDI{DnJ+kqrImdDT+AEk1=cdfLd5ne&*ef zff;A&4f*9u$s5hPd*A|%>#s1O^d&&{g`|^lS=GODgIxUtvZW>OBCFx5mW}Hg^J6}j z(SKYL$u}UZPHTNCyCJPy`(?2sba#12pr>vEuTm(t>XhsaW)yN5tY&_>rCF9=9uP5)u-P_0kf$GViFYB`Og01BG zT~$r#!J^R1){j0fTL7}NQgoVTH%6ZU3ZOawDw1mP(gbtTYgcJIdD#n2`|S8Pc^39& z6@wZHJBI%;H&?&IT)a4b0mAKlec=df#<~)LcQ3^zR_WZmo=;0JE_aDOyBgp3k$)#@ z7y$f;cZDtC&0Wv#a|4t~B=IPX-pb0F;E~kKG&&sV?W#*Mxgvml zj;*+qR^d?bKSdg(d-t*-U8?{$&ODK0dEPtH#S~3f#)mv#omd?RKFqbZo^9(SSGblJ z7V@HWioCp`i(@eVZ$W@bL}n|$IsgxzvybBwt?0IlyoxQI%5!@)K9sbkv#s5h)MXIp zV3to2R0A|K3Dcl>874?%rB7O{5^|9O=SxD^wv+4A`oFo7M6Qk3&%!n}rAAE7# z8D2Iaz`+5*`ypG#v`y(q9=kK}jpxxc zo6vK&3XE$zisK*I|dP1u_q0nsDPma6zYnz-a0xh`N}Uo?Tm|`O%_9riT%qe$c;y?&iP`9y9o~IG`I9>e*WI zsTZIz`7ZlSFaiGZ3ob3Y@Miy4mBX+;UshbOHn;*`oiQIZ=aeFc#Tnhx#oXW4jA2du-K`Ti0jK~E=hd&r z*IX7n({pdbdtl`UnI7vsp58y2zPBlV+NmoCjE&VN4$9_Lk$=$IrtJ!?J3dc%yILH)#=q}@cjEEy@DXrjg2t_H9efjA|hn(g2B zg>4LndxoK!2s-An)~Rq>(g+BM<=mi3N+csb(c0em%;U!i2*7ax4dk zsTeYv?rFzH7#O17%+;2Q1tFCuftkw5Mli_8$doOK4-RC_2}9-bl%$1)*-e$v3}@{j zDl|X!3hV3Z%Uh>#3~SyS_D%Tq(_?6%GAGoEOZK}QwZ{z4x-D2w1)8gaD|=8taqmGj zhJ^FcA&r}M2AGso>fBdF*zY10zi{hb6bAkSFuJ21o%!y%alHXVB81BoU^*9PwbdQC zZ`)5iyPTwcF!Dh2wPdu29Ng{_X#n1V+pCJ0F5}xW{qZwT6~JY3Z)&CM{A(}Mcvt1D z`9^lNKmd)>`_X6Z3X9Z-_d59VB^Gw*7$?o{>kpy&O_Yd)tm$uHhO3r>jcOinTZ zTgkC6r^$WoB3qxZuAR!u=+*W`>)j*p?U##nDIbpsvlFkI=YI(w*F-*c!~a--`h%=D zpZ{BhO8k>w4ep;;%Si|Oy&cGT1zYQw9`{q_>B0jB%1{|+$d4^Jt@_h#*HE$}EdUlF zDW$^~E;=?PlrV@=v|6~s+Dk~Y)xG>*9+v?g)LtUs5R*)jiF=I)@SxWy*j@CYR4+g> zODg28+dG8w7HganiD}Su*(YA~^Q(X0>?9Wy&K2E-afdSSf)vJlq&w{3m^A z0^2<3Z5*wdu<%d%@SIazbTA@32@B$fJmkrTV_F^a?NyIaH*cTi5%z_Z)T6Yh^c$v% z7M`0-9t#EMV~b3D#k@f##+>S_2_=No(7c4!`5DxC#S1RGc|8T;@>1HmBo+ z-+z1W+4qQ8DjzS9BB4`S`LpMI=oehE^;=&`N5e>C!-Qc=xD+^$7|^MmJ*k~0M_X^- zwXHj<_#QlSkM1? zfaijgb5|lsQoVwUHx;DCu@537Hi2ux&98(snA34K&nG$G>r;H>X}652^dGNA7Y#kA zcz#~SBIOj`@Hy|sWxd}|e&Fnk3mMCR8L$oEh_99q_#CygG5=co_*jWmFjf4?wwEpQ zzVV0Ry{k|;O0>{**G|@w9oj_;TZURwr+y6KM~80tIei*4x;5^14KL}6nXaN0Quv~} z&I7M=C8LYzPf}lE<6uO}QSG)p(PzP%|VMl{DkDVSbnhEt&iUsx|5g zl?(NkMl+FfR5@sZ9gy|`)4W{;lOpMG-b?d?`N7iI!IZcj_H3y#y}EOWosMSA+p216 z?{UIZO9V4Yq~GK2ReyJL)8b71rc3uOf`fz#&!1GP+gM-lUCehwJW0z)%r2nm6cHY~ z5lRx#gA<0xg$&GdGKRx@Pu6EohGz;l$XL>%Nfk~nF8M*T;cdpTz6-SWU{wLl82-IY zZ*`{8>+Jkb9AnLQQX0tsw1loZ*Lv$;;klERe0?TBBHnWEk*smuQuGNfPo;Rtwuv=S z?%E9gNr5p{@B&>=(=d2Da;@bu$J@T`>5@+I@%P)c{XVt}&>6CZ=Ruw#aQ(qz8`q8Z zZdM2Pfj~HeymrGa$x+ zsE8DMjqT?Wjdn?LQM5poxCn$M31B~vG@>?S&MDV4(;I|nL?1|zqbc}Kv70)PFs2VE zt25(Y2(rb6Hba>LLvemn7)wZej z4$|fqL9w6EX!jgbw9WWnFWQdb+Ao4Ecz+sD==IF?#JSiXM}6G5k+k@ebGs>%8Zvzws$Bj(myk>PV318u9G`)wE|{y50gc2_>cdMs-`p+K24 z)b2fS)An@bD!AH=9SF)2xXbf6X@QlLREu-XIoY+}-D6#k7e$9A>tD`{oeaDkCDJ|qbi)50ffB_PwBk-X7&g=0Hj%xPfySHhg45saTGm@BQGui}Rd*bofAX?KC>) z&2yT@#X=-hD$HPn_mz@<=_hKZQjN%$jz9J0UAjESXk#K8--@{7*%uKTh1tL0ooO;} zyTwbxp&=PcAV!vT99r6-YW_HA2um?fM6U@}{pC5bXrJLqwchg=MHCKI1VbBz3S^eZ zO^h9j&24gnqeJ4yjE#t3WQ1ts_o?#R&PCIi9(TWuk95PVL2aR?h$X`yyra3$`S{bE zu3OLQCm-!7km&HI%O|0>vqb?P&yAFc+iLS@ZWp?&YXrm3-3gg(p| zCNynC>)7%cuP%SG@5Lhfc!JgbFf%~s)gr~cm6t%5`E8lp`=&JJok1C5WkP?NmzWk>Y-h$qXd( zX(KgTPk;Nxzu(c{Fay69|E&=_U;nTh;$6y@g1J$ z{eAE2;vZROvGzXa-goWmx^9lgO&C#@&jkrkX=?=BFFt?1s`sIEE*D}7!2aYYL@#Oj z$fq<&oe+g3Wlbkvow6S{Y96n)7;*q2P>*6NVM!+gER);o@7fo?qmcC&5-wkzSWD{s zDbdF?{1~Cl*$$t5RaM~`n_V^PZovEk?!Na0Z1;C9>Pla(l=KB1ea?A2xo9~&P)SbB z&QJ1of7t8c#`&L5?f4~JeS=RKTV`nM?As{T*Z4msmP{}ja<&g6O6>1vUkvSAo12{k z$tmdSYRxYsNJx*l0wX2@kquPqYwOYSkfe==cgE>@3uB;fK-*1R(BEi-lN%uf)so_J z3KBn(Y!}*T`k#G%-{~GD-skGhUhL~c-00kQK^~1=s%2p@zs>a>_Ji@e4R@M6EFpUf z^Ru2!H1Zs=EEN-wKtO*F-~YYUunvnxV|BQfvZL51ocqrD_T_UPr=>=lk>>No`Wb=4 zIozwyaKY^Gg2Vw$W-Y^%Y`_^BedzQRdu@HF)^j+T;j_&q*4(^)P6bY>@b%ePMC{K^ zB#mqekChdbXLI^5)fOen;3$(weFh$2Gin0(k~d*tnTY3j$DXXXvD#YN(Ik8>)BU*j zj+Q&CB*9cV^`;xm?xgiIvsSK6{T)@s)uv~K@fr-OyLDqG9dRR;sq98g{`X;F2aB#v znN$kE*l$hSv+e&pQ4wv>VX{8C!NTvjG@U;1S{+c^JznnxK$ozNzuJg^qddyTe+A?f zE#*FNBGvn&_%Aq=?};LN1o=68?Ms|IuV-c*Z;n-8kv0&KJ|;XL6FaZTIUH3$vl3%_ z8EMgAT)WU{8!_<;7pY~9M(>}geyDd~7U7PE8mk^+c;WAsOPW3LE4aycwcmUI(e?+~ zFPK<;!W?Toc*otn*rdQ}A<)6Q0sHg}{aw;mdX zI1mPb=7{lQ`&gKnYvF^$>w@-J3H{aIL~jF&5I#B76Nkw!(a=975KZr!ewT!kt0_+j*W+{h)#mmlXMF;kIXemB&%Yc(uP{#>WP<>>LhuLrtuJ{K z)L+spp05r&F51>wI(+DR%K%v726xj)hQON`MQMf5Y?>tQjE3o10e(JS{?NQnA;y+T z$gsGW#Hc>A^2l(7B2VGbVdi%rarO6_T7q0-m%(*=v&tEwG(=a3QoWh!rBa^3WP(fc zVb?I65)vL$PZ?bW@Sr76Z%Ftb&zLQ*2x&=RtnFuNEcsj$*`n5N^cc~0-#Jck*5RQA zHAub2&^-+}RI#4IucDIu`^(6lYKG$@n%I}81R}MIUUCJh8R~z_@AJn^t8}31gzn1N z`bZ!jed;!vgCup)&39nx5UMil#5fghgG~WmF!Wg-d;tdqiW}R<%*>pR2~o$ZRH6O5 z-WOd`YQNlEXEFp23md^^o3{st!9Z_)#^wuL%*o$YSq#A!xXkbd{{5&*1X@U`Kei}7 zb+0L=atQc0@+IO%NdJV*gJ|)J($}=gsW>IPbc^eYg_FO&DW6ur_$>Ka(v+0~UtL~~ z_&Z4yyp;GIDSc^av5K~)G>NBz&hG2t!@1Smk2eA1muNt01B9UL?vhg?f|k1PMPC2< zahFN{mB}b7ES1$qhy#WW%wzkpPCHb>tsHH?V5=(qv$)>nyv4F(f6i|<60y)Ncp)iG z>n2>J%k|xoK}*DUaiLBU9Z#3(;LF#Ll#m^_>Y}$iHC(@FCBMT0-3BER-C!|vnQ%B( z0%{nQreop2>>v(45a1NFW;A0d#4N9hla)tC7Wo}M#z*sW8C4iGGQK>ehtIsF7N#W( z$xbaVWaUsSKu&%hT$-c~2DJESKZob0NjdPwQ74G!@zeGcR+;rDr|a&<KxFtPrCp zC8^r${y6dV55NqC&68-pmDh>$L?aF)+mFS1j!-TIteiDPB*NkRyvK3bbMK&G<)Z_4 z8ly<(-7_acpDs4KS4tV&O*qe=9(!aOEcs%U;Xyn#OKaFeVAdYe1__FGCSynt*&s~b zn3)l(x5rZt;{S{pib=*jV9s5R4_uoPA2emZe+43A!i)F@*CSPG%zm$@Fl!Ch4OlF8 zpQn;5pQ2j&d7d%baRcv-Cefbqf%()&Z1Q zfkx4M%>{YohCW~>6k%>l)w|6H`T~dQpsc*_vt}*%y9X7Bo3XnwFA14okr9l-pXpI?KCV2M*9p^9*AHCK*KOVfWURz*qKot36%M&sY4*$wLG&J_qYyLhh(XuJ}4?pHFg&zP*4>d&ZaerI8rOuNzY`vd45j5lHaHgI|2(fzSZ7(!&s{C#U2S z=UF`*S+Fqh2D)2&=S>Ta=4xRUM-`9R_gG5F|LMmS<_nXAqHzWmoea8JL?cb4Y!d*p z7_Ut1T8xlu_s#pXeP_qVw^H!RYTld(9gFe?`+RUn- zgjP3+GxG$RaAw(BL3ed3tE_MA66E0}unM3u0Yae83Bp#;CM?4#7? zQh0vS_6MR-IVml{^8b`)G)I2he~tI1ilQSv<%P~$ho@~3@3`#}T`qg=k$a>TzMTu6 zP%oNNxgNI3xsBSIC&mIkC)3xxo*`zt4AO=R=S*Z14ceArmBm*m+%@aopxh+M&y&s} zG+Gcm+_#?tX~X4-yYkc}!63LEO%Nm`++G}9mSn>OAx41xkIKr@0$PGy7}VTvT+3R{%C(0UQ)?T4YDqC1z`Az6X^rnVaj`{t=C}# zb%HVCl2B7v^kleoZHhW3Eu4P%8Wj@jGhrGoZ^srYMcei6i%FxOk1M1wu6EBmkSa7O zMu{QeMZ(sAIrN47uS&PjB<{okMp(iCEgpt8vzbmVGQFJbh(eKd5g_Oa3ucTEZwA_n zhqs?`UNO9E%l}kS!YRjs%t`anoDX*QUYfm89u-WUIQXx`2!xVA#bELs{q`9P_{l%S zT5SlG7-n?*$gD6R&p`HA1l`!k`%v(6Mb>}M^3-LK z1)LV|R%eZ7=HDBW6aEC51!W=>`iR%%Bc$=vuJbUv%!ehU%lL3HsDVarIaic88(Z3D z%6mw7P@sh1WPA(_?W+r6hAf3jz$Df%rH9SA0WOuMCKYb8dd+d2y1h6a!c7U78+s~i z{0VmUgO|3XPc_qhd#RPmzQBDELlR_)e;tYUm)+sSVoT`e@8K}_*G@&fQ{w;=1KIz+ zhsluRMEa=J<&q~y|LFH)T?>b@#-U3nlxT4vyI+{tV8F=gF#n~G32vmX-YeOclen}4 z&M0D}A)kj{PzO<2)KcLxZwSU9eKey07^ab6HEvSLDD_1~*s}=FCHx>s7$_bb0Y=jL zgObY9Lfh9%7X(-gF$qsz8`o{N7zE0Lbhm=1ojpSSO+NhzX_C*1O zenEePKHpsITp5sSJQ-t^PO~D^Ailhm?F<}^rF-3p=k;aa&fO9X6@~0xP1cTItuCxH z{&b}v92^)ZnbH3sC#Z=E{;HKb2g)<1?J*ALtw9>kmB@|w3Rfgb`$fgWRYTA%tN6!! z_#8D}EkTz!STKZ>(;x22xY?vs7^p?e4EwPcM-V1X6Q)3e$@Bpu2+Nk-mb@C1T$1;d z?iZ67U6eq6V_qcu3`UO^_$HcxTpc*WrP2Jq&}t=meqe`*6W`D8$SxQP;TxZ!lZ1c3 zga4e#nOpw3h9CxF;W}^$g-Srp zsc0q4ztzFj?!D2(!^Roo@IbX(@%-nehVs(k>ViQb6Jg3=Q2BaytB&~+z^!JoNOJjL zW%b*`l3x;BR%f~1_#*zDN&hc#;(vGIp;!;kEeKC5miWtb%5j3wxDBUCfy2T~b?mgU2~t@q(iJBM$mASfVh_r2LKDqH~otmARXqY}{aqF8TW%8|h5GihLM z9|*%1F6W6QY4bU^?8=Zh)71c0Q4CE8zn#GwKKU?nCbw zQamcBAhS2MjOGGz{BJNdmO6cg{?SbkA8CVfCa=ximGgkPX@Q9w5$;^oU*SXVlGCLh zpnvr7rFEYRR# zFu^ABOkQ+VvW<=t(@Tn@p^gt*EB%Q_2{acMEJw9>aCj+AOG_os6ij9O0-E@JuQ{HT z*WRUx44O|6n2Y^AEPr{URxfY zxfxZ)h;kZtQz`4)kXAd#?uGnsq55YlJr8+7QLCwz~FCnNNbp=n9(nM zS3SAVUed~gS1)y|x4dHZde@!fJTmG^pCHkZI zci`W>jX)%&1(hWMLV>tf3nA1A=6~Et`T2R#!#jx+hb$OWvvo8yXtEfGn7L8pn9LSt z@~v+f4r}R5O%&c+zElYc;rZjV4~FhJIn->tyyU?>{>MzR;x4U`dz_A;07DJ5R3SVf1)4R)x}p4vKr@*9hfT z_hm_6PaRn>9b;0_QbE5)`DAZ3y&J=Flj+=_6tYy zi+QdT#?mgjb1c^PQFF3B6I3*CE&s3dXrSq;sKz{OUZWKMu?y(YARC1aI+GdmH?uWu zvlH@RCFU{)CPXL5*46WJ=+!nB*T47VT3Pv_-73YK*Idk8{mj%#3;lU8r^hmFoXUr} z+I*C#|I{i^Huh36r?8l`tn;H>*Ji!o{x_l!H3;>VmWj#8jr^62dL?IvwObn>e!b}s|_gbP4P2Cb9quic$^ZXda{&6s!TaBhIY(ds!QD!!ki;Tt3IqQk1b@tqnp9` zr(fYqcSza?{uASKl$_77b@@IF&A9}dnO=FhYIw*$g&jaie#1yI=_m|Dj2mc*XEzz) zj@)3sROz+eTYaLi)AOm;eAaPpzwx-*oD3s8t%@^Ni8ORAG@RHW!_Bla0{g<}dZp_> zS^m>zB4HKN(dks=9*aNuh8@!6^4{S~{=j^`%lM67)8x+|l_YhxGJkqfxYO#|XVX$C zb7NX)pL707h!P{Wzcn!ijGJuJB~QKF4Z^j4%dA^@8?bWxRSdJZjrBh6T7+`$7Ao{zGnfxO5mF6;d!u&oC1{X+5x= zMw0^}mN4v!NQU*8J$_z(I(+>^GdZECUt!_H<@&SK^H|UzH~h)s-x9z7&t52jFC!yq zZ+Q9X$JL1~*i zDEPeIA)3&_qK?oaArKp-?mG6679qFrY2Y{kuHiQR-wwfl9WW(x@t5a2<+^pRb0}cg zmi+WzLFQ(2oUc%rc}t0j28M$5xJuAkU`vU zZWwy{$y~CHXcIVEg;7cDl@kgq$gsKj{yvD$>oD2zP*YgtuW!JS0Z66ppiZT(WlK3)1aB0*6`2ksA)g0%p{&zu!e(i zpSeOQPJ_p1qok36nT7BB>21l9+0Rgn_8_=5KreTan7lYHI_4zQ+fYdX4>=TKLM%(8 zNDWcL+-Y_kcGb^FmvZU*8arAfKX~Ex4Dp@rdEqG2%f0 z0%0bGSF3@HLD<1bT=Wa|7Lk;UCDV zs0jOHNib}+P9F+=n-<w;C?H`p09Z_2Pi zdGK74ZvYHPU*ll=J%|K*+Ts~x$Cr@)MHcJTj$na>QoU8jNBKxgz9=j#A^j65 zppnEeCBQ)c>Rq_}S(-eM9vRFMsx47bL%9OCn!K%WC2FQnUdDgYMazrJXC*07pwpxu zlE^Tb0)?ex2MwZ<@;i~!h4p-(eumFUgR$dvtUz1Uvf6M7{ZEhezZt`Q?7DYi!Pr9M zMHY5Rik$W*Wjb3dp1_Z7o_J?zvoKT0Q+K`Qy)-gR965wvVh}5aKr*~A6@okmp$4j|IB|JAR>;I@YZkT+*u?;S z4iGHE%~xl}D+w=*mau+KPYV?{N0XNKR*`|i69x)7qik`w15G*6ct1*ML=w4vnb8E( zq8-oidJw#D7xaZYP- z&9FLnRDWQn^WP%Pan|o4FnQEoPLPS_@#hn`n6#z>h6LzOvpSQR4mGq!0?JUVA5K+R zCW8C6&!p@v1X-Smd{zb|MkLQfL!<$PqN1XqNkgeW!71!DAVNdTLF&aLmSD(+O0sk| z1F4=%Bae@5lDI4>Czc6#F+)$?nIlP4$UbVl$78SM%&_l&J+%dhsiY7i~Yiie9y0SHmgK&xYv4MCKt! z&7TO07l@h*OJWbrNd{rWr2uh1%I-B<(9MoCvpYZas znKzgd`1PO2510&z&Ib;Dzm8ydxn8~Z#tB@hzKCfm{WzX)23~LO^2>Sw`T5$iikS78wdcWjg&+M3q(@rj4nebDFa#UxFJoIGZ^qNu zT`pSbK`9p7oW9V-{%kgt&K)zqlXc-u0k=Z(F$AY|u7m_Q*77nO#47>uCV>+!zdh96sj+M&x9dSHyQvx(F z2~qsJ5Gr#f3EbA^)^XA22%ayWlR5;Aqi&-;2)ngWO=D1FAc3Qi_#IySRFjbGa|TIJ z;R<#VYh$KzZjr*UsO0g07U7@`q0JlUp#BS*661gLn59522h2?b_r)7yAuHfXqXsTm zqZxzn-D&Z{y^xCP=*@ICurq^V(lxXgMf+#L#{ zSBHlEab1T|FrhhD;D}vNnO4oU(%|IO&l*;Y8dbXVU`mH%Jwqqy=j0^S7BqyCTq$Fj z&jp$oBSB`UeFaiD@Z7zgQwZYy8u{JwM*h3$nF+CF&AjinvFN(BIVcIr%xwK(*38nF z9?E3F>q5}-%|mec8p@fFI-xN4jd6IKINQCSn!)!bX`$Jq#m;3xM8QG*LcB%}Q0Dizx{oKj>`u&4_I{G!eEv`pzHqDnHN&ZG zD55lEq8^KyVc+AAAwrB7FhO@&v)I3GgxTs4!(T_2``>-+u$t6^|&I znn~nd|4a}8j}nRi%-(L|@A)2_r-r)kEimkwpMji6oQ7D|a>UX~odl=?lKLG0O&j-Q z_0O0zSudBPze@dcH(R+$ZJ?Cxq0f(0GwX9E+mSwTv9g`w=P?5&6N8Zghd=PSEcM&m z#-!KB5~&^@velkpv4DNViNgTJXruO%19G8H#9cdXwo7wdKFT>MDh16`BB?`c2JL^d zJl-R$ZPgXDwX`&~wGlP5QG@4R`2=@f+?U$-(GDa`~3`A-DPs`!$_(Xb@ z@OH?q&^b1?VrrSSwY9ws@NeXGj1o+}NYiD1dm@qHz@$dn{{uk=@q>qX}G=GrM() zscaQa*8S!+Tw^8W#p(ZrgM1f0K@qtXDw$<9YUfUGW6dfrE+yl!oIWekK=ZbZw7`K< z#EvX@Y?k_20rJdjhOKVLlN*A=eEW0eRD*wzK~W*KHKx8va=ds>O&{2S`E!CJqS9&s z6U&|Netil>hb0Lu%i)5a2(FFgER4l$dEd2U5;%>jB^A<2OG)3juc}R5Mi<;3e1>!P zw|YFwL!hINh!juhKf&*w&|OCgeo|c`ICp;|7;+qKZ5?NIz|H{pFY`K7!*Xn)VeW&| zoz>ctxMIM=E!`^4!m?zy?(Ko=34_?mz1-Ir`=#3JSXJTcrPBbTW_vG*Km@xylhxS5 zWq+5b=bf`$fyT0WWhk-P6PzjM;beNKVtDnvkx^+{5+F4BmQwKKD3;8}%|ZEmJd-ZJ zj_-81Cg65xD!`!8+S$(Tr%p|A@u2SQ@ZGjtOqS2~?QH_%^~fXp-GNzb$K9ZveuMi% zb8ifZAF$w3-InqKn$*_=AWo;lyoS5&U5fLYIk}j7K!9`p@b6&5>UB?r|LOiI2fsjr z+dj9i&NA-lX14oHk(QQ7IVju)oSHJF6 zZ?5EGh3_h}W0pGhQywMA#hwGmna4Zr4zIrhR{T!$cm{9&{XuEDJ7{Yeo!k;TEmGvT zv2BS2?DJuxBH$KKuKRQ&#Gj@#41kEN#LfrOe8#pnk-fXaqBAJGui~HRi|u)N>&px2 zyI!t1B72bqFG@;C!{^yjM3SnjtAk$%{>cq~G1u(ASn~MLcXG|Kor^>qPlxneBECE_ zi6ZzDnScrfh|`)0A|l81EC!5>T^b)sljUnlED>&uiXlM-hUkJx-pVo2fr$BmJ7wH> zT4XAuP?K%CDKq+E;x`T*)#<-)xreyLjk)fR#8e}2CN4)75DzU*LyxCvRymJ{TC)JK zZxY2Hz?NV4J{l0ioCc?sCS7Lg1ORow8iVq!KPZoNP7%=QaV9C2L44bs2gA;nQecb1JaL1wjJ_)BtH~&jb z3xMUPiG2`F9dhrO?Z@rva2ZXbd~>*c=n>&*cbq_J=&_#Pqv*51)#<(*>;G>q7-ueE z@Lz<*&J!-Vg`vlvD1_&ys^8UbpVPj&(HrHAduTdC9z7WILD&$j$_rgDU^n$L4k3!s4^14@m56;C_=ZH$9r6wK2E2Z+dzO>OQ^Wj(XG0go>Iqpe##4>2Ue_f zyFS?tzy;kMKHjet+eP}%5uMDf2Q1Wj4aGV&s~ny+6ggSH-WxLVv6|UEbPL!?o(1Np zbfO=&x7||wh!uG#*mJsZj?LzQj?FyH@d3Qa!`fvBkJW`gjI^QNvi&#<@pumy(FDPS zQ_1S%am1G4)ot?X^=50+mlvVBP~d-J70nl6rm|sp_H&IpCy2)*#N*xN#_C;%W4aFD z$~{&+R)cY5R$jFk-!)cwJY4nScA@j>o(?u>%M=wN8Q;c`Z)FD@Cs+)%OjW_^&Ug2m zik+;t)v>TPvPg#UCz*mPALCEQa4K4ktNI?NN3`0Gb3${3?)Njw+jax*S7%4q{j3^t z68pEHH>{1P*|&)HODmDx_QkQuNeh+}F{jn4bznY^{uTVMm?>zwA z^l1we27=Uu%tTb06N>pP>WreZC3G`FUV-{wOAsesoR?QxVWEnqCIgG@ zWF5`mZtv@=D@VVbl+T@*ohsH2BY@zq|Ngg$iuQ{eU~^TsdTpD)`fq#gJ=|@!IeS0&-79z81_W$B{5hBdzH{nm%TY^ez&!v9WrF{7 zzKyByJxBn?J)C=|{QSA82z)`7imOfFlzLu{;vMg;ihf#-Zn)@Sewa4OS!}f!T5eeV z>*ctUGVHrCBX$G)-9r?Qf=tiOQib89GGD^hp-;ejEnTmZ zx0D=kV@AZVadw<3*4cxc)9;3MA1x1IB1{`c$Ln3X)yAzD{fo3F?O4H!7$5?3r|qY^ z@AXBK+!rup?-sox!?Y@b)w$n?MFYGE;B{oWu7nPyRP3u=0@%x%c9-9N9?S5)@%9hw z9;04F7iY9$XGb(Z_%{+hIQ2_gF!kbqDE{bGTJ#vM^*~8YC#^TnsNd2{zk}0v)TJQ> zeUQCwqDsZSFI`O^;YENz94}ML_q?;$nWa`~TRbPwmx7*QV?Z;~l z)l0XdPmE<1jVG^Wons!}Z6R@{1|W*tw`;*==NkYa6aai*WHntnW}w?uHr+`C&_zgPE| z?$GZPH@hWcch{W-K43ImuLAQ+EIR!ERVimQoYm6stls__n{}GI0#MQA9vZ8_L?T$5 zIO1;S-cLIici6WFr(Cx^KM_4t3}7Myoimq?5tj`{05APD9 zyK_K=5^y1kygESo2IMOl?W*0wtB-5vS?v-igC~S6t9q^fJYq#wS`O2cF5ruZ!!vl? za+-uFHH@V3KGtwY6@h5WUj)uUmRMWI>~hP%_#1}ERQB6zjsPIay=Bia#VV?)b({Zs z{d~puZ;_R^NM}mfqT|D{s`v8!%=Pn!vu=&HJyKK&qo4Ip7aIaqFFQxQ{%m#ZyRUYL zUUnMg?DQcC=c-UT6y542 zsPX0|Bhsd4#Tme`_#L5qA#gox+kxjK#dsGNm5W~G-tXDk)y(e$0Syk=Ab}l8kN5q4 zwuB$EHvrvlKe`M!w+Jc7xnHzM{qRn0MRb1be_xZ;Cc?noAXQgH;dHT&0%2K}Wys?; zIzah&r)l@o+HhQjMBQqq2oP`HY}&eQ4D6$LoI38KShg7Ul3UTMz52=`^wxI9&+w|R z13)~_YF=_@)cyws5KI_iu-<(5fXLwUo|cE|wS8K)J0)^skkO}pl1R0?nRXLgZuGka zaGdt@)pc&SV)q+GDggTQrr6GjajnPu>`P4R(VgpJ&#-+-MaO#it-@;~;1X|OD&St< zWb+K0l%M^BChw)VFh$4B?Gr`IAfb`Bq4w^mnKZ|@xsY-JfVH~`74g5Wkv5upQZndi zmY#-dj(rm?D=$xh2so@%TOUv2xpRR29C*Cv_)y!ZfinZR7wxSM!+b}t^Nw8Q`Ul9aT;W8oqCk@W)*EszG0SD6k>jYP)$R!i??V~8nY`+z#+5z#4QK!2&N zAaeYV=P4cUjFkFhg?1g{HUK&h1GS!=l+Zm)5pn~yPEjI*mX;P!j04Ap)$>&BT4_iE zvgAI7BI2toVl1DPs8T`$`c>K7Y@@-Tos^1b8Dn=kekY_@B1D!Pf!{mwjlV2EUpRHh zVWHLHAxNMlF>U_=opLvn%0BfS!=@J^qir>Ott&sV_2XK8%#hs|_zge;aNvn~0;&a? zJ~JW8^quS*6T95+tmta6pT9Xk7@Y6kJzL%p_1jHC_}}h@>Mlfab=-|09GlN$@R|<+ zs^xG23G2Vu0Dl)?c1w?Ume&oF(f!FA@`_na?SRY0)M#>^UAWL!?|2cw8;1scbROu3 zxsJXr%ThrJbYpg(xnwtLHJ=Ami-ivA{)jy~cvyV2L<;PCJWIvH$EJMmG2cTpeE$dC z{C5}&?BV3hsG?;Jo&txPk^{7gR6##!pJU2L=Htz$WOe6?pB!M=J@w#;MsF0V497$t z{~BX<3LkskW%8)p?hi2Z2v;240M>Q)C!I1mg^r``L{CV9+uXx4PF4PLXZ0REaP|Hn z)2^0&=&e8ql;vn1X?QVP&Hk2P{$R~ZCl z0zRhoWOpy}UNqJd;8^?@#~f&C23B-_03JJ?j9hplH#0M{^6B(^y*Fm3`gWQr@vv>L z@4)2~{zOsE{h4;Vn`J~yJEa@*o&UM~L}NF~=CCLrrK}ir-7_U>1U0(rl!*kuQgM%4 z@Ule(EIwDns4JENwV*nCPhxM}>PhtN@g9dUyG-OH3Uy%t=jQVC* zapM?4$nIH#e~baLgC}__i2)UhySHD5pCyIMI8x8njcNoJdiY6o_3h!^F_G%6uMi(z zRs%Ys&Go8MookQ$c^~$C7Rf*SN#z^AN77fXj69DiVGQ>RUPjFT{Y9{toT(e5kNh;l zXh{rg!4S6p=6XE-H2dnFW(%lIO>IB+XVX!4U$Q^;)`?vmHojo-Fu44o>btLvv!Z_^ z6`dCF*e7=BcmY&X=!p9Prks1XKDQ!QOT^{r?IGEld`+!AnVyE}r%v>JP#xr@#pwS1 z6D?N7sYe*WvXyy!KGb349Rg%L@2mbI3UW^70nUy`4_PM2c>W|AXkak5R{Qo0;@SKi z6}gRK9}3R)AG9*^D=VadD{^#r1901y_e+y{%$AlT3+@0Ed3mP{pn`V)Mt|u?pXic$ z3V0fjxRjZhmX<|9w3zR|H5$1XM*itMgjnOx=H_n#+J5nS`jI{XTH0F4o?~{zYC$u@Pdp*AL%>kA5>i9bYt>ljXv0Z_k%lm-y z^JfZscs$vjozAeDyVPWuQ`5t)#@OBDBH}DJS4EX1eG70vH{G)w*LtZPmR~k?n*pvR zkhwY2y^10h-LKCmzdd8JO8Q9Ycbz=jtns&+T-xm(r(^YSy)+K^z(5<}Pp4vsR;h&9 z`bF6yFy_Xd_z937n=}Sr>=5k|O z+k4+*N}0WSU}aW_2Gyz-ugzAc6o@2|7xV)#{Ne3U)m-{v!(pnNxI<3Jtx+?h7r?#R4$5aw4IODe4j{^QU6C=U z-{+G$%G~F1t#~TvQ*A$5XTyLF$m#W%Z}E^N&_fbEJ<~XB+*>il2GXMy8&)YBfJh}( zV|Ae4JhCLUY9xszmp=ZV+ZvMBrBwTw-?qp-e`QW$_r{XyLo=>TDDT8~m@d*q%R;Dt zJ{aQiO4e$^8TaW_s~|Uj+tSi{Z_jq3e0_tQI)V2>Eq-I|pT?N*amf0MrGU{N5$kf% zWD`?zA-^+Wu~dY9r~pnQ%TSe&W3JA9kAD^CYjT=lhlyELOwqqz^zQrpR-#(P#Filt zQ-J0wX0-IYYMJX{buR7G+20ViFQ4Zg8(tDB_h5kf{I3duFMC*e@aujH7>#1{r#KJm zx9iFMP{$i^M*!M3Yu3cU!XZP})kF*8Ubl+2_RXr%^+j=!2K~A9MWWdJ`CIyYzsvdC zrY$2@crL8YZ$9>^L;X~r$g%A714ZA;$#+pYiMl78B6cZNJS~#n|HA_8X9BtwgXN+R zc6P;z!g$I#@27={xmtfjmzd3$z=pI5?Cw@WBQ42!8_wJeCg1((5cKKr6B#?d&rh2I z5}PO19KdOjg^xO$^(g^uxXut~{NC6MVPM*hXO#fX5WpA zgT)MeJw3&nicbnvtweN^z+_TiVAxiS(2W$i)3yK6-E*0oeLFk!^F`-bcPqqfPwEXW zOv~@I{5aERd=zUNV3%d3&zQOZStX_1#wa1 zHjZ@bQR8f3(PKDOaBsDy-}9=qrr0WoGd?`^qYwKJxAW_m)wn0%8;tU)ju;$jdH5-u zrj*pEuVy$Qb17YPct9qUtPaztJj;?4U>kE4yNF+ZDhHN#h{M*tCOcK=Tp!k0vc6pO z>8-f~@MK*Z2wzF+c`@!fxQ`8>Uy4iw1}8e|9ecYEHudsU_uwwtudpcbz$X1|2?Nc;Q^hE+e`{Z`=e&jT@=1-trQK}gIn2Wm!?{^;>?Gm$ zP9x?DcQ4-3C6brSrRKIWMEeZB6Jn@o#?4!fp>|7QZoaauCar zyBzKf0B>IoflQvQWlMNqqF=^;x+bZz=2ZjT2Xd3UB4T!=5tDm+;8kXlrkosasM8HiC&za&gzjORg zLxT>b>Jc^)3_IWW?6J(gt(t-{7xS7jm(TyE^_Tnr)*Zb?ZqcM%!@TCS| zxjYg3fVKS=&p=1YEv~*N8n`{+InBF8H2>R6tzg%EY*qxCUsos3QiYGE7B928+SZpP zzF7bEm}r2qclD3-Merm>*p~?k{`z;%`u*A8jS@ zD$D6Lqi4Lxt%bcCT#>5Zr_0UFrNRW?eZvd~V02g({f5vflJ|4Iel*vy<~Iy$=|*=5r+(xbXu3~KU#7iA}6Kq zV@!h4v3wd-@Ias>d^8(P9Ky41S&ghoyf>ace6OnmW;4^KmOfzLMz<0dX$smM;;7r~ zT$?eUu`0k^j>lM-uOFg%Oz$6t#?E))OaWSEVk8qk-4!)>2U)+ZB^M=7s9)RHmPRP> z@Y;NdA@oq{^u58zaT51d=h;_WXm_uhSjGnw*It1@?+pH6n4IVOs7(BN@Ov2`))1uP zU}C~$dleVog(l{GceW6%KbHMPrZtAw^lN~v-c1L?7#1aWWx|-t-E^AvT7^5=WS&8W z1j-Gc&C>Y9@4SCY6I?j~FrRaN2Qo6-ec)2<@KVfHpGlvRChSG}8;7qGUose6Te!7m zq?pqVfBmd1lxK}UVs$S&DPZF0_$7TXIj97@t$m}OZze{ALt_GkzNYF4F>TH0Nys2^ zzR{b0g75=EFt&=RBOq=f8I%!1lbQG|D4U_b{MHrliO)iMfVT(`O9w)zEGxS3A>Jtn_c@ZHt`f( z9^A)ISP58Jx?2=ie$k|Vk>OxrK{r{QnWbLjOHMMw`^#u-;qPC3rG@6nG%c}-FC-VR zxkl8c52I__w~sfef+%{y*{Q&K-9HCbE*u>nPriiEx4mU@_jy)TS$T9}`{!UryiKP3 zopfuAu-`@JM1blpao(BmK1_*ziYKT2?Px+q>f>1x+(uq>#V{wDq^O0Y$t&!>y_{*^ z;_7FakmG8b)419TzJ2rygpl_%=ki;P628+WZmmTrX59n5?07XjBAixN#ODVjK&ef* zZYt;c4BzjYl~H~TR|6&nyx^m+Wd7!)DBTQS50xPZ#3=v1S6zJxUmzS52QSQ>w zu7CwSRBD{2%_J9f0)~5c|7s5methHX{r5`s+$-sf^K4+R&|0IR{B_MP!wu(Rd#x+N zUj(J|{!7g6oSn_U%w6aU5J;5xX$vak3yw?GZPNxWp?~*7hl0`1SZWgL0v!E`ZyKi! z$oOw7#AZJ>eTbLmwY<|_u-#s+=?ghFt2@BPJgd-Xd7ppbn-?l0<5u|ojn%;5=)`;3 zoNLb1a>H2z@eGk$9EZ8qvVRv5>tb2lHbKtr{=~xvgPK}6MR*U&cQNE*Fb5}D+0D%| zp`km1ZqwthT6WC2?g*|1|9qU914Tf!_O;jMhey#iHzFUxhnu6SbwNyC$L-tSKZMXg zAmyj!%VPVHPFIkUYTry}l?i>i++UgLmDPR-PD+~Yz@bQySD>^fA=p-ZEOoT7NX|@P z27bf#X>ZhkJWp@n*owgj31Mp!U=dQGiT73Ol zqYICMO~SN)DtOCl*-Qw_dwh0ixqugIxr%qJEMS6BJ0C}ki1A6ak;-mkNiG1haW&Fw zHcgI>R-RQMuO*b1S4!u%E01Bw42g^TqhNpyT$(Urr(_kXh>~zAEtV%u*Iq~ZPjc0; zcjOxPE>YIX1c$`M94r~QlEnKv}Rhfu)2pX|$v56uo3#pk~T(o16 z1vmMg%Q`w57Pb6P-wBdYoSJlx{-e%F04vAwI@ok@n7vM2g;NOLI4OPbH8F{ohO(WD zEnk|?q>~t?G4D`o!Bh_PMuO9K8FMw6iSBECCsc{muA`TV0oQCaG&OVVRXTtWu`XV7_PqLkWcEv3ZADe;xNIgHl6#9Kbzf-}* zL9NJF+7Mp>pWk=$Jl+A*Sv}qe^w4jOSM#SYcNv`+ulc*jKGmEZcGtV<`SpU)8$QQvtqy-|AE7+?r0n?X zfAg-NM;VOi&BN}cDJ@r0HJNAP&H7@=gjO2kD?EbH`1TLJqITAvosBb$o#~cTDwjZ1 zRdK$L0f%i93(3U2bM52H;jHV8Bt$JC*oRQ`BJwQ|jW96JJU)JWSCllsRFtQhz47ng zZu#}p1kJ(z)vnm>*>08D*y;l2bJWgfktyZ69aUa6R5%{m&tqi#B_z7{3W+$3b{?=> zX62T7A4@}Oc+UV;E{EsO>&o%TDRAEy&uynr{}wgGeodyL;&@oIWpYz6og-?uBDdV) z-PjUM#;?;hkx+mzbzC68K$St3a8cNu*=vA5R2BIx!g&gmi;OB9-;|WOSbAC%|M;Sr zM`0l3G}p=oc&6cmicX_F9rX&wtObmS=9i@`I6V7+yBTZ$_jkYBvWAaREKE)s_}Mi= zak!gP#S%dUfr_7_hRi4pz5ETN>dVb$u`%4o7!knF&FnlY8rR2ACZDY# zX0K9nh%U)_JKYYv|9gO~jBNkTHX0$aYc}tVyGdryaJGpik@3MuAW@xfQabla*S0|z z+WNpe2c0%kn}H9fJOzI1hnu#=H4w>{qd5)^2EHay3a^J!jeITLEQLjZ1TO_UjDm2f z)aJInEL!M0KVAfY03a+FGvIWnOCA=kP};bRE1s#AIg0yyA1#_WVmX%mrtW8f9Cnt* z=3tlbZG`A>4Tt;QBv+wL;KY}frsmOa{5?O|kU^l+puozTJjUIE(9I6H8HfKLdv6&QRrJ1%VuBzcDM|=Rm(mRm4Bg!*DJ=~{3@UkXQ}`H?_B4cFYkNKdtK+lIs2Lqqr+Z%@3q%@;(qSuc}ysR z(wkDa$|dS~HJxInE3JKb929Pi(R6@{?lCY- z#1+xP1kDqDmr04Vbk2GYyET0A^YjeZQDF<(G3du3c*M}ly?O`fFzt7ixFKwF*ssjBy!&`(p-p~I;|6g|BIR zp<|m|ed8mh>sv>+=?dTDpcGQ_aN|%*tF!i+7Yv~nhHE~Sbcw6ywdJI}aaLBkKhLwK zruNA=zHwQuaia$u7~mUYsZD9P@(M6#)YYwbgKeM5IgA_GiGvjME$O%hSk%u%GOd9v zQDj)@{c{v?e4(1>+gJxCNSzXw5OTrH`Gotw?tb0~7jI{7C`;W-qzAP-bvZb{^Xcvl zP;E`tJfBaS3`vKB^x*=$g%M=2LH&a(LA4p!eG0sej{29eN|b2S#_Hm(#h@M=LBKaJ=ySVWD_450Vg;V8oPR{WuMLf|4QWjcy z=0G>1k9P&umwmmPieOJu9O@5&x!`cI)s|Oo@;P%wRpT?8klWt5>4?@xNSbo%ecIR_ z>HyK*(X^y4n_`MJZ*%7Iz;CxmgO4}X!!OT|1mCVq=5ZnA6-Bx&$|c?>;G%0B!t4!$ z*f2)g5I1i@oOl1pmtQK&;MFHLVx6D=8hTv4`$;nE_D`Tg429e&c)VqnrJ7dxfs)l= zh|(=exW5YOnHfp9KV!&QSy=^SMBd$c&88 z41dVbVM#0yWKif$(%`bnN>`V+xyA^kIl)Sv@&jN~xX%SN%Te{OIx&2qK3A<~-?c9m zrb3wA$Wn`oPIA6p%oy9^eWTwRPr&Jn<};PX<-1VSZnSnWXRZkRma`HyI-xq0muGb| zZSZ~T;^7%k8&;!(IS0rv%?#i4u%KOe!p{|&9lO(;8?&XC@y4;yv+U7w873yCCd&th zIFhubS#%RqU4Ir5u219C&$V{2!mY7y(D*SyJK91Yo4hMcJT%vmpc*?PVb^D19D&IFZ5XZv6NDUD1$H=a@9#pkko}J!G;we>?pr-UH z*a+jV;Y`IpBEkS2eFFRlzDH37hz?b;G^eTz$Qd;S4VF&GdYR}r2e8~ii;Dp}A=eBG z>xKdoi;(_I@xu5O4M9{A=ZmARa*~4ab;iNLj+x1EO*#YeK}_^L3=DBPE|f4P#+ZD_ zBc2MnO8C*~Mu&tL`CzlM%-ixhS8E|b!MALzQOwbjtX+LQ@`{RHB>QM|pHNte=!mM= z!g!Ug%Bzn&%{LeSJ_-h~!pLtzFx0J85XGT)xXPTJfC*mjGhvjmMJ?#zcZMurB`LnY ze(MtGt<;Fuos51=xKx9PC1%uH_k`#6w(VoaX>ZNNsn;36P|^4QOs4DZWRs@MTj*#n za;Dg@vgPN`I$CDL?f=u9=&+9GuZrBs#ahx@#LR^?S? zN5N&ypU}tq(yppXonDc@#7qAJ+EsC=F5mw@v#3BEvwkP{3DAOYYJTsN7IVtDqsqmS zsULkGL^tOvl?MQVtVJ^No%zFmPPq*xp4mXb;38v?!EyhdLxj4TnmP_naGS|z8P=Gd zRSMpj_dS)BrB&tS&aRFpZ`})wn~k0tr(nRvlC#V;`5IdO_i4lf)A;uYXrcJ;Z;(Ih z1RUR0z=i&PdkEIs-!B*vG*7^sg5wqAZU2A&b^oIu!QXFwX#@m7tAXP=@qdQ>pB=0{ zZ}aZ+_mZ@BW2N;~jY&dP#)n&F-F6TDj*F2@0Lkj<>p^VN|NHk3B1o<B&Nd=}x}abl-i z$eH{MVbHj{_d)u|R(w6u@@0`=%&hN5uUNm55=`sg;#dvz`z_{qc9u}Jdx1Qa{VbVZ zpQ*v@gWhf*vE;a#Z76a;gf%4z4-F0dcxLytXBBQgUxnQJFm0j=k+IM^Sv2iXP8JZe z`hyiqZy48#KbdW?L#Ab1gO(bcILMV1qgvm??&E}Hs$FCF8MIkce0aYCUH8R z#WEpAh3H%kE)U4j3K)P{7wQ5m{}$!pk0Zag9m&n$-YxQ{@u<#_ll$sY6sG`b0Qx#aC{Kg= z(r50jomYmbNzd83+6b5*#6p8z{`B8_NVR?;_{96c$!1uHzJfl|!RA^nSD+5dY(PCETpAgafSgR`)Ew>u6+RRLhJ=bnM zIfKcE@8sx6@1L2SE#iwVEK$+6CwV7ct0?~*4@{G zgX0zb^+?Jc7yIMVXdDh5SEy7+o2V~Z0aeQ0uN3l_$Hmq4wW@|5>}SF6iD?sgJzE3F zWWjIoYkKp>e|!45doM25Kw|wv63UDWKMCA(-36bS0%<Nj{W;Ui=04xXirJkM>Pj#TEXO-Dhw|^Tbkt%xM$AI&RuSp4SGQL>w2!MCAvF#J(BkMh*- zid$N-vqsy7ppgqukWkazuK=&d+nU@`Y4t^phxmB;WRl|!%MasIbG|)a69e@zDkxcq zgNhBBt=@PUfd1=tbI&bCe@8f~0;yJCZFh9^cEa(r;L`*hUqEbCxCKbK^cg`HCkZ*t z!#_~q<-k*nSYp_R9g7(TjEHAiUYE16)oxzbkk&zjNTF7wTGu(t|KxZs)_q(oV1CdV zNpCWiy!nw>S7~Df%;=8u1X&^%iYsOVn`c_gq`{R3Nb1H%qZ3-+=lktXzT^p7clPzM z)aFYn*%xPINaS7Pu20O2j7aF)?aWlUxj7A2@#!PKsr_A7x$F-G&5ovV4f8l}T_(8f zP8zx60tD;a=Tgd!rL&{$C;4cyXd{dp2Yb{o+w{B&$PYpK)77=LTK;CTc1od=j#P^` zPR_1FZpDd-!WjksWbxsTLBHP{hf5!*c#d~2RpWScbanqg3@=}A*h~S#@ouGM+t-)o zF|SK?-rZ0Hrgd78c(}NB)nf^eoIS%1q-zV&-)$qV`TC9csUDK>cai?bb#>WTC39iu zXk=w&G?16*s6xYn?9fIiRBz;aF5NSF5sxGO?bU(_)fb+gourlxC$?W-C|M7%rg<1L}@$_VVm(NCL*HF>7G@xPJw*A1}C&r*Lv; zE8h~=s>$4Qc}`l_I~m>-K<>_Y$$+BDw+9) z-az?1(G8)EBh7Kb_S=k2@zIZ<@c4~;Q3Lh=#53{geo(0 zWK>;GdzV#r=Dk%L)7q7_RWY9{$cz!S17hIk7uNCs>^Ee8NBH(2;I6;otxJcw+8nz* z;7MWn%qqL%0)af^zN5|@owaL6O02WX8zXPv-B4Ba0HRlIo93lp3&0kblvF!+2%}4J z>^~V69q?J!9E&Dtx_YD(wLfAx;V?gL*!rXOdi_&P!{!8w^p=A7OosfUg>fy^B7jgi z2Bw%|ir=Vrls=|Rh##sHpF}69 zq_SvRJIMusm38ySiJ@1Ivs-djR%jzrp4AJ-{nb3`zk?~fPrE@wV|LV;CU~;8wia^j z)C~X^;>Qb&;WDUQv7_TrQ1n@0rW8y$<#hE6D^pwx2Lwyx=4t_-lk=h699^^=8LM7= zOtDQvE+lQRG3IGtD^pyR#z;hpHYqXjCgb0$U_|?+Ib+?Uxja9auSAIh2)*hin7ZKD z;!~fwfUl%|Kmcj_XJ%%Kf>+oZeThEWws)}9zM;tLmrxM=_X++B-YcCIc+JtOeNmO& zJ4PS%7u=JecAzvD7MUd?j0DqgLR*OX{@PCOqeo3`9@Yaa|If0r1zN0QjkNz}{~M|E zEeKvh>d&v-+ z#CxBHM{oHP6K7{F2U?ewKn5S11-j6a+I4SpahKO=@|POUsR!TS0|G(_sPbEiQ=sxH z4d>6p`^mM>(q#NWd@YDN;MUWEmI_Hrz~P=24Eb(RF690OQU3*!R^zqzW7h{0I3DMF zpGVNr0QnuQI5PA?88^F1>%UW?bjjNJr-GfouY)zrdDb&4&0-P}b9sr!^|t4`;~ySmBcVdOlljDyX> zJb#yi_(zEN>G-c3m!Z`2Gl(4{zsvvT0w^n2(X_I93eT7PxD`q{IRa3JAlszZ2n%t54(MqHG{6UwvOGf;@1p_WG0Kk&!&$j z!iVIt%0?rEsW?()mqE}2cYPty!Dg3hH30Z@S?cE?Hj*st!~T@3V3e~-)aVk1qBwFC z>-!Ajw#a068eq92uGD=;<0#?NEiJt$R-Pocgz=)7}V%PEAh>0;?UNodY0OiqNKJCYh|^ z&fIVQZJEHTFO_VuC@m`V7}wGYRSK=NfsG#>{c<{neF2^`II`dW$H96oga2=`{#UqK z|4aS0|9>!yfd5%v_kVEC67%iBOMkfCb*N2-n3q$O*Jjp4Q?G6=jW)RkhwkX9YCw5G zpaLTwUoMODmjzJj`LjL0JDc68o}+)SDJFcO6!_K-QTZcHd%YmJYT;Yedvi@>@zF`9 zgB)A_1f;=EZcerf+0Y!fBId$(zu&Pap3Mjr6(j}7q< z<^Q|;Sg2#bfZ3An7W!m;x-{go-tAtK2&!CM7`;UAw`Pqr6+(zF+Bbl5L2Dqx%;_xo z@>&=NC+GS;_V4rUNSaUgz)pJ@>L_czI}5jcFSq+KKAr8mB>l!aJr?N!v%7+ip82m+ z1IUz>KXKnQpz6N8-1XT}djX{vK1+0Wkkuc#?qSFJBMg%WK#$z}(s>yBK;Mlr@pKA{G}uuL?~mmZ;&>wsjePrb zw|y)ym!%eVxH}a*Va^$sQ?Ea;OL;4So72j4@s>t1Rjy)J@;z~^dvHocED-vypFBys z1tTJ<^+eIotY}J(R*3}CdkYeJN`uGxcCQ&bwx_+InvO6}Ee{qMQDuip>s+tNw+BvW zTFRB(^c5<52QCIDS9RL^dZSSy9&;GcY6P8cI8PmZ{O286ymM5>QeEE!S^-w27T^1XIeJr5GD?HlZhW*k zf5rskoyyUYx#X)zsqt~GI=)BJ*HpP`dG>QYZ&%Sf{+WDgdA(gjG>oaT*P?5h3QNTTB87d z&2;T&pmuqGzRwlphRXecO=dAmCt0(H(&BZlJNHmtR9HuC+E=d7Kr^a#1RWae^- zMu}&s)!HvtoreM7XwsH6tfQy1w+=jih!Dy`F=-T7twfEcxtw6BPzrp-!y4h@JQk3= zzEZ#16O2=+x~M*8%G)4@PR-2d>gmPo?f5=9ki2tFb?r=tc2jVBufn`^X#1$naU&7*ZQE|P1gA9 zB~uyuycjR=H4I9p%`oM&pG%5KE`8ktdOIp0qE0wJ8n6RI?V%JUZqv+fH#M?jdpGUZ z;jiYhlU6_9JKlSDoh%5y9wK6)a%ibh)~kH;)1sV&e^#p=>hqpCN>1;OR8`uYTxu#RQD z=lewPZ%4M>rFW)U431-jRexNzF`$T2w(&&DOxrf~PNA|h8@;h?`6VsSx#M?+yoV1SD5~^DAh#8Qg)!4x3sy7a+rxoj+_^ zJBR|qxh4}c`|HYBGkRW|;Z~u!W;=e1?bM3+$(wY1E)=ZW4 z%RpSpa8qIcSM;Xrdq;NU4RL(f0Yh>nfga;PYAxvT}}5NGM;K&8kLK zBH2NEt6rm;rOiGsDeYqT^@}cdLn#YhnoJPXF2#3WoZZvnFmC%kl-6}-l|r4EM_y|c z)Tfl_?UEdni$$ZXZshCkU24IdORwIIOg8cr+y9*A8q+oP^Y+s&P0re9FPMDJ=OBxNKizD^^5)6N}`nWpEjOCh}9jm{dwWad>`rr+ng5dx+i5=tRzazyLC{tSg`Hs z4O=ca%C%%dV9_)$OfqXXQ5JKijpc$|Qux7K?W)h3UJ1WH0g39R$D+H$Rwmnq0E@-E z9lCklvpRmlTj)^=?Pz+KMbzOA*=!T_=EMQC`i5+ec26ZD70d(?Tu-)n4F)^iog|=e za-G!o27L>k+Kc9ES5=5lv{mslQD9=!%Kubc>a_x8GCGH;KV%wTbKf(q-z)UL65w3)vS5~HitM^2DyZbAbXR7e5L|s(GSg67X%B<(I0jXl+ zfT7=j-*N3G&B}~yO10hCdji^U8Fr&uN2Ko?DXka{W>-fy>!F5??@f(`g&t@FcqH)A zA6M;WWxQlznddY1-CWb_K4j<9MmjX1ZFhcBg0NYM%x|Qz*J$MZ<4idIiq2cXDx1Dl zy(kJYX2M`Bn(=XSxOy@2a> zZkmtiX1;C?B<;d_+V-6|zZZJqXmn7GC@4zzI)F{cVcv5v&u2^4*gIczv-hmW3y*@% zzxQ;l=j*R0$8L2(ArCoUy=qRjn1pka$UAG^Pw2BIUAJ^u1)|T%ccN&fW@oovn$Mlu zKi}A9SbQZMQw8Qvv9|ChTwnXqQLjn+uwITD-QL&rIH7}0E9TFSW z`i+L-g+W9h-wt(TQpxd^FQjv;_Zu>A@f}sjabfxB$+oY{ilfuROPE3D?_YX1NX~Yz zBjG#QtX#pqhVEV7^(RI5XlCQ7?3YCy9}7XU)E+<>N#*IHN#?yI$;$193dd*n)+hQ~ z?*^GJ4Oi`?S<+x9Hy0*a+`{W2Dtbd_gThV3my6%)*=qGCOO~Q}|4X!F?+K z_{fjtv5gIBY;;VrBl=?XarItb(wW@{nCmFr;Q@|T;nh=`eO$xg?)Q<25iyAoZkOYY zXXV-B`DgPL1mQA?Ns;Hv2V+wLwN=w7v{~yUXc;jU8}=C{6te+>&Iy@!;(E7qAPU7A3h+bBfzpN zEV_01*hVN%^ca(Szupu1qvy9w->s*nOY=!|dXxnD8u_0(-$1aEa!2l;htUi6kz9iC z$ktKrkkOpb@$OLGMfBrAcsI(h134ooBoNeQ@=m;SRc~^C;T)Ng6j{J*)18sLz+iD@ zMOt2nGKf=?!YW4>$InDYQ z%eiSD*H{h4sDcRxC0Ruq6-0lVW5!0HgRFyE)h=3yQ}6^OcBR*7QhFy)VxhhG%vm0? zlpt7XqeQ_sUH?)9O>c#qmrza)EHWrxj7=dO#2*>uwVj$8?68?#U*zQ-_dL6^#b*a6 zc~E=4_tZZ(o)7n-koBjKO4rw~hXRV~0lx6(hrWcn#KC4?ieILwTvYiw!=x+{w0=Ad zyq}tLzfo3ct_dlAx|Pc&bkY~kn>U#?Sz^vG(PA<-fMWxz1KtQQEp4Z(fMVa>?3LGn~&e%u`1;x7ay6?+M8!QhR_deVujnzKOiAO?R7bu zO5wd|m$rTg3wk1Artypru~6ZRo=A!Hmid+(e%!HUNE`l*pryKUDA7zu34Qea;(g1f z&hBS_Hoa0|$EN#}dZfscqDx^3FsFhDD$ByTA#9tu}&8RwV z3%mkdV267Z3D3@=-Y2-rlM*%go@Y;vYpF(B63=_dTr&-H^@zX)P)lPl`DS-2-mgV3 z2&5kF&nt_?E8l@WP^F4&9z-@^U{F8**+*kmzR+YIx5&ge7FeQ_T}uSwq(JXA`);vA zRZXSwm=0x!<}kxdmWhfy8fv+kH5bAsidO|3)~W#)MR!U-dEpYi`*M8*K>8II4l~kWQUAz&zl+be9)lLa3Ev?Fi z*IqkaJQQ3sQ*#Q+v#xQzRy9#`8u6oF@I9_Y?>1;DhQ+BjmsKwZiuxqS#~5uJY>?|Z zTl%Vxj3oKK8P7INVt16bM-End)Mnd2VK8k#u(U-)?-RW5A=|#SL>MIu*Jgt;td3oaI1~`|Q z=M0AQLJWKid;pAls*GP<*6zLi`7UE~scd_@ z+eQ$>uhwLis70_+c$sjX0Aj(WBZdETdoIH{7O#k>$Djcw?{0rLlJy}Y;b%U(nad|f zOf)OO%F=hZhXijcg%}YROIL@wtfc;2*6mL?Q#A5@2Ya$UcOHgCBT`4o(PdE%cvkxz zYm_LefMKHaQPItDS!e!q0Fr|sB+wN~JNh>BjRK=3?(WXAO0x3G1BL1Z*|OHD3)-=v zcENM>AfCf_aNKmgE-PvFC9pnWtr+M4GOw1Y%+kHCax(RiZW%YofQ_62ETgtG9D(ZZ zvvHx;8Cw7L)mMp;01G&K)o=T&HyabGuGt8%Hr~9o{v5^ibEq8Csg|@~DfA=s=4Z)t zwY+3u9GiLWR5vzdk%8eX)0syn*M0V5*X8yzbx;>uKrYKM6spYV0m%l)wbhw@y9Dl& zF*m`>E&L;tyn>uBIn`9Hgg!*Z%!QSvZKj|pI$BfC8m6JZ>hrSYY5M3gw6FF$D$^(- zQy52kGxeJ9+k*wy)igMDu2hOe+?rd`aNFfhbMSzKRIk`M&L!-yu`yw9hfUXrk%U|$ zC!2?_D&KfuojaV_X1neg4U^|_iXb*W?ykf2Zi;7I6eNT-)z&TP4p)-s?$d!j`BO*e zk0LJ@wYX6>p~r4C}cZdYJ8a^ zNh{)fm2V+_I?#nPVILhH*Qop1pD`dPG{m^tQ6EI;KYnbF`kYdUJ!I2IjYioD_L;uG z-JBS^;1b=(rD>a(DKL|zMxb3ZnWN!uN%6mh z6w_=dV$+x*=RbJy_|{~R+`D}=nqe~2*!YNeW=SREZW3+!ivIYKYPFKK{cO|u6K=5G z1{VNZH{R7|f|=(Wx;paZ-dI?f)scji;3Mv?%f>5_lC-c1w5I$$8o(qv$Gap#ubd4> z+i0=sisuBgs%Heu$HO zVU@6@fT8B=hbTjBDm3by^^sM~20SN5Q~_4iFkk@b{FEA&k`+Mh4NKRFn-8f=46c#l zc>20)(K;{hNN_AUyP<*f&`r|ZPdcM`-W<~jY~n;NG8f{Kd)R|wPt2?`?>Lda=yctd zfnd$d_uK4n4AdW~6BFkn$p!JLHI7(Ww+6-9t>3QCiJ)f9Vf%_P=2H_lFn)dLsPP*_ zgb!Gz+^=;x){@h^n-nL`9GQor67x}&=I%661?yk&(V5#`;ofzfoa@fooLxPtXVuA_ zZ^Fu0c`%7ZIa1~stv|ouC}Vl13S%bObH>`>c>tmTrj-SKqe zO}TNY3GrqGD4(aAqSuA-{b!xWiM(AA^UtBQp9uM8F-LUEvgPZ*YXMmY7e}|v*|Z(M zCTgpMI4!GLgCu0%1^Ox@=jDw(^A8uWmPsT>tNtBK-B^!hsprL*fbi|Zx7zbjUi;@y zhU8`!JG0L`N%Bz4O=~oK9FW&99-TGahhLsf1-lhVtiKzq>j-|N0S9j#L;-=zXSG2N zSc|GdrL3d{FB=72+_Q2X(9`7r-ne}+JA1#whqqjNV(~Skq;~puv?cq{DC&&A0$lVJ zUdkHcvgx{V0d=%>I?D$vP^seioaV`_<=iPl`hi_e-RvmoOlc1O^PZj~8AxZEC4-p# zaE9XCD@bSAytCk_!x?HBZj{+b+S8VNVt?z>%V*vx6LyqGi8!5bjdLAd7tX3MA;Mw7 zlwQtZJjb&eLrl4OCtx(cpF!QDIoPI%R#4J?yLox>!RMTp97xqxBc^tf;=sU>?@hbQ z(#Yh-qZ7vSrK#E*7!N-=g1~5|=LmgY0xyj}KuIK>&%4TF5wjlWt(G!}--lW;Hw6aT zQ)@rw^c&5(uT!I&+?HyF+MQBC$@~5Fa4%R)CMG$lghM7{cdEKJV_jtr>44_IN}dW( zet_6XDP)~1&Zr>cO+*y?c4M=z^*XV)vv<_MKQ(_+N8fd4GJ8^rq2rOEAo0Q*3Ln_3 z;e;qM1T7%EkOVhP+G+)_DVi=5Wtbi(y@px}sBx{A+fN9tn4fM*how~7P(}a`DJFSQ zrL`$dnQ5RgBzNI`tJTWZ<&wFFnM+}0h6G3so13m=Wn%+|CkXRNwzu`H9(3mFKxDqx z@27KvH%Ov*?ND;yJrN!F(xxVm?zfKAN4XO~|Z2{-?-D+7; zho7PAnZa?gu_CVzl4V5 zV8-UNyg0m4KaiJI&r{FFO~2;T5%{lNww97jAEffZU5TKF&j0Hg&m}&*PPb7$vs6+t zTtgg7;P;WFtsB6{-`l+?ABkn%v>SC<8iOzM>W|`a=PFI-{qEW1VMFuoN+Dc~=|d%O zJ5V`=v6#)S!M*Mu-r?+uRP0TM`xY@W6Z$tQW^@;52dZeS{V-jc^nhUXw4|?nVOlXf z`sQ=64X?B+}?1kk)SqJFX26N5e0?HkZ7llbZFm zf6Aj~As->NJ_9!(GR;PFA@vg0}L zXhMBeNmEG_bPp#O&WCb6eWdbI?=Ig{|HNjO%9Z!iJ@9kWmMC5A;swrhJ-k45&ZKRG z4PXD&>kW_^*B7!m(=^-Q{Zz^9>%mIxnACvfC>r%0?P@~=DJyd(njff6Q(>eM@s-2M zv)P^$`ygub*k&vBJ3W9G%uj6u=iWR{T882lr5N=-=M{ayOdmtAbiO-fx*Q-1WZU{A ze^B}bx6`DC=k<`HJ1;AsdhX}HxQ=@B55$fRyz=?pE7oTMK)`x~`Q?=oV}zXoM>kg17YB)uP-gaw01w0`C^x z&yRHDy$C|P8aLaMTxZLxX6Q2<^ACJ!#N|!{!>LEX#POvr{y!<4JSQaMf;huwx>{4)@)pn;6eYGg1hK1)krdL2@)x-XbFY zh1$raY2VE^=R6xrKw;5nJIrS>a;7@q!|GuNG$Pf&v`E{n{W#ESWNwZGk_?}EcA@5O zwGE{cfOInXn0mB|5}xly0v6a%#(CA=REn|6`tDkKsLV_iwt%X&zUF^(0lXHi4IoQp z#uItktP1i2v%qyWQ7Z#>?z~7{jB8c*NRwjc#$l|<5}~htv*&ha+O9qO(IZCyj$pwl z+p8-#28*K~jMecA4aYW?$fWjHJtAI*?=Fq83855|f5#!*Zwa=xoy=kW!S=K^^Grx` zbdvSs9jEN&hcCbAT~n=ZEbi`2Fm7wMP2F6VFn zx+h?|WoRp;X@Zr(_AfRkcNfCVn=r;Yh!{0Q4>n&%UDp>2AMM@x1G^k*CcJPbgltSC z?C#FFRD0|W#-Hq7Uepmmr1+>S&W9en(~!ZMy889~1QRnTI&on~AHA4-$ymv}VjaW%{8)O;aG2jDU(dxmj=ZcTk1Tr_}Si4&%SPQE4g?khn= zlc@tbbZM{nq+yQ{M{42D4UEBumzsbXa(>dNa-4F*7gy}u>}{7ND=BH>Zqm$97y)9o zrpKfdjq=@#9a@OJP94}-B8*sHcbv+1g+d{52E`i8+>xmj4n&+LpNb_ZDex@EM`gvX z>(~W(eRo^Tfvb$2_1;%+9S|1Rsw4UL)@(|r3V~0ncQu2aJZ#OC1g&G4Kyuhc#L0B}vAX0ClKmum;o0&IYA@qH?2~_GrDB0Lk zXiyq}7oe0@wI(cW9jxc?EEha4z4n0e!uhy#NX6^8NIat@4o(P z@ZNAf=iU++4T^KupP4^7)@R>-afy`QBFR^~Ydhgw*yuVb_qdpcrtQNXx+679J(Nua zL5Yxhuvs5Ugo#FRS13U`Gx!EZ+w~@J*=t2#XE~DdXhcLMR%Jys2m#<$G-0B|W|9a} zvui)Ur7Z02r8mZ$WV%Vc)MMP%fG4K3| zLWhESeU{PA&aS#(khnu_069g(gr{lb1EyC3-|^l=eSKq6xbCm*(<*>+l<)m2_0Kem zQ&2)ylz^yU-P``S!&q?Pi1--x7#YBS_ilWAv^CclW4Uk6#SG~ZuI9chpNq+~ywd*w zOU;l7F%Mn?_QUE`1_Ll}K`?u)U0|@;aUpqbLz-O*SXWgmPQc`7@Y#DEcLoh95F2dX z?-N4tP~#8lGCoFoh8ATYCdq<;Em^1&y&OFzrF!Sef&UAxc+il+2!xeGkSafvq zjW=MbMo#g#9d3-39De;iZ*T%WDOZ}-w~`h z7IS`PHz+~yd0wH5&W~b#S==y{zR#JkE%u5%=$M=nAu?z8U_M-{yFp;T}~|L7l2fV|1_E)*e!5H!`h88q+S3!rIINNdWQHshgH0 zi7EXzNbd7Mg47^NG_t~?YUVqA2q2;4vo$}!pjkOOE7;6ZS6s>{rbM4?o3vm9>P2eZ zcOr-%GcVW%EL3~Ll`}xlofPSN&*liFzZ@*`Lb?&=abLK)Xt{-|SzUy|U~4&mW#@LW+bgAx*dj@BnnSU;-e5#Ss?O9y#_P^%~YwEm3I-R|%!X-d+34d$S9 z)|x8P7+&HQ$e+LWb0P}Lj1#zXWLO=Yb!Q-=r+QV>HA9pD3u_@mjTzeF{W;f9jY*d^ zh?clLoe{#M!3=po+OC!#Mfmv{1B-r^n(oZxuV zqr&Llq^##(RGx)|D$C3C!y@A&BegZk{31UTX&dtG!FG>r>NTLZ#K85gpT^4L91cfg zlS3cir7@BQ!%*wWvKlwT*Ig%jIy-Yg`sC3lcj%ptzE+V;^PaC?V+>dT51B5ZPYct3 zvp}wtU-+>!DULJGU%KQGA!W+Rk!H)*Hw}>45>7~Pa?ZOhe8czkQoeeE04D(9HuL*L^wFK z@%dQfsV0Ua+AYd;So^?*bz-zaZSkt*7XC7!{c3|lm4&>Hbzg>e*0gSoyX;OB>Ee1l zRWo4`xM97GVLvhDss%-PwwCAqoAV-ez{79q(T-9tE6lZ^Urbj}P>^k5QLTZ!mPNKf@tf%e@lmIAxT zm)GO>N@GxNt;`c&%7o2@l9)S~@;^3OW;-SGG**zA`BB)ADAp`UmIfgtO3u?rseej6 zhk6#yNF0>K87IT4#jMF3#K;8dIcNY0k*CHKWq>ykV=4PdjyaNy_;VI#mdvvVnP*Ob zYFFzVjxf56eG_F=@fAD|Y1${rh>qVmWqZU+2^B-z8)m&rC`c=r!{+4A>_E984`OJIX7b`Rjl~Umqle z0CXr^oHeqGM2-%sDw)R0%nUwi%+a8d^5$v}Vd-h3LB`752qYRpL#8z~^EW;0`7O@4 zrODdBcHXM{2iR8Aemy)EeY`^Y1yw(q6uoV%>=hKcnqWxtGd=V>({;25_D2Jqs2sH+ znuG`#O70nHU^6T_GV-Z*CkeJd%7A65j$C?#TxL|z?=x>8Dlg$)DDsEU%IX^z3Uf^f z^H2QeU&b>se&S{#)gXRsLRRCp3vJK=3>ku2MxB!M)Fxg6CbeiP7%p4VQBz}Iucw%T zPD?gH1lFc#Gskv+e{!5*S(m7D{~2|P^2zxu6~{nnkitFrhXHR{%^6+m4M>AOF4y!P z^+rTQtbP8>Lv85f{16}!;GnTxKFJ`_vK(Wh|KK+1XYc=Y@m76GH>1ovTu=y6A*$%Q zH<~Vu%xv?QaTPc>&2h>XtutNN;ALCZdnU#<}-iec*J7(o$ zgaC!nW^8jpsg*&N$hU5%jHp59B!7Q7-`FRQIQA0$+$_dh-4&7;ZR6LprE(TF3(Unn z1vMW!%7)8uOfy&=hhAGm_NPufba_-ivh3o?Y+L+3Jt%_nJX*U#)|!gMBi)E0K>z+K zQo8S0A5YAC;uwC_gVx7x7ia8=n6aYsCv3^RB{de58l?tgv4+OVA-Ynph_xr^S-G&~ z=!Uluhjd=gqFJuV;0E`jA^U0~xz9=jw_g%NArC{BU13RctE?OxsPoIb9CgHF;csL* z0k0WptbO9T&;0!v%M#QmTVDmR{CNof#AE$XjfFRZHXo{v)+0tUTOL2!T#%9 zcc355wSZ#4GQXuMHNf#SV@+B2;NWHq-EF^SqLhV{1nsX0+A>&HF$5FBG)e@J{XIaP z<(7iEsVcqHJ>H3U#wUC33&-8Dl@)y_u9Km1A+dWXI1sw1QIEyK(#;sOba;Ld(N$^oXE!(Iw1!2U<2085^=%V{5Yyuf`adW`cD#`3c95WsT zjlVUwo|^gCy@13WI*mQfFX^XPXZ3emCXC@e^9`PxJB?nbD*HLKQ;MpDh7Sq+Vqd!2 zer~fIX}oz16Q0Lp5Ru2yeU7YLXZ-R7;j-RY3hLuj$=IHZ_ipg)s%RONnv6MsTrd_y z?l1mg&vO48m*;qt+;kVfGs?CN$q;` zrp0vZkkZ3(P2Y-Kx02_ezc7|)X*vFP;T0*p=w-v01p+b#6G54EJzb%S|73Nb>)>hXm+JRoCx z+wwsDZk9&Q!~-qExvzHvGC5g^Ceu4bkAJTE%41xq5<|~icYX@G$tVtd<3Wj3&q!ds zNcdtO#=yh&6ns1fu8=7{5aN$$iuP5v<`!YvltB;W$DK?;2Jlj_xmafzVTGY; zRFM!0^nif3h*Z1p4VTJ>YF@jG8`AJ{(l-1w&ZG!@apq5O(0ZULc3FmAQ*C{Kb>kOD z%hq9v+yG*qri!WQ;kkFjON3)x!x*)oxA5b4C3RnM{6&>lBk-fr{L7-cqaw=9&2zO7 z%ly2(wzi~{{V$^AMv<`(AqG^{QW;rg&AO8OX=P8aC_3KWXJ9Ea*5Xf|*mSx3A++R+ z5#q)AQI=bvaRjp$7CGcz*8O{uKfXUygPu4yc<3(R`@!+#tlX4#-Ni1PxK}0Z&TB-H zYp>4-cTSG#^HDy__F-7~8Bt&m@kYo6Hb)w?~N-Dln#_|_dyTAwEgRfGh6dEy@x6_b^ykQF5taj{xPosxX| zD!45k>0}UvZoVbj97b?3ZZm?5Zr*wmOixDkIAdbV^J4kIW=`Hb@R)?E%b9{ED4mgFpqxVbVN3o*n!q@NEOu5<;lmKA z4@Lub327w0m8)YG{DeTm`QMA5^8H>4=4d)y3XVNzO=Wi&Fm~Zy^`AD^2wdR3u&JROs++?SEm7lC4i4$TpA67K}to=yfB)`ZMbUjO1If|1d znOAs5NJUjVvdNf5nB~uWzr*^@kIa$4g!|#7&IJ~IA%aEUOIcqZ=_M)UjBL5+sb&nl zx&87LRzatvnP<#vVki?kYs52IRh)4R-6_cBO*yQzs5EYm3la=&1?b0}_f}sC%AR@x zTs=Fy_5EG)lbsRMY3J+squ;0hTY29Z4Tt)@>m-6CT7u|A??j7Eh!V-sdmB+k4I&Ii zJs2cvqKsZbl;~~rk`NI_A0_HUkI{SQzB#}D|E~M_u6x)0aD8AY^A2XeyFB~Z&)(;I zGRDuwm##a-&m!m$X&Uv-Zrwpd$9G92t)HKcq_ z8_uoV2`;C5M)&3eWu1c2`MndUIyrvqVoN+fMXsoWN^n`FjK~P)d1Rz@xFTiI|1hfR2eMDl$_3Vy6?fJ`ROK)#E9dnH64s`R5%4mltl7r&242vUkkLveoy{ zsbow#=<0%s&v0pZIV>W=GU)P9uTaxvvKp!l{M<5gz`<~uxU-0;Xe-3c-~ZxE@Xb)u z@~cE|Y=HN2Z_?@M>4y&=LJ7&Tq+w3h)~^{02{JsMKkwa`tnuEQI_%-fCT5VV%-RIg zp5}g=C{(74r{_UW0++KN$e?0qMeVldT5+@KZf&O+Ero#neu>b?Ui^U?qY4Q9<5xvM zxI)x^pxB_~&Ye4~tgNcB3{nAS2XEiLor-;P`xDgmvdQjpSMRe|^V331o$I9a=nf#x zc;B9KS-=c7&unop3+?!}@=cUt+nlx39UCWYTh4kRHg2stg=HIlRnv@da^gu=DbQ>I zVyF3L<{*=+^{M&L3cuGE4Frs{HA{=vlN+(j$m^!-U-wLOczH{Yv7%l}m0t~x`FRE5 zzBn{-Cx3>-3laiFyZ)314FO9(-tnl@y|R0H*btsuqw>F5Vz?p00w)Jf6!eU+ ziJxYpRtbvuibc}5a$7pmk}aPQt<7gNI7V=vzK8{x?`7G;iqBo{N2iGkjeE(dNq9N#?+I8efjq58kPNCzEbRE!@{q5 z8%TDAMY`efjJq@Xlk#ErNyKtHle||4&@bY6+o%Uv8R_dT?4Rc5v7R3^S@-?EC(Y<<{@1TT1H<{! zw&9!!L~8n*iy+pOJL6_QZYdRKZ1NN!cvnYq&MNrT<+BHKhhMimq^N7?!zJ66bk2J< z<_Ua%;J$0cnWsv5A>WQ(=0#1tdC~iv_f~qYh}K00rmYm8OFhmOXuc-Y!7fuJ8*=(u(F!3#|Ab#jTL7Ho_+S-o(q;sNJ!Xj-fkNi8To<)stOftXHIJ)`89U^ zz8h8D#o2$Gm`pafFM@B6HiN72B3bbA5Ttfjr>jhIzG5dCaZGY)@$n+o--seQKaQ6> z7|T$1Z69l;JYsoI5JAe=fb^g=H~7G9m3Ykjga|M~N095N&%1Z)=epD4EgcVcMG zGe#<#;M`_NH4nLjEZHKBj<%{?R<{_Nj|gsc%=(d$D@l95 zGP0p7Eu3k&q4f4^j>PXnK!_~~oKDF~-8?=|@9HLL+e$kl>_D6}n5j-^w~aW}P$ehc zSF*OT85NLfgd^U+-KoUgQX(M;@JY!BmY$H#{MIE1f~Ky8ua}Fj2Oawcpf-oe&^gB;N=(K%Ld7txIX=Xuc7VTJ5h$0b?)dR3rvCRWD_*%OBR*k^agy}9=clO zhr^ebsCXz*@hi@y`RaVTFij$=Vybj{lShCr=&&E24_#Ibom<>xlJZ&@tA&eR$%L(* z7}*bcNLz5`ece7TEU<5r3mQ%=WZIt1^TX7UzZ6c-Wo66IOrBmGc0}RgB30Rn6C5pl zH(d@XK4P52b2)|Dcaja(k2Yk)_Pc`UNi0TxlgtAx>VE`H9s5 zSHPrvgTipw9u%y)XpzuQxt*TCAI^PWX{=(TU?R{uF?qyGqtc(e3r^mm6tbISP)+{R3!Udh;f zr4bCKJBHrAX0o`xl)Jaq5tEyj=hk{So)ZD_UCVz$M_g%IMy7DF6U!(gMyJs^FggEP z_T*Qn6Wyym=L$_rw1mp@wWXc}3D;77?aB6_YoYcF-Oa?JP|O;GLZJUc5`sS;b{D#| zaur3Z6eT58`eZoIAS;G zhg~l#RY0a2Rr+DS-g`=@(QrKQz4p5Qn2$UET|dQ*MP4*tDdPQ#*`Y#~+ zQ^E#DQFEPfD7~?tAE|#Go{hWB84f-%PRDKkV;Rq#?^=I+nhYUp837|E356I?pzU3F zXpQRF8z7V5+X0EmmP5db)tDQMHvMH15{`S4(VF=M?dMnB`*wrmK9Eb)qoDbP(v6G< z@A{bne$CaDnHA4^TKZoG5t1^4y>+?ukaVWs&x{02FI0fs22;baW~id26{_`k-X2tC z0wzZ{S!yS!X1`+tniVVRrx~NN=tEYeL z^J~^S`nGIsAO;CI%s<}l@My`+@qP|F%bmOay^nFEMH`j3ZkJEf?`Pk_8^R;mVE@dB z&W#TW2%8y=`Vt@N^vq`9XymiEIDv(^e&WRX;c0+Z#CXCG^uM_PAe!qze!^J;_J=W4 zf#9+?STnvQ6r3 zeCI@0s%x3}S=KYOFGcvNhB94@-Egi-YHBKQs5SP3q_x)rGTZt=;{%y8*?wD%Pa{b4 z3k!uHEt34DZ+;sY8jklS@r_OY@$L^v<^s<*2}v0wVYpEcCvHa7Xk^RJF7D!^THLU0 zMiBV&@8LXfEVKVkaF^~Wi8vb@+t8cNfzyLE47h2gO`eXvzGpd#p*XDO>OdyC_(xyg z-_NBbcmuX(8pYeHfNQ^qQ@ARc+ZrpGkih!u4PLb_EiI*r+Goka_rATONZ`^UeRaK@ zY>C_V1RBm!WK8m$>big=9QjZHf9OeU&3W%Et%*fV9xRO7kBU7b zdiO3iZEy4&QXq{wIq|A)J}&1wn(tKtQ={c(?A@9aX1^VX(zD)odK~%7aSEQ106JyZfmnH#+}S{kV&5tQ$ZF zyry{63ta2t7FCoeYbM#9Q0~MNg)Sat+;^-!vIjf)bYG=(QP4u)RuXaAK=%>&$h4QV z<@|=%}q&I&lCq#=TVb$Mfq7C z6_ee&sF;C%K5l+~*WnfuOxhs_ne3}+)7z7IxdD36D9jp!&HZYv7AEOv~12n>@Guv zbCPY%G`qT2lQ{!gmF6&46;$LzFNpFP2A##|RN@1OsXtTIa@IWN&xo&ip{39C;bS5R zL+0mwU;h|gUELcsYb%4<3PFJ+K`jCD*|V+^*$R)g>!w{xe2)v#UGGF&8xZ<4$$0&Z zP|XHYLznt6;3 zQXu#Ig+UET7ckn1Y!`&uHY%W+ohvP#GBR6M<6mEM)M(ov4s?AtUS? zor}Qh?+SIyvi`LVpGLQjORBzZy*A8@F);wzz@c@(w|<6l34R@x@p3RbbRFE9<+sz* zbiGQ=X!*8s+ewIoWht$s;?U%P(KoxV3JMRR8e2N(sOcFB1?)UrA&Q6B z-@ezsR_u&0uXjWrWi}rtB^h~cPf2}NsrmG61x9vx3(tYqG)7wvkP7cal~oOsR8W$$ zS5%e{^JyD^1U8n!c5SAYB2wDPur)=K2wmfwC#+O5d^_apE0GeLB$oM2l+W;>IpDBaw5) zFQd$YDeAAF!JNa`(a{0Qv@w&{iUQr=GLBJsxLMEv%t3~FjXR;NtgLXZIN~n^*&f)o zRSg>FCo(-fJs^}9`^pFkx`HedMi!|j0Cs`TG`Qa+81>#VH=luvKPqm2f4BHbu2i^- zrR?$fx?6vHkzyf1z|OCqHF>Ys!ER|UC%^(9jKeZ?ll~4G1GLhOnO31Hcu41(Y~Wt4 zVaeKTp4sJe>4z@!$#pZ7?OR@65hco7*@Kq4x`x+JPR_?OvjtC8<3Bb0Oey42&bG|kU)m5qCF;};Bky>`P)HIL9T_(d*F^H5 z-F?eqS0$Brx`iRxaoo|Gqg9E^v{}5RgNvdLFzLK^BQoGjtmava6`5l$m9(-vXkFe*ia{nD05rsK|kH zBB}>jX5$$1GS+AA?(W*?;30=cG;|&V^YimD@Xh@Ed{B>6PHpzM&lF|{(lIhxT=E!@ zQTu3R!BD6Td0w$Gwit3N zP>`FYZLgY02%q_b70scHMA>oek>y0GYj*eZx_W8<|~!Bd?k5G$BureYF3 zqh=x9^!;=W^J*mhKhRXa?wHe)eK&3Z#C!DAD+pPl+HB4rSLbGE_E%2wq2)m=p3j=5 zzc$exTV<5=KR5w`V)6IW$>?Pt{bp&K($Xg{Gj#TrT*v%XzO^QM9bumDYD-h_9do{3 zlhVJq4Yb#{sx*`N_++>Q*ql38%COfh)kO_H3!TT7TQxWT393f?+|gSTHM!e#eWFxV6#dYNCqMGOF(<_0UF9C%L-d=Kot?KoecCNqi5HWhCh_<> zLk?ZlB1WdB$U(k)N<~U4I+WsRxgC40ez8l-RN6}1R<Qpd0asF3t~@2wfBq3)#A)XBEU+}zw4dI?u>WoMgUid|Bn;mY@wZj9*>u{dRv zOzi=PkbP~24$Eg)N;I=IC)d#SCoL(SvuN{&|ME(I>;f!9(NYjZKNTG&zm2MvmR{4l zpbCA{9(alKvB}HL9WS@=>t^&W@)i{lne$yQlRk1YCGNafT~iP6wfH#Zv|yc=?YB_n zJ3#cNiMPEla-p&E(|Bjf>+Q?{Ux*HDK~LSWvs-h=o==b0;O$6GDc>4IL!FB$ z=oT$4Q*S8vq>#VSX#XBd&4HjHA%Hb{z^a0a0ZcRI?mhhB5SK3Z`gc*9@%Pxq-JW-3 zV-Ie3@Y|vE2LZAZ!lYaOt?N7fA=x!tK*D&H)PsS*?j;pBVWbrxGuJZkuOud7VO zN=}bG!p0h1U+he&kknk%w~D(``$Yd!q{vO6g!;yvudKt(XH@VMHTa$dvFZ~`RitG| zi9382XmaG!8rH>8s97jAE3d=mJHylk+6TL*&@d>ip=^LOpMV_N)XGD07Gh#ZldGLu zg&6MTycwLplE`MD`r&bD#OvQfA90)8ZRf6ELv8`*KpL^w%^cJMzEGf*Ef3J)u)EZB z#bvCAyWPy~`vWong##!yHt6~i)MGcvKC2k^+3k}^=p$7@9hM>KZVe=|9Ua&F03y;a zfi0Q_rT`M@rW_=-pt&Rf#-E^tT6UCvef%0qD6x`3zdyCwms$hgw}Y*IU{jMnT}psS zTn(!+qnszUz~iH3(+M94n)?9gt|@yA(ZRpMqa}ZJO1Rx7b3QgF=L|6@Z5loJ8}`*T zZ+^yD1UTscN(W8XKu~y)$jg-(<^>8VWcorbWIV-GGHu6})Y=1|C@B3Hm<3&24bYsN z1-RT!kI`6#n~>6vrkEgnyT0P_a%|~?6dgUoSzD9SjS{!pEC4O`Jr~Hs&B1?(8&N0) zS~P#`?GRPgNwl1BeTXpk-h@X9UjR$etrujxl7YTC{8)!OnZDLcok4k=b4qCg&<*C} zWay=-ZJNNoR!`CneAeid6mGhikj1uhMZonK-qXE##DCtNJyDL`*f$v9Z@~3t;j$Ey zm26F}V4J>5KMVqVA0MC~thh0Tpd@F1#Cxgh^Ov|671^=@bG}l+y}Hz>m$&i0bUlxb zBS)mQ-`ti;^pZWy<>MlptiAl1+LFd+QYEq(nZFF=@taPfd z+eWrw6tLSCeyZ#!>G}bDD3O(kszzv^$*Y?yAgKYhJQa+aE#LEdG%cfy*Q?>&uDm#T zx)f^(U{^S!(q_-Kz3=pHo&fRHNPi59cAuB4piY|e)Pv53fnEm9;DFBQT46R&{+TSd zXa?8-hc?j?Y64Qy_SO$Z#E}ZzoeIHNn=5Q6V0>Q7ee|+d+`efh7s- zjuTmzZhfcOQo1yC&j6++154F^@gFF*=*QcG=u$F;%jXtEAbAcvS&Ijs190d=C!u8; zl3qxfcVSJPO!o8yasEA~5k5L(rb^Q4Hj`4wDZg{@i7+=Wt&~Rn5*ys`lSVvR;N%zP z%W`<`LVV+0`M#e1dgH#l@Cj`#V_5%~5$9tq?mwunx8nHZ^K|<+d!So9cl#oXap@@j=Xez3C!+9%h{{q^71{q_|n~!{&~pR{!!++ zv%vmbs|ShEW0s{qwsY|Pk(qjMleIt8@?)fK$D`b@f<>l%UTCmXhv-Xyi>qwmjkfz;1|?jKFj{TSyNxj1|L_;^smOWr1)MR6XgW`w=l( z;`Nkzp=M^=)!8~Bvwx-U2P!1AP%As|c-Gq}XdE=!m_b0x$k>T;wCOTIOMW=3b#A88!*$2`*tfOz zFX#gz7Q~EuH{&0V8BRh^#1a8efQHvrm(I8fHJC`}L_{}V zu1sLFf)@0rmh3GbrAK>3BwCbe&f(rZT8V816}K}x%L9-Ce4!8Vy%iYnfUZiZ4Uo@%?UcPjQfMRgWLv;)!7QC zo4eLeF?x0mDTY=wtIqbXANmEfO|7IrZ8LSaCi>Hwzs$crDw26fN2%V9-gFcwhpGN^bFD1! zgwiaWy|S;K)aTxxZeyc`Qjx+$=66SDvMfxGevgRsnO9iWOg82d#zC2+;03V({G0*0 z0(k~c&AG2eCg}Ocb7eco!#^|heHMJ6MFgc)r9?Csmc7$V2sqI;(L~O!@19*E7Ht)W zolJrk#d62D_H@Hc|0W2(U%`HU8(mq7T!l%?aqYRQmnoNVk`wSiiJ-Ti4kRJ1Xu`>n z&OTw$>lN+tQc|bDeVPI^%FeC;yB!x7$NcE!m&G`RIS?)BvhPz+)C69hdC$S^fqF++ zSlGRL_x`JqJVrHrn`HvxS2ndbg>oK4+Su3-64N|#9Qh<@(WLKI2l{Hl!onb}OOtS$ z!;O{f@9lw_kbs0v4AlLGWv1eL8RKDERMufgF}w<7B59y%+RNV`TIK|qgB*}w>w~4C{|?eVtIfx%_Qh)ySlbvx3%8JZO-PM|GxM1 zCKc3~f`U=pZzX7=bULHOJ4n&$UVbh3bCO!iY2>UOv_E74k1PR6t#e&umPt5XrlLH% zXhWR*gvd2v#onYe9`&70SUsjK)PP&Bc`HCOdk)VzU>o(|B3>@98eP{3gKi2Cg^8P> zXQrM8cbs00w6zFo1Zu_t6LTN)+WefK&xv?=t0oz?+T^AVHiuW%v+JPCVBfR3EB2_h zux?upD!gWs+xN75jgA)5SuqnW+BJQg!Ovgi+j5l_>yhPrCf2_Z9>i4em60dNc|87x znTcXf?ytsN5rhcw;_-ReG8ObzBbV0|%l(1XBVV~K(bKM6Lo|hE$(X8&s(PfkzB%r| z*S4Y_X8pIVZOUBEa9ZDeA&B2}%&XG>m3U`ZcI(d0 zgoN`}2wS)2jQe^_{j7iRq-Rh#a7aPMZMYz}!Ipyvz@Ry<3VMj}TUL(h$mrls~F379$PRVlyAn9CSwDPY5^RZ+LYB zHSqM;26BL#S;WioxJB#OBY?^*UW@X9S-UqmAKwDC4H*=6mDpPLlTWHmY+a zZEjLp4n!q`{hhcfq`qLV(%=BQK4r(FJNb#!J~5iiNu!+SKmJP9HZhb$x+IMD*}-zoFrU{+MK-`GMdsJe5K65H{cg$`>|e zU!i$+{_xZonR)R8e=)3g@Uaxw3=v?ZI=E%gjTJw-&kVM&KJ+AREO=q)s_yOxwR%f3 z9XBVllIF^IKfPP!)wIWmlSh~dvHLKxPQ3~ycAuKU>+4OJ>jZHP;W~LP7;dz_6SpVn z0*QO75gM8Mi1_IrMxjMW2sM7$*QG-J_zU8E<&Sg%%q%G!3NRlASs@zy;a8)2#g+W4 zHgB_avpyoY6M2JGNN<0%M@JemC0j|3yqyfiyM9Idbi<2~<&#;mCfF11IyD)3_|1KT zYkRQupc{+^5%I_l#xRa~?^bKXmy#=q3{u*!BI_I;(7FoGh}9a&(GNQdX)6nG_WCcr zKdXC?rsPTb@j3G1)+854yA~2KYq%)Gc<*$QBcXCkT9BhuHZuucd9qFr{wMB-Yj+5@ z@be(ZvlctpIwXS=j2GA=d_ zqSH2u2h!x<>}ytXZ>xE8>Ss|DcMK9~aIz^R12{Q37+KGWhc!Az)1>4$@V{F)Y}gZt z<%{I!i_HD5WMUWcOu^fqs(j+xYx#2w;JVg61L;tFB$m%_e(exHAWih%<_57;RwJ=3iz zTG_qFkG`*j=FN~G-R$x}9Tkk;BLY_1S`VSPcO+3n6aWv~daskDS`rtn!QY2|Pql1r zK&yUq9>5e=*ibvOzQ4<`daO=b&x+Q!rOB$iZ_rZBPEkzGDm*IWXVT$-|H0k_u=>o7 z%FtYg+gJ(NzHgPp`3UW>NBn$r@4*r^@K)@UM05FtaU!7IDH*fOfv{ah7DZ_lsgkXk zbbC#2Ml#~zT99HR{N&h#laKFimql?k00E2Z_aeB$1DyC@Ow05 zn8tiBq9S^_>8cfmwm&?Hv>321%=@;+?LC>vRKIk*CeAwO4tQP&_Ovhx9v(;9M!~{l z?nLr1^4=)l*lsQMKflwHN;~)YxH(ZtVV3H%r!XVyQejdsHn}z5Ou#}iu2*zSUfhwl zfL|tfd7{{}3TWE#>}HH}A|+-Y8M=2{zKHVMSerDXxzr|(2 zTvG7uuTRzdArdzI6Mks?tb0C+OSkGL*563oW>L)p9OJB26rB!VWXr>6z&NTc<7Xj3 z%7tyAsxiY3kN*D8x{j+Raw2DF&7@YAbf(wEv!2~dQ`V@Eq6sIcihE;5q>@xDW({Uq zX2^EOROcxTE6*t9v}`pz{lv?H1i6C_IQwHFczD$RJn^v#Y_E8@sPE`nFzA>dCm4b1 zgP8WdHaFH!FUTGOROiOc-YIS6k2f2Ypy{vi+h zuWeocw*8+!cH`fHBD^PWl^*^(IEDv!u!#RWlI2eDe~x{q%<^Ajk^ftVg$=?R@$hcA zs4FY#|9kTPse}K&IxMm=sIL?q0My-WJ(H;gk9qL$-qY)zS#0Uy{loe4I9c4vS;G4= z_v_$gz{2_A%>M9h*}Bhxo~Xyy2M+{f>1w-9C$)m7yW_C52M&r8uMYnE6&3t{Z65y@ dUPavXUsS#?>if8q75<#AuA-@2uJkhGe*ik(G=2a8 literal 0 HcmV?d00001