Compare commits

...

52 Commits

Author SHA1 Message Date
Santo Shakil
3da42ca655 ci: run only fmt, clippy, and tests for the native core 2026-07-20 21:08:58 +06:00
Santo Shakil
8b22917228 ci: add native mise lockfile so the native job resolves tools 2026-07-20 20:42:16 +06:00
Santo Shakil
2786cc16a1 fix(mobile): target minSdk for the native lib, move memory-card thumbhash to the core, add native CI 2026-07-20 20:11:39 +06:00
Santo Shakil
ce718e6c10 Merge remote-tracking branch 'origin/main' into feat/native-core 2026-07-20 15:33:46 +06:00
Matthew Momjian
00cb50cc67 fix(docs): remove ref to synology channel (#30051)
synology channel
2026-07-19 21:22:17 -05:00
Alex
f2b0b696f6 fix: long press share quality override preference settings (#30030) 2026-07-18 12:07:18 -05:00
Luis Nachtigall
b5401eb120 fix(mobile): update album creation to use user-defined name from dialog (#30002)
fix: update album creation to use user-defined name from dialog
2026-07-17 23:45:20 +05:30
Ben Beckford
12fc8bac18 feat: workflow filter assets by upload path (#30000)
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2026-07-17 09:46:48 +00:00
Ben Beckford
f2c00c107d fix(server): return workflow steps in ascending order (#29999) 2026-07-17 11:30:40 +02:00
shenlong
f19f30ec66 fix: reset override for kebab buttons (#29380)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-17 01:19:55 +05:30
shenlong
297316c7b8 refactor: owned action assets filter (#29382)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-16 19:49:30 +00:00
renovate[bot]
f18065b3c2 chore(deps): update dependency @types/node to ^24.13.3 (#29892) 2026-07-16 20:31:45 +02:00
renovate[bot]
a3dd19cd2f fix(deps): update typescript-projects (#29633)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-07-16 20:14:05 +02:00
shenlong
9cea3f2375 chore: pump drift (#29113)
* chore: pump drift

* run codegen

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-16 17:32:30 +00:00
shenlong
507448d797 chore: pump home_widget (#29981)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-16 21:58:11 +05:30
Yaros
fe3eb7d865 feat(web): undo archive from toast (#27061)
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2026-07-16 17:53:02 +02:00
Adam Gastineau
90dc328c22 chore(mobile): update maplibre_gl to 0.26.2 (#29772) 2026-07-16 07:47:48 -07:00
shenlong
a5623c41ec chore: pump flutter to 3.44.6 (#29886)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-16 20:17:03 +05:30
Adam Gastineau
3a016f4451 chore(mobile): drop support for iOS 14 (#29780)
chore(mobile): drop support for iOS 14 in advance of iOS and Xcode 27
2026-07-16 07:45:22 -07:00
Adam Gastineau
0af8284456 chore(mobile): expose signing and bundle identities through envvars and config (#29077)
* chore(mobile): expose signing and bundle identities through envvars and config

* Move instructions to now shared docs/developer/setup.md
2026-07-16 07:44:50 -07:00
renovate[bot]
ad7a9bd189 chore(deps): update prom/prometheus docker digest to 3c42b89 (#29667) 2026-07-16 14:01:41 +02:00
Ryan Rockey
80cdfebbc7 fix: zero byte image uploads (#29426) 2026-07-16 13:00:07 +02:00
Diogo Correia
c50a807bc1 feat(web): add wake lock when uploading assets (#29820) 2026-07-16 12:41:37 +02:00
bo0tzz
26e07548a7 chore: add GHSA as contact in security.txt (#29940) 2026-07-16 12:30:44 +02:00
cevdet
f73c53c563 chore(seo): remove redundant twitter metadata tags (#29801) 2026-07-16 12:29:38 +02:00
Mert
d2744f52b1 fix(server): disable heif security limit (#29954)
disable security limit
2026-07-16 04:40:33 -05:00
Yuxin Wang
d8ed7d7bb9 feat(cli): Add --visibility flag to immich CLI upload subcommand (#29614) 2026-07-15 14:11:03 -05:00
renovate[bot]
834cc54abc chore(deps): update ghcr.io/jdx/mise docker tag to v2026.7.7 (#29893) 2026-07-15 18:45:35 +00:00
Daniel Dietzler
eda605c534 fix(web): clear birth date (#29959) 2026-07-15 18:32:53 +00:00
Daniel Dietzler
b75e6520bb chore: mobile code checks for i18n changes (#29958) 2026-07-15 20:20:42 +02:00
Timon
4c2d2284aa refactor(server): album user route params (#29503) 2026-07-15 18:15:41 +00:00
Daniel Dietzler
5d29f3edc1 fix: mobile translations (#29955) 2026-07-15 18:02:29 +00:00
Mees Frensel
e00dcf7850 fix(web): URI encode slug and reduce confusion for users (#29796)
Co-authored-by: bwees <brandonwees@gmail.com>
2026-07-15 19:32:05 +02:00
Andreas Heinz
e7f773197f fix(server/workflow): add trigger for external libraries AssetCreate (#29597) 2026-07-15 19:30:33 +02:00
Santo Shakil
9b15a1cfa8 Merge remote-tracking branch 'origin/main' into feat/native-core
# Conflicts:
#	CODEOWNERS
2026-07-11 14:40:29 +06:00
Santo Shakil
ae284a6c30 refactor(mobile): consolidate thumbhash into the rust core and clean up the ffi layer 2026-07-11 14:39:42 +06:00
Santo Shakil
110c8e1a0f feat(mobile): log native failures to logcat and the unified log 2026-07-11 04:57:13 +06:00
Santo Shakil
13a050fc67 feat(mobile): decode thumbhash in the native core 2026-07-11 04:56:46 +06:00
Santo Shakil
ef359c02f1 refactor(mobile): replace the android C layer with the rust core 2026-07-11 04:56:06 +06:00
Santo Shakil
f899d33120 Merge remote-tracking branch 'origin/main' into feat/native-core 2026-07-09 16:33:39 +06:00
Santo Shakil
f53477e474 refactor: drop hashing, the core ships the two pixel ports 2026-07-07 00:58:46 +06:00
Santo Shakil
b2b5841472 feat: add the 10 bit to 8888 convert to the core 2026-07-07 00:34:39 +06:00
Santo Shakil
c0f9c50abe Merge remote-tracking branch 'origin/main' into feat/native-core 2026-07-07 00:34:39 +06:00
Santo Shakil
7d27eeceb6 refactor: rename ffi crate to immich_core_ffi 2026-07-05 01:55:32 +06:00
Santo Shakil
bca556b6c5 fix: trust mise configs and ensure rust target in the build hook 2026-07-04 18:52:31 +06:00
Santo Shakil
ead25d62b5 chore: show raw xcodebuild output for pr builds 2026-07-04 18:26:12 +06:00
Santo Shakil
8734066187 fix: record rust tool options in mise lockfile 2026-07-04 17:40:23 +06:00
Santo Shakil
d2580e9d53 fix: preinstall rust cross targets via mise 2026-07-04 17:05:03 +06:00
Santo Shakil
07043c4af1 fix: add rust to mobile mise lockfile 2026-07-04 16:37:24 +06:00
Santo Shakil
fec11ab156 fix: provide rustup via mise for mobile builds 2026-07-04 16:29:14 +06:00
Santo Shakil
3919887c2a fix: include host targets in rust toolchain file 2026-07-04 16:06:36 +06:00
Santo Shakil
bb8e242fbe feat: add native rust core 2026-06-29 20:53:47 +06:00
169 changed files with 7516 additions and 4369 deletions

View File

@@ -65,6 +65,7 @@ jobs:
filters: |
mobile:
- 'mobile/**'
- 'native/**'
force-filters: |
- '.github/workflows/build-mobile.yml'
force-events: 'workflow_call,workflow_dispatch'
@@ -154,6 +155,59 @@ jobs:
flutter build apk --release
fi
- name: Verify native Android compatibility
run: |
apk=mobile/build/app/outputs/flutter-apk/app-release.apk
sdk=${ANDROID_SDK_ROOT:-${ANDROID_HOME:?Android SDK path is not set}}
min_sdk=$(sed -nE 's/^[[:space:]]*minSdk[[:space:]]*=[[:space:]]*([0-9]+)[[:space:]]*$/\1/p' mobile/android/app/build.gradle)
[[ $min_sdk =~ ^[0-9]+$ ]] || { printf 'Could not parse minSdk from mobile/android/app/build.gradle\n' >&2; exit 1; }
readelf=$(find "$sdk/ndk" -path '*/toolchains/llvm/prebuilt/*/bin/llvm-readelf' -print | sort -V | tail -n 1)
[[ -n $readelf ]] || { printf 'No llvm-readelf found under %s\n' "$sdk/ndk" >&2; exit 1; }
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
test -f "$apk"
[[ -x $readelf ]] || { printf 'llvm-readelf is not executable: %s\n' "$readelf" >&2; exit 1; }
for abi in armeabi-v7a arm64-v8a x86_64; do
so="$dir/$abi.so"
unzip -p "$apk" "lib/$abi/libimmich_core_ffi.so" > "$so"
test -s "$so"
notes=$("$readelf" -n "$so")
headers=$("$readelf" -lW "$so")
printf '%s notes:\n%s\n' "$abi" "$notes"
printf '%s LOAD headers:\n%s\n' "$abi" "$(printf '%s\n' "$headers" | awk '/^[[:space:]]*LOAD[[:space:]]/')"
bytes=$(printf '%s\n' "$notes" | awk '
/^[[:space:]]*Android[[:space:]]/ { android = 1; next }
android && /description data:/ {
sub(/^.*description data:[[:space:]]*/, "")
print $1, $2, $3, $4
exit
}
')
read -r b0 b1 b2 b3 <<< "$bytes"
for byte in "$b0" "$b1" "$b2" "$b3"; do
[[ $byte =~ ^[0-9a-fA-F]{2}$ ]]
done
api=$((16#$b0 + (16#$b1 << 8) + (16#$b2 << 16) + (16#$b3 << 24)))
printf '%s Android API: %d\n' "$abi" "$api"
if ((api > min_sdk)); then
printf '%s Android API %d exceeds minSdk %d\n' "$abi" "$api" "$min_sdk" >&2
exit 1
fi
alignments=$(printf '%s\n' "$headers" | awk '/^[[:space:]]*LOAD[[:space:]]/ { print $NF }')
test -n "$alignments"
while read -r alignment; do
if [[ $alignment != 0x4000 ]]; then
printf '%s LOAD alignment %s is not 0x4000\n' "$abi" "$alignment" >&2
exit 1
fi
done <<< "$alignments"
done
- name: Publish Android Artifact
id: upload-apk
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1

View File

@@ -34,6 +34,8 @@ jobs:
filters: |
mobile:
- 'mobile/**'
- 'i18n/en.json'
- 'native/**'
force-filters: |
- '.github/workflows/static_analysis.yml'
force-events: 'workflow_dispatch,release'

View File

@@ -59,6 +59,10 @@ jobs:
- 'mise.toml'
mobile:
- 'mobile/**'
- 'native/**'
- 'mise.toml'
native:
- 'native/**'
- 'mise.toml'
machine-learning:
- 'machine-learning/**'
@@ -69,6 +73,45 @@ jobs:
- '.github/workflows/test.yml'
force-events: 'workflow_dispatch'
native-tests:
name: Test & Lint Native Core
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).native == true }}
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: ./native
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@1af396ae134e4bc3b63d947e672bc68bf4ff9dc5 # create-workflow-token-action-v3.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@3bca63ca3c15020293b36b51737a3ee2c773340b # use-mise-action-v3.1.0
with:
github_token: ${{ steps.token.outputs.token }}
working_directory: ./native
- name: Check formatting
run: cargo fmt --all --check
- name: Run Clippy
run: cargo clippy --workspace --all-targets --locked -- -D warnings
- name: Run tests
run: cargo test --workspace --locked
script-unit-tests:
name: Scripts unit tests
needs: pre-job

View File

@@ -5,3 +5,4 @@
/machine-learning/ @mertalev
/e2e/ @danieldietzler
/mobile/ @shenlong-tanwen @santoshakil @agg23
/native/ @santoshakil @mertalev

View File

@@ -85,7 +85,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:a75c5a35bc21d7afe69551eefa3cb1e1fb1775fe759408007a66b54ec3de1f29
image: prom/prometheus@sha256:3c42b892cf723fa54d2f262c37a0e1f80aa8c8ddb1da7b9b0df9455a35a7f893
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus

View File

@@ -99,6 +99,21 @@ To see local changes to `@immich/ui` in Immich, do the following:
2. Run `mise //mobile:translation` to generate the translation file.
3. Change to the `mobile/` directory and run `flutter run` to start the app.
##### iOS Code Signing
The Immich Apple Team ID and bundle IDs are specified in `mobile/ios/Signing.xcconfig`. For local development, we provide an override mechanism.
Create `mobile/ios/Signing.local.xcconfig` and populate it with the necessary values needed to build and sign Immich yourself. This local override file is gitignored.
```
IMMICH_TEAM_ID = ABCDE12345
IMMICH_BUNDLE_ID_PROD = com.customuniqueid.immich
IMMICH_BUNDLE_ID_DEV = com.customuniqueid.immichdev
IMMICH_GROUP_ID = group.com.customuniqueid.immich
```
The environment values are used across Immich's targets and schemes to prevent redundant edits by contributors.
#### Translation
To add a new translation text, enter the key-value pair in the `i18n/en.json` in the root of the immich project. Then run:

View File

@@ -99,6 +99,7 @@ Options:
-H, --include-hidden Include hidden folders (default: false, env: IMMICH_INCLUDE_HIDDEN)
-a, --album Automatically create albums based on folder name (default: false, env: IMMICH_AUTO_CREATE_ALBUM)
-A, --album-name <name> Add all assets to specified album (env: IMMICH_ALBUM_NAME)
--visibility <visibility> Set the visibility of uploaded assets (choices: "archive", "timeline", "hidden", "locked", env: IMMICH_VISIBILITY)
-n, --dry-run Don't perform any actions, just show what will be done (default: false, env: IMMICH_DRY_RUN)
-c, --concurrency <number> Number of assets to upload at the same time (default: 4, env: IMMICH_UPLOAD_CONCURRENCY)
-j, --json-output Output detailed information in json format (default: false, env: IMMICH_JSON_OUTPUT)
@@ -176,6 +177,12 @@ By default, hidden files are skipped. If you want to include hidden files, use t
immich upload --include-hidden --recursive directory/
```
You can set the visibility of uploaded assets to `archive`, `timeline`, `hidden`, or `locked` with the `--visibility` option:
```bash
immich upload --visibility archive --recursive directory/
```
You can use the `--json-output` option to get a json printed which includes
three keys: `newFiles`, `duplicates` and `newAssets`. Due to some logging
output you will need to strip the first three lines of output to get the json.

View File

@@ -7,7 +7,7 @@ sidebar_position: 85
:::note
This is a community contribution and not officially supported by the Immich team, but included here for convenience.
Community support can be found in the dedicated channel on the [Discord Server](https://discord.immich.app/).
Community support should be directed to Synology-specific support platforms.
:::
Immich can easily be installed on a Synology NAS using Container Manager within DSM. If you have not installed Container Manager already, you can install it in the Packages Center. Refer to the [Container Manager docs](https://kb.synology.com/en-us/DSM/help/ContainerManager/docker_desc?version=7) for more information on using Container Manager.

View File

@@ -121,7 +121,7 @@ alt="Go to Docker Tab and visit the address listed next to immich-web"
width="90%"
alt="Go to Docker Tab and visit the address listed next to immich-web"
/>
</details>
:::tip

View File

@@ -1,7 +1,7 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.wrangler]]
version = "4.100.0"
version = "4.110.0"
backend = "npm:wrangler"
[tools.wrangler.options]

View File

@@ -28,4 +28,4 @@ run = "prettier --write ."
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
[tools]
wrangler = "4.100.0"
wrangler = "4.110.0"

View File

@@ -32,7 +32,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.13.2",
"@types/node": "^24.13.3",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^7.0.0",

View File

@@ -814,7 +814,6 @@
"custom_date": "Custom date",
"custom_locale": "Custom locale",
"custom_locale_description": "Format dates, times, and numbers based on the selected language and region",
"custom_url": "Custom URL",
"cutoff_date_description": "Keep photos from the last…",
"cutoff_day": "{count, plural, one {day} other {days}}",
"cutoff_year": "{count, plural, one {year} other {years}}",
@@ -1948,7 +1947,9 @@
"shared_link_app_bar_title": "Shared Links",
"shared_link_clipboard_copied_massage": "Copied to clipboard",
"shared_link_create_error": "Error while creating shared link",
"shared_link_custom_url_description": "Access this shared link with a custom URL",
"shared_link_custom_url_description": "Access this shared link with a custom URL name",
"shared_link_custom_url_title": "Custom URL",
"shared_link_custom_url_warning": "Warning: a custom URL name with a forward slash may not behave as you expect.",
"shared_link_edit_description_hint": "Enter the share description",
"shared_link_edit_expire_after_option_day": "1 day",
"shared_link_edit_expire_after_option_days": "{count} days",

View File

@@ -287,43 +287,43 @@ url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536406"
[[tools.pnpm]]
version = "11.6.0"
version = "11.11.0"
backend = "aqua:pnpm/pnpm"
[tools.pnpm."platforms.linux-arm64"]
checksum = "sha256:2fec653ff6dadab340d1c3d2214688a7451cc471f39710839440b293ca7c53b0"
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120174"
checksum = "sha256:4871093439f036b3082df6102255ac119f3b6f4f50c51c5711fc7c358b8b22d7"
url = "https://github.com/pnpm/pnpm/releases/download/v11.11.0/pnpm-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/471734816"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-arm64-musl"]
checksum = "sha256:56a78c08cf22adf29e7dacb6f7f100139731693863d20fb94a7883463a62169e"
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-arm64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120177"
checksum = "sha256:f97316cee0deb3ae3fd64e8a09e1c8c88a76da33373a2b465bd6a4c3e85e0690"
url = "https://github.com/pnpm/pnpm/releases/download/v11.11.0/pnpm-linux-arm64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/471734819"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-x64"]
checksum = "sha256:74d64c1646385fb21691f32f0ab6aca1a9f5c829ba54d3cda3a24838a228e68c"
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-x64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120176"
checksum = "sha256:df4699e897012ab14df2cc6eaa942910e830eb7fcaa420a2a1421a9461fd9108"
url = "https://github.com/pnpm/pnpm/releases/download/v11.11.0/pnpm-linux-x64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/471734818"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-x64-musl"]
checksum = "sha256:7a0c463a09d912fba6b7d9eca0a946bc228ea50f3015a05c09a29e7e85a932d7"
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-x64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120175"
checksum = "sha256:aad186f8a8ae72d827d5efe63e99e801456715fb7f2798949405fa33f20260db"
url = "https://github.com/pnpm/pnpm/releases/download/v11.11.0/pnpm-linux-x64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/471734821"
provenance = "github-attestations"
[tools.pnpm."platforms.macos-arm64"]
checksum = "sha256:87c901635a14481fb30566a3749041134ffd4317bc6fe866c345b69fdf9b6b85"
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-darwin-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120180"
checksum = "sha256:ad46ad16c2376c2b78354ac488c1b166e076aa59bfcbcfd567d55957d755a690"
url = "https://github.com/pnpm/pnpm/releases/download/v11.11.0/pnpm-darwin-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/471734814"
provenance = "github-attestations"
[tools.pnpm."platforms.windows-x64"]
checksum = "sha256:91c753435542b04859c689304fae0dd64eba6b40937cfa426a48485b712e4e9c"
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-win32-x64.zip"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120173"
checksum = "sha256:13ad8a9b139c4c829fea33462cf04d061d72f0926b2b76a92ac03c9a36e405f9"
url = "https://github.com/pnpm/pnpm/releases/download/v11.11.0/pnpm-win32-x64.zip"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/471734820"
provenance = "github-attestations"
[[tools.terragrunt]]

View File

@@ -12,11 +12,12 @@ config_roots = [
"docs",
".github",
"machine-learning",
"native",
]
[tools]
node = "24.15.0"
pnpm = "11.6.0"
pnpm = "11.11.0"
terragrunt = "1.0.3"
opentofu = "1.11.6"
"npm:oazapfts" = "7.5.0"

View File

@@ -1,13 +0,0 @@
cmake_minimum_required(VERSION 3.12)
set(CMAKE_C_STANDARD 17)
set(CMAKE_C_STANDARD_REQUIRED ON)
project(native_buffer LANGUAGES C)
add_library(native_buffer SHARED
src/main/cpp/native_buffer.c
src/main/cpp/native_image.c
)
target_link_libraries(native_buffer jnigraphics)

View File

@@ -77,11 +77,6 @@ android {
}
namespace 'app.alextran.immich'
externalNativeBuild {
cmake {
path "CMakeLists.txt"
}
}
}
flutter {
@@ -89,13 +84,6 @@ flutter {
}
dependencies {
constraints {
implementation("androidx.glance:glance-appwidget") {
version { strictly libs.versions.glance.get() }
because 'home_widget requests 1.+ which can resolve to pre-releases incompatible with our compileSdk/AGP'
}
}
implementation libs.okhttp
implementation libs.cronet.embedded
implementation libs.media3.datasource.okhttp

View File

@@ -1,52 +0,0 @@
#include <jni.h>
#include <stdlib.h>
#include <string.h>
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeBuffer_allocate(
JNIEnv *env, jclass clazz, jint size) {
void *ptr = malloc(size);
return (jlong) ptr;
}
JNIEXPORT void JNICALL
Java_app_alextran_immich_NativeBuffer_free(
JNIEnv *env, jclass clazz, jlong address) {
free((void *) address);
}
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeBuffer_realloc(
JNIEnv *env, jclass clazz, jlong address, jint size) {
void *ptr = realloc((void *) address, size);
return (jlong) ptr;
}
JNIEXPORT jobject JNICALL
Java_app_alextran_immich_NativeBuffer_wrap(
JNIEnv *env, jclass clazz, jlong address, jint capacity) {
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity);
}
JNIEXPORT void JNICALL
Java_app_alextran_immich_NativeBuffer_copy(
JNIEnv *env, jclass clazz, jobject buffer, jlong destAddress, jint offset, jint length) {
void *src = (*env)->GetDirectBufferAddress(env, buffer);
if (src != NULL) {
memcpy((void *) destAddress, (char *) src + offset, length);
}
}
/**
* Creates a JNI global reference to the given object and returns its address.
* The caller is responsible for deleting the global reference when it's no longer needed.
*/
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeBuffer_createGlobalRef(JNIEnv *env, jobject clazz, jobject obj) {
if (obj == NULL) {
return 0;
}
jobject globalRef = (*env)->NewGlobalRef(env, obj);
return (jlong) globalRef;
}

View File

@@ -1,173 +0,0 @@
#include <jni.h>
#include <stdlib.h>
#include <stdint.h>
#include <android/bitmap.h>
// Cache-friendly block size for the tiled rotation (in pixels). 32x32 uint32 = 4KB, fits L1.
#define TILE 32
// EXIF orientation values (androidx.exifinterface.media.ExifInterface.ORIENTATION_*).
enum {
ORIENTATION_FLIP_HORIZONTAL = 2,
ORIENTATION_ROTATE_180 = 3,
ORIENTATION_FLIP_VERTICAL = 4,
ORIENTATION_TRANSPOSE = 5,
ORIENTATION_ROTATE_90 = 6,
ORIENTATION_TRANSVERSE = 7,
ORIENTATION_ROTATE_270 = 8,
};
// The orientations that swap width and height. Must stay in sync with affine_for's dim usage.
static int swaps_dims(int o) {
return o == ORIENTATION_ROTATE_90 || o == ORIENTATION_ROTATE_270 ||
o == ORIENTATION_TRANSPOSE || o == ORIENTATION_TRANSVERSE;
}
// A source pixel (sx, sy) maps to destination index base + sx*stepX + sy*stepY, where dw is the
// destination width. This affine form covers all 8 EXIF orientations and matches the pixel layout
// of Bitmap.createBitmap(src, matrixForExifOrientation(o)). int64_t so it stays correct on
// armeabi-v7a (32-bit long) regardless of how large MAX_RAW_DECODE_PIXELS grows.
static void affine_for(int o, int sw, int sh, int dw, int64_t *base, int64_t *stepX, int64_t *stepY) {
switch (o) {
case ORIENTATION_ROTATE_90: *base = sh - 1; *stepX = dw; *stepY = -1; break;
case ORIENTATION_ROTATE_270: *base = (int64_t) (sw - 1) * dw; *stepX = -dw; *stepY = 1; break;
case ORIENTATION_ROTATE_180: *base = (int64_t) (sh - 1) * dw + (sw - 1); *stepX = -1; *stepY = -dw; break;
case ORIENTATION_FLIP_HORIZONTAL: *base = sw - 1; *stepX = -1; *stepY = dw; break;
case ORIENTATION_FLIP_VERTICAL: *base = (int64_t) (sh - 1) * dw; *stepX = 1; *stepY = -dw; break;
case ORIENTATION_TRANSPOSE: *base = 0; *stepX = dw; *stepY = 1; break;
case ORIENTATION_TRANSVERSE: *base = (int64_t) (sw - 1) * dw + (sh - 1); *stepX = -dw; *stepY = -1; break;
default: *base = 0; *stepX = 1; *stepY = dw; break;
}
}
// Copy each source pixel (whole uint32, so channel order/premult is irrelevant) to its rotated
// destination, walking TILE x TILE blocks so the scattered writes of a 90/270 transpose stay
// cache-resident. dst is densely packed (rowBytes == dw*4, no padding), which the affine math relies on.
static void rotate_tiled(const uint8_t *src, int srcStride, uint32_t *dst,
int sw, int sh, int64_t base, int64_t stepX, int64_t stepY) {
for (int ty = 0; ty < sh; ty += TILE) {
int yEnd = ty + TILE < sh ? ty + TILE : sh;
for (int tx = 0; tx < sw; tx += TILE) {
int xEnd = tx + TILE < sw ? tx + TILE : sw;
for (int sy = ty; sy < yEnd; sy++) {
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) sy * srcStride);
int64_t idx = base + (int64_t) sy * stepY + (int64_t) tx * stepX;
for (int sx = tx; sx < xEnd; sx++) {
dst[idx] = srcRow[sx];
idx += stepX;
}
}
}
}
}
// Rotates an RGBA_8888 bitmap to the given EXIF orientation into a freshly malloc'd buffer (free it
// via NativeBuffer.free). Fills outInfo with {width, height, rowBytes} and returns the buffer
// address, or 0 if the bitmap can't be handled (e.g. a non-8888 format) so the caller can fall back.
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeImage_rotate(
JNIEnv *env, jclass clazz, jobject bitmap, jint orientation, jintArray outInfo) {
AndroidBitmapInfo info;
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
return 0;
}
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_8888) {
return 0;
}
int sw = (int) info.width;
int sh = (int) info.height;
int dw = swaps_dims(orientation) ? sh : sw;
int dh = swaps_dims(orientation) ? sw : sh;
uint32_t *dst = (uint32_t *) malloc((size_t) dw * dh * 4);
if (dst == NULL) {
return 0;
}
void *srcPixels = NULL;
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
free(dst);
return 0;
}
int64_t base, stepX, stepY;
affine_for(orientation, sw, sh, dw, &base, &stepX, &stepY);
rotate_tiled((const uint8_t *) srcPixels, (int) info.stride, dst, sw, sh, base, stepX, stepY);
AndroidBitmap_unlockPixels(env, bitmap);
jint dims[3] = {dw, dh, dw * 4};
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
// Keep ownership in C until the buffer is safely handed back: if outInfo was somehow too small,
// SetIntArrayRegion left a pending exception and Kotlin will never receive (or free) dst.
if ((*env)->ExceptionCheck(env)) {
free(dst);
return 0;
}
return (jlong) dst;
}
// Convert an RGBA_1010102 buffer to densely-packed RGBA_8888, matching Skia's
// Bitmap.copy(ARGB_8888) byte-for-byte so it's a drop-in for the intermediate 8888 bitmap.
// Each source pixel is a native (little-endian on every Android ABI) u32 with R in bits 0-9,
// G in 10-19, B in 20-29, A in 30-31 (standard RGB10_A2). Each 10-bit channel maps to 8-bit via
// round(v*255/1023). The 2-bit alpha maps to a*85. Both are kept as plain arithmetic as the whole
// loop auto-vectorizes to NEON and measures faster than a LUT on-device. Output is R,G,B,A bytes
// per pixel, i.e. Android ARGB_8888 memory == Dart PixelFormat.rgba8888.
static void convert_1010102(const uint8_t *src, int srcStride, uint32_t *dst, int w, int h) {
for (int y = 0; y < h; y++) {
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) y * srcStride);
uint32_t *dstRow = dst + (size_t) y * w;
for (int x = 0; x < w; x++) {
uint32_t px = srcRow[x];
uint32_t r = ((px & 0x3FF) * 16336u + 32768u) >> 16;
uint32_t g = (((px >> 10) & 0x3FF) * 16336u + 32768u) >> 16;
uint32_t b = (((px >> 20) & 0x3FF) * 16336u + 32768u) >> 16;
uint32_t a = ((px >> 30) & 0x3) * 85u;
dstRow[x] = r | (g << 8) | (b << 16) | (a << 24);
}
}
}
// Converts an RGBA_1010102 bitmap (what a 10-bit HEIC/AVIF decodes to on API 33+) into a freshly
// malloc'd RGBA_8888 buffer. Fills outInfo with {width, height, rowBytes} and returns the buffer
// address, or 0 (so the caller falls back to a Skia copy) if the bitmap isn't 1010102 or can't be
// locked. Same ownership contract as rotate: free the returned buffer via NativeBuffer.free.
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeImage_convert1010102(
JNIEnv *env, jclass clazz, jobject bitmap, jintArray outInfo) {
AndroidBitmapInfo info;
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
return 0;
}
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_1010102) {
return 0;
}
int w = (int) info.width;
int h = (int) info.height;
uint32_t *dst = (uint32_t *) malloc((size_t) w * h * 4);
if (dst == NULL) {
return 0;
}
void *srcPixels = NULL;
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
free(dst);
return 0;
}
convert_1010102((const uint8_t *) srcPixels, (int) info.stride, dst, w, h);
AndroidBitmap_unlockPixels(env, bitmap);
jint dims[3] = {w, h, w * 4};
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
if ((*env)->ExceptionCheck(env)) {
free(dst);
return 0;
}
return (jlong) dst;
}

View File

@@ -6,7 +6,7 @@ const val INITIAL_BUFFER_SIZE = 32 * 1024
object NativeBuffer {
init {
System.loadLibrary("native_buffer")
System.loadLibrary("immich_core_ffi")
}
@JvmStatic
@@ -21,9 +21,6 @@ object NativeBuffer {
@JvmStatic
external fun wrap(address: Long, capacity: Int): ByteBuffer
@JvmStatic
external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int)
@JvmStatic
external fun createGlobalRef(obj: Any): Long
}
@@ -35,8 +32,12 @@ class NativeByteBuffer(initialCapacity: Int) {
inline fun ensureHeadroom() {
if (offset == capacity) {
capacity *= 2
pointer = NativeBuffer.realloc(pointer, capacity)
check(capacity <= Int.MAX_VALUE / 2) { "Native buffer capacity overflow" }
val newCapacity = capacity * 2
val newPointer = NativeBuffer.realloc(pointer, newCapacity)
check(newPointer != 0L) { "Native buffer realloc failed" }
pointer = newPointer
capacity = newCapacity
}
}

View File

@@ -4,26 +4,27 @@ import android.graphics.Bitmap
object NativeImage {
init {
// rotate() is compiled into the native_buffer shared lib (which already links jnigraphics).
System.loadLibrary("native_buffer")
System.loadLibrary("immich_core_ffi")
}
/**
* Rotates an RGBA_8888 [bitmap] to the given EXIF [orientation], writing the result into a freshly
* malloc'd native buffer. Returns the buffer address (free it with [NativeBuffer.free]) and fills
* [outInfo] with {width, height, rowBytes}. Returns 0 when the bitmap can't be handled (e.g. a
* non-8888 config) so the caller can fall back.
* Rotates an RGBA_8888 [bitmap] and returns a malloc'd buffer, or 0 on failure.
* [outInfo] receives width, height, and row bytes.
*/
@JvmStatic
external fun rotate(bitmap: Bitmap, orientation: Int, outInfo: IntArray): Long
/**
* Converts an RGBA_1010102 [bitmap] (what a 10-bit HEIC/AVIF decodes to on API 33+) to RGBA_8888,
* writing the result into a freshly malloc'd native buffer in one pass, with no intermediate
* ARGB_8888 bitmap. Returns the buffer address (free it with [NativeBuffer.free]) and fills
* [outInfo] with {width, height, rowBytes}. Returns 0 when the bitmap isn't RGBA_1010102 so the
* caller can fall back to a Skia copy.
* Converts an RGBA_1010102 [bitmap] to RGBA_8888 and returns a malloc'd buffer, or 0 on failure.
* [outInfo] receives width, height, and row bytes.
*/
@JvmStatic
external fun convert1010102(bitmap: Bitmap, outInfo: IntArray): Long
/**
* Decodes a ThumbHash into a malloc'd RGBA_8888 buffer, or 0 on failure.
* [outInfo] receives width, height, and row bytes.
*/
@JvmStatic
external fun thumbhash(hash: ByteArray, outInfo: IntArray): Long
}

View File

@@ -13,6 +13,7 @@ import android.provider.MediaStore.Video
import android.util.Size
import androidx.annotation.RequiresApi
import androidx.exifinterface.media.ExifInterface
import app.alextran.immich.BuildConfig
import app.alextran.immich.NativeBuffer
import app.alextran.immich.NativeImage
import kotlin.math.*
@@ -49,17 +50,23 @@ fun Bitmap.toNativeBuffer(): Map<String, Long> {
// Dart reads the buffer as rgba8888, but 10-bit sources decode to RGBA_1010102, which garbles
// colors when copied verbatim. Convert those straight into the output buffer in native code -
// one pass, no intermediate ARGB_8888 bitmap.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && config == Bitmap.Config.RGBA_1010102) {
val source1010102 =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && config == Bitmap.Config.RGBA_1010102
if (source1010102) {
val info = IntArray(3)
val pointer = NativeImage.convert1010102(this, info)
if (pointer != 0L) {
recycle()
return mapOf(
"pointer" to pointer,
"width" to info[0].toLong(),
"height" to info[1].toLong(),
"rowBytes" to info[2].toLong()
)
return buildMap {
put("pointer", pointer)
put("width", info[0].toLong())
put("height", info[1].toLong())
put("rowBytes", info[2].toLong())
if (BuildConfig.DEBUG) {
put("source1010102", 1L)
put("converted1010102", 1L)
}
}
}
// native convert declined (OOM/lock) -> fall through to the Skia copy path below.
}
@@ -70,12 +77,16 @@ fun Bitmap.toNativeBuffer(): Map<String, Long> {
try {
val buffer = NativeBuffer.wrap(pointer, size)
bitmap.copyPixelsToBuffer(buffer)
return mapOf(
"pointer" to pointer,
"width" to bitmap.width.toLong(),
"height" to bitmap.height.toLong(),
"rowBytes" to (bitmap.width * 4).toLong()
)
return buildMap {
put("pointer", pointer)
put("width", bitmap.width.toLong())
put("height", bitmap.height.toLong())
put("rowBytes", (bitmap.width * 4).toLong())
if (BuildConfig.DEBUG) {
put("source1010102", if (source1010102) 1L else 0L)
put("converted1010102", 0L)
}
}
} catch (e: Throwable) {
NativeBuffer.free(pointer)
throw e
@@ -101,12 +112,14 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
threadPool.execute {
try {
val bytes = Base64.getDecoder().decode(thumbhash)
val image = ThumbHash.thumbHashToRGBA(bytes)
val info = IntArray(3)
val pointer = NativeImage.thumbhash(bytes, info)
require(pointer != 0L) { "Invalid thumbhash" }
val res = mapOf(
"pointer" to image.pointer,
"width" to image.width.toLong(),
"height" to image.height.toLong(),
"rowBytes" to (image.width * 4).toLong()
"pointer" to pointer,
"width" to info[0].toLong(),
"height" to info[1].toLong(),
"rowBytes" to info[2].toLong()
)
callback(Result.success(res))
} catch (e: Exception) {

View File

@@ -1,166 +0,0 @@
package app.alextran.immich.images;
// Copyright (c) 2023 Evan Wallace
// 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.
import java.nio.ByteBuffer;
import app.alextran.immich.NativeBuffer;
// modified to use native allocations
public final class ThumbHash {
/**
* Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A.
*
* @param hash The bytes of the ThumbHash.
* @return The width, height, and pixels of the rendered placeholder image.
*/
public static Image thumbHashToRGBA(byte[] hash) {
// Read the constants
int header24 = (hash[0] & 255) | ((hash[1] & 255) << 8) | ((hash[2] & 255) << 16);
int header16 = (hash[3] & 255) | ((hash[4] & 255) << 8);
float l_dc = (float) (header24 & 63) / 63.0f;
float p_dc = (float) ((header24 >> 6) & 63) / 31.5f - 1.0f;
float q_dc = (float) ((header24 >> 12) & 63) / 31.5f - 1.0f;
float l_scale = (float) ((header24 >> 18) & 31) / 31.0f;
boolean hasAlpha = (header24 >> 23) != 0;
float p_scale = (float) ((header16 >> 3) & 63) / 63.0f;
float q_scale = (float) ((header16 >> 9) & 63) / 63.0f;
boolean isLandscape = (header16 >> 15) != 0;
int lx = Math.max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7);
int ly = Math.max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7);
float a_dc = hasAlpha ? (float) (hash[5] & 15) / 15.0f : 1.0f;
float a_scale = (float) ((hash[5] >> 4) & 15) / 15.0f;
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
int ac_start = hasAlpha ? 6 : 5;
int ac_index = 0;
Channel l_channel = new Channel(lx, ly);
Channel p_channel = new Channel(3, 3);
Channel q_channel = new Channel(3, 3);
Channel a_channel = null;
ac_index = l_channel.decode(hash, ac_start, ac_index, l_scale);
ac_index = p_channel.decode(hash, ac_start, ac_index, p_scale * 1.25f);
ac_index = q_channel.decode(hash, ac_start, ac_index, q_scale * 1.25f);
if (hasAlpha) {
a_channel = new Channel(5, 5);
a_channel.decode(hash, ac_start, ac_index, a_scale);
}
float[] l_ac = l_channel.ac;
float[] p_ac = p_channel.ac;
float[] q_ac = q_channel.ac;
float[] a_ac = hasAlpha ? a_channel.ac : null;
// Decode using the DCT into RGB
float ratio = thumbHashToApproximateAspectRatio(hash);
int w = Math.round(ratio > 1.0f ? 32.0f : 32.0f * ratio);
int h = Math.round(ratio > 1.0f ? 32.0f / ratio : 32.0f);
int size = w * h * 4;
long pointer = NativeBuffer.allocate(size);
ByteBuffer rgba = NativeBuffer.wrap(pointer, size);
int cx_stop = Math.max(lx, hasAlpha ? 5 : 3);
int cy_stop = Math.max(ly, hasAlpha ? 5 : 3);
float[] fx = new float[cx_stop];
float[] fy = new float[cy_stop];
for (int y = 0, i = 0; y < h; y++) {
for (int x = 0; x < w; x++, i += 4) {
float l = l_dc, p = p_dc, q = q_dc, a = a_dc;
// Precompute the coefficients
for (int cx = 0; cx < cx_stop; cx++)
fx[cx] = (float) Math.cos(Math.PI / w * (x + 0.5f) * cx);
for (int cy = 0; cy < cy_stop; cy++)
fy[cy] = (float) Math.cos(Math.PI / h * (y + 0.5f) * cy);
// Decode L
for (int cy = 0, j = 0; cy < ly; cy++) {
float fy2 = fy[cy] * 2.0f;
for (int cx = cy > 0 ? 0 : 1; cx * ly < lx * (ly - cy); cx++, j++)
l += l_ac[j] * fx[cx] * fy2;
}
// Decode P and Q
for (int cy = 0, j = 0; cy < 3; cy++) {
float fy2 = fy[cy] * 2.0f;
for (int cx = cy > 0 ? 0 : 1; cx < 3 - cy; cx++, j++) {
float f = fx[cx] * fy2;
p += p_ac[j] * f;
q += q_ac[j] * f;
}
}
// Decode A
if (hasAlpha)
for (int cy = 0, j = 0; cy < 5; cy++) {
float fy2 = fy[cy] * 2.0f;
for (int cx = cy > 0 ? 0 : 1; cx < 5 - cy; cx++, j++)
a += a_ac[j] * fx[cx] * fy2;
}
// Convert to RGB
float b = l - 2.0f / 3.0f * p;
float r = (3.0f * l - b + q) / 2.0f;
float g = r - q;
rgba.put(i, (byte) Math.max(0, Math.round(255.0f * Math.min(1, r))));
rgba.put(i + 1, (byte) Math.max(0, Math.round(255.0f * Math.min(1, g))));
rgba.put(i + 2, (byte) Math.max(0, Math.round(255.0f * Math.min(1, b))));
rgba.put(i + 3, (byte) Math.max(0, Math.round(255.0f * Math.min(1, a))));
}
}
return new Image(w, h, pointer);
}
/**
* Extracts the approximate aspect ratio of the original image.
*
* @param hash The bytes of the ThumbHash.
* @return The approximate aspect ratio (i.e. width / height).
*/
public static float thumbHashToApproximateAspectRatio(byte[] hash) {
byte header = hash[3];
boolean hasAlpha = (hash[2] & 0x80) != 0;
boolean isLandscape = (hash[4] & 0x80) != 0;
int lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7;
int ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7;
return (float) lx / (float) ly;
}
public static final class Image {
public int width;
public int height;
public long pointer;
public Image(int width, int height, long pointer) {
this.width = width;
this.height = height;
this.pointer = pointer;
}
}
private static final class Channel {
int nx;
int ny;
float[] ac;
Channel(int nx, int ny) {
this.nx = nx;
this.ny = ny;
int n = 0;
for (int cy = 0; cy < ny; cy++)
for (int cx = cy > 0 ? 0 : 1; cx * ny < nx * (ny - cy); cx++)
n++;
ac = new float[n];
}
int decode(byte[] hash, int start, int index, float scale) {
for (int i = 0; i < ac.length; i++) {
int data = hash[start + (index >> 1)] >> ((index & 1) << 2);
ac[i] = ((float) (data & 15) / 7.5f - 1.0f) * scale;
index++;
}
return index;
}
}
}

View File

@@ -0,0 +1,97 @@
import 'dart:convert';
import 'dart:ffi';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_native_core/immich_native_core.dart';
import 'package:integration_test/integration_test.dart';
Uint8List _px1010102(int r, int g, int b, int a) {
final px = (r & 0x3FF) | ((g & 0x3FF) << 10) | ((b & 0x3FF) << 20) | ((a & 0x3) << 30);
return Uint8List(4)..buffer.asByteData().setUint32(0, px, Endian.little);
}
Uint8List? _withBuffers(
Uint8List src,
int dstLen,
bool Function(Pointer<Uint8> src, int dstLen, Pointer<Uint8> dst) call,
) {
final srcPtr = calloc<Uint8>(src.length);
final dstPtr = calloc<Uint8>(dstLen);
try {
srcPtr.asTypedList(src.length).setAll(0, src);
if (!call(srcPtr, dstLen, dstPtr)) {
return null;
}
return Uint8List.fromList(dstPtr.asTypedList(dstLen));
} finally {
calloc.free(srcPtr);
calloc.free(dstPtr);
}
}
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
test('loads the native core', () {
final ptr = immich_core_version();
expect(ptr, isNot(equals(nullptr)));
final version = ptr.cast<Utf8>().toDartString();
immich_core_free_string(ptr);
expect(version, isNotEmpty);
});
test('reports swapped orientations', () {
for (final o in [5, 6, 7, 8]) {
expect(immich_core_orientation_swaps_dims(o), isTrue, reason: 'o=$o');
}
for (final o in [0, 1, 2, 3, 4, 9]) {
expect(immich_core_orientation_swaps_dims(o), isFalse, reason: 'o=$o');
}
});
test('rotates RGBA pixels', () {
final src = Uint8List.fromList([255, 0, 0, 255, 0, 255, 0, 255]);
final out = _withBuffers(src, 8, (s, len, d) => immich_core_rotate_rgba8888(s, src.length, 8, 2, 1, 3, d, len));
expect(out, [0, 255, 0, 255, 255, 0, 0, 255]);
});
test('converts RGBA_1010102 pixels', () {
// 179 and 111 distinguish rounded scaling from `>> 2`.
final src = Uint8List.fromList([..._px1010102(1023, 0, 0, 3), ..._px1010102(179, 111, 0, 3)]);
final out = _withBuffers(
src,
8,
(s, len, d) => immich_core_rgba1010102_to_rgba8888(s, src.length, 8, 2, 1, d, len),
);
expect(out, isNotNull);
expect(out!.sublist(0, 4), [255, 0, 0, 255]);
expect(out.sublist(4, 8), [45, 28, 0, 255]);
});
test('decodes a thumbhash', () {
final hash = base64Decode('1QcSHQRnh493V4dIh4eXh1h4kJUI');
final hashPtr = malloc<Uint8>(hash.length);
final info = malloc<Uint32>(3);
try {
hashPtr.asTypedList(hash.length).setAll(0, hash);
final ptr = immich_core_thumbhash_decode(hashPtr, hash.length, info);
expect(ptr, isNot(equals(nullptr)));
expect((info[0], info[1], info[2]), (23, 32, 23 * 4));
final len = info[0] * info[1] * 4;
final pixels = ptr.asTypedList(len);
for (var i = 3; i < len; i += 4) {
expect(pixels[i], 255, reason: 'alpha at $i');
}
expect(pixels.toSet().length, greaterThan(2));
malloc.free(ptr);
expect(immich_core_thumbhash_decode(hashPtr, 4, info), equals(nullptr));
} finally {
malloc.free(hashPtr);
malloc.free(info);
}
});
}

View File

@@ -0,0 +1,194 @@
import 'dart:convert';
import 'dart:ffi';
import 'dart:io';
import 'dart:typed_data';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:ffi/ffi.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/platform/local_image_api.g.dart';
import 'package:integration_test/integration_test.dart';
import 'package:photo_manager/photo_manager.dart';
const _fixture10BitAvifB64 =
'AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAAD5bWV0YQAAAAAAAAAvaGRscgAAAAAA'
'AAAAcGljdAAAAAAAAAAAAAAAAFBpY3R1cmVIYW5kbGVyAAAAAA5waXRtAAAAAAABAAAAHmlsb2MA'
'AAAARAAAAQABAAAAAQAAASEAAAAmAAAAKGlpbmYAAAAAAAEAAAAaaW5mZQIAAAAAAQAAYXYwMUNv'
'bG9yAAAAAGppcHJwAAAAS2lwY28AAAAUaXNwZQAAAAAAAABAAAAAQAAAABBwaXhpAAAAAAMKCgoA'
'AAAMYXYxQ4EATAAAAAATY29scm5jbHgAAQACAAEAAAAAF2lwbWEAAAAAAAAAAQABBAECgwQAAAAu'
'bWRhdAoNAAAAAq//jV86AgQCCDIVEACLggAAAAAAgAAifC/LKY1kV6Bd';
// 16x12 linear DNG with EXIF orientation 6; the pixel strip is appended below.
const _fixtureOrientedDngHeaderB64 =
'SUkqAAgAAAAVAAABAwABAAAAEAAAAAEBAwABAAAADAAAAAIBAwADAAAACgEAAAMBAwABAAAAAQAAAAYBAwABAAAATIgAAAoB'
'AwABAAAAAQAAABEBBAABAAAAvAEAABIBAwABAAAABgAAABUBAwABAAAAAwAAABYBAwABAAAADAAAABcBBAABAAAAgAQAABwB'
'AwABAAAAAQAAACkBAwACAAAAAAABAD4BBQACAAAAEAEAAD8BBQAGAAAAIAEAABLGAQAEAAAAAQQAABPGAQAEAAAAAQEAABTG'
'AgAMAAAAUAEAACHGCgAJAAAAXAEAACjGBQADAAAApAEAAFrGAwABAAAAFQAAAAAAAAAQABAAEAA3GqAAAAAAAiuHCgAAACAA'
'hetRAAAAgADD9agAAAAAAs3MTAAAAAABzcxMAAAAgADNzEwAAAAAAo/C9QAAAAAQSW1taWNoIFRlc3QAAQAAAAEAAAAAAAAA'
'AQAAAAAAAAABAAAAAAAAAAEAAAABAAAAAQAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAA'
'AQAAAAEAAAABAAAA';
Uint8List _orientedDng() {
final header = base64Decode(_fixtureOrientedDngHeaderB64);
final pixels = ByteData(16 * 12 * 6);
for (var y = 0; y < 12; y++) {
final r = (11 - y) * 65535 ~/ 11;
final b = y * 65535 ~/ 11;
for (var x = 0; x < 16; x++) {
final offset = (y * 16 + x) * 6;
pixels.setUint16(offset, r, Endian.little);
pixels.setUint16(offset + 2, 0, Endian.little);
pixels.setUint16(offset + 4, b, Endian.little);
}
}
return Uint8List(header.length + pixels.lengthInBytes)
..setAll(0, header)
..setAll(header.length, pixels.buffer.asUint8List());
}
Uint8List _read(int address, int length) => Uint8List.fromList(Pointer<Uint8>.fromAddress(address).asTypedList(length));
void _free(int address) => malloc.free(Pointer<Uint8>.fromAddress(address));
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
if (!Platform.isAndroid) {
return;
}
final api = LocalImageApi();
final fixture = base64Decode(_fixture10BitAvifB64);
String? assetId;
String? orientedAssetId;
setUpAll(() async {
await PhotoManager.setIgnorePermissionCheck(true);
final entity = await PhotoManager.editor.saveImage(fixture, filename: 'immich_jni_fixture.avif');
assetId = entity.id;
});
tearDownAll(() async {
final ids = [assetId, orientedAssetId].whereType<String>().toList();
if (ids.isNotEmpty) {
try {
await PhotoManager.editor.deleteWithIds(ids);
} catch (_) {}
}
});
test('thumbhash JNI roundtrip', () async {
final a = await api.getThumbhash('1QcSHQRnh493V4dIh4eXh1h4kJUI');
final b = await api.getThumbhash('1QcSHQRnh493V4dIh4eXh1h4kJUI');
final (w, h, rowBytes) = (a['width']!, a['height']!, a['rowBytes']!);
expect(a['pointer'], isNot(0));
expect(w, inInclusiveRange(1, 128));
expect(h, inInclusiveRange(1, 128));
expect(rowBytes, w * 4);
final pixelsA = _read(a['pointer']!, rowBytes * h);
final pixelsB = _read(b['pointer']!, rowBytes * h);
_free(a['pointer']!);
_free(b['pointer']!);
expect(pixelsA, pixelsB);
expect(pixelsA.toSet().length, greaterThan(1));
});
test('encoded image buffer roundtrip', () async {
final res = await api.requestImage(
assetId!,
requestId: 900001,
width: 0,
height: 0,
isVideo: false,
preferEncoded: true,
);
expect(res, isNotNull);
expect(res!['length'], fixture.length);
final bytes = _read(res['pointer']!, res['length']!);
_free(res['pointer']!);
expect(bytes, fixture);
});
test('10-bit decode runs NativeImage.convert1010102', () async {
final Map<String, int>? res;
try {
res = await api.requestImage(
assetId!,
requestId: 900002,
width: 0,
height: 0,
isVideo: false,
preferEncoded: false,
);
} on PlatformException catch (e) {
markTestSkipped('device cannot decode the 10-bit AVIF fixture: ${e.message}');
return;
}
expect(res, isNotNull);
final (w, h, rowBytes) = (res!['width']!, res['height']!, res['rowBytes']!);
expect(w, 64);
expect(h, 64);
expect(rowBytes, w * 4);
final pixels = _read(res['pointer']!, rowBytes * h);
_free(res['pointer']!);
final source1010102 = res['source1010102'];
expect(source1010102, isNotNull, reason: 'decode result did not report its source format');
if (source1010102 == 0) {
markTestSkipped('device decoded the 10-bit AVIF fixture without RGBA_1010102');
return;
}
expect(source1010102, 1);
expect(
res['converted1010102'],
1,
reason: 'RGBA_1010102 source fell back instead of running the native conversion',
);
for (final (x, y) in [(2, 2), (32, 32), (61, 61)]) {
final o = (y * w + x) * 4;
expect(pixels[o], closeTo(45, 12), reason: 'R at ($x,$y)');
expect(pixels[o + 1], closeTo(139, 12), reason: 'G at ($x,$y)');
expect(pixels[o + 2], closeTo(107, 12), reason: 'B at ($x,$y)');
expect(pixels[o + 3], 255, reason: 'A at ($x,$y)');
}
});
test('raw EXIF orientation rotates through NativeImage.rotate', () async {
final sdkInt = (await DeviceInfoPlugin().androidInfo).version.sdkInt;
if (sdkInt < 29) {
markTestSkipped('raw image rotation needs Android 10 or newer');
return;
}
final entity = await PhotoManager.editor.saveImage(_orientedDng(), filename: 'immich_jni_orientation.dng');
orientedAssetId = entity.id;
expect(await entity.mimeTypeAsync, anyOf('image/dng', 'image/x-adobe-dng'));
expect(entity.orientation, 90);
expect((entity.width, entity.height), (16, 12));
final Map<String, int>? res;
try {
res = await api.requestImage(
entity.id,
requestId: 900003,
width: 0,
height: 0,
isVideo: false,
preferEncoded: false,
);
} on PlatformException catch (e) {
markTestSkipped('device cannot decode the DNG fixture: ${e.message}');
return;
}
expect(res, isNotNull);
final (w, h, rowBytes) = (res!['width']!, res['height']!, res['rowBytes']!);
expect((w, h, rowBytes), (12, 16, 48));
final pixels = _read(res['pointer']!, rowBytes * h);
_free(res['pointer']!);
for (final (x, r, b) in [(0, 0, 255), (11, 255, 0)]) {
final o = 8 * rowBytes + x * 4;
expect(pixels[o], closeTo(r, 12), reason: 'R at ($x,8)');
expect(pixels[o + 2], closeTo(b, 12), reason: 'B at ($x,8)');
}
});
}

View File

@@ -26,6 +26,9 @@ Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Local signing overrides.
Signing.local.xcconfig
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3

View File

@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
platform :ios, '14.0'
platform :ios, '15.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -45,7 +45,7 @@ post_install do |installer|
installer.generated_projects.each do |project|
project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0'
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
end
end
end

View File

@@ -12,12 +12,8 @@ PODS:
- Flutter
- fluttertoast (0.0.2):
- Flutter
- home_widget (0.0.1):
- Flutter
- native_video_player (1.0.0):
- Flutter
- permission_handler_apple (9.3.0):
- Flutter
- share_handler_ios (0.0.14):
- Flutter
- share_handler_ios/share_handler_ios_models (= 0.0.14)
@@ -34,9 +30,7 @@ DEPENDENCIES:
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- home_widget (from `.symlinks/plugins/home_widget/ios`)
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- share_handler_ios (from `.symlinks/plugins/share_handler_ios/ios`)
- share_handler_ios_models (from `.symlinks/plugins/share_handler_ios/ios/Models`)
@@ -53,12 +47,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios"
home_widget:
:path: ".symlinks/plugins/home_widget/ios"
native_video_player:
:path: ".symlinks/plugins/native_video_player/ios"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
share_handler_ios:
:path: ".symlinks/plugins/share_handler_ios/ios"
share_handler_ios_models:
@@ -71,12 +61,10 @@ SPEC CHECKSUMS:
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
PODFILE CHECKSUM: 938abbae4114b9c2140c550a2a0d8f7c674f5dfe
PODFILE CHECKSUM: 3c43a700a4bffb4120bf696cad263aefd4bb3c8c
COCOAPODS: 1.16.2

View File

@@ -36,7 +36,6 @@
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F52F11980E006016CB /* LocalImagesImpl.swift */; };
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */; };
FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */; };
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; };
FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; };
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; };
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; };
@@ -96,6 +95,7 @@
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
51516E0F2A0000000000C0F1 /* Signing.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Signing.xcconfig; sourceTree = "<group>"; };
8AB817AA297EDEC88B23F3F6 /* Pods_ShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
937632897A02DE9C249F20A6 /* Pods-ShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.debug.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
@@ -130,7 +130,6 @@
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalImagesImpl.swift; sourceTree = "<group>"; };
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImagesImpl.swift; sourceTree = "<group>"; };
FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = "<group>"; };
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -153,11 +152,15 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
B231F52D2E93A44A00BC45D1 /* Core */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Core;
sourceTree = "<group>";
};
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Sync;
sourceTree = "<group>";
};
@@ -179,6 +182,8 @@
};
FEE084F22EC172080045228E /* Schemas */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Schemas;
sourceTree = "<group>";
};
@@ -256,6 +261,7 @@
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
51516E0F2A0000000000C0F1 /* Signing.xcconfig */,
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
FAC6F8B62D287F120078CB2F /* ShareExtension */,
@@ -349,7 +355,6 @@
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */,
FE5499F12F1197D8006016CB /* LocalImages.g.swift */,
FE5499F22F1197D8006016CB /* RemoteImages.g.swift */,
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */,
);
path = Images;
sourceTree = "<group>";
@@ -370,7 +375,6 @@
FAC6F89A2D287C890078CB2F /* Embed Foundation Extensions */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
513DA7292DED6106813332F4 /* [CP] Embed Pods Frameworks */,
2FA39DEC809D6D7C4A01EFCB /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -464,7 +468,7 @@
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */,
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */,
);
@@ -511,27 +515,6 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
2FA39DEC809D6D7C4A01EFCB /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -556,14 +539,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -646,7 +625,6 @@
B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */,
B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */,
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */,
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
@@ -714,6 +692,7 @@
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 51516E0F2A0000000000C0F1 /* Signing.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@@ -744,7 +723,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
CUSTOM_GROUP_ID = group.app.immich.share.profile;
CUSTOM_GROUP_ID = "$(IMMICH_GROUP_ID).profile";
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -757,7 +736,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
NEW_SETTING = "";
SDKROOT = iphoneos;
@@ -777,16 +756,16 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
DEVELOPMENT_TEAM = "$(IMMICH_TEAM_ID)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 3.0.0;
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.profile;
PRODUCT_BUNDLE_IDENTIFIER = "$(IMMICH_BUNDLE_ID_DEV).profile";
PRODUCT_NAME = "Immich-Profile";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -797,6 +776,7 @@
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 51516E0F2A0000000000C0F1 /* Signing.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
@@ -827,7 +807,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
CUSTOM_GROUP_ID = group.app.immich.share.debug;
CUSTOM_GROUP_ID = "$(IMMICH_GROUP_ID).debug";
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@@ -846,7 +826,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
NEW_SETTING = "";
ONLY_ACTIVE_ARCH = YES;
@@ -857,6 +837,7 @@
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 51516E0F2A0000000000C0F1 /* Signing.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
@@ -887,7 +868,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
CUSTOM_GROUP_ID = group.app.immich.share;
CUSTOM_GROUP_ID = "$(IMMICH_GROUP_ID)";
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -900,7 +881,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
NEW_SETTING = "";
SDKROOT = iphoneos;
@@ -922,16 +903,16 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
DEVELOPMENT_TEAM = "$(IMMICH_TEAM_ID)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 3.0.0;
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.debug;
PRODUCT_BUNDLE_IDENTIFIER = "$(IMMICH_BUNDLE_ID_DEV).debug";
PRODUCT_NAME = "Immich-Debug";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -951,16 +932,16 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
DEVELOPMENT_TEAM = "$(IMMICH_TEAM_ID)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 3.0.0;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich;
PRODUCT_BUNDLE_IDENTIFIER = "$(IMMICH_BUNDLE_ID_PROD)";
PRODUCT_NAME = Immich;
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -984,7 +965,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
DEVELOPMENT_TEAM = "$(IMMICH_TEAM_ID)";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1001,7 +982,7 @@
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.debug.Widget;
PRODUCT_BUNDLE_IDENTIFIER = "$(IMMICH_BUNDLE_ID_DEV).debug.Widget";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
@@ -1027,7 +1008,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
DEVELOPMENT_TEAM = "$(IMMICH_TEAM_ID)";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1043,7 +1024,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.Widget;
PRODUCT_BUNDLE_IDENTIFIER = "$(IMMICH_BUNDLE_ID_PROD).Widget";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -1067,7 +1048,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
DEVELOPMENT_TEAM = "$(IMMICH_TEAM_ID)";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1083,7 +1064,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.profile.Widget;
PRODUCT_BUNDLE_IDENTIFIER = "$(IMMICH_BUNDLE_ID_DEV).profile.Widget";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -1106,7 +1087,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
DEVELOPMENT_TEAM = "$(IMMICH_TEAM_ID)";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1123,7 +1104,7 @@
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.debug.ShareExtension;
PRODUCT_BUNDLE_IDENTIFIER = "$(IMMICH_BUNDLE_ID_DEV).debug.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
@@ -1149,7 +1130,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
DEVELOPMENT_TEAM = "$(IMMICH_TEAM_ID)";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1165,7 +1146,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.ShareExtension;
PRODUCT_BUNDLE_IDENTIFIER = "$(IMMICH_BUNDLE_ID_PROD).ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
@@ -1189,7 +1170,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
DEVELOPMENT_TEAM = "$(IMMICH_TEAM_ID)";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1205,7 +1186,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.profile.ShareExtension;
PRODUCT_BUNDLE_IDENTIFIER = "$(IMMICH_BUNDLE_ID_DEV).profile.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
@@ -1261,7 +1242,7 @@
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};

View File

@@ -122,8 +122,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-sharing",
"state" : {
"revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818",
"version" : "2.7.4"
"revision" : "8244fe63bf43e58188ab13851ad693eecf6a9e90",
"version" : "2.9.1"
}
},
{

View File

@@ -0,0 +1,57 @@
import Foundation
import OSLog
// Native assets embed the framework without linking Runner, so resolve its symbol at runtime.
enum NativeCore {
typealias ThumbhashDecode = @convention(c) (
UnsafePointer<UInt8>?, UInt, UnsafeMutablePointer<UInt32>?
) -> UnsafeMutablePointer<UInt8>?
static let thumbhashDecode: ThumbhashDecode? = symbol("immich_core_thumbhash_decode")
private static let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "app.alextran.immich",
category: "NativeCore"
)
private static let handle: UnsafeMutableRawPointer? = load()
private static func load() -> UnsafeMutableRawPointer? {
if let frameworks = Bundle.main.privateFrameworksPath {
let path = "\(frameworks)/immich_core_ffi.framework/immich_core_ffi"
dlerror()
if let handle = dlopen(path, RTLD_NOW) {
return handle
}
let error = lastError()
logger.warning("dlopen failed for \(path, privacy: .public): \(error, privacy: .public)")
}
dlerror()
guard let handle = dlopen(nil, RTLD_NOW) else {
let error = lastError()
logger.error("dlopen failed for process scope: \(error, privacy: .public)")
return nil
}
return handle
}
private static func symbol<T>(_ name: String) -> T? {
guard let handle else {
logger.error("native core is unavailable while loading \(name, privacy: .public)")
return nil
}
dlerror()
guard let sym = dlsym(handle, name) else {
let error = lastError()
logger.error("dlsym failed for \(name, privacy: .public): \(error, privacy: .public)")
return nil
}
return unsafeBitCast(sym, to: T.self)
}
private static func lastError() -> String {
guard let error = dlerror() else { return "unknown error" }
return String(cString: error)
}
}

View File

@@ -38,15 +38,26 @@ class LocalImageApiImpl: LocalImageApi {
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
ImageProcessing.queue.addOperation {
guard let data = Data(base64Encoded: thumbhash)
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
guard let data = Data(base64Encoded: thumbhash) else {
return completion(.failure(PigeonError(code: "invalid-base64", message: "Invalid base64 thumbhash", details: nil)))
}
guard let decode = NativeCore.thumbhashDecode else {
return completion(.failure(PigeonError(code: "native-core-unavailable", message: "Native thumbhash decoder is unavailable", details: nil)))
}
var info = [UInt32](repeating: 0, count: 3)
let pointer = data.withUnsafeBytes { bytes in
decode(bytes.bindMemory(to: UInt8.self).baseAddress, UInt(bytes.count), &info)
}
guard let pointer else {
return completion(.failure(PigeonError(code: "invalid-thumbhash", message: "Invalid thumbhash", details: nil)))
}
let (width, height, pointer) = thumbHashToRGBA(hash: data)
completion(.success([
"pointer": Int64(Int(bitPattern: pointer.baseAddress)),
"width": Int64(width),
"height": Int64(height),
"rowBytes": Int64(width * 4)
"pointer": Int64(Int(bitPattern: pointer)),
"width": Int64(info[0]),
"height": Int64(info[1]),
"rowBytes": Int64(info[2])
]))
}
}

View File

@@ -1,225 +0,0 @@
// Copyright (c) 2023 Evan Wallace
// 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.
import Foundation
// NOTE: Swift has an exponential-time type checker and compiling very simple
// expressions can easily take many seconds, especially when expressions involve
// numeric type constructors.
//
// This file deliberately breaks compound expressions up into separate variables
// to improve compile time even though this comes at the expense of readability.
// This is a known workaround for this deficiency in the Swift compiler.
//
// The following command is helpful when debugging Swift compile time issues:
//
// swiftc ThumbHash.swift -Xfrontend -debug-time-function-bodies
//
// These optimizations brought the compile time for this file from around 2.5
// seconds to around 250ms (10x faster).
// NOTE: Swift's debug-build performance of for-in loops over numeric ranges is
// really awful. Debug builds compile a very generic indexing iterator thing
// that makes many nested calls for every iteration, which makes debug-build
// performance crawl.
//
// This file deliberately avoids for-in loops that loop for more than a few
// times to improve debug-build run time even though this comes at the expense
// of readability. Similarly unsafe pointers are used instead of array getters
// to avoid unnecessary bounds checks, which have extra overhead in debug builds.
//
// These optimizations brought the run time to encode and decode 10 ThumbHashes
// in debug mode from 700ms to 70ms (10x faster).
// changed signature and allocation method to avoid automatic GC
func thumbHashToRGBA(hash: Data) -> (Int, Int, UnsafeMutableRawBufferPointer) {
// Read the constants
let h0 = UInt32(hash[0])
let h1 = UInt32(hash[1])
let h2 = UInt32(hash[2])
let h3 = UInt16(hash[3])
let h4 = UInt16(hash[4])
let header24 = h0 | (h1 << 8) | (h2 << 16)
let header16 = h3 | (h4 << 8)
let il_dc = header24 & 63
let ip_dc = (header24 >> 6) & 63
let iq_dc = (header24 >> 12) & 63
var l_dc = Float32(il_dc)
var p_dc = Float32(ip_dc)
var q_dc = Float32(iq_dc)
l_dc = l_dc / 63
p_dc = p_dc / 31.5 - 1
q_dc = q_dc / 31.5 - 1
let il_scale = (header24 >> 18) & 31
var l_scale = Float32(il_scale)
l_scale = l_scale / 31
let hasAlpha = (header24 >> 23) != 0
let ip_scale = (header16 >> 3) & 63
let iq_scale = (header16 >> 9) & 63
var p_scale = Float32(ip_scale)
var q_scale = Float32(iq_scale)
p_scale = p_scale / 63
q_scale = q_scale / 63
let isLandscape = (header16 >> 15) != 0
let lx16 = max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7)
let ly16 = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7)
let lx = Int(lx16)
let ly = Int(ly16)
var a_dc = Float32(1)
var a_scale = Float32(1)
if hasAlpha {
let ia_dc = hash[5] & 15
let ia_scale = hash[5] >> 4
a_dc = Float32(ia_dc)
a_scale = Float32(ia_scale)
a_dc /= 15
a_scale /= 15
}
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
let ac_start = hasAlpha ? 6 : 5
var ac_index = 0
let decodeChannel = { (nx: Int, ny: Int, scale: Float32) -> [Float32] in
var ac: [Float32] = []
for cy in 0 ..< ny {
var cx = cy > 0 ? 0 : 1
while cx * ny < nx * (ny - cy) {
let iac = (hash[ac_start + (ac_index >> 1)] >> ((ac_index & 1) << 2)) & 15;
var fac = Float32(iac)
fac = (fac / 7.5 - 1) * scale
ac.append(fac)
ac_index += 1
cx += 1
}
}
return ac
}
let l_ac = decodeChannel(lx, ly, l_scale)
let p_ac = decodeChannel(3, 3, p_scale * 1.25)
let q_ac = decodeChannel(3, 3, q_scale * 1.25)
let a_ac = hasAlpha ? decodeChannel(5, 5, a_scale) : []
// Decode using the DCT into RGB
let ratio = thumbHashToApproximateAspectRatio(hash: hash)
let fw = round(ratio > 1 ? 32 : 32 * ratio)
let fh = round(ratio > 1 ? 32 / ratio : 32)
let w = Int(fw)
let h = Int(fh)
let pointer = UnsafeMutableRawBufferPointer.allocate(
byteCount: w * h * 4,
alignment: MemoryLayout<UInt8>.alignment
)
var rgba = pointer.baseAddress!.assumingMemoryBound(to: UInt8.self)
let cx_stop = max(lx, hasAlpha ? 5 : 3)
let cy_stop = max(ly, hasAlpha ? 5 : 3)
var fx = [Float32](repeating: 0, count: cx_stop)
var fy = [Float32](repeating: 0, count: cy_stop)
fx.withUnsafeMutableBytes { fx in
let fx = fx.baseAddress!.bindMemory(to: Float32.self, capacity: fx.count)
fy.withUnsafeMutableBytes { fy in
let fy = fy.baseAddress!.bindMemory(to: Float32.self, capacity: fy.count)
var y = 0
while y < h {
var x = 0
while x < w {
var l = l_dc
var p = p_dc
var q = q_dc
var a = a_dc
// Precompute the coefficients
var cx = 0
while cx < cx_stop {
let fw = Float32(w)
let fxx = Float32(x)
let fcx = Float32(cx)
fx[cx] = cos(Float32.pi / fw * (fxx + 0.5) * fcx)
cx += 1
}
var cy = 0
while cy < cy_stop {
let fh = Float32(h)
let fyy = Float32(y)
let fcy = Float32(cy)
fy[cy] = cos(Float32.pi / fh * (fyy + 0.5) * fcy)
cy += 1
}
// Decode L
var j = 0
cy = 0
while cy < ly {
var cx = cy > 0 ? 0 : 1
let fy2 = fy[cy] * 2
while cx * ly < lx * (ly - cy) {
l += l_ac[j] * fx[cx] * fy2
j += 1
cx += 1
}
cy += 1
}
// Decode P and Q
j = 0
cy = 0
while cy < 3 {
var cx = cy > 0 ? 0 : 1
let fy2 = fy[cy] * 2
while cx < 3 - cy {
let f = fx[cx] * fy2
p += p_ac[j] * f
q += q_ac[j] * f
j += 1
cx += 1
}
cy += 1
}
// Decode A
if hasAlpha {
j = 0
cy = 0
while cy < 5 {
var cx = cy > 0 ? 0 : 1
let fy2 = fy[cy] * 2
while cx < 5 - cy {
a += a_ac[j] * fx[cx] * fy2
j += 1
cx += 1
}
cy += 1
}
}
// Convert to RGB
var b = l - 2 / 3 * p
var r = (3 * l - b + q) / 2
var g = r - q
r = max(0, 255 * min(1, r))
g = max(0, 255 * min(1, g))
b = max(0, 255 * min(1, b))
a = max(0, 255 * min(1, a))
rgba[0] = UInt8(r)
rgba[1] = UInt8(g)
rgba[2] = UInt8(b)
rgba[3] = UInt8(a)
rgba = rgba.advanced(by: 4)
x += 1
}
y += 1
}
}
}
return (w, h, pointer)
}
func thumbHashToApproximateAspectRatio(hash: Data) -> Float32 {
let header = hash[3]
let hasAlpha = (hash[2] & 0x80) != 0
let isLandscape = (hash[4] & 0x80) != 0
let lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7
let ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7
return Float32(lx) / Float32(ly)
}

View File

@@ -0,0 +1,29 @@
// Xcode Signing Constants
//
// These are integrated into the Xcode project at build time to allow for dynamic signing configs
// across our many targets.
//
// Create `Signing.local.xcconfig` overriding each variable to provide your own personal signing
// overrides during local development:
// IMMICH_TEAM_ID = ABCDE12345
// IMMICH_BUNDLE_ID_PROD = com.customuniqueid.immich
// IMMICH_BUNDLE_ID_DEV = com.customuniqueid.immichdev
// IMMICH_GROUP_ID = group.com.customuniqueid.immich
// Apple Developer Team ID used for automatically provisioning signing credentials
IMMICH_TEAM_ID = 2W7AC6T8T5
// Root bundle identifier for the Release configuration.
// Extension suffixes (`.ShareExtension`/`.Widget`) will be appended
IMMICH_BUNDLE_ID_PROD = app.alextran.immich
// Root bundle identifier for the Debug and Profile configurations.
// Build scheme suffixes (`.debug`/`.profile`) will be appended
IMMICH_BUNDLE_ID_DEV = app.futo.immich
// Root App Group identifier.
// Build scheme suffixes (`.debug`/`.profile`) will be appended
IMMICH_GROUP_ID = group.app.immich.share
// Optional per-developer configuration override
#include? "Signing.local.xcconfig"

View File

@@ -34,6 +34,13 @@ platform :ios do
)
end
# Xcode build phases need mise trust saved to disk.
def trust_mise_configs
return unless system("command -v mise > /dev/null 2>&1")
sh("mise trust ../../../mise.toml")
sh("mise trust ../../mise.toml")
end
# Helper method to assemble xcargs with optional CUSTOM_GROUP_ID override
def build_xcargs(group_id: nil)
args = "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual"
@@ -102,6 +109,8 @@ end
)
app_identifier = base_bundle_id
trust_mise_configs
# Set version number if provided
if version_number
increment_version_number(version_number: version_number)
@@ -258,6 +267,8 @@ end
api_key = get_api_key
trust_mise_configs
# Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain
sigh(api_key: api_key, app_identifier: DEV_BUNDLE_ID, force: true)
@@ -284,6 +295,7 @@ end
configuration: "Release",
export_method: "app-store",
skip_package_ipa: true,
xcodebuild_formatter: "", # raw xcodebuild output so script-phase errors show in CI logs
xcargs: build_xcargs(group_id: DEV_GROUP_ID),
export_options: {
provisioningProfiles: {

View File

@@ -70,6 +70,10 @@ class RemoteAsset extends BaseAsset {
bool get isTrashed => deletedAt != null;
bool get isStacked => stackId != null;
bool get isArchived => visibility == .archive;
@override
String toString() {
return '''Asset {

View File

@@ -44,16 +44,7 @@ final class $$AssetEditEntityTableReferences
static i5.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
i6.ReadDatabaseContainer(db)
.resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(db)
.resultSet<i1.$AssetEditEntityTable>('asset_edit_entity')
.assetId,
i6.ReadDatabaseContainer(
db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
);
.createAlias('asset_edit_entity__asset_id__remote_asset_entity__id');
i5.$$RemoteAssetEntityTableProcessedTableManager get assetId {
final $_column = $_itemColumn<String>('asset_id')!;

View File

@@ -59,16 +59,7 @@ final class $$AssetFaceEntityTableReferences
static i4.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(db)
.resultSet<i1.$AssetFaceEntityTable>('asset_face_entity')
.assetId,
i5.ReadDatabaseContainer(
db,
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
);
.createAlias('asset_face_entity__asset_id__remote_asset_entity__id');
i4.$$RemoteAssetEntityTableProcessedTableManager get assetId {
final $_column = $_itemColumn<String>('asset_id')!;
@@ -91,16 +82,7 @@ final class $$AssetFaceEntityTableReferences
static i6.$PersonEntityTable _personIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i6.$PersonEntityTable>('person_entity')
.createAlias(
i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(db)
.resultSet<i1.$AssetFaceEntityTable>('asset_face_entity')
.personId,
i5.ReadDatabaseContainer(
db,
).resultSet<i6.$PersonEntityTable>('person_entity').id,
),
);
.createAlias('asset_face_entity__person_id__person_entity__id');
i6.$$PersonEntityTableProcessedTableManager? get personId {
final $_column = $_itemColumn<String>('person_id');

View File

@@ -61,16 +61,7 @@ final class $$AssetOcrEntityTableReferences
static i4.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(
db,
).resultSet<i1.$AssetOcrEntityTable>('asset_ocr_entity').assetId,
i5.ReadDatabaseContainer(
db,
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
);
.createAlias('asset_ocr_entity__asset_id__remote_asset_entity__id');
i4.$$RemoteAssetEntityTableProcessedTableManager get assetId {
final $_column = $_itemColumn<String>('asset_id')!;

View File

@@ -75,16 +75,7 @@ final class $$RemoteExifEntityTableReferences
static i3.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
i4.ReadDatabaseContainer(db)
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteExifEntityTable>('remote_exif_entity')
.assetId,
i4.ReadDatabaseContainer(
db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
);
.createAlias('remote_exif_entity__asset_id__remote_asset_entity__id');
i3.$$RemoteAssetEntityTableProcessedTableManager get assetId {
final $_column = $_itemColumn<String>('asset_id')!;

View File

@@ -50,14 +50,7 @@ final class $$LocalAlbumEntityTableReferences
) => i6.ReadDatabaseContainer(db)
.resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity')
.createAlias(
i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(db)
.resultSet<i1.$LocalAlbumEntityTable>('local_album_entity')
.linkedRemoteAlbumId,
i6.ReadDatabaseContainer(
db,
).resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity').id,
),
'local_album_entity__linked_remote_album_id__remote_album_entity__id',
);
i5.$$RemoteAlbumEntityTableProcessedTableManager? get linkedRemoteAlbumId {

View File

@@ -41,16 +41,7 @@ final class $$LocalAlbumAssetEntityTableReferences
i4.ReadDatabaseContainer(db)
.resultSet<i3.$LocalAssetEntityTable>('local_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
.resultSet<i1.$LocalAlbumAssetEntityTable>(
'local_album_asset_entity',
)
.assetId,
i4.ReadDatabaseContainer(
db,
).resultSet<i3.$LocalAssetEntityTable>('local_asset_entity').id,
),
'local_album_asset_entity__asset_id__local_asset_entity__id',
);
i3.$$LocalAssetEntityTableProcessedTableManager get assetId {
@@ -75,16 +66,7 @@ final class $$LocalAlbumAssetEntityTableReferences
i4.ReadDatabaseContainer(db)
.resultSet<i5.$LocalAlbumEntityTable>('local_album_entity')
.createAlias(
i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
.resultSet<i1.$LocalAlbumAssetEntityTable>(
'local_album_asset_entity',
)
.albumId,
i4.ReadDatabaseContainer(
db,
).resultSet<i5.$LocalAlbumEntityTable>('local_album_entity').id,
),
'local_album_asset_entity__album_id__local_album_entity__id',
);
i5.$$LocalAlbumEntityTableProcessedTableManager get albumId {

View File

@@ -53,16 +53,7 @@ final class $$MemoryEntityTableReferences
static i5.$UserEntityTable _ownerIdTable(i0.GeneratedDatabase db) =>
i6.ReadDatabaseContainer(db)
.resultSet<i5.$UserEntityTable>('user_entity')
.createAlias(
i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(
db,
).resultSet<i1.$MemoryEntityTable>('memory_entity').ownerId,
i6.ReadDatabaseContainer(
db,
).resultSet<i5.$UserEntityTable>('user_entity').id,
),
);
.createAlias('memory_entity__owner_id__user_entity__id');
i5.$$UserEntityTableProcessedTableManager get ownerId {
final $_column = $_itemColumn<String>('owner_id')!;

View File

@@ -39,14 +39,7 @@ final class $$MemoryAssetEntityTableReferences
i4.ReadDatabaseContainer(db)
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
.resultSet<i1.$MemoryAssetEntityTable>('memory_asset_entity')
.assetId,
i4.ReadDatabaseContainer(
db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
'memory_asset_entity__asset_id__remote_asset_entity__id',
);
i3.$$RemoteAssetEntityTableProcessedTableManager get assetId {
@@ -70,16 +63,7 @@ final class $$MemoryAssetEntityTableReferences
static i5.$MemoryEntityTable _memoryIdTable(i0.GeneratedDatabase db) =>
i4.ReadDatabaseContainer(db)
.resultSet<i5.$MemoryEntityTable>('memory_entity')
.createAlias(
i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
.resultSet<i1.$MemoryAssetEntityTable>('memory_asset_entity')
.memoryId,
i4.ReadDatabaseContainer(
db,
).resultSet<i5.$MemoryEntityTable>('memory_entity').id,
),
);
.createAlias('memory_asset_entity__memory_id__memory_entity__id');
i5.$$MemoryEntityTableProcessedTableManager get memoryId {
final $_column = $_itemColumn<String>('memory_id')!;

View File

@@ -39,16 +39,7 @@ final class $$PartnerEntityTableReferences
static i4.$UserEntityTable _sharedByIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i4.$UserEntityTable>('user_entity')
.createAlias(
i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(
db,
).resultSet<i1.$PartnerEntityTable>('partner_entity').sharedById,
i5.ReadDatabaseContainer(
db,
).resultSet<i4.$UserEntityTable>('user_entity').id,
),
);
.createAlias('partner_entity__shared_by_id__user_entity__id');
i4.$$UserEntityTableProcessedTableManager get sharedById {
final $_column = $_itemColumn<String>('shared_by_id')!;
@@ -71,16 +62,7 @@ final class $$PartnerEntityTableReferences
static i4.$UserEntityTable _sharedWithIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i4.$UserEntityTable>('user_entity')
.createAlias(
i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(db)
.resultSet<i1.$PartnerEntityTable>('partner_entity')
.sharedWithId,
i5.ReadDatabaseContainer(
db,
).resultSet<i4.$UserEntityTable>('user_entity').id,
),
);
.createAlias('partner_entity__shared_with_id__user_entity__id');
i4.$$UserEntityTableProcessedTableManager get sharedWithId {
final $_column = $_itemColumn<String>('shared_with_id')!;

View File

@@ -48,16 +48,7 @@ final class $$PersonEntityTableReferences
static i4.$UserEntityTable _ownerIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i4.$UserEntityTable>('user_entity')
.createAlias(
i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(
db,
).resultSet<i1.$PersonEntityTable>('person_entity').ownerId,
i5.ReadDatabaseContainer(
db,
).resultSet<i4.$UserEntityTable>('user_entity').id,
),
);
.createAlias('person_entity__owner_id__user_entity__id');
i4.$$UserEntityTableProcessedTableManager get ownerId {
final $_column = $_itemColumn<String>('owner_id')!;

View File

@@ -52,14 +52,7 @@ final class $$RemoteAlbumEntityTableReferences
) => i6.ReadDatabaseContainer(db)
.resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAlbumEntityTable>('remote_album_entity')
.thumbnailAssetId,
i6.ReadDatabaseContainer(
db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
'remote_album_entity__thumbnail_asset_id__remote_asset_entity__id',
);
i5.$$RemoteAssetEntityTableProcessedTableManager? get thumbnailAssetId {

View File

@@ -39,16 +39,7 @@ final class $$RemoteAlbumAssetEntityTableReferences
i4.ReadDatabaseContainer(db)
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAlbumAssetEntityTable>(
'remote_album_asset_entity',
)
.assetId,
i4.ReadDatabaseContainer(
db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
'remote_album_asset_entity__asset_id__remote_asset_entity__id',
);
i3.$$RemoteAssetEntityTableProcessedTableManager get assetId {
@@ -73,16 +64,7 @@ final class $$RemoteAlbumAssetEntityTableReferences
i4.ReadDatabaseContainer(db)
.resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity')
.createAlias(
i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAlbumAssetEntityTable>(
'remote_album_asset_entity',
)
.albumId,
i4.ReadDatabaseContainer(
db,
).resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity').id,
),
'remote_album_asset_entity__album_id__remote_album_entity__id',
);
i5.$$RemoteAlbumEntityTableProcessedTableManager get albumId {

View File

@@ -42,16 +42,7 @@ final class $$RemoteAlbumUserEntityTableReferences
i5.ReadDatabaseContainer(db)
.resultSet<i4.$RemoteAlbumEntityTable>('remote_album_entity')
.createAlias(
i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAlbumUserEntityTable>(
'remote_album_user_entity',
)
.albumId,
i5.ReadDatabaseContainer(
db,
).resultSet<i4.$RemoteAlbumEntityTable>('remote_album_entity').id,
),
'remote_album_user_entity__album_id__remote_album_entity__id',
);
i4.$$RemoteAlbumEntityTableProcessedTableManager get albumId {
@@ -75,18 +66,7 @@ final class $$RemoteAlbumUserEntityTableReferences
static i6.$UserEntityTable _userIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i6.$UserEntityTable>('user_entity')
.createAlias(
i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAlbumUserEntityTable>(
'remote_album_user_entity',
)
.userId,
i5.ReadDatabaseContainer(
db,
).resultSet<i6.$UserEntityTable>('user_entity').id,
),
);
.createAlias('remote_album_user_entity__user_id__user_entity__id');
i6.$$UserEntityTableProcessedTableManager get userId {
final $_column = $_itemColumn<String>('user_id')!;

View File

@@ -74,16 +74,7 @@ final class $$RemoteAssetEntityTableReferences
static i5.$UserEntityTable _ownerIdTable(i0.GeneratedDatabase db) =>
i6.ReadDatabaseContainer(db)
.resultSet<i5.$UserEntityTable>('user_entity')
.createAlias(
i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAssetEntityTable>('remote_asset_entity')
.ownerId,
i6.ReadDatabaseContainer(
db,
).resultSet<i5.$UserEntityTable>('user_entity').id,
),
);
.createAlias('remote_asset_entity__owner_id__user_entity__id');
i5.$$UserEntityTableProcessedTableManager get ownerId {
final $_column = $_itemColumn<String>('owner_id')!;

View File

@@ -45,16 +45,7 @@ final class $$RemoteAssetCloudIdEntityTableReferences
i4.ReadDatabaseContainer(db)
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAssetCloudIdEntityTable>(
'remote_asset_cloud_id_entity',
)
.assetId,
i4.ReadDatabaseContainer(
db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
'remote_asset_cloud_id_entity__asset_id__remote_asset_entity__id',
);
i3.$$RemoteAssetEntityTableProcessedTableManager get assetId {

View File

@@ -38,16 +38,7 @@ final class $$StackEntityTableReferences
static i4.$UserEntityTable _ownerIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i4.$UserEntityTable>('user_entity')
.createAlias(
i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(
db,
).resultSet<i1.$StackEntityTable>('stack_entity').ownerId,
i5.ReadDatabaseContainer(
db,
).resultSet<i4.$UserEntityTable>('user_entity').id,
),
);
.createAlias('stack_entity__owner_id__user_entity__id');
i4.$$UserEntityTableProcessedTableManager get ownerId {
final $_column = $_itemColumn<String>('owner_id')!;

View File

@@ -40,18 +40,7 @@ final class $$UserMetadataEntityTableReferences
static i5.$UserEntityTable _userIdTable(i0.GeneratedDatabase db) =>
i6.ReadDatabaseContainer(db)
.resultSet<i5.$UserEntityTable>('user_entity')
.createAlias(
i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(db)
.resultSet<i1.$UserMetadataEntityTable>(
'user_metadata_entity',
)
.userId,
i6.ReadDatabaseContainer(
db,
).resultSet<i5.$UserEntityTable>('user_entity').id,
),
);
.createAlias('user_metadata_entity__user_id__user_entity__id');
i5.$$UserEntityTableProcessedTableManager get userId {
final $_column = $_itemColumn<String>('user_id')!;

View File

@@ -132,10 +132,10 @@ class SharedLinkEditPage extends HookConsumerWidget {
textInputAction: TextInputAction.done,
autofocus: false,
decoration: InputDecoration(
labelText: slugController.text.isNotEmpty ? context.t.custom_url : null,
labelText: slugController.text.isNotEmpty ? context.t.shared_link_custom_url_title : null,
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
border: const OutlineInputBorder(),
hintText: context.t.custom_url,
hintText: context.t.shared_link_custom_url_title,
prefixText: slugController.text.isNotEmpty ? '/s/' : null,
prefixStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),

View File

@@ -3,25 +3,23 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
import 'package:immich_ui/immich_ui.dart';
class FavoriteAction extends AssetAction<RemoteAsset> {
final bool shouldFavorite;
final bool favorite;
FavoriteAction({required super.assets}) : shouldFavorite = assets.any((asset) => !asset.isFavorite);
FavoriteAction({required super.assets}) : favorite = assets.any((asset) => !asset.isFavorite);
@override
IconData get icon => shouldFavorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
IconData get icon => favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
@override
String label(ActionScope scope) => shouldFavorite ? scope.context.t.favorite : scope.context.t.unfavorite;
String label(ActionScope scope) => favorite ? scope.context.t.favorite : scope.context.t.unfavorite;
@override
Iterable<RemoteAsset> filter(ActionScope scope) => assets
.where(
(asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite,
)
.cast<RemoteAsset>();
Iterable<RemoteAsset> filter(ActionScope scope) =>
AssetFilter(assets).owned(scope.authUser.id).favorite(isFavorite: !favorite);
@override
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
@@ -31,8 +29,8 @@ class FavoriteAction extends AssetAction<RemoteAsset> {
final ActionScope(:ref) = scope;
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).updateFavorite(assets, shouldFavorite);
final message = shouldFavorite
await ref.read(assetServiceProvider).updateFavorite(assets, favorite);
final message = favorite
? StaticTranslations.instance.favorite_action_prompt(count: assets.length)
: StaticTranslations.instance.unfavorite_action_prompt(count: assets.length);
snackbar.success(message);

View File

@@ -7,7 +7,6 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
@@ -129,12 +128,6 @@ class ShareActionButton extends ConsumerWidget {
return;
}
await ref.read(settingsProvider).write(SettingsKey.shareFileType, fileType);
if (!context.mounted) {
return;
}
await _share(context, ref, fileType);
}

View File

@@ -745,10 +745,15 @@ class AddToAlbumHeader extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> onCreateAlbum() async {
final albumName = await showDialog<String?>(context: context, builder: (context) => const NewAlbumNameModal());
if (albumName == null) {
return;
}
final selectedAssets = ref.read(multiSelectProvider).selectedAssets;
final newAlbum = await ref
.read(remoteAlbumProvider.notifier)
.createAlbumWithAssets(title: "Untitled Album", assets: selectedAssets);
.createAlbumWithAssets(title: albumName, assets: selectedAssets);
if (newAlbum == null) {
ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.tr());

View File

@@ -63,7 +63,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
ActionIconButtonWidget(action: FavoriteAction(assets: assetForAction)),
ViewerKebabMenu(originalTheme: originalTheme),
ImmichColorOverride(color: null, child: ViewerKebabMenu(originalTheme: originalTheme)),
];
final lockedViewActions = <Widget>[ViewerKebabMenu(originalTheme: originalTheme)];

View File

@@ -0,0 +1,22 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
extension type const AssetFilter<T extends BaseAsset>(Iterable<T> assets) implements Iterable<T> {
AssetFilter<T> where(bool Function(T asset) test) => AssetFilter(assets.where(test));
AssetFilter<T> whereNot(bool Function(T asset) test) => AssetFilter(assets.where((asset) => !test(asset)));
AssetFilter<T> type(AssetType type) => where((asset) => asset.type == type);
AssetFilter<T> favorite({bool isFavorite = true}) => where((asset) => asset.isFavorite == isFavorite);
AssetFilter<RemoteAsset> remote() => AssetFilter(assets.whereType<RemoteAsset>());
AssetFilter<RemoteAsset> owned(String ownerId) => remote().where((asset) => asset.ownerId == ownerId);
AssetFilter<RemoteAsset> visibility(AssetVisibility visibility) =>
remote().where((asset) => asset.visibility == visibility);
AssetFilter<RemoteAsset> notVisibility(AssetVisibility visibility) =>
remote().where((asset) => asset.visibility != visibility);
AssetFilter<RemoteAsset> archived({bool isArchived = true}) =>
remote().where((asset) => asset.isArchived == isArchived);
AssetFilter<RemoteAsset> stacked({bool isStacked = true}) => remote().where((asset) => asset.isStacked == isStacked);
AssetFilter<LocalAsset> local() => AssetFilter(assets.whereType<LocalAsset>());
AssetFilter<LocalAsset> backedUp() => local().where((asset) => asset.remoteAssetId != null);
}

View File

@@ -1,16 +1,76 @@
import 'dart:convert';
import 'dart:ffi';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:thumbhash/thumbhash.dart' as thumbhash;
import 'package:immich_native_core/immich_native_core.dart';
ObjectRef<Uint8List?> useDriftBlurHashRef(RemoteAsset? asset) {
if (asset?.thumbHash == null) {
return useRef(null);
return useRef(decodeDriftThumbHash(asset?.thumbHash));
}
Uint8List? decodeDriftThumbHash(String? thumbHash) {
if (thumbHash == null || thumbHash.isEmpty) {
return null;
}
final rbga = thumbhash.thumbHashToRGBA(base64Decode(asset!.thumbHash!));
final Uint8List hash;
try {
hash = base64Decode(thumbHash);
} on FormatException {
return null;
}
return useRef(thumbhash.rgbaToBmp(rbga));
final hashPtr = malloc<Uint8>(hash.length);
final info = malloc<Uint32>(3);
try {
hashPtr.asTypedList(hash.length).setAll(0, hash);
final rgba = immich_core_thumbhash_decode(hashPtr, hash.length, info);
if (rgba == nullptr) {
return null;
}
try {
return _rgbaToBmp(rgba, info[0], info[1], info[2]);
} finally {
malloc.free(rgba);
}
} finally {
malloc.free(hashPtr);
malloc.free(info);
}
}
Uint8List? _rgbaToBmp(Pointer<Uint8> rgba, int width, int height, int stride) {
if (width <= 0 || width > 32 || height <= 0 || height > 32 || stride != width * 4) {
return null;
}
const headerSize = 54;
final imageSize = stride * height;
final data = ByteData(headerSize + imageSize);
data
..setUint16(0, 0x4d42, Endian.little)
..setUint32(2, data.lengthInBytes, Endian.little)
..setUint32(10, headerSize, Endian.little)
..setUint32(14, 40, Endian.little)
..setInt32(18, width, Endian.little)
..setInt32(22, -height, Endian.little)
..setUint16(26, 1, Endian.little)
..setUint16(28, 32, Endian.little)
..setUint32(34, imageSize, Endian.little);
final pixels = rgba.asTypedList(imageSize);
for (var src = 0, dst = headerSize; src < imageSize; src += 4, dst += 4) {
data
..setUint8(dst, pixels[src + 2])
..setUint8(dst + 1, pixels[src + 1])
..setUint8(dst + 2, pixels[src])
..setUint8(dst + 3, pixels[src + 3]);
}
return data.buffer.asUint8List();
}

View File

@@ -1,29 +1,29 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools."aqua:flutter/flutter"]]
version = "3.44.1"
version = "3.44.6"
backend = "aqua:flutter/flutter"
[tools."aqua:flutter/flutter"."platforms.linux-arm64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.6-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-arm64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.6-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.6-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.6-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.macos-arm64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.1-stable.zip"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.6-stable.zip"
[tools."aqua:flutter/flutter"."platforms.macos-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.1-stable.zip"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.6-stable.zip"
[tools."aqua:flutter/flutter"."platforms.windows-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.1-stable.zip"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.6-stable.zip"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.37.0"
@@ -184,3 +184,10 @@ url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c55847602
[tools.java."platforms.windows-x64"]
checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
[[tools.rust]]
version = "1.92.0"
backend = "core:rust"
[tools.rust.options]
targets = "aarch64-apple-ios,aarch64-apple-ios-sim,aarch64-linux-android,armv7-linux-androideabi,x86_64-apple-ios,x86_64-linux-android"

View File

@@ -1,6 +1,8 @@
[tools]
"aqua:flutter/flutter" = "3.44.1"
"aqua:flutter/flutter" = "3.44.6"
java = "21.0.2"
# RUSTUP_TOOLCHAIN makes build hooks ignore rust-toolchain.toml targets.
rust = { version = "1.92.0", targets = "armv7-linux-androideabi,aarch64-linux-android,x86_64-linux-android,aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios" }
[tools."github:CQLabs/homebrew-dcm"]
version = "1.37.0"

View File

@@ -681,8 +681,10 @@ class AlbumsApi {
/// Parameters:
///
/// * [String] id (required):
/// Album ID
///
/// * [String] userId (required):
/// Album user ID, or \"me\" to reference the current user.
Future<Response> removeUserFromAlbumWithHttpInfo(String id, String userId, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/albums/{id}/user/{userId}'
@@ -718,8 +720,10 @@ class AlbumsApi {
/// Parameters:
///
/// * [String] id (required):
/// Album ID
///
/// * [String] userId (required):
/// Album user ID, or \"me\" to reference the current user.
Future<void> removeUserFromAlbum(String id, String userId, { Future<void>? abortTrigger, }) async {
final response = await removeUserFromAlbumWithHttpInfo(id, userId, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
@@ -798,8 +802,10 @@ class AlbumsApi {
/// Parameters:
///
/// * [String] id (required):
/// Album ID
///
/// * [String] userId (required):
/// Album user ID, or \"me\" to reference the current user.
///
/// * [UpdateAlbumUserDto] updateAlbumUserDto (required):
Future<Response> updateAlbumUserWithHttpInfo(String id, String userId, UpdateAlbumUserDto updateAlbumUserDto, { Future<void>? abortTrigger, }) async {
@@ -837,8 +843,10 @@ class AlbumsApi {
/// Parameters:
///
/// * [String] id (required):
/// Album ID
///
/// * [String] userId (required):
/// Album user ID, or \"me\" to reference the current user.
///
/// * [UpdateAlbumUserDto] updateAlbumUserDto (required):
Future<void> updateAlbumUser(String id, String userId, UpdateAlbumUserDto updateAlbumUserDto, { Future<void>? abortTrigger, }) async {

View File

@@ -3,7 +3,7 @@ import 'package:flutter/widgets.dart';
class ImmichColorOverride extends InheritedWidget {
const ImmichColorOverride({super.key, required this.color, required super.child});
final Color color;
final Color? color;
static Color? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<ImmichColorOverride>()?.color;

View File

@@ -366,18 +366,18 @@ packages:
dependency: "direct main"
description:
name: drift
sha256: "8033500116b24398fba0cca0369cc31678cd627c01e41753a61186911cea743e"
sha256: "6cc0b623c0e83f7080524d8396e9301b1d78b9c66a4fdceeb0f798211303254c"
url: "https://pub.dev"
source: hosted
version: "2.33.0"
version: "2.34.0"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: b3dd5b75e30522a91da8abda9f5bb17230cb038097f6d15fa75d42bb563428aa
sha256: "9cfff1576b49725da0d32c040651a41ae195e8c4af8d8da301593e41d7abc2f7"
url: "https://pub.dev"
source: hosted
version: "2.33.0"
version: "2.34.0"
drift_sqlite_async:
dependency: "direct main"
description:
@@ -772,10 +772,10 @@ packages:
dependency: "direct main"
description:
name: home_widget
sha256: "908d033514a981f829fd98213909e11a428104327be3b422718aa643ac9d084a"
sha256: fece84c7464099873c3ace38394ad7053509a4b0e36e57a1105a6aa31f47f23c
url: "https://pub.dev"
source: hosted
version: "0.8.1"
version: "0.9.3"
hooks:
dependency: transitive
description:
@@ -912,6 +912,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.2"
immich_native_core:
dependency: "direct main"
description:
path: "../native/immich_native_core"
relative: true
source: path
version: "0.1.0"
immich_ui:
dependency: "direct main"
description:
@@ -1056,26 +1063,26 @@ packages:
dependency: "direct main"
description:
name: maplibre_gl
sha256: "5c7b1008396b2a321bada7d986ed60f9423406fbc7bd16f7ce91b385dfa054cd"
sha256: b676f124a2fcf88c4dafedc7b462155e6396d61ce7af46354b4de11b45c9812f
url: "https://pub.dev"
source: hosted
version: "0.22.0"
version: "0.26.2"
maplibre_gl_platform_interface:
dependency: transitive
description:
name: maplibre_gl_platform_interface
sha256: "08ee0a2d0853ea945a0ab619d52c0c714f43144145cd67478fc6880b52f37509"
sha256: "1f0ca8a99f03fa9434618ee21f4e42dd615830a7dd973e632b11c73ffec993a8"
url: "https://pub.dev"
source: hosted
version: "0.22.0"
version: "0.26.2"
maplibre_gl_web:
dependency: transitive
description:
name: maplibre_gl_web
sha256: "2b13d4b1955a9a54e38a718f2324e56e4983c080fc6de316f6f4b5458baacb58"
sha256: bbf022f29ceef26d73f63e584819fbd02fbaf4eb1facf5234206c78936cf8f1c
url: "https://pub.dev"
source: hosted
version: "0.22.0"
version: "0.26.2"
matcher:
dependency: transitive
description:
@@ -1124,6 +1131,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.19.1"
native_toolchain_rust:
dependency: transitive
description:
name: native_toolchain_rust
sha256: faa57d2258a3b0fd2a634054f54e4496c9fcbd971977e7d2b7e6916d56892857
url: "https://pub.dev"
source: hosted
version: "1.0.4+0"
native_video_player:
dependency: "direct main"
description:
@@ -1740,14 +1755,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.11"
thumbhash:
dependency: "direct main"
description:
name: thumbhash
sha256: "5f6d31c5279ca0b5caa81ec10aae8dcaab098d82cb699ea66ada4ed09c794a37"
url: "https://pub.dev"
source: hosted
version: "0.1.0+1"
timezone:
dependency: "direct main"
description:
@@ -1756,6 +1763,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.9.4"
toml:
dependency: transitive
description:
name: toml
sha256: "35a35f782228656a2af31e8c73d1353cc4ef3d683fd68af1111b44631879c05e"
url: "https://pub.dev"
source: hosted
version: "0.18.0"
typed_data:
dependency: transitive
description:
@@ -2014,4 +2029,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.12.0 <4.0.0"
flutter: "3.44.1"
flutter: "3.44.6"

View File

@@ -6,7 +6,7 @@ version: 3.0.3+3056
environment:
sdk: '>=3.12.0 <4.0.0'
flutter: 3.44.1
flutter: 3.44.6
dependencies:
async: ^2.13.1
@@ -19,7 +19,7 @@ dependencies:
crypto: ^3.0.7
device_info_plus: ^12.4.0
diacritic: ^0.1.6
drift: ^2.32.1
drift: ^2.34.0
drift_sqlite_async: 0.3.1
dynamic_color: ^1.8.1
easy_localization: ^3.0.8
@@ -35,16 +35,18 @@ dependencies:
flutter_web_auth_2: ^5.0.2
fluttertoast: ^8.2.14
geolocator: ^14.0.2
home_widget: ^0.8.1
home_widget: ^0.9.3
hooks_riverpod: ^2.6.1
http: ^1.6.0
image_picker: ^1.2.1
immich_native_core:
path: ../native/immich_native_core
immich_ui:
path: './packages/ui'
intl: ^0.20.2
local_auth: ^2.3.0
logging: ^1.3.0
maplibre_gl: ^0.22.0
maplibre_gl: ^0.26.0
native_video_player:
git:
url: https://github.com/immich-app/native_video_player
@@ -70,7 +72,6 @@ dependencies:
sqlite3: ^3.3.2
sqlite_async: 0.14.2
sqlite3_connection_pool: ^0.2.6
thumbhash: 0.1.0+1
timezone: ^0.9.4
url_launcher: ^6.3.2
uuid: ^4.5.3
@@ -96,7 +97,7 @@ dev_dependencies:
auto_route_generator: ^10.5.0
build_runner: ^2.13.1
# Drift generator
drift_dev: ^2.32.1
drift_dev: ^2.34.0
fake_async: ^1.3.3
file: ^7.0.1 # for MemoryFileSystem
flutter_launcher_icons: ^0.14.4

View File

@@ -5,7 +5,14 @@ import '../../utils.dart';
class RemoteAssetFactory {
const RemoteAssetFactory();
static RemoteAsset create({String? id, String? name, String? ownerId, bool isFavorite = false}) {
static RemoteAsset create({
String? id,
String? name,
String? ownerId,
bool isFavorite = false,
AssetVisibility visibility = AssetVisibility.timeline,
String? stackId,
}) {
id = TestUtils.uuid(id);
return RemoteAsset(
@@ -17,6 +24,8 @@ class RemoteAssetFactory {
createdAt: TestUtils.yesterday(),
updatedAt: TestUtils.now(),
isFavorite: isFavorite,
visibility: visibility,
stackId: stackId,
isEdited: false,
);
}

View File

@@ -0,0 +1,117 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
class _RecordingActionNotifier extends ActionNotifier {
final List<ShareAssetType> sharedFileTypes = [];
@override
void build() {}
@override
Future<ActionResult> shareAssets(
ActionSource source,
BuildContext context, {
ShareAssetType fileType = ShareAssetType.original,
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) async {
sharedFileTypes.add(fileType);
return const ActionResult(count: 1, success: true);
}
}
class _FakeAssetViewerNotifier extends AssetViewerStateNotifier {
final BaseAsset asset;
_FakeAssetViewerNotifier(this.asset);
@override
AssetViewerState build() => AssetViewerState(currentAsset: asset);
}
void main() {
late PresentationContext context;
late _RecordingActionNotifier actionNotifier;
setUpAll(() async {
final db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await SettingsRepository.ensureInitialized(db);
});
setUp(() async {
context = await PresentationContext.create();
actionNotifier = _RecordingActionNotifier();
await SettingsRepository.instance.clear([SettingsKey.shareFileType]);
});
tearDown(() {
context.dispose();
});
Future<void> pumpShareButton(WidgetTester tester) async {
final asset = RemoteAssetFactory.create(ownerId: context.currentUser.id);
await tester.pumpTestWidget(
context,
const ShareActionButton(source: ActionSource.viewer),
overrides: [
actionProvider.overrideWith(() => actionNotifier),
assetViewerProvider.overrideWith(() => _FakeAssetViewerNotifier(asset)),
],
);
}
Future<void> longPressAndPickPreview(WidgetTester tester) async {
await tester.longPress(find.byType(BaseActionButton));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.photo_size_select_large_rounded));
await tester.pumpAndSettle();
}
group('ShareActionButton', () {
testWidgets('single press shares with the configured default quality', (tester) async {
await pumpShareButton(tester);
await tester.tap(find.byType(BaseActionButton));
await tester.pumpAndSettle();
expect(actionNotifier.sharedFileTypes, [ShareAssetType.original]);
});
testWidgets('long press shares with the quality picked in the dialog', (tester) async {
await pumpShareButton(tester);
await longPressAndPickPreview(tester);
expect(actionNotifier.sharedFileTypes, [ShareAssetType.preview]);
});
testWidgets('quality picked on long press is a one-time choice and does not change the default', (tester) async {
await pumpShareButton(tester);
await longPressAndPickPreview(tester);
expect(actionNotifier.sharedFileTypes, [ShareAssetType.preview]);
await tester.tap(find.byType(BaseActionButton));
await tester.pumpAndSettle();
expect(actionNotifier.sharedFileTypes, [ShareAssetType.preview, ShareAssetType.original]);
expect(SettingsRepository.instance.appConfig.share.fileType, ShareAssetType.original);
});
});
}

View File

@@ -0,0 +1,200 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
import '../factories/local_asset_factory.dart';
import '../factories/remote_asset_factory.dart';
void main() {
group('AssetFilter', () {
group('type promotion', () {
test('a bare filter retains every BaseAsset', () {
final remoteAsset = RemoteAssetFactory.create();
final localAsset = LocalAssetFactory.create();
final AssetFilter<BaseAsset> filter = AssetFilter(<BaseAsset>[remoteAsset, localAsset]);
expect(filter.toList(), [remoteAsset, localAsset]);
});
test('remote keeps only remote assets', () {
final remoteAsset = RemoteAssetFactory.create();
final localAsset = LocalAssetFactory.create();
final AssetFilter<RemoteAsset> remoteOnly = AssetFilter(<BaseAsset>[remoteAsset, localAsset]).remote();
expect(remoteOnly.toList(), [remoteAsset]);
});
test('local keeps only local assets', () {
final remoteAsset = RemoteAssetFactory.create();
final localAsset = LocalAssetFactory.create();
final AssetFilter<LocalAsset> localOnly = AssetFilter(<BaseAsset>[remoteAsset, localAsset]).local();
expect(localOnly.toList(), [localAsset]);
});
test('owned promotes to RemoteAsset and drops local assets', () {
final remoteAsset = RemoteAssetFactory.create();
final localAsset = LocalAssetFactory.create();
final AssetFilter<RemoteAsset> remoteOnly = AssetFilter(<BaseAsset>[
remoteAsset,
localAsset,
]).owned(remoteAsset.ownerId);
expect(remoteOnly.toList(), [remoteAsset]);
});
test('backedUp promotes to LocalAsset and drops remote assets', () {
final syncedPhoto = LocalAssetFactory.create().copyWith(remoteId: 'remote');
final offlinePhoto = LocalAssetFactory.create();
final remotePhoto = RemoteAssetFactory.create();
final AssetFilter<LocalAsset> syncedPhotos = AssetFilter(<BaseAsset>[
syncedPhoto,
offlinePhoto,
remotePhoto,
]).backedUp();
expect(syncedPhotos.toList(), [syncedPhoto]);
});
});
group('named filters', () {
test('owned keeps only assets of the given owner', () {
final asset1 = RemoteAssetFactory.create();
final asset2 = RemoteAssetFactory.create();
final alexPhotos = AssetFilter([asset1, asset2]).owned(asset1.ownerId);
expect(alexPhotos.toList(), [asset1]);
});
test('favorites keeps only favorite assets', () {
final asset1 = RemoteAssetFactory.create(isFavorite: true);
final asset2 = RemoteAssetFactory.create(ownerId: asset1.ownerId);
final favorites = AssetFilter([asset1, asset2]).favorite();
expect(favorites.toList(), [asset1]);
});
test('type keeps only assets of the given type', () {
final image = RemoteAssetFactory.create();
final video = RemoteAssetFactory.create(ownerId: image.ownerId).copyWith(type: .video);
final videos = AssetFilter([image, video]).type(.video);
expect(videos.toList(), [video]);
});
test('visibility keeps only assets with the given visibility', () {
final locked = RemoteAssetFactory.create(visibility: AssetVisibility.locked);
final onTimeline = RemoteAssetFactory.create(ownerId: locked.ownerId);
final lockedPhotos = AssetFilter([locked, onTimeline]).visibility(.locked);
expect(lockedPhotos.toList(), [locked]);
});
test('archived keeps only archived assets', () {
final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive);
final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId);
final archivedPhotos = AssetFilter([archived, onTimeline]).archived();
expect(archivedPhotos.toList(), [archived]);
});
test('stacked keeps only assets belonging to a stack', () {
final stacked = RemoteAssetFactory.create(stackId: 'stack');
final loose = RemoteAssetFactory.create(ownerId: stacked.ownerId);
final stackedPhotos = AssetFilter([stacked, loose]).stacked();
expect(stackedPhotos.toList(), [stacked]);
});
});
group('inversion', () {
test('notArchived keeps every non-archived visibility', () {
final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive);
final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.timeline);
final hidden = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.hidden);
final locked = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.locked);
final visiblePhotos = AssetFilter([archived, onTimeline, hidden, locked]).archived(isArchived: false);
expect(visiblePhotos.toSet(), {onTimeline, hidden, locked});
});
test('notVisibility keeps every asset not at the target visibility', () {
final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive);
final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.timeline);
final locked = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.locked);
final toArchive = AssetFilter([archived, onTimeline, locked]).notVisibility(.archive);
expect(toArchive.toSet(), {onTimeline, locked});
});
test('notStacked keeps only assets without a stack', () {
final stacked = RemoteAssetFactory.create(stackId: 'stack');
final loose = RemoteAssetFactory.create(ownerId: stacked.ownerId);
final loosePhotos = AssetFilter([stacked, loose]).stacked(isStacked: false);
expect(loosePhotos.toList(), [loose]);
});
test('whereNot inverts an arbitrary predicate', () {
final favorite = RemoteAssetFactory.create(isFavorite: true);
final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId);
final nonFavorites = AssetFilter([favorite, regular]).whereNot((asset) => asset.isFavorite);
expect(nonFavorites.toList(), [regular]);
});
test('notFavorites keeps only non-favorite assets', () {
final favorite = RemoteAssetFactory.create(isFavorite: true);
final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId);
final nonFavorites = AssetFilter([favorite, regular]).favorite(isFavorite: false);
expect(nonFavorites.toList(), [regular]);
});
});
group('chaining', () {
test('combines predicates across owner, visibility and stack', () {
final asset = RemoteAssetFactory.create();
final wrongOwner = RemoteAssetFactory.create();
final archived = RemoteAssetFactory.create(ownerId: asset.ownerId, visibility: AssetVisibility.archive);
final stacked = RemoteAssetFactory.create(ownerId: asset.ownerId, stackId: 'stack-1');
final localPhoto = LocalAssetFactory.create();
final result = AssetFilter(<BaseAsset>[
asset,
wrongOwner,
archived,
stacked,
localPhoto,
]).owned(asset.ownerId).archived(isArchived: false).stacked(isStacked: false);
expect(result.toList(), [asset]);
});
test('a base filter after a promotion retains the promoted type', () {
final favorite = RemoteAssetFactory.create(isFavorite: true);
final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId);
final AssetFilter<RemoteAsset> result = AssetFilter([favorite, regular]).owned(favorite.ownerId).favorite();
expect(result.toList(), [favorite]);
});
});
});
}

View File

@@ -0,0 +1,56 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
RemoteAsset _asset(String thumbHash) => RemoteAsset(
id: '1',
name: 'test.jpg',
ownerId: '1',
checksum: 'checksum',
type: AssetType.image,
createdAt: DateTime(2024),
updatedAt: DateTime(2024),
thumbHash: thumbHash,
isEdited: false,
);
void main() {
test('decodes the native RGBA output into a BMP', () async {
final bmp = decodeDriftThumbHash('1QcSHQRnh493V4dIh4eXh1h4kJUI');
expect(bmp, isNotNull);
final data = ByteData.sublistView(bmp!);
expect(data.getUint16(0, Endian.little), 0x4d42);
expect(data.getUint32(2, Endian.little), bmp.length);
expect(data.getInt32(18, Endian.little), 23);
expect(data.getInt32(22, Endian.little), -32);
expect(data.getUint16(28, Endian.little), 32);
final codec = await ui.instantiateImageCodec(bmp);
final frame = await codec.getNextFrame();
expect((frame.image.width, frame.image.height), (23, 32));
frame.image.dispose();
codec.dispose();
});
testWidgets('bad hashes fall back without throwing during build', (tester) async {
for (final hash in ['not base64!', 'AQIDBA==']) {
await tester.pumpWidget(
MaterialApp(
home: HookBuilder(
key: UniqueKey(),
builder: (context) => Text(useDriftBlurHashRef(_asset(hash)).value == null ? 'fallback' : 'decoded'),
),
),
);
expect(find.text('fallback'), findsOneWidget);
expect(tester.takeException(), isNull);
}
});
}

2
native/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
smoke/*.node

693
native/Cargo.lock generated Normal file
View File

@@ -0,0 +1,693 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cbindgen"
version = "0.29.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20"
dependencies = [
"heck",
"indexmap",
"log",
"proc-macro2",
"quote",
"serde",
"serde_json",
"syn",
"tempfile",
"toml",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]]
name = "convert_case"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "ctor"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb22e947478ccf9dc44d8922042c677a63fbb88f2cb468521d1145816e5087cb"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "immich_core"
version = "0.1.0"
[[package]]
name = "immich_core_ffi"
version = "0.1.0"
dependencies = [
"cbindgen",
"immich_core",
"jni",
"libc",
"tokio",
]
[[package]]
name = "immich_core_napi"
version = "0.1.0"
dependencies = [
"immich_core",
"napi",
"napi-build",
"napi-derive",
]
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys",
"log",
"simd_cesu8",
"thiserror",
"walkdir",
"windows-link",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libloading"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "napi"
version = "3.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c71997d6f7ad4a756966e452426848ac27d3b37a295302d63afbbcce0270f93"
dependencies = [
"bitflags",
"ctor",
"futures",
"napi-build",
"napi-sys",
"nohash-hasher",
"rustc-hash",
]
[[package]]
name = "napi-build"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1"
[[package]]
name = "napi-derive"
version = "3.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4ba572deef53e2c386759a8c2014175a62679d74ff83adc205c8bc0e0285727"
dependencies = [
"convert_case",
"ctor",
"napi-derive-backend",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "napi-derive-backend"
version = "5.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd961eb2aa8965e3f29722d754f3a86907eb1984e2fbcbe3fe87b9a02d6bfba"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"semver",
"syn",
]
[[package]]
name = "napi-sys"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400"
dependencies = [
"libloading",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "simd_cesu8"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"pin-project-lite",
]
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow 1.0.3",
]
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]]
name = "winnow"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

34
native/Cargo.toml Normal file
View File

@@ -0,0 +1,34 @@
[workspace]
resolver = "2"
members = [
"crates/immich_core",
"crates/immich_core_ffi",
"crates/immich_core_napi",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "AGPL-3.0-only"
[workspace.dependencies]
tokio = { version = "1", default-features = false, features = [
"rt-multi-thread",
"time",
] }
libc = { version = "0.2", default-features = false }
jni = { version = "0.22", default-features = false }
napi = { version = "3", default-features = false }
napi-derive = "3"
napi-build = "2"
cbindgen = { version = "0.29", default-features = false }
[workspace.lints.clippy]
undocumented_unsafe_blocks = "deny"
# FFI panic guards require the default unwind strategy.
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true

29
native/README.md Normal file
View File

@@ -0,0 +1,29 @@
# Native core
Shared Rust code used by the Immich mobile app. Flutter builds it from source
through the `immich_native_core` native-assets hook.
## Layout
```
crates/
immich_core shared image and thumbhash code
immich_core_ffi C ABI and Android JNI exports
immich_core_napi Node binding
immich_native_core/ Flutter package and build hook
smoke/ host Dart and Node checks
```
## Commands
Building the mobile app requires `rustup`. The toolchain and targets are pinned
in `crates/immich_core_ffi/rust-toolchain.toml`.
```
mise run build
mise run test
mise run lint
mise run fmt
mise run codegen
mise run test:flutter
mise run smoke
```

View File

@@ -0,0 +1,13 @@
[package]
name = "immich_core"
version.workspace = true
edition.workspace = true
license.workspace = true
[features]
default = ["image", "thumbhash"]
image = []
thumbhash = []
[lints]
workspace = true

View File

@@ -0,0 +1,310 @@
//! RGBA8888 EXIF rotation and RGBA_1010102 conversion.
// androidx ExifInterface orientation values.
const FLIP_HORIZONTAL: i32 = 2;
const ROTATE_180: i32 = 3;
const FLIP_VERTICAL: i32 = 4;
const TRANSPOSE: i32 = 5;
const ROTATE_90: i32 = 6;
const TRANSVERSE: i32 = 7;
const ROTATE_270: i32 = 8;
// Keep transpose writes inside a 4 KB tile.
const TILE: usize = 32;
pub fn swaps_dims(orientation: i32) -> bool {
matches!(orientation, ROTATE_90 | ROTATE_270 | TRANSPOSE | TRANSVERSE)
}
// src(sx, sy) maps to base + sx*step_x + sy*step_y in dst.
fn affine_for(o: i32, sw: i64, sh: i64, dw: i64) -> (i64, i64, i64) {
match o {
ROTATE_90 => (sh - 1, dw, -1),
ROTATE_270 => ((sw - 1) * dw, -dw, 1),
ROTATE_180 => ((sh - 1) * dw + (sw - 1), -1, -dw),
FLIP_HORIZONTAL => (sw - 1, -1, dw),
FLIP_VERTICAL => ((sh - 1) * dw, 1, -dw),
TRANSPOSE => (0, dw, 1),
TRANSVERSE => ((sw - 1) * dw + (sh - 1), -dw, -1),
_ => (0, 1, dw),
}
}
/// Rotates RGBA8888 pixels for an EXIF orientation.
/// Returns false when the dimensions or buffers are invalid.
pub fn rotate_rgba8888(
src: &[u8],
src_stride: usize,
sw: usize,
sh: usize,
orientation: i32,
dst: &mut [u8],
) -> bool {
let Some(src_row_len) = sw.checked_mul(4) else {
return false;
};
if sw == 0 || sh == 0 || src_stride < src_row_len {
return false;
}
let dw = if swaps_dims(orientation) { sh } else { sw };
let dh = if swaps_dims(orientation) { sw } else { sh };
let Some(src_len) = src_stride.checked_mul(sh) else {
return false;
};
let Some(dst_len) = dw.checked_mul(dh).and_then(|len| len.checked_mul(4)) else {
return false;
};
if src.len() < src_len || dst.len() < dst_len {
return false;
}
let (base, step_x, step_y) = affine_for(orientation, sw as i64, sh as i64, dw as i64);
for ty in (0..sh).step_by(TILE) {
let y_end = (ty + TILE).min(sh);
for tx in (0..sw).step_by(TILE) {
let x_end = (tx + TILE).min(sw);
for sy in ty..y_end {
let row = sy * src_stride;
let mut idx = base + sy as i64 * step_y + tx as i64 * step_x;
for sx in tx..x_end {
let s = row + sx * 4;
let d = idx as usize * 4;
dst[d..d + 4].copy_from_slice(&src[s..s + 4]);
idx += step_x;
}
}
}
}
true
}
#[inline]
fn scale10(v: u32) -> u32 {
(v * 16336 + 32768) >> 16
}
/// Converts little-endian RGBA_1010102 pixels to RGBA8888.
/// Returns false when the dimensions or buffers are invalid.
pub fn rgba1010102_to_rgba8888(
src: &[u8],
src_stride: usize,
w: usize,
h: usize,
dst: &mut [u8],
) -> bool {
let Some(row_len) = w.checked_mul(4) else {
return false;
};
if w == 0 || h == 0 || src_stride < row_len {
return false;
}
let Some(src_len) = src_stride.checked_mul(h) else {
return false;
};
let Some(dst_len) = w.checked_mul(h).and_then(|len| len.checked_mul(4)) else {
return false;
};
if src.len() < src_len || dst.len() < dst_len {
return false;
}
// Plain arithmetic auto-vectorizes to NEON and measured faster than a LUT on-device (#29631).
for y in 0..h {
let s_row = y * src_stride;
let d_row = y * w * 4;
for x in 0..w {
let s = s_row + x * 4;
// Avoid unaligned u32 reads.
let px = u32::from_le_bytes([src[s], src[s + 1], src[s + 2], src[s + 3]]);
let d = d_row + x * 4;
let r = scale10(px & 0x3FF);
let g = scale10((px >> 10) & 0x3FF);
let b = scale10((px >> 20) & 0x3FF);
let a = ((px >> 30) & 0x3) * 85;
let rgba = r | (g << 8) | (b << 16) | (a << 24);
dst[d..d + 4].copy_from_slice(&rgba.to_le_bytes());
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
// Independent EXIF mapping for the affine tests.
fn ref_dst_xy(o: i32, sx: usize, sy: usize, sw: usize, sh: usize) -> (usize, usize) {
match o {
FLIP_HORIZONTAL => (sw - 1 - sx, sy),
ROTATE_180 => (sw - 1 - sx, sh - 1 - sy),
FLIP_VERTICAL => (sx, sh - 1 - sy),
TRANSPOSE => (sy, sx),
ROTATE_90 => (sh - 1 - sy, sx),
TRANSVERSE => (sh - 1 - sy, sw - 1 - sx),
ROTATE_270 => (sy, sw - 1 - sx),
_ => (sx, sy),
}
}
fn pixel(i: usize) -> [u8; 4] {
[
(i & 0xff) as u8,
((i >> 8) & 0xff) as u8,
((i >> 16) & 0xff) as u8,
0xff,
]
}
fn check(o: i32, sw: usize, sh: usize) {
let mut src = vec![0u8; sw * sh * 4];
for sy in 0..sh {
for sx in 0..sw {
let i = sy * sw + sx;
src[i * 4..i * 4 + 4].copy_from_slice(&pixel(i));
}
}
let (dw, dh) = if swaps_dims(o) { (sh, sw) } else { (sw, sh) };
let mut dst = vec![0u8; dw * dh * 4];
assert!(rotate_rgba8888(&src, sw * 4, sw, sh, o, &mut dst));
for sy in 0..sh {
for sx in 0..sw {
let (dx, dy) = ref_dst_xy(o, sx, sy, sw, sh);
let di = dy * dw + dx;
let si = sy * sw + sx;
assert_eq!(&dst[di * 4..di * 4 + 4], &pixel(si), "o={o} src({sx},{sy})");
}
}
}
#[test]
fn all_orientations_match_exif_reference() {
for o in [1, 2, 3, 4, 5, 6, 7, 8] {
check(o, 4, 3);
check(o, 1, 5);
check(o, 5, 1);
check(o, 40, 33); // spans multiple tiles
}
}
#[test]
fn swaps_dims_only_for_the_90_270_transpose_family() {
for o in [TRANSPOSE, ROTATE_90, TRANSVERSE, ROTATE_270] {
assert!(swaps_dims(o), "o={o}");
}
for o in [0, 1, FLIP_HORIZONTAL, ROTATE_180, FLIP_VERTICAL, 9, -1] {
assert!(!swaps_dims(o), "o={o}");
}
}
#[test]
fn identity_for_normal_orientation() {
let src: Vec<u8> = (0..24u8).collect();
let mut dst = vec![0u8; 24];
assert!(rotate_rgba8888(&src, 8, 2, 3, 1, &mut dst));
assert_eq!(src, dst);
}
#[test]
fn respects_src_stride_padding() {
let (sw, sh, stride) = (2usize, 2usize, 12usize);
let mut src = vec![0u8; stride * sh];
for sy in 0..sh {
for sx in 0..sw {
let i = sy * sw + sx;
src[sy * stride + sx * 4..sy * stride + sx * 4 + 4].copy_from_slice(&pixel(i));
}
}
let mut dst = vec![0u8; sw * sh * 4];
assert!(rotate_rgba8888(&src, stride, sw, sh, ROTATE_180, &mut dst));
for i in 0..4 {
assert_eq!(&dst[i * 4..i * 4 + 4], &pixel(3 - i));
}
}
#[test]
fn rejects_bad_sizes() {
let src = vec![0u8; 16];
let mut small = vec![0u8; 4];
assert!(!rotate_rgba8888(&src, 8, 2, 2, ROTATE_90, &mut small));
assert!(!rotate_rgba8888(&src, 4, 2, 2, 1, &mut small));
assert!(!rotate_rgba8888(
&src,
usize::MAX,
usize::MAX,
1,
1,
&mut small
));
assert!(!rotate_rgba8888(&src, usize::MAX, 1, 2, 1, &mut small));
}
#[test]
fn scale10_matches_exact_mapping() {
for v in 0..1024 {
assert_eq!(scale10(v), (v * 255 + 511) / 1023, "v={v}");
}
}
// 179 and 111 distinguish rounded scaling from `>> 2`.
#[test]
fn scale10_matches_skia() {
for (v, want) in [
(0, 0),
(111, 28),
(179, 45),
(230, 57),
(304, 76),
(1023, 255),
] {
assert_eq!(scale10(v), want, "v={v}");
}
for (a, want) in [0, 85, 170, 255].into_iter().enumerate() {
assert_eq!(a as u32 * 85, want);
}
}
#[test]
fn convert_packing() {
let red = 0x0000_03FFu32.to_le_bytes();
let alpha = 0xC000_0000u32.to_le_bytes();
let mut src = Vec::new();
src.extend_from_slice(&red);
src.extend_from_slice(&alpha);
let mut dst = vec![0u8; 8];
assert!(rgba1010102_to_rgba8888(&src, 8, 2, 1, &mut dst));
assert_eq!(&dst[0..4], &[255, 0, 0, 0]);
assert_eq!(&dst[4..8], &[0, 0, 0, 255]);
}
#[test]
fn convert_respects_src_stride_padding() {
let (w, h, stride) = (2usize, 2usize, 12usize);
let px = |v: u32| (v | 0xC000_0000).to_le_bytes();
let mut src = vec![0u8; stride * h];
for (i, v) in [0u32, 179, 111, 1023].iter().enumerate() {
let (x, y) = (i % w, i / w);
src[y * stride + x * 4..y * stride + x * 4 + 4].copy_from_slice(&px(*v));
}
let mut dst = vec![0u8; w * h * 4];
assert!(rgba1010102_to_rgba8888(&src, stride, w, h, &mut dst));
for (i, want) in [0u8, 45, 28, 255].iter().enumerate() {
assert_eq!(dst[i * 4], *want, "R of pixel {i}");
assert_eq!(dst[i * 4 + 3], 255, "A of pixel {i}");
}
}
#[test]
fn convert_rejects_bad_sizes() {
let src = vec![0u8; 16];
let mut small = vec![0u8; 4];
assert!(!rgba1010102_to_rgba8888(&src, 0, 0, 0, &mut small));
assert!(!rgba1010102_to_rgba8888(&src, 4, 2, 2, &mut small));
assert!(!rgba1010102_to_rgba8888(&src, 8, 2, 2, &mut small));
assert!(!rgba1010102_to_rgba8888(
&src,
usize::MAX,
usize::MAX,
1,
&mut small,
));
assert!(!rgba1010102_to_rgba8888(&src, usize::MAX, 1, 2, &mut small));
}
}

View File

@@ -0,0 +1,21 @@
//! Shared native logic for Immich.
#[cfg(feature = "image")]
pub mod image;
#[cfg(feature = "thumbhash")]
pub mod thumbhash;
pub fn core_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_is_present() {
assert!(!core_version().is_empty());
}
}

View File

@@ -0,0 +1,481 @@
//! ThumbHash placeholder decoding.
//
// Ported from Evan Wallace's reference implementation:
// Copyright (c) 2023 Evan Wallace
// 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.
struct Header {
l_dc: f32,
p_dc: f32,
q_dc: f32,
l_scale: f32,
p_scale: f32,
q_scale: f32,
a_dc: f32,
a_scale: f32,
has_alpha: bool,
lx: usize,
ly: usize,
ac_start: usize,
w: usize,
h: usize,
}
fn ac_len(nx: usize, ny: usize) -> usize {
let mut n = 0;
for cy in 0..ny {
let mut cx = if cy > 0 { 0 } else { 1 };
while cx * ny < nx * (ny - cy) {
n += 1;
cx += 1;
}
}
n
}
fn parse(hash: &[u8]) -> Option<Header> {
if hash.len() < 5 {
return None;
}
let header24 = (hash[0] as u32) | ((hash[1] as u32) << 8) | ((hash[2] as u32) << 16);
let header16 = (hash[3] as u32) | ((hash[4] as u32) << 8);
let has_alpha = (header24 >> 23) != 0;
if has_alpha && hash.len() < 6 {
return None;
}
let is_landscape = (header16 >> 15) != 0;
let lx_raw = if is_landscape {
if has_alpha {
5
} else {
7
}
} else {
(header16 & 7) as usize
};
let ly_raw = if is_landscape {
(header16 & 7) as usize
} else if has_alpha {
5
} else {
7
};
// The format computes size before clamping the component counts.
if lx_raw == 0 || ly_raw == 0 {
return None;
}
let ratio = lx_raw as f32 / ly_raw as f32;
let w = (if ratio > 1.0 { 32.0 } else { 32.0 * ratio }).round() as usize;
let h = (if ratio > 1.0 { 32.0 / ratio } else { 32.0 }).round() as usize;
let lx = lx_raw.max(3);
let ly = ly_raw.max(3);
let ac_start = if has_alpha { 6 } else { 5 };
let mut nibbles = ac_len(lx, ly) + 2 * ac_len(3, 3);
if has_alpha {
nibbles += ac_len(5, 5);
}
if hash.len() < ac_start + nibbles.div_ceil(2) {
return None;
}
let (a_dc, a_scale) = if has_alpha {
((hash[5] & 15) as f32 / 15.0, (hash[5] >> 4) as f32 / 15.0)
} else {
(1.0, 1.0)
};
Some(Header {
l_dc: (header24 & 63) as f32 / 63.0,
p_dc: ((header24 >> 6) & 63) as f32 / 31.5 - 1.0,
q_dc: ((header24 >> 12) & 63) as f32 / 31.5 - 1.0,
l_scale: ((header24 >> 18) & 31) as f32 / 31.0,
p_scale: ((header16 >> 3) & 63) as f32 / 63.0,
q_scale: ((header16 >> 9) & 63) as f32 / 63.0,
a_dc,
a_scale,
has_alpha,
lx,
ly,
ac_start,
w,
h,
})
}
fn decode_channel(
hash: &[u8],
ac_start: usize,
ac_index: &mut usize,
nx: usize,
ny: usize,
scale: f32,
) -> Vec<f32> {
let mut ac = Vec::with_capacity(ac_len(nx, ny));
for cy in 0..ny {
let mut cx = if cy > 0 { 0 } else { 1 };
while cx * ny < nx * (ny - cy) {
let byte = hash[ac_start + (*ac_index >> 1)];
let nibble = (byte >> ((*ac_index & 1) << 2)) & 15;
ac.push((nibble as f32 / 7.5 - 1.0) * scale);
*ac_index += 1;
cx += 1;
}
}
ac
}
/// Returns the decoded size, or None for a malformed hash.
pub fn dims(hash: &[u8]) -> Option<(u32, u32)> {
parse(hash).map(|hdr| (hdr.w as u32, hdr.h as u32))
}
/// Decodes a hash into a caller-owned RGBA8888 buffer.
pub fn to_rgba(hash: &[u8], dst: &mut [u8]) -> bool {
let Some(hdr) = parse(hash) else {
return false;
};
if dst.len() < hdr.w * hdr.h * 4 {
return false;
}
let mut ac_index = 0;
let l_ac = decode_channel(
hash,
hdr.ac_start,
&mut ac_index,
hdr.lx,
hdr.ly,
hdr.l_scale,
);
let p_ac = decode_channel(hash, hdr.ac_start, &mut ac_index, 3, 3, hdr.p_scale * 1.25);
let q_ac = decode_channel(hash, hdr.ac_start, &mut ac_index, 3, 3, hdr.q_scale * 1.25);
let a_ac = if hdr.has_alpha {
decode_channel(hash, hdr.ac_start, &mut ac_index, 5, 5, hdr.a_scale)
} else {
Vec::new()
};
let cx_stop = hdr.lx.max(if hdr.has_alpha { 5 } else { 3 });
let cy_stop = hdr.ly.max(if hdr.has_alpha { 5 } else { 3 });
let mut fx = vec![0.0f32; cx_stop];
let mut fy = vec![0.0f32; cy_stop];
let mut i = 0;
for y in 0..hdr.h {
for x in 0..hdr.w {
let mut l = hdr.l_dc;
let mut p = hdr.p_dc;
let mut q = hdr.q_dc;
let mut a = hdr.a_dc;
for (cx, f) in fx.iter_mut().enumerate() {
*f = (std::f64::consts::PI / hdr.w as f64 * (x as f64 + 0.5) * cx as f64).cos()
as f32;
}
for (cy, f) in fy.iter_mut().enumerate() {
*f = (std::f64::consts::PI / hdr.h as f64 * (y as f64 + 0.5) * cy as f64).cos()
as f32;
}
let mut j = 0;
for (cy, fyv) in fy.iter().enumerate().take(hdr.ly) {
let fy2 = fyv * 2.0;
let mut cx = if cy > 0 { 0 } else { 1 };
while cx * hdr.ly < hdr.lx * (hdr.ly - cy) {
l += l_ac[j] * fx[cx] * fy2;
j += 1;
cx += 1;
}
}
j = 0;
for (cy, fyv) in fy.iter().enumerate().take(3) {
let fy2 = fyv * 2.0;
let start = if cy > 0 { 0 } else { 1 };
for fxv in &fx[start..3 - cy] {
let f = fxv * fy2;
p += p_ac[j] * f;
q += q_ac[j] * f;
j += 1;
}
}
if hdr.has_alpha {
j = 0;
for (cy, fyv) in fy.iter().enumerate().take(5) {
let fy2 = fyv * 2.0;
let start = if cy > 0 { 0 } else { 1 };
for fxv in &fx[start..5 - cy] {
a += a_ac[j] * fxv * fy2;
j += 1;
}
}
}
let b = l - 2.0 / 3.0 * p;
let r = (3.0 * l - b + q) / 2.0;
let g = r - q;
dst[i] = to_u8(r);
dst[i + 1] = to_u8(g);
dst[i + 2] = to_u8(b);
dst[i + 3] = to_u8(a);
i += 4;
}
}
true
}
fn to_u8(v: f32) -> u8 {
(255.0 * v.clamp(0.0, 1.0)).round() as u8
}
#[cfg(test)]
mod tests {
use super::*;
// Captured from the old Swift decoder; channel values may differ by one.
const OPAQUE_B64: &str = "1QcSHQRnh493V4dIh4eXh1h4kJUI";
const ALPHA_B64: &str = "1QeSHQR6Z4ePd1eHSIeHl4dYeJCVCIQ8WuEpd7M=";
const OPAQUE_RGBA_HEX: &str = "404d71ff424f72ff475375ff4d5878ff545d7cff5b6480ff626984ff686e87ff6d7389ff71758bff72778bff72778bff\
70768aff6d7589ff6a7388ff667287ff627087ff5e6f87ff5b6e88ff586d89ff566d8aff556d8aff546d8bff414d70ff\
434f71ff475374ff4d5877ff545d7bff5b637fff626983ff696e86ff6d7288ff717589ff727689ff717689ff707588ff\
6d7487ff697286ff657085ff616e85ff5d6d85ff5a6c86ff576b86ff566b87ff546b88ff546b89ff434e6fff455070ff\
495373ff4f5876ff565e7aff5d637eff646981ff6a6e84ff6e7286ff717487ff727587ff727586ff707485ff6c7283ff\
687082ff646e81ff606c81ff5c6b81ff596a82ff576983ff556984ff546985ff536985ff47506fff495270ff4d5573ff\
535a76ff5a5f7aff61657dff686b81ff6d6f83ff727385ff747586ff757685ff747584ff717382ff6e7180ff696f7fff\
656c7eff616a7dff5d697dff5a687eff57687fff566880ff556881ff546882ff4e5571ff505772ff555a74ff5a5f78ff\
61647bff686a7fff6f6f82ff747485ff787786ff7b7986ff7b7986ff7a7884ff777682ff727380ff6e707eff696e7cff\
646c7cff616a7cff5e697dff5b697eff5a697fff596a80ff596a81ff595c75ff5b5e76ff5f6279ff65667cff6c6c80ff\
737283ff7a7787ff7f7b89ff837f8bff85808bff86808aff847f88ff807d85ff7c7983ff767680ff71737eff6d717dff\
696f7dff666e7eff646e80ff626e81ff626f82ff616f83ff67677cff69697dff6d6c7fff737183ff7a7787ff817d8bff\
88828eff8e8791ff928a92ff948c93ff948c92ff928a8fff8e878cff898389ff848086ff7e7c84ff797983ff757883ff\
727783ff707785ff6f7887ff6e7888ff6e7989ff767384ff797585ff7d7888ff837e8bff8a848fff928a94ff999098ff\
a0959bffa4999dffa69a9dffa69a9cffa4989affa09596ff9a9192ff948c8fff8e888cff89858bff84838bff81838bff\
7f838dff7e838fff7e8490ff7e8591ff857e8bff88808dff8d848fff938a93ff9b9098ffa3979dffab9ea2ffb2a3a5ff\
b7a7a8ffb9a9a8ffb9a9a7ffb7a7a5ffb2a4a1ffac9f9dffa59a99ff9f9696ff999294ff959093ff918f94ff908f95ff\
8f9097ff8e9199ff8e919aff928790ff958a92ff998e95ffa09499ffa99b9effb1a2a4ffbaa9a9ffc1afadffc7b4b0ff\
cab7b1ffcab7b1ffc8b5aeffc3b1aaffbcaba5ffb5a6a1ffaea19dffa89d9bffa39a9aff9f999aff9d999cff9d9a9eff\
9c9ba0ff9c9ca1ff9a8c91ff9c8e92ffa29396ffa9999affb2a0a0ffbba8a6ffc4b0acffccb7b1ffd2bcb5ffd6bfb6ff\
d6bfb6ffd4bdb3ffcfb9afffc8b3aaffc0ada5ffb8a8a0ffb1a39effaca09cffa89f9dffa69f9effa5a0a0ffa5a0a2ff\
a5a1a3ff9b8a8cff9e8d8dffa39191ffab9896ffb4a09cffbea8a3ffc8b1a9ffd0b8afffd7beb3ffdbc1b5ffdbc2b5ff\
d9bfb2ffd3bbadffccb5a8ffc4aea2ffbba89dffb4a39affae9f98ffaa9e98ffa89d9affa79e9bffa79f9dffa7a09eff\
958381ff988583ff9e8a86ffa5908bffaf9892ffb9a199ffc3aa9fffccb2a6ffd3b8aaffd8bcacffd8bcacffd6baaaff\
d0b5a5ffc8af9fffbfa799ffb6a093ffae9b8fffa8978dffa4958dffa2958effa19590ffa19692ffa19793ff8a7571ff\
8c7873ff927c76ff99827bffa38a81ffad9389ffb79c90ffc1a596ffc8ab9bffcdaf9dffcdaf9dffcbad9bffc5a896ff\
bca18fffb39989ffaa9283ffa18c7eff9b877cff96857bff94857cff93857eff938780ff938781ff79645eff7c665fff\
816a63ff887067ff91786dff9b8174ffa68a7bffaf9282ffb79886ffbb9c89ffbc9d89ffb99a86ffb39581ffaa8e7bff\
a18674ff977e6dff8e7768ff877366ff837165ff817066ff807169ff80726bff80736cff675149ff69534bff6d564eff\
745c52ff7d6357ff866b5eff907464ff997c6bffa1826fffa58672ffa68772ffa3846fff9d7f6aff947763ff8a6f5cff\
806756ff786051ff715c4eff6d5a4eff6b5a4fff6a5b52ff6a5c54ff6b5d55ff533e37ff554038ff59433aff5f483dff\
674e42ff705548ff795d4eff826554ff896b58ff8e6f5bff8f705bff8c6d58ff866853ff7d614dff735846ff69503fff\
614a3bff5a4638ff574438ff55443aff55463dff554740ff564841ff422e27ff432f27ff473129ff4c352cff523b2fff\
5a4134ff634839ff6b4f3fff725543ff775946ff785a46ff755844ff70533fff674c39ff5e4432ff543d2cff4c3728ff\
473326ff433227ff42332aff42342dff433730ff443832ff33211bff34221bff36231cff3a261eff402a20ff473024ff\
4f3629ff573d2dff5d4232ff624634ff634735ff614633ff5c412fff553b2aff4c3424ff432d1fff3c281bff37251aff\
34251cff34261fff352923ff362b26ff372d28ff271814ff281814ff291914ff2c1b14ff311e16ff372218ff3e281cff\
452e21ff4c3325ff503728ff533929ff513828ff4d3525ff462f20ff3e291bff372317ff301f15ff2c1d15ff2b1d17ff\
2b201bff2d2320ff2f2624ff302826ff1e1211ff1e1210ff1f120fff21130fff24150fff291811ff301d14ff372318ff\
3d281cff432d20ff452f22ff452f22ff422d1fff3c281cff352318ff2f1e15ff291b13ff261a14ff251b18ff271e1dff\
292222ff2c2626ff2d2829ff170f11ff170e10ff170e0eff180d0dff1a0e0cff1f110dff241510ff2b1b13ff322018ff\
38251cff3b291fff3c2a1fff3a281eff35251cff2f2119ff2a1d17ff251b17ff231b19ff231d1dff252122ff282528ff\
2b292dff2d2b30ff110d13ff110c11ff100b0fff110a0dff120a0bff160c0cff1b100eff221512ff291b16ff2f211bff\
33251eff352720ff342620ff31241fff2c211dff271e1cff231d1cff221d1fff232024ff25242aff282930ff2c2d35ff\
2d3038ff0c0d16ff0c0b14ff0b0a11ff0a080eff0b070cff0e090cff130c0eff1a1212ff211817ff281e1cff2d2320ff\
302623ff302624ff2d2524ff292222ff252022ff221f23ff202026ff21232bff242731ff272c37ff2b313dff2d3340ff\
080c19ff070b17ff050814ff040610ff04050eff07060dff0c090fff130f13ff1a1618ff221c1eff282223ff2b2527ff\
2c2728ff2a2628ff262328ff222127ff1f2028ff1d212bff1e2430ff212836ff242d3cff273142ff293445ff030c1cff\
020a1aff000717ff000513ff000310ff00040fff050710ff0c0d14ff14141aff1d1b21ff232126ff27252aff28272cff\
26262dff22242cff1e212bff1a202cff18202eff192232ff1b2638ff1e2b3eff212f43ff223146ff000c20ff000a1dff\
000719ff000415ff000212ff000211ff000512ff070b16ff0f121dff181a23ff1e2129ff23252eff232630ff212530ff\
1d232fff18202dff141d2dff111d2fff111e32ff122137ff14253cff172941ff182b43ff000b23ff000920ff00061cff\
000317ff000114ff000113ff000414ff020a19ff0b121fff131926ff1a202cff1f2430ff1f2632ff1d2432ff182130ff\
121d2eff0d1a2dff09182dff071830ff081a33ff091d38ff0b203cff0c223eff000b25ff000923ff00061eff00021aff\
000016ff000015ff000316ff00091bff071121ff101928ff171f2eff1b2433ff1b2534ff182333ff131e31ff0c192dff\
05152bff00122aff00112bff00122eff001532ff001735ff001837ff000c28ff000925ff000621ff00021cff000018ff\
000016ff000318ff00091cff051123ff0d192aff141f30ff182334ff182435ff142134ff0e1c30ff06162cff001129ff\
000d27ff000b27ff000b29ff000c2cff000e2fff000f30ff000c29ff000a27ff000622ff00021dff000019ff000018ff\
000319ff00091eff031124ff0c182bff121f31ff162235ff162336ff112034ff0a1a30ff02132bff000d27ff000824ff\
000523ff000525ff000627ff000729ff00082bff000c2aff000a28ff000623ff00021eff00001aff000019ff00031aff\
00091fff021125ff0b182cff111f32ff152236ff142236ff101f34ff081930ff00122aff000b26ff000622ff000321ff\
000222ff000224ff000326ff000427ff";
const ALPHA_RGBA_HEX: &str = "4c4950004d4a52004f4c5400524f570056545c005c596100625f683169666f80706d77bc77757ede7e7c86df84828cbe\
898892798d8c96148f8e99008f8f99008e8f98008c8d9600888a930084868f0080828a007b7f8600787c8300757a8000\
74797f00747a7e00747b7f00757d8000777f8100798083707a8284d27b8385ff504d5500514e56005451580057545c00\
5b586000605d660066636c466d6a739574717bd37b7882f6827f8af9888690d98d8b9696918f9a3293929c0093939d00\
92929c008f909a008c8d96008889920083868e007f828a007c808600797e8400787d8200777d8200787e820079808300\
7b8285077c84868e7e8588f07e8688ff59555d005a565e005c5860005f5c64006360680068656d106e6b746c74717abe\
7b7882fd827f89ff898690ff8f8c97ff94929ccd9796a06c9998a2009a99a3009998a2009696a00092939c008e909800\
8a8c94008688900082868c0080848a007e8388007e8388007f8488008086890081888b41838a8cc6848b8eff858c8eff\
646067006561680067636b006a666e006e6a7200736e774178747d9f7e7b84f485818bff8c8892ff928f99ff98959fff\
9d9aa5ffa09ea8bca2a0ab45a2a1ab00a1a0aa009e9ea8009b9ba4009797a00092939c008e9097008b8d9400888b9100\
878b9000868b8f00878c9000888e91008a9093908b9294ff8d9395ff8d9496ff706b7300716c7400736e760075717900\
79747d197e79827a837e87da89848eff908b95ff96919cff9c98a2ffa29ea8ffa6a2adffa9a6b1ffaba8b3a9aba9b325\
aaa8b200a7a6af00a3a2ac009f9fa8009b9ba30096989f0093959c00909399008f9297008f9297008f93970091959956\
92979aec94999cff959b9dff969b9eff7b757d007c767e007e788000807a8300847e874f88828bb28d8791ff938d97ff\
99939dff9f99a4ffa59faaffaaa5afffaea9b4ffb1adb7ffb3aeb9ffb2afb996b1aeb80eaeacb500aaa8b200a6a5ae00\
a1a1a9009d9da5009a9ba10097999f0096989d0096989d0096999d1f979b9eb9999da0ff9b9fa1ff9ca0a3ff9da1a3ff\
847d8500857e8600867f880089828a248c858e7f908992e4948d97ff9a939dff9f98a3ffa59ea9ffaaa4afffafa9b4ff\
b3adb8ffb6b0bbffb7b2bdffb7b2bdffb5b1bb84b2aeb802aeabb500aaa7b000a5a3ac00a1a0a7009d9da4009b9ba100\
9a9aa000999a9f009a9ca0839b9da1ff9d9fa2ff9ea1a4ff9fa3a5ffa0a3a6ff898189008a828a008b838c008d858e48\
908891a4948b95ff989099ff9d959fffa29aa4ffa79faaffaca4afffb0a9b4ffb4adb8ffb6afbaffb7b1bcffb7b1bbff\
b5afbaeeb2adb773aea9b301a9a5ae00a5a1aa00a09ea6009d9ba2009a999f0099989e0098989d5299999ed89a9b9fff\
9c9da0ff9d9fa2ff9fa0a3ff9fa1a3ff8a8089008a818a008b828b118d848d5d908690ba938a93ff978d97ff9b929cff\
a096a1ffa49ba6ffa9a0abffada4afffb0a8b3ffb2aab5ffb2abb6ffb2abb5ffb0a9b3ffaca6b0d2a8a3ac64a49fa808\
9f9ba3009b979f0097949b00959298009391972f9391969a939296ff949497ff969699ff97989aff99999cff999a9cff\
867b8400867c8500877d8618897e87638b808abf8e838dff918790ff958a95ff998f99ff9d939effa197a2ffa49ba6ff\
a79ea9ffa9a0abffa9a0abffa8a0abffa69ea8ffa29ba5ff9e97a1b199939c57958f9717908b93008d888f008a868c1f\
88858b6788858aca88868aff8a888bff8b8a8dff8d8c8eff8e8d8fff8e8e90ff7e737b007e737c007f747d1080757e5a\
827780b5847983ff877c86ff8a7f89ff8e838dff928791ff958a95ff988d99ff9b909bff9c919dff9c929dff9b919cff\
988f9aff948c96ff908892e68b848d8e8680884c827c83297e7980267b777d467a757b8579757ade7a767aff7b787bff\
7c7a7dff7e7b7eff7f7d7fff7f7e80ff73677000746770007468710075697246766a749f786c76ff7b6e78ff7d717cff\
80747fff847882ff877b86ff897e89ff8b808bff8c818cff8c818cff8a808bff887e88ff847a84ff7f7680ff7a727bac\
756e766a716a71436d666d3c6a646a546863688b686368da686468ff696568ff6a676aff6c696bff6d6a6cff6e6b6dff\
675b6400685b6400685b6400695c652a6a5d66816b5e68e26d606aff6f626dff726570ff746873ff776a76ff796d78ff\
7b6e7aff7b6f7bff7b6f7aff796e79ff766b76ff726872ff6e646dff695f68b6645b63735f575e495b545a3c5851574e\
5650557b565054c1565054ff575255ff585356ff595557ff5a5658ff5b5759ff5c4f58005c4f58005c4f58005d4f580d\
5d50595f5e515bbd60525cff61545eff645661ff665963ff685b66ff6a5d68ff6b5e6aff6b5f6aff6b5e69ff695d68ff\
665a65ff625761ff5d525cff584e57b05349516c4e454d404a42482e47404538453e435d443e4299443e42e4454042ff\
464144ff484345ff494446ff494546ff52454d0052454e0052454e0052454e0053454e4153464f99544751f7564852ff\
584a54ff594c57ff5b4e59ff5d4f5bff5e515cff5e515cff5d505bff5b4f59ff584c56ff544852ff4f444df14a3f489f\
453b435c40373e2c3c333a163931361a372f3437362f3369362f33ab363033f5383234ff393335ff3a3536ff3a3536ff\
4b3e46004b3e46004b3d46004b3d46004b3d47294b3e477b4c3e48d34d404aff4f414bff50434dff52444fff534651ff\
544752ff544752ff534651ff51454fff4e424cff4a3e48ff453a43db40353e8b3b313847362c331632292f002f262b00\
2c25290f2b2428392b2528722c2628b32d2729f42e282aff2f2a2bff302a2bff483a4300473a4300473a4300473a4300\
4739431d473a4367483a44b7483b45ff4a3c46ff4b3e48ff4c3f4aff4e404bff4e414cff4e414cff4d414bff4b3f49ff\
483c46ff443942ff3f343dc53a303878352b333530272e032c24290029212600271f2300261f2210261f22412620227a\
272123b3282324e6292424ff2a2425ff473a4300473a4300473a4300463942004639421d4639425f463943a6473a44f0\
483b45ff4a3d47ff4b3e48ff4c3f4aff4d404bff4d404bff4c404aff4a3e48ff473c45ff433841ff3f343cb43a30376a\
352b322930272d002c23290029212500271f2300251f2100251f211b2620214d26212280272222ae282323d0292424e2\
493d4500493d4500493c4500483c4500483c4429483b4562483c45a1493c46e14a3d47ff4b3f49ff4c404aff4e424cff\
4f434dff4f434dff4e434cff4c414bff4a3f48ff463c44f2423840aa3d333b63382f3624332b31002f282c002c252900\
2a2327002923250029232405292425312a25255f2b2626882c2727a72c2727b74c4149004c4149004c4049004b404817\
4b40483f4b3f486f4b4049a44c404adc4d414bff4e434dff50444eff514650ff524751ff534852ff524851ff514750ff\
4f454dff4b424aea473e46a7433a41653e363c293a323700362f3300332c3000312b2d00302a2c002f2a2b00302b2b27\
302c2c50312d2d75322e2d91322f2ea04f454c0c4e454c154e444c254e444c3d4d434b5d4d434c834e434cae4e444ddc\
50454fff514750ff534952ff554b54ff564d56ff574e57ff574e57ff564d56ff544b54ff514951e74e464daa4a42486e\
453e4437413b3f0a3e373b003b35380039343600373334003733340b3834342e38353454393635763a37358f3a37369d\
4f464e454f464e4a4e464d564e454d674e454d7e4e454d9a4e454ebc4f464fe0514851ff534a53ff554c55ff574e58ff\
59515aff5b525bff5b535bff5b535bff595159ff574f57e8534d53b250494f7c4c464b4a4842472144404305423d4000\
403c3d003f3b3c083f3c3c233f3c3c44403d3c67413e3d86413f3d9e42403eab4b444b7b4b444b7e4b444b854b434b8f\
4b434b9e4b434bb14c444cc84d454ee34f4750ff514a52ff544c55ff574f58ff59525aff5b545cff5c555dff5c555dff\
5b555cff59535aea575157bb534e538b504b4f604c484c3c49454823474345184542431b4442422b4442414444434164\
45444285464543a3464643ba474644c6433e44ac433e44ac433d44ae433d44b2433d44b8433e45c3443f46d1464048e3\
48424af84b454dff4e4851ff524c54ff554f57ff575159ff59535bff5a545cff59545bff58535aeb565257c3534f549b\
504d51764d4a4d584a484a444846473c46454541454544524545446c4646448b474744ac484845c9484946df494946ea\
363239d4363239d2363238cf363239cd363239cc37333ace39343bd33b363edd3d3940ea413c44fa444048ff48444bff\
4c474fff4f4b52ff514d54ff534f56ff535056ff524f55e8514e53c84e4c50a74c4a4d8949484a714746476345444560\
434343684343427a43444295434542b5444643d5454744f2464844ff464844ff252228f1252228ed252228e7252229de\
262329d627242ad028252cce2b282fd02e2b32d7322f36e136333aee3a373ffb3f3c43ff424047ff46434aff48454cff\
49464cf548474ce147464bc8464548af43434698414143873f3f417e3d3e3f7f3c3e3d8b3c3e3ca03c3e3cbc3d3f3cdd\
3e403dfe3e423eff3f423eff40433fff100f15ff100f15fe100f15f4110f15e7111016d8131117cc15131ac218161dbd\
1b1920be201e25c324222acd29282fd82e2c33e3333138eb36353bee39383eec3b3a3fe33b3a3fd53b3a3fc4393a3db2\
38383ba236373997343636943235349a323433a9313433c1323532e0323633ff333734ff343934ff353a35ff363a35ff\
000000ff000000ff000000f8000000e7000001d4000003c1000005b1030209a707060da20c0b12a4111017aa16161db4\
1c1b22bf212027c925252bcf28282ed12b2b30ce2c2c31c62c2d30bc2b2c2fb02a2b2ea7282a2ca227292aa4252928ae\
252827c2252927dc252a27fd262b27ff272c28ff282d29ff292e29ff292f2aff000000ff000000ff000000f7000000e2\
000000ca000000b30000009e0000008f00000086000000840000058904040b910a0a119d0f1016a814151bb118191eb7\
1a1c21b91c1d22b61c1e22b11c1e21ab1b1e20a81a1d1ea8191c1caf181c1bbd171c1ad4171c1af1181d1aff191f1bff\
1a201bff1b211cff1c221dff1c231dff000000ff000000ff000000f2000000da000000bf000000a40000008c00000079\
0000006e000000690000006c000000740000017f0001078c05060c97090b10a00c0e13a50e1014a70f1115a70f1214a6\
0e1213a60d1112ab0c1110b50b100fc70b100ee00b110eff0c120eff0d130fff0e1510ff0f1611ff101712ff101812ff\
000000ff000000ff000000ed000000d3000000b6000000990000007e000000690000005b00000055000000560000005e\
0000006a00000077000001840000058f0104089704060a9c05080b9e05080ba004080aa4030809ab030807b9020706cd\
020806e8020805ff030a06ff040b06ff050c07ff060e08ff070f09ff080f09ff000000ff000000fd000000ea000000cf\
000000b1000000920000007600000060000000510000004a0000004b000000520000005e0000006c0000007a00000086\
0000038f00010595000306990003069d000305a2000304ab000302ba000301cf000301ec000401ff000501ff000602ff\
000803ff010904ff020a04ff030b05ff";
fn b64(s: &str) -> Vec<u8> {
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = Vec::new();
let mut buf = 0u32;
let mut bits = 0;
for &c in s.as_bytes() {
if c == b'=' {
break;
}
let v = T.iter().position(|&t| t == c).unwrap() as u32;
buf = (buf << 6) | v;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((buf >> bits) as u8);
}
}
out
}
fn unhex(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
fn check_against_swift(hash_b64: &str, expected_hex: &str, ew: u32, eh: u32) {
let hash = b64(hash_b64);
let (w, h) = dims(&hash).unwrap();
assert_eq!((w, h), (ew, eh));
let mut dst = vec![0u8; (w * h * 4) as usize];
assert!(to_rgba(&hash, &mut dst));
let expected = unhex(expected_hex);
assert_eq!(dst.len(), expected.len());
for (i, (a, b)) in dst.iter().zip(expected.iter()).enumerate() {
assert!(
(*a as i16 - *b as i16).abs() <= 1,
"byte {i}: rust={a} swift={b}"
);
}
}
#[test]
fn matches_swift_ground_truth_opaque() {
check_against_swift(OPAQUE_B64, OPAQUE_RGBA_HEX, 23, 32);
}
#[test]
fn matches_swift_ground_truth_alpha() {
check_against_swift(ALPHA_B64, ALPHA_RGBA_HEX, 32, 32);
}
#[test]
fn opaque_hash_is_fully_opaque() {
let hash = b64(OPAQUE_B64);
let (w, h) = dims(&hash).unwrap();
let mut dst = vec![0u8; (w * h * 4) as usize];
assert!(to_rgba(&hash, &mut dst));
assert!(dst.chunks_exact(4).all(|px| px[3] == 255));
}
#[test]
fn rejects_truncated_hashes_at_every_length() {
let hash = b64(ALPHA_B64);
let mut first_ok = None;
for len in 0..=hash.len() {
let cut = &hash[..len];
match dims(cut) {
Some((w, h)) => {
first_ok.get_or_insert(len);
let mut dst = vec![0u8; (w * h * 4) as usize];
assert!(to_rgba(cut, &mut dst), "len {len}");
}
None => {
assert!(first_ok.is_none(), "rejection after acceptance at {len}");
let mut dst = vec![0u8; 32 * 32 * 4];
assert!(!to_rgba(cut, &mut dst), "len {len}");
}
}
}
let first_ok = first_ok.expect("full hash must parse");
assert!(first_ok > 6, "accepted a bare header at {first_ok}");
}
#[test]
fn rejects_short_dst() {
let hash = b64(OPAQUE_B64);
let mut small = vec![0u8; 16];
assert!(!to_rgba(&hash, &mut small));
}
}

View File

@@ -0,0 +1,26 @@
[package]
name = "immich_core_ffi"
version.workspace = true
edition.workspace = true
license.workspace = true
# The build hook uses cdylib, iOS uses staticlib, and tests use lib.
[lib]
crate-type = ["lib", "cdylib", "staticlib"]
[dependencies]
immich_core = { path = "../immich_core", default-features = false, features = ["image", "thumbhash"] }
libc = { workspace = true }
tokio = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
jni = { workspace = true }
[dev-dependencies]
libc = { workspace = true }
[build-dependencies]
cbindgen = { workspace = true }
[lints]
workspace = true

View File

@@ -0,0 +1,22 @@
use std::path::Path;
fn main() {
// Directory entries in Cargo's dep-info make Flutter rerun the hook every build.
// Add new files here when they define exported items.
println!("cargo:rerun-if-changed=src/lib.rs");
println!("cargo:rerun-if-changed=src/capi/mod.rs");
println!("cargo:rerun-if-changed=src/capi/image.rs");
println!("cargo:rerun-if-changed=src/capi/thumbhash.rs");
println!("cargo:rerun-if-changed=cbindgen.toml");
let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
let out = Path::new(&crate_dir).join("include").join("immich_core.h");
std::fs::create_dir_all(out.parent().unwrap()).ok();
match cbindgen::generate(&crate_dir) {
Ok(bindings) => {
bindings.write_to_file(&out);
}
Err(e) => panic!("cbindgen failed: {e}"),
}
}

View File

@@ -0,0 +1,3 @@
language = "C"
pragma_once = true
autogen_warning = "// Generated by cbindgen. Do not edit."

View File

@@ -0,0 +1,64 @@
#pragma once
// Generated by cbindgen. Do not edit.
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
/**
* Returns the version as a C string. Free it with [`immich_core_free_string`].
*/
char *immich_core_version(void);
/**
* Release a string returned by this library.
*
* # Safety
* `ptr` must be a pointer previously returned by this library, or null.
*/
void immich_core_free_string(char *ptr);
/**
* Returns whether an EXIF orientation swaps width and height.
*/
bool immich_core_orientation_swaps_dims(int32_t orientation);
/**
* Rotates RGBA8888 buffers. Invalid input or a failed operation returns false; discard `dst`.
*
* # Safety
* `src` and `dst` must be valid for their lengths, initialized, and must not overlap.
*/
bool immich_core_rotate_rgba8888(const uint8_t *src,
uintptr_t src_len,
uintptr_t src_stride,
uint32_t width,
uint32_t height,
int32_t orientation,
uint8_t *dst,
uintptr_t dst_len);
/**
* Converts RGBA_1010102 to RGBA8888. Invalid input or a failed operation returns false; discard `dst`.
*
* # Safety
* `src` and `dst` must be valid for their lengths, initialized, and must not overlap.
*/
bool immich_core_rgba1010102_to_rgba8888(const uint8_t *src,
uintptr_t src_len,
uintptr_t src_stride,
uint32_t width,
uint32_t height,
uint8_t *dst,
uintptr_t dst_len);
/**
* Decodes a ThumbHash into a libc buffer and writes width, height, and row bytes to `out_info`.
* Free the buffer with `free`; malformed hashes return null.
*
* # Safety
* `hash` must be valid for `hash_len` bytes and `out_info` for three u32 writes.
*/
uint8_t *immich_core_thumbhash_decode(const uint8_t *hash, uintptr_t hash_len, uint32_t *out_info);

View File

@@ -0,0 +1,17 @@
# Keep this version in sync with native/mise.toml.
[toolchain]
channel = "1.92.0"
targets = [
"armv7-linux-androideabi",
"aarch64-linux-android",
"x86_64-linux-android",
"aarch64-apple-ios",
"aarch64-apple-ios-sim",
"x86_64-apple-ios",
"aarch64-pc-windows-msvc",
"x86_64-pc-windows-msvc",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
"aarch64-apple-darwin",
"x86_64-apple-darwin",
]

View File

@@ -0,0 +1,87 @@
use jni::objects::{JClass, JObject};
use jni::sys::{jint, jlong, jobject};
use jni::{EnvUnowned, Outcome};
#[no_mangle]
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_allocate<'local>(
_env: EnvUnowned<'local>,
_class: JClass<'local>,
size: jint,
) -> jlong {
// SAFETY: malloc accepts the converted size and returns null on failure.
unsafe { libc::malloc(size as usize) as jlong }
}
#[no_mangle]
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_free<'local>(
_env: EnvUnowned<'local>,
_class: JClass<'local>,
address: jlong,
) {
// SAFETY: callers pass a libc allocation from this module, or null.
unsafe { libc::free(address as usize as *mut libc::c_void) }
}
#[no_mangle]
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_realloc<'local>(
_env: EnvUnowned<'local>,
_class: JClass<'local>,
address: jlong,
size: jint,
) -> jlong {
// SAFETY: callers pass a libc allocation from this module, or null.
unsafe { libc::realloc(address as usize as *mut libc::c_void, size as usize) as jlong }
}
#[no_mangle]
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_wrap<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
address: jlong,
capacity: jint,
) -> jobject {
crate::log::ensure_panic_hook();
let outcome = env
.with_env(|env| -> jni::errors::Result<jobject> {
// SAFETY: Kotlin keeps this allocation live while the buffer is used.
let buffer = unsafe {
env.new_direct_byte_buffer(address as usize as *mut u8, capacity as usize)
}?;
Ok(buffer.into_raw())
})
.into_outcome();
match outcome {
Outcome::Ok(buffer) => buffer,
Outcome::Err(e) => {
super::log_error(&format!("buffer wrap failed: {e}"));
std::ptr::null_mut()
}
Outcome::Panic(_) => std::ptr::null_mut(),
}
}
#[no_mangle]
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_createGlobalRef<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
obj: JObject<'local>,
) -> jlong {
crate::log::ensure_panic_hook();
let outcome = env
.with_env(|env| -> jni::errors::Result<jlong> {
if obj.is_null() {
return Ok(0);
}
// This reference backs a process-wide singleton.
Ok(env.new_global_ref(&obj)?.into_raw() as jlong)
})
.into_outcome();
match outcome {
Outcome::Ok(raw) => raw,
Outcome::Err(e) => {
super::log_error(&format!("global ref failed: {e}"));
0
}
Outcome::Panic(_) => 0,
}
}

View File

@@ -0,0 +1,223 @@
use std::panic::{catch_unwind, AssertUnwindSafe};
use jni::objects::{JByteArray, JClass, JIntArray, JObject};
use jni::sys::{jint, jlong};
use jni::{Env, EnvUnowned, Outcome};
use super::jnigraphics::{
AndroidBitmapInfo, AndroidBitmap_getInfo, AndroidBitmap_lockPixels, AndroidBitmap_unlockPixels,
ANDROID_BITMAP_FORMAT_RGBA_1010102, ANDROID_BITMAP_FORMAT_RGBA_8888,
ANDROID_BITMAP_RESULT_SUCCESS,
};
fn with_bitmap_into_buffer(
env: &mut Env,
bitmap: &JObject,
out_info: &JIntArray,
expected_format: i32,
out_dims: impl FnOnce(&AndroidBitmapInfo) -> (u32, u32),
work: impl FnOnce(&AndroidBitmapInfo, &[u8], &mut [u8]) -> bool,
) -> jlong {
let raw_env = env.get_raw();
let raw_bitmap = bitmap.as_raw();
let mut info = AndroidBitmapInfo {
width: 0,
height: 0,
stride: 0,
format: 0,
flags: 0,
};
// SAFETY: raw_env/raw_bitmap come from live JNI arguments of this call.
if unsafe { AndroidBitmap_getInfo(raw_env, raw_bitmap, &mut info) }
!= ANDROID_BITMAP_RESULT_SUCCESS
|| info.format != expected_format
{
return 0;
}
let (dw, dh) = out_dims(&info);
let Some(dst_len) = (info.width as usize)
.checked_mul(info.height as usize)
.and_then(|len| len.checked_mul(4))
else {
return 0;
};
let Some(src_len) = (info.stride as usize).checked_mul(info.height as usize) else {
return 0;
};
let Ok(dw) = i32::try_from(dw) else {
return 0;
};
let Ok(dh) = i32::try_from(dh) else {
return 0;
};
let Some(row_bytes) = dw.checked_mul(4) else {
return 0;
};
if dst_len == 0
|| src_len == 0
|| dst_len > isize::MAX as usize
|| src_len > isize::MAX as usize
{
return 0;
}
// SAFETY: calloc returns dst_len zeroed bytes; callers free them through the libc heap.
let dst = unsafe { libc::calloc(dst_len, 1) } as *mut u8;
if dst.is_null() {
return 0;
}
let mut src_pixels: *mut core::ffi::c_void = std::ptr::null_mut();
// SAFETY: lock/unlock pair on every path below; the pixels stay valid between.
if unsafe { AndroidBitmap_lockPixels(raw_env, raw_bitmap, &mut src_pixels) }
!= ANDROID_BITMAP_RESULT_SUCCESS
|| src_pixels.is_null()
{
// SAFETY: dst was just allocated above and never escaped.
unsafe { libc::free(dst as *mut libc::c_void) };
return 0;
}
// Keep cleanup below reachable if the pixel operation panics.
let ok = catch_unwind(AssertUnwindSafe(|| {
// SAFETY: the locked bitmap is valid for `stride * height` bytes.
let src = unsafe { std::slice::from_raw_parts(src_pixels as *const u8, src_len) };
// SAFETY: dst points to dst_len initialized bytes and has not escaped.
let dst_slice = unsafe { std::slice::from_raw_parts_mut(dst, dst_len) };
work(&info, src, dst_slice)
}))
.unwrap_or(false);
// SAFETY: paired with the successful lock above.
unsafe { AndroidBitmap_unlockPixels(raw_env, raw_bitmap) };
if !ok {
// SAFETY: dst never escaped; free before reporting failure.
unsafe { libc::free(dst as *mut libc::c_void) };
return 0;
}
let dims = [dw, dh, row_bytes];
if out_info.set_region(env, 0, &dims).is_err() || env.exception_check() {
// SAFETY: dst never escaped.
unsafe { libc::free(dst as *mut libc::c_void) };
return 0;
}
dst as jlong
}
#[no_mangle]
pub extern "system" fn Java_app_alextran_immich_NativeImage_rotate<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
bitmap: JObject<'local>,
orientation: jint,
out_info: JIntArray<'local>,
) -> jlong {
crate::log::ensure_panic_hook();
let outcome = env
.with_env(|env| -> jni::errors::Result<jlong> {
Ok(with_bitmap_into_buffer(
env,
&bitmap,
&out_info,
ANDROID_BITMAP_FORMAT_RGBA_8888,
|info| {
if immich_core::image::swaps_dims(orientation) {
(info.height, info.width)
} else {
(info.width, info.height)
}
},
|info, src, dst| {
immich_core::image::rotate_rgba8888(
src,
info.stride as usize,
info.width as usize,
info.height as usize,
orientation,
dst,
)
},
))
})
.into_outcome();
match outcome {
Outcome::Ok(ptr) => ptr,
Outcome::Err(e) => {
super::log_error(&format!("bitmap op failed: {e}"));
0
}
Outcome::Panic(_) => 0,
}
}
#[no_mangle]
pub extern "system" fn Java_app_alextran_immich_NativeImage_convert1010102<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
bitmap: JObject<'local>,
out_info: JIntArray<'local>,
) -> jlong {
crate::log::ensure_panic_hook();
let outcome = env
.with_env(|env| -> jni::errors::Result<jlong> {
Ok(with_bitmap_into_buffer(
env,
&bitmap,
&out_info,
ANDROID_BITMAP_FORMAT_RGBA_1010102,
|info| (info.width, info.height),
|info, src, dst| {
immich_core::image::rgba1010102_to_rgba8888(
src,
info.stride as usize,
info.width as usize,
info.height as usize,
dst,
)
},
))
})
.into_outcome();
match outcome {
Outcome::Ok(ptr) => ptr,
Outcome::Err(e) => {
super::log_error(&format!("bitmap op failed: {e}"));
0
}
Outcome::Panic(_) => 0,
}
}
#[no_mangle]
pub extern "system" fn Java_app_alextran_immich_NativeImage_thumbhash<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
hash: JByteArray<'local>,
out_info: JIntArray<'local>,
) -> jlong {
crate::log::ensure_panic_hook();
let outcome = env
.with_env(|env| -> jni::errors::Result<jlong> {
let hash = env.convert_byte_array(&hash)?;
let Some((dst, w, h)) = crate::capi::thumbhash::decode_malloc(&hash) else {
return Ok(0);
};
let dims = [w as i32, h as i32, w as i32 * 4];
if out_info.set_region(env, 0, &dims).is_err() {
// SAFETY: dst never escaped; free before reporting failure.
unsafe { libc::free(dst as *mut libc::c_void) };
return Ok(0);
}
Ok(dst as jlong)
})
.into_outcome();
match outcome {
Outcome::Ok(ptr) => ptr,
Outcome::Err(e) => {
super::log_error(&format!("thumbhash decode failed: {e}"));
0
}
Outcome::Panic(_) => 0,
}
}

View File

@@ -0,0 +1,29 @@
use jni::sys::jobject;
#[repr(C)]
pub(super) struct AndroidBitmapInfo {
pub width: u32,
pub height: u32,
pub stride: u32,
pub format: i32,
pub flags: u32,
}
pub(super) const ANDROID_BITMAP_RESULT_SUCCESS: i32 = 0;
pub(super) const ANDROID_BITMAP_FORMAT_RGBA_8888: i32 = 1;
pub(super) const ANDROID_BITMAP_FORMAT_RGBA_1010102: i32 = 10;
#[link(name = "jnigraphics")]
extern "C" {
pub(super) fn AndroidBitmap_getInfo(
env: *mut jni::sys::JNIEnv,
jbitmap: jobject,
info: *mut AndroidBitmapInfo,
) -> i32;
pub(super) fn AndroidBitmap_lockPixels(
env: *mut jni::sys::JNIEnv,
jbitmap: jobject,
addr_ptr: *mut *mut core::ffi::c_void,
) -> i32;
pub(super) fn AndroidBitmap_unlockPixels(env: *mut jni::sys::JNIEnv, jbitmap: jobject) -> i32;
}

View File

@@ -0,0 +1,16 @@
use std::ffi::CString;
const ANDROID_LOG_ERROR: i32 = 6;
#[link(name = "log")]
extern "C" {
fn __android_log_write(prio: i32, tag: *const libc::c_char, text: *const libc::c_char) -> i32;
}
pub(crate) fn log_error(msg: &str) {
let Ok(text) = CString::new(msg.replace('\0', " ")) else {
return;
};
// SAFETY: both pointers are live NUL-terminated strings for the call's duration.
unsafe { __android_log_write(ANDROID_LOG_ERROR, c"immich_core".as_ptr(), text.as_ptr()) };
}

View File

@@ -0,0 +1,7 @@
// Native buffers must stay on libc's heap: Dart frees them with malloc.free and Kotlin uses realloc.
mod buffer;
mod image;
mod jnigraphics;
mod log;
pub(crate) use log::log_error;

View File

@@ -0,0 +1,86 @@
/// Runs a pixel operation on caller-owned buffers. Invalid input or a failed operation returns false; discard `dst`.
///
/// # Safety
/// `src` and `dst` must be valid for their lengths, initialized, and must not overlap.
pub(super) unsafe fn fill_dst(
src: *const u8,
src_len: usize,
dst: *mut u8,
dst_len: usize,
op: impl FnOnce(&[u8], &mut [u8]) -> bool,
) -> bool {
crate::log::ensure_panic_hook();
if src.is_null() || dst.is_null() {
return false;
}
// SAFETY: guaranteed by the caller.
let src_slice = unsafe { std::slice::from_raw_parts(src, src_len) };
// SAFETY: guaranteed by the caller.
let dst_slice = unsafe { std::slice::from_raw_parts_mut(dst, dst_len) };
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| op(src_slice, dst_slice)))
.unwrap_or(false)
}
/// Returns whether an EXIF orientation swaps width and height.
#[no_mangle]
pub extern "C" fn immich_core_orientation_swaps_dims(orientation: i32) -> bool {
super::guard(false, || immich_core::image::swaps_dims(orientation))
}
/// Rotates RGBA8888 buffers. Invalid input or a failed operation returns false; discard `dst`.
///
/// # Safety
/// `src` and `dst` must be valid for their lengths, initialized, and must not overlap.
#[no_mangle]
pub unsafe extern "C" fn immich_core_rotate_rgba8888(
src: *const u8,
src_len: usize,
src_stride: usize,
width: u32,
height: u32,
orientation: i32,
dst: *mut u8,
dst_len: usize,
) -> bool {
// SAFETY: guaranteed by this function's caller.
unsafe {
fill_dst(src, src_len, dst, dst_len, |s, d| {
immich_core::image::rotate_rgba8888(
s,
src_stride,
width as usize,
height as usize,
orientation,
d,
)
})
}
}
/// Converts RGBA_1010102 to RGBA8888. Invalid input or a failed operation returns false; discard `dst`.
///
/// # Safety
/// `src` and `dst` must be valid for their lengths, initialized, and must not overlap.
#[no_mangle]
pub unsafe extern "C" fn immich_core_rgba1010102_to_rgba8888(
src: *const u8,
src_len: usize,
src_stride: usize,
width: u32,
height: u32,
dst: *mut u8,
dst_len: usize,
) -> bool {
// SAFETY: guaranteed by this function's caller.
unsafe {
fill_dst(src, src_len, dst, dst_len, |s, d| {
immich_core::image::rgba1010102_to_rgba8888(
s,
src_stride,
width as usize,
height as usize,
d,
)
})
}
}

View File

@@ -0,0 +1,65 @@
pub mod image;
pub mod thumbhash;
use std::ffi::{c_char, CString};
use std::ptr;
/// Returns the version as a C string. Free it with [`immich_core_free_string`].
#[no_mangle]
pub extern "C" fn immich_core_version() -> *mut c_char {
guard(ptr::null_mut(), || {
into_c_string(immich_core::core_version().to_owned())
})
}
/// Release a string returned by this library.
///
/// # Safety
/// `ptr` must be a pointer previously returned by this library, or null.
#[no_mangle]
pub unsafe extern "C" fn immich_core_free_string(ptr: *mut c_char) {
if ptr.is_null() {
return;
}
guard((), || {
// SAFETY: guaranteed by the caller.
let s = unsafe { CString::from_raw(ptr) };
drop(s);
});
}
fn guard<T>(sentinel: T, f: impl FnOnce() -> T + std::panic::UnwindSafe) -> T {
crate::log::ensure_panic_hook();
std::panic::catch_unwind(f).unwrap_or(sentinel)
}
fn into_c_string(s: String) -> *mut c_char {
match CString::new(s) {
Ok(c) => c.into_raw(),
Err(_) => ptr::null_mut(),
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use std::ffi::CStr;
#[test]
fn version_roundtrips_and_frees() {
let p = immich_core_version();
assert!(!p.is_null());
// SAFETY: p is a C string returned by this library.
let s = unsafe { CStr::from_ptr(p) }.to_str().unwrap();
assert!(!s.is_empty());
// SAFETY: p was returned by this library and is freed once.
unsafe { immich_core_free_string(p) };
}
#[test]
fn free_null_is_noop() {
// SAFETY: null is allowed by the function contract.
unsafe { immich_core_free_string(ptr::null_mut()) };
}
}

View File

@@ -0,0 +1,54 @@
use std::panic::{catch_unwind, AssertUnwindSafe};
pub(crate) fn decode_malloc(hash: &[u8]) -> Option<(*mut u8, u32, u32)> {
let (w, h) = immich_core::thumbhash::dims(hash)?;
let len = w as usize * h as usize * 4;
// SAFETY: calloc returns len zeroed bytes; callers free them through the libc heap.
let dst = unsafe { libc::calloc(len, 1) } as *mut u8;
if dst.is_null() {
return None;
}
// SAFETY: dst points to len initialized bytes and has not escaped.
let dst_slice = unsafe { std::slice::from_raw_parts_mut(dst, len) };
let ok = catch_unwind(AssertUnwindSafe(|| {
immich_core::thumbhash::to_rgba(hash, dst_slice)
}))
.unwrap_or(false);
if !ok {
// SAFETY: dst never escaped.
unsafe { libc::free(dst as *mut libc::c_void) };
return None;
}
Some((dst, w, h))
}
/// Decodes a ThumbHash into a libc buffer and writes width, height, and row bytes to `out_info`.
/// Free the buffer with `free`; malformed hashes return null.
///
/// # Safety
/// `hash` must be valid for `hash_len` bytes and `out_info` for three u32 writes.
#[no_mangle]
pub unsafe extern "C" fn immich_core_thumbhash_decode(
hash: *const u8,
hash_len: usize,
out_info: *mut u32,
) -> *mut u8 {
crate::log::ensure_panic_hook();
if hash.is_null() || out_info.is_null() {
return std::ptr::null_mut();
}
// SAFETY: guaranteed by the caller.
let hash_slice = unsafe { std::slice::from_raw_parts(hash, hash_len) };
match catch_unwind(|| decode_malloc(hash_slice)) {
Ok(Some((dst, w, h))) => {
// SAFETY: guaranteed by the caller.
unsafe {
*out_info = w;
*out_info.add(1) = h;
*out_info.add(2) = w * 4;
}
dst
}
_ => std::ptr::null_mut(),
}
}

View File

@@ -0,0 +1,40 @@
use std::ffi::{c_char, c_void, CString};
const K_CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100;
#[link(name = "CoreFoundation", kind = "framework")]
extern "C" {
fn CFStringCreateWithCString(
alloc: *const c_void,
c_str: *const c_char,
encoding: u32,
) -> *const c_void;
fn CFRelease(cf: *const c_void);
}
#[link(name = "Foundation", kind = "framework")]
extern "C" {
fn NSLog(format: *const c_void, ...);
}
pub(crate) fn log_error(msg: &str) {
let Ok(text) = CString::new(format!("immich_core: {}", msg.replace('\0', " "))) else {
return;
};
// SAFETY: both CFStrings stay live through NSLog and are released below.
unsafe {
let format =
CFStringCreateWithCString(std::ptr::null(), c"%@".as_ptr(), K_CF_STRING_ENCODING_UTF8);
let message =
CFStringCreateWithCString(std::ptr::null(), text.as_ptr(), K_CF_STRING_ENCODING_UTF8);
if !format.is_null() && !message.is_null() {
NSLog(format, message);
}
if !message.is_null() {
CFRelease(message);
}
if !format.is_null() {
CFRelease(format);
}
}
}

View File

@@ -0,0 +1,3 @@
mod log;
pub(crate) use log::log_error;

View File

@@ -0,0 +1,21 @@
//! Mobile FFI bindings for `immich_core`.
#![deny(clippy::unwrap_used, clippy::expect_used)]
mod capi;
mod log;
pub mod runtime;
/// cbindgen:ignore
#[cfg(target_os = "android")]
mod android;
/// cbindgen:ignore
#[cfg(target_os = "ios")]
mod ios;
pub use capi::image::{
immich_core_orientation_swaps_dims, immich_core_rgba1010102_to_rgba8888,
immich_core_rotate_rgba8888,
};
pub use capi::thumbhash::immich_core_thumbhash_decode;
pub use capi::{immich_core_free_string, immich_core_version};

View File

@@ -0,0 +1,32 @@
use std::sync::Once;
#[cfg(target_os = "android")]
use crate::android::log_error;
#[cfg(target_os = "ios")]
use crate::ios::log_error;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn log_error(msg: &str) {
eprintln!("immich_core: {msg}");
}
pub(crate) fn ensure_panic_hook() {
static HOOK: Once = Once::new();
HOOK.call_once(|| {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let msg = info
.payload()
.downcast_ref::<&str>()
.copied()
.or_else(|| info.payload().downcast_ref::<String>().map(String::as_str))
.unwrap_or("panic");
let location = info
.location()
.map(|l| format!("{}:{}", l.file(), l.line()))
.unwrap_or_default();
log_error(&format!("panic at {location}: {msg}"));
previous(info);
}));
});
}

View File

@@ -0,0 +1,40 @@
//! Shared Tokio runtime for FFI work.
use std::sync::LazyLock;
use tokio::runtime::{Builder, Runtime};
static RUNTIME: LazyLock<Runtime> = LazyLock::new(|| {
Builder::new_multi_thread()
.worker_threads(2)
.thread_name("immich-core")
.enable_time()
.build()
.unwrap_or_else(|e| panic!("immich-core runtime failed to start: {e}"))
});
/// Returns the process-wide runtime.
pub fn runtime() -> &'static Runtime {
&RUNTIME
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn runtime_is_shared_and_reusable() {
let a = runtime() as *const Runtime;
let b = runtime() as *const Runtime;
assert_eq!(a, b);
let first = runtime().block_on(async { 21 * 2 });
assert_eq!(first, 42);
let handle = runtime().spawn(async {
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
7
});
assert_eq!(runtime().block_on(handle).unwrap(), 7);
}
}

View File

@@ -0,0 +1,213 @@
#![allow(clippy::unwrap_used, clippy::undocumented_unsafe_blocks)]
use std::ffi::CStr;
use std::ptr;
use immich_core_ffi::{
immich_core_free_string, immich_core_orientation_swaps_dims,
immich_core_rgba1010102_to_rgba8888, immich_core_rotate_rgba8888, immich_core_thumbhash_decode,
immich_core_version,
};
const THUMBHASH: [u8; 21] = [
0xd5, 0x07, 0x12, 0x1d, 0x04, 0x67, 0x87, 0x8f, 0x77, 0x57, 0x87, 0x48, 0x87, 0x87, 0x97, 0x87,
0x58, 0x78, 0x90, 0x95, 0x08,
];
#[test]
fn version_is_a_valid_c_string_and_frees() {
let p = immich_core_version();
assert!(!p.is_null());
let s = unsafe { CStr::from_ptr(p) }.to_str().unwrap();
assert!(!s.is_empty());
assert!(s.chars().next().unwrap().is_ascii_digit());
unsafe { immich_core_free_string(p) };
}
#[test]
fn free_string_accepts_null() {
unsafe { immich_core_free_string(ptr::null_mut()) };
}
#[test]
fn swaps_dims_matches_the_exif_family() {
for o in [5, 6, 7, 8] {
assert!(immich_core_orientation_swaps_dims(o), "o={o}");
}
for o in [-1, 0, 1, 2, 3, 4, 9] {
assert!(!immich_core_orientation_swaps_dims(o), "o={o}");
}
}
#[test]
fn rotate_180_matches_expected_bytes() {
let src: [u8; 8] = [255, 0, 0, 255, 0, 255, 0, 255];
let mut dst = [0u8; 8];
let ok = unsafe {
immich_core_rotate_rgba8888(src.as_ptr(), src.len(), 8, 2, 1, 3, dst.as_mut_ptr(), 8)
};
assert!(ok);
assert_eq!(dst, [0, 255, 0, 255, 255, 0, 0, 255]);
}
#[test]
fn rotate_90_swaps_output_dims() {
let src: [u8; 8] = [255, 0, 0, 255, 0, 255, 0, 255];
let mut dst = [0u8; 8];
let ok = unsafe {
immich_core_rotate_rgba8888(src.as_ptr(), src.len(), 8, 2, 1, 6, dst.as_mut_ptr(), 8)
};
assert!(ok);
assert_eq!(dst, [255, 0, 0, 255, 0, 255, 0, 255]);
}
#[test]
fn rotate_rejects_bad_input_without_touching_dst() {
let src = [0u8; 16];
let mut dst = [0xAAu8; 16];
unsafe {
assert!(!immich_core_rotate_rgba8888(
ptr::null(),
16,
8,
2,
2,
3,
dst.as_mut_ptr(),
16
));
assert!(!immich_core_rotate_rgba8888(
src.as_ptr(),
16,
8,
2,
2,
3,
ptr::null_mut(),
16
));
assert!(!immich_core_rotate_rgba8888(
src.as_ptr(),
8,
8,
2,
2,
3,
dst.as_mut_ptr(),
16
));
assert!(!immich_core_rotate_rgba8888(
src.as_ptr(),
16,
8,
2,
2,
3,
dst.as_mut_ptr(),
8
));
}
assert!(dst.iter().all(|&b| b == 0xAA));
}
#[test]
fn convert_matches_skia_ground_truth() {
// 179 and 111 distinguish rounded scaling from `>> 2`.
let px = |r: u32, g: u32, b: u32, a: u32| -> [u8; 4] {
((r & 0x3FF) | ((g & 0x3FF) << 10) | ((b & 0x3FF) << 20) | ((a & 0x3) << 30)).to_le_bytes()
};
let mut src = Vec::new();
src.extend_from_slice(&px(1023, 0, 0, 3));
src.extend_from_slice(&px(179, 111, 0, 3));
let mut dst = [0u8; 8];
let ok = unsafe {
immich_core_rgba1010102_to_rgba8888(src.as_ptr(), src.len(), 8, 2, 1, dst.as_mut_ptr(), 8)
};
assert!(ok);
assert_eq!(&dst[0..4], &[255, 0, 0, 255]);
assert_eq!(&dst[4..8], &[45, 28, 0, 255]);
}
#[test]
fn convert_rejects_bad_input_without_touching_dst() {
let src = [0u8; 16];
let mut dst = [0xAAu8; 16];
unsafe {
assert!(!immich_core_rgba1010102_to_rgba8888(
ptr::null(),
16,
8,
2,
2,
dst.as_mut_ptr(),
16
));
assert!(!immich_core_rgba1010102_to_rgba8888(
src.as_ptr(),
16,
8,
2,
2,
ptr::null_mut(),
16
));
assert!(!immich_core_rgba1010102_to_rgba8888(
src.as_ptr(),
8,
8,
2,
2,
dst.as_mut_ptr(),
16
));
assert!(!immich_core_rgba1010102_to_rgba8888(
src.as_ptr(),
16,
8,
2,
2,
dst.as_mut_ptr(),
8
));
}
assert!(dst.iter().all(|&b| b == 0xAA));
}
#[test]
fn thumbhash_decodes_into_a_malloc_buffer() {
let mut info = [0u32; 3];
let ptr = unsafe {
immich_core_thumbhash_decode(THUMBHASH.as_ptr(), THUMBHASH.len(), info.as_mut_ptr())
};
assert!(!ptr.is_null());
assert_eq!(info, [23, 32, 92]);
let len = (info[0] * info[1] * 4) as usize;
let pixels = unsafe { std::slice::from_raw_parts(ptr, len) };
assert!(pixels.chunks_exact(4).all(|px| px[3] == 255));
assert!(pixels.chunks_exact(4).any(|px| px[0] != pixels[0]));
unsafe { libc::free(ptr as *mut libc::c_void) };
}
#[test]
fn thumbhash_rejects_bad_input() {
let mut info = [7u32; 3];
unsafe {
assert!(immich_core_thumbhash_decode(ptr::null(), 21, info.as_mut_ptr()).is_null());
assert!(immich_core_thumbhash_decode(THUMBHASH.as_ptr(), 4, info.as_mut_ptr()).is_null());
assert!(
immich_core_thumbhash_decode(THUMBHASH.as_ptr(), THUMBHASH.len(), ptr::null_mut())
.is_null()
);
}
assert_eq!(info, [7, 7, 7]);
}
#[test]
fn runtime_is_shared_across_external_callers() {
let a = immich_core_ffi::runtime::runtime() as *const _;
let b = immich_core_ffi::runtime::runtime() as *const _;
assert_eq!(a, b);
let out = immich_core_ffi::runtime::runtime().block_on(async { 7 * 6 });
assert_eq!(out, 42);
}

View File

@@ -0,0 +1,19 @@
[package]
name = "immich_core_napi"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
immich_core = { path = "../immich_core", default-features = false }
napi = { workspace = true, features = ["napi4", "dyn-symbols"] }
napi-derive = { workspace = true }
[build-dependencies]
napi-build = { workspace = true }
[lints]
workspace = true

View File

@@ -0,0 +1,3 @@
fn main() {
napi_build::setup();
}

View File

@@ -0,0 +1,8 @@
//! Node binding for `immich_core`.
use napi_derive::napi;
#[napi]
pub fn core_version() -> String {
immich_core::core_version().to_owned()
}

Some files were not shown because too many files have changed in this diff Show More