Compare commits

..

22 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
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
484 changed files with 7890 additions and 5168 deletions

View File

@@ -83,7 +83,7 @@
}
},
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:4": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
// https://github.com/devcontainers/features/issues/1466
"moby": false
}

View File

@@ -15,7 +15,7 @@ jobs:
outputs:
uses_template: ${{ steps.check.outputs.uses_template }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
sparse-checkout: .github/pull_request_template.md
sparse-checkout-cone-mode: false

View File

@@ -65,6 +65,7 @@ jobs:
filters: |
mobile:
- 'mobile/**'
- 'native/**'
force-filters: |
- '.github/workflows/build-mobile.yml'
force-events: 'workflow_call,workflow_dispatch'
@@ -87,7 +88,7 @@ jobs:
permission-contents: read
permission-pull-requests: write
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.ref }}
persist-credentials: false
@@ -106,14 +107,14 @@ jobs:
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
- uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with:
distribution: 'zulu'
java-version: '17'
- name: Restore Gradle Cache
id: cache-gradle-restore
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
with:
path: |
~/.gradle/caches
@@ -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
@@ -184,7 +238,7 @@ jobs:
- name: Save Gradle Cache
id: cache-gradle-save
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
if: github.ref == 'refs/heads/main'
with:
path: |
@@ -201,7 +255,7 @@ jobs:
contents: read
# Run on main branch or workflow_dispatch, or on PRs/other branches (build only, no upload)
if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
runs-on: macos-26
runs-on: macos-15
steps:
- id: token
@@ -215,7 +269,7 @@ jobs:
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
@@ -241,7 +295,7 @@ jobs:
run: flutter build ios --config-only --no-codesign
- name: Setup Ruby
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
with:
ruby-version: '3.3'
bundler-cache: true

View File

@@ -27,7 +27,7 @@ jobs:
permission-actions: write
- name: Check out code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -20,12 +20,12 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@024f6c399f9a21ada1addb0f9a36ce1bfac995f1 # v0.1.6
uses: oasdiff/oasdiff-action/breaking@291b6594ce617f22f49af5898ee038ed0b46e8fe # v0.1.5
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
@@ -38,7 +38,7 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

View File

@@ -38,7 +38,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -71,7 +71,7 @@ jobs:
permission-contents: read
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -39,7 +39,7 @@ jobs:
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
image: ghcr.io/immich-app/mdq:main@sha256:d603f5618c1fc9f2b4d506ffed54ba6bcfec83d56b893b5fa8b3f899a402fa0a
image: ghcr.io/immich-app/mdq:main@sha256:e3fe456f346b02c2f479462100763aee3ba8245768dfeac3c4262acb068e5c62
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:

View File

@@ -51,14 +51,14 @@ jobs:
permission-contents: read
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -71,7 +71,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -84,6 +84,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
category: '/language:${{matrix.language}}'

View File

@@ -62,7 +62,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -137,7 +137,7 @@ jobs:
permission-pull-requests: write
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -25,7 +25,7 @@ jobs:
permission-pull-requests: write
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -14,7 +14,7 @@ jobs:
contents: read
steps:
- name: Checkout pubspec for versionCode
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.release.tag_name }}
persist-credentials: false

View File

@@ -23,7 +23,7 @@ jobs:
permission-pull-requests: write
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.ref }}
persist-credentials: true

View File

@@ -61,7 +61,7 @@ jobs:
permission-contents: write
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
token: ${{ steps.token.outputs.token }}
persist-credentials: true
@@ -140,7 +140,7 @@ jobs:
permission-actions: read
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
@@ -152,7 +152,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
draft: true
prerelease: ${{ needs.bump_version.outputs.rc }}

View File

@@ -23,7 +23,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -35,6 +35,7 @@ jobs:
mobile:
- 'mobile/**'
- 'i18n/en.json'
- 'native/**'
force-filters: |
- '.github/workflows/static_analysis.yml'
force-events: 'workflow_dispatch,release'
@@ -58,7 +59,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

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
@@ -85,7 +128,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -114,7 +157,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -146,7 +189,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -178,7 +221,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -223,7 +266,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -262,7 +305,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -291,7 +334,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -342,7 +385,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -375,7 +418,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: 'recursive'
@@ -412,7 +455,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: 'recursive'
@@ -422,7 +465,7 @@ jobs:
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
@@ -490,7 +533,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: 'recursive'
@@ -500,7 +543,7 @@ jobs:
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
@@ -598,7 +641,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -637,7 +680,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -669,7 +712,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -699,7 +742,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -722,7 +765,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -781,7 +824,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

38
.vscode/settings.json vendored
View File

@@ -29,6 +29,9 @@
"editor.formatOnSave": true,
"tailwindCSS.lint.suggestCanonicalClasses": "ignore"
},
"svelte.plugin.svelte.compilerWarnings": {
"state_referenced_locally": "ignore"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
@@ -40,40 +43,37 @@
"eslint.useFlatConfig": true,
"eslint.validate": ["javascript", "typescript", "svelte"],
"eslint.workingDirectories": [
{ "changeProcessCWD": true, "directory": "cli" },
{ "changeProcessCWD": true, "directory": "e2e" },
{ "changeProcessCWD": true, "directory": "server" },
{ "changeProcessCWD": true, "directory": "web" }
{ "directory": "cli", "changeProcessCWD": true },
{ "directory": "e2e", "changeProcessCWD": true },
{ "directory": "server", "changeProcessCWD": true },
{ "directory": "web", "changeProcessCWD": true }
],
"files.watcherExclude": {
"**/.jj/**": true,
"**/.git/**": true,
"**/node_modules/**": true,
"**/build/**": true,
"**/dist/**": true,
"**/.svelte-kit/**": true
},
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
"*.js": "${capture}.spec.js,${capture}.mock.js",
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
"*.js": "${capture}.spec.js,${capture}.mock.js",
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
},
"files.watcherExclude": {
"**/.git/**": true,
"**/.jj/**": true,
"**/.svelte-kit/**": true,
"**/build/**": true,
"**/dist/**": true,
"**/node_modules/**": true
},
"js/ts.preferences.importModuleSpecifier": "non-relative",
"search.exclude": {
"**/.svelte-kit": true,
"**/node_modules": true,
"**/build": true,
"**/dist": true,
"**/node_modules": true,
"**/.svelte-kit": true,
"**/open-api/typescript-sdk/src": true
},
"svelte.enable-ts-plugin": true,
"svelte.plugin.svelte.compilerWarnings": {
"state_referenced_locally": "ignore"
},
"tailwindCSS.experimental.configFile": {
"web/src/app.css": "web/src/**"
},
"js/ts.preferences.importModuleSpecifier": "non-relative",
"vitest.maximumConfigs": 10
}

View File

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

View File

@@ -32,7 +32,6 @@
<a href="readme_i18n/README_zh_TW.md">正體中文</a>
<a href="readme_i18n/README_uk_UA.md">Українська</a>
<a href="readme_i18n/README_ru_RU.md">Русский</a>
<a href="readme_i18n/README_bg_BG.md">Български</a>
<a href="readme_i18n/README_pt_BR.md">Português Brasileiro</a>
<a href="readme_i18n/README_sv_SE.md">Svenska</a>
<a href="readme_i18n/README_ar_JO.md">العربية</a>

View File

@@ -154,7 +154,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -61,7 +61,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
user: '1000:1000'
security_opt:
- no-new-privileges:true

View File

@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

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

View File

@@ -40,8 +40,7 @@
"@docusaurus/tsconfig": "^3.10.0",
"@docusaurus/types": "^3.10.0",
"prettier": "^3.7.4",
"@typescript/native": "npm:typescript@^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2"
"typescript": "^6.0.0"
},
"browserslist": {
"production": [

View File

@@ -45,7 +45,7 @@ services:
redis:
container_name: immich-e2e-redis
image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -41,18 +41,9 @@ export default typescriptEslint.config([
'@typescript-eslint/no-floating-promises': 'error',
'unicorn/prefer-module': 'off',
'unicorn/import-style': 'off',
'unicorn/consistent-boolean-name': 'off',
'unicorn/no-non-function-verb-prefix': 'off',
'unicorn/no-unreadable-for-of-expression': 'off',
'unicorn/max-nested-calls': 'off',
'unicorn/prefer-uint8array-base64': 'off',
'unicorn/isolated-functions': 'off',
'unicorn/prefer-promise-with-resolvers': 'off',
'unicorn/no-declarations-before-early-exit': 'off',
'unicorn/prefer-simple-condition-first': 'off',
curly: 2,
'prettier/prettier': 0,
'unicorn/name-replacements': 'off',
'unicorn/prevent-abbreviations': 'off',
'unicorn/filename-case': 'off',
'unicorn/no-null': 'off',
'unicorn/prefer-top-level-await': 'off',

View File

@@ -40,7 +40,7 @@
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^72.0.0",
"eslint-plugin-unicorn": "^64.0.0",
"exiftool-vendored": "^35.0.0",
"globals": "^17.0.0",
"luxon": "^3.4.4",
@@ -51,8 +51,7 @@
"sharp": "^0.34.5",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
"@typescript/native": "npm:typescript@^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"typescript": "^6.0.0",
"typescript-eslint": "^8.28.0",
"utimes": "^5.2.1",
"vitest": "^4.0.0"

View File

@@ -118,7 +118,7 @@ describe('/admin/database-backups', () => {
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';', 1)[0];
cookie = headers['set-cookie'][0].split(';')[0];
await expect
.poll(
@@ -224,7 +224,7 @@ describe('/admin/database-backups', () => {
});
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';', 1)[0];
cookie = headers['set-cookie'][0].split(';')[0];
await expect
.poll(
@@ -295,7 +295,7 @@ describe('/admin/database-backups', () => {
});
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';', 1)[0];
cookie = headers['set-cookie'][0].split(';')[0];
await expect
.poll(

View File

@@ -85,7 +85,7 @@ describe('/admin/maintenance', () => {
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';', 1)[0];
cookie = headers['set-cookie'][0].split(';')[0];
expect(cookie).toEqual(
expect.stringMatching(/^immich_maintenance_token=[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*$/),
);
@@ -149,7 +149,7 @@ describe('/admin/maintenance', () => {
const { status, body } = await request(app)
.post('/admin/maintenance/login')
.send({
token: cookie!.split('=', 2)[1].trim(),
token: cookie!.split('=')[1].trim(),
});
expect(status).toBe(201);
expect(body).toEqual(

View File

@@ -27,7 +27,7 @@ test.describe('Maintenance', () => {
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
const setCookie = await utils.enterMaintenance(admin.accessToken);
const cookie = setCookie
?.map((cookie) => cookie.split(';', 1)[0].split('='))
?.map((cookie) => cookie.split(';')[0].split('='))
?.find(([name]) => name === 'immich_maintenance_token');
expect(cookie).toBeTruthy();

View File

@@ -120,7 +120,6 @@ describe('/albums', () => {
}),
]);
// eslint-disable-next-line unicorn/no-unreadable-array-destructuring
[user2Albums[0]] = await Promise.all([
getAlbumInfo({ id: user2Albums[0].id }, { headers: asBearerAuth(user2.accessToken) }),
deleteUserAdmin({ id: user3.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) }),

View File

@@ -781,7 +781,7 @@ describe('/asset', () => {
exifImageWidth: 4032,
exifImageHeight: 3024,
latitude: 41.2203,
longitude: -96.071625,
longitude: -96.071_625,
make: 'Apple',
model: 'iPhone 7',
lensModel: 'iPhone 7 back camera 3.99mm f/1.8',
@@ -973,9 +973,9 @@ describe('/asset', () => {
fileSizeInByte: 31_175_472,
focalLength: 18.3,
iso: 100,
latitude: 36.61324,
latitude: 36.613_24,
lensModel: '18.3mm F2.8',
longitude: -121.89785,
longitude: -121.897_85,
make: 'RICOH IMAGING COMPANY, LTD.',
model: 'RICOH GR III',
orientation: '1',

View File

@@ -75,7 +75,7 @@ describe('/map', () => {
country: 'United States of America',
id: expect.any(String),
lat: expect.closeTo(39.115),
lon: expect.closeTo(-108.400968),
lon: expect.closeTo(-108.400_968),
state: 'Colorado',
},
{
@@ -83,7 +83,7 @@ describe('/map', () => {
country: 'United States of America',
id: expect.any(String),
lat: expect.closeTo(41.2203),
lon: expect.closeTo(-96.071625),
lon: expect.closeTo(-96.071_625),
state: 'Nebraska',
},
]);
@@ -123,7 +123,7 @@ describe('/map', () => {
country: 'United States of America',
id: expect.any(String),
lat: expect.closeTo(39.115),
lon: expect.closeTo(-108.400968),
lon: expect.closeTo(-108.400_968),
state: 'Colorado',
},
{
@@ -131,7 +131,7 @@ describe('/map', () => {
country: 'United States of America',
id: expect.any(String),
lat: expect.closeTo(41.2203),
lon: expect.closeTo(-96.071625),
lon: expect.closeTo(-96.071_625),
state: 'Nebraska',
},
]);
@@ -188,20 +188,20 @@ describe('/map', () => {
const reverseGeocodeTestCases = [
{
name: 'Vaucluse',
lat: -33.85897705866313,
lon: 151.27849073027048,
lat: -33.858_977_058_663_13,
lon: 151.278_490_730_270_48,
results: [{ city: 'Vaucluse', state: 'New South Wales', country: 'Australia' }],
},
{
name: 'Ravenhall',
lat: -37.76573239917475,
lon: 144.7524531648833,
lat: -37.765_732_399_174_75,
lon: 144.752_453_164_883_3,
results: [{ city: 'Ravenhall', state: 'Victoria', country: 'Australia' }],
},
{
name: 'Scarborough',
lat: -31.894346156789997,
lon: 115.75761710390464,
lat: -31.894_346_156_789_997,
lon: 115.757_617_103_904_64,
results: [{ city: 'Scarborough', state: 'Western Australia', country: 'Australia' }],
},
];

View File

@@ -44,7 +44,7 @@ const loginWithOAuth = async (sub: OAuthUser | string, redirectUri?: string) =>
});
// login
const response1 = await redirect(url.replace(authServer.internal, () => authServer.external));
const response1 = await redirect(url.replace(authServer.internal, authServer.external));
const response2 = await request(authServer.external + response1.location)
.post('')
.set('Cookie', response1.cookies)

View File

@@ -87,23 +87,23 @@ describe('/search', () => {
// note: the coordinates here are not the actual coordinates of the images and are random for most of them
const coordinates = [
{ latitude: 48.85341, longitude: 2.3488 }, // paris
{ latitude: 35.6895, longitude: 139.69171 }, // tokyo
{ latitude: 52.52437, longitude: 13.41053 }, // berlin
{ latitude: 1.3146631, longitude: 103.8454093 }, // singapore
{ latitude: 41.01384, longitude: 28.94966 }, // istanbul
{ latitude: 5.55602, longitude: -0.1969 }, // accra
{ latitude: 37.5442706, longitude: -4.7277528 }, // andalusia
{ latitude: 23.13302, longitude: -82.38304 }, // havana
{ latitude: 41.69411, longitude: 44.83368 }, // tbilisi
{ latitude: 31.22222, longitude: 121.45806 }, // shanghai
{ latitude: 48.853_41, longitude: 2.3488 }, // paris
{ latitude: 35.6895, longitude: 139.691_71 }, // tokyo
{ latitude: 52.524_37, longitude: 13.410_53 }, // berlin
{ latitude: 1.314_663_1, longitude: 103.845_409_3 }, // singapore
{ latitude: 41.013_84, longitude: 28.949_66 }, // istanbul
{ latitude: 5.556_02, longitude: -0.1969 }, // accra
{ latitude: 37.544_270_6, longitude: -4.727_752_8 }, // andalusia
{ latitude: 23.133_02, longitude: -82.383_04 }, // havana
{ latitude: 41.694_11, longitude: 44.833_68 }, // tbilisi
{ latitude: 31.222_22, longitude: 121.458_06 }, // shanghai
{ latitude: 38.9711, longitude: -109.7137 }, // thompson springs
{ latitude: 40.71427, longitude: -74.00597 }, // new york
{ latitude: 47.04057, longitude: 9.06804 }, // glarus
{ latitude: 32.77152, longitude: -89.11673 }, // philadelphia
{ latitude: 31.63416, longitude: -7.99994 }, // marrakesh
{ latitude: 38.5237354, longitude: -78.4886194 }, // tanners ridge
{ latitude: 59.93863, longitude: 30.31413 }, // st. petersburg
{ latitude: 40.714_27, longitude: -74.005_97 }, // new york
{ latitude: 47.040_57, longitude: 9.068_04 }, // glarus
{ latitude: 32.771_52, longitude: -89.116_73 }, // philadelphia
{ latitude: 31.634_16, longitude: -7.999_94 }, // marrakesh
{ latitude: 38.523_735_4, longitude: -78.488_619_4 }, // tanners ridge
{ latitude: 59.938_63, longitude: 30.314_13 }, // st. petersburg
{ latitude: 0, longitude: 0 }, // null island
];
@@ -112,7 +112,7 @@ describe('/search', () => {
);
await Promise.all(updates);
for (const i of coordinates.keys()) {
for (const [i] of coordinates.entries()) {
await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: assets[i].id });
}

View File

@@ -31,7 +31,7 @@ describe(`immich login`, () => {
it('should login and save auth.yml with 600', async () => {
const admin = await utils.adminSetup();
const key = await utils.createApiKey(admin.accessToken, [Permission.All]);
const { stdout, stderr, exitCode } = await immichCli(['login', app, key.secret]);
const { stdout, stderr, exitCode } = await immichCli(['login', app, `${key.secret}`]);
expect(stdout.split('\n')).toEqual([
'Logging in to http://127.0.0.1:2285/api',
'Logged in as admin@immich.cloud',
@@ -48,7 +48,7 @@ describe(`immich login`, () => {
it('should login without /api in the url', async () => {
const admin = await utils.adminSetup();
const key = await utils.createApiKey(admin.accessToken, [Permission.All]);
const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), key.secret]);
const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), `${key.secret}`]);
expect(stdout.split('\n')).toEqual([
'Logging in to http://127.0.0.1:2285',
'Discovered API at http://127.0.0.1:2285/api',

View File

@@ -119,9 +119,7 @@ describe(`immich upload`, () => {
const baseDir = `/tmp/upload/`;
const testPaths = Object.keys(files).map((filePath) => `${baseDir}/${filePath}`);
for (const filePath of testPaths) {
utils.createImageFile(filePath);
}
testPaths.map((filePath) => utils.createImageFile(filePath));
const commandLine = paths.map((argument) => `${baseDir}/${argument}`);
@@ -137,9 +135,7 @@ describe(`immich upload`, () => {
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
expect(assets.total).toBe(expectedCount);
for (const filePath of testPaths) {
utils.removeImageFile(filePath);
}
testPaths.map((filePath) => utils.removeImageFile(filePath));
});
}
});

View File

@@ -21,7 +21,7 @@ export const randomImageFromString = async (
let seedNumber = 0;
for (let i = 0; i < seed.length; i++) {
seedNumber = (seedNumber << 5) - seedNumber + (seed.codePointAt(i) ?? 0);
seedNumber &= seedNumber; // Convert to 32bit integer
seedNumber = seedNumber & seedNumber; // Convert to 32bit integer
}
return randomImage(new SeededRandom(Math.abs(seedNumber)), { width, height });
};

View File

@@ -64,7 +64,7 @@ export function generateAsset(
const asset: MockTimelineAsset = {
id: assetId,
ownerId,
ratio: Number(ratio.split(':', 1)[0]) / Number(ratio.split(':', 2)[1]),
ratio: Number.parseFloat(ratio.split(':')[0]) / Number.parseFloat(ratio.split(':')[1]),
thumbhash: generateThumbhash(rng),
localDateTime: date.toISOString(),
fileCreatedAt: date.toISOString(),
@@ -214,7 +214,7 @@ export function generateTimelineData(config: TimelineConfig): MockTimelineData {
}
// Create a mock album from random assets
const allAssets = buckets.values().toArray().flat();
const allAssets = [...buckets.values()].flat();
// Select 10-30 random assets for the album (or all assets if less than 10)
const albumSize = Math.min(allAssets.length, globalRng.nextInt(10, 31));

View File

@@ -172,7 +172,11 @@ function shouldIncludeAsset(
if (isArchived !== undefined && actuallyArchived !== isArchived) {
return false;
}
return isFavorite === undefined || actuallyFavorited === isFavorite;
if (isFavorite !== undefined && actuallyFavorited !== isFavorite) {
return false;
}
return true;
}
/**
* Get summary for all buckets (mimics getTimeBuckets API)
@@ -357,7 +361,7 @@ export function getAsset(
owner?: UserResponseDto,
): AssetResponseDto | undefined {
// Search through all buckets for the asset
const buckets = timelineData.buckets.values().toArray();
const buckets = [...timelineData.buckets.values()];
for (const assets of buckets) {
const asset = assets.find((a) => a.id === assetId);
if (asset) {
@@ -391,7 +395,7 @@ export function getAlbum(
// Get the actual asset objects from the timeline data
const albumAssets: AssetResponseDto[] = [];
const allAssets = timelineData.buckets.values().toArray().flat();
const allAssets = [...timelineData.buckets.values()].flat();
for (const assetId of album.assetIds) {
const assetConfig = allAssets.find((a) => a.id === assetId);

View File

@@ -143,7 +143,7 @@ export function validateTimelineConfig(config: TimelineConfig): void {
}
// Validate seed if provided
if (config.seed !== undefined && (config.seed < 0 || !Number.isSafeInteger(config.seed))) {
if (config.seed !== undefined && (config.seed < 0 || !Number.isInteger(config.seed))) {
throw new Error('Seed must be a non-negative integer');
}

View File

@@ -153,8 +153,11 @@ export function getMockAsset(
const isInDifferentPeriod = (date1: DateTime, date2: DateTime): boolean => {
if (unit === 'day') {
return !date1.startOf('day').equals(date2.startOf('day'));
} else if (unit === 'month') {
return date1.year !== date2.year || date1.month !== date2.month;
} else {
return date1.year !== date2.year;
}
return unit === 'month' ? date1.year !== date2.year || date1.month !== date2.month : date1.year !== date2.year;
};
if (direction === 'next') {

View File

@@ -40,8 +40,7 @@ export const setupTimelineMockApiRoutes = async (
contentType: 'application/json',
json: getTimeBuckets(timelineRestData, isTrashed, isArchived, isFavorite, albumId, changes),
});
}
if (pathname === '/api/timeline/bucket') {
} else if (pathname === '/api/timeline/bucket') {
const timeBucket = url.searchParams.get('timeBucket');
if (!timeBucket) {
return route.continue();

View File

@@ -38,7 +38,7 @@ test.describe('asset-viewer', () => {
assets.push(...timeBucket);
}
for (const yearMonth of timelineRestData.buckets.keys()) {
const [year, month] = yearMonth.split('-', 2);
const [year, month] = yearMonth.split('-');
yearMonths.push(`${year}-${Number(month)}`);
}
});

View File

@@ -1,4 +1,3 @@
/* eslint-disable unicorn/no-this-outside-of-class */
import type { AssetResponseDto } from '@immich/sdk';
import { expect, Page } from '@playwright/test';

View File

@@ -49,7 +49,7 @@ test.describe('Timeline', () => {
assets.push(...timeBucket);
}
for (const yearMonth of timelineRestData.buckets.keys()) {
const [year, month] = yearMonth.split('-', 2);
const [year, month] = yearMonth.split('-');
yearMonths.push(`${year}-${Number(month)}`);
}
});

View File

@@ -7,7 +7,7 @@ export const sleep = (ms: number) => {
};
export const padYearMonth = (yearMonth: string) => {
const [year, month] = yearMonth.split('-', 2);
const [year, month] = yearMonth.split('-');
return `${year}-${month.padStart(2, '0')}`;
};
@@ -72,7 +72,6 @@ export const thumbnailUtils = {
},
async queryThumbnailInViewport(page: Page, collector: (assetId: string) => boolean) {
const assetIds: string[] = [];
// eslint-disable-next-line unicorn/no-this-outside-of-class
for (const thumb of await this.locator(page).all()) {
const box = await thumb.boundingBox();
if (box) {
@@ -152,7 +151,6 @@ export const timelineUtils = {
page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return document.querySelector('#asset-grid').scrollTop;
});
await expect.poll(queryTop).toBeGreaterThan(0);
@@ -179,7 +177,6 @@ export const assetViewerUtils = {
page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line unicorn/no-optional-chaining-on-undeclared-variable
return document.activeElement?.dataset?.asset;
});
await expect(poll(page, activeElement, (result) => result === assetId)).resolves.toBe(assetId);

View File

@@ -1,4 +1,3 @@
/* eslint-disable unicorn/no-top-level-assignment-in-function */
import {
AssetMediaCreateDto,
AssetMediaResponseDto,
@@ -178,7 +177,7 @@ export const utils = {
resetDatabase: async (tables?: string[]) => {
client = await utils.connectDatabase();
tables ||= [
tables = tables || [
// TODO e2e test for deleting a stack, since it is quite complex
'stack',
'library',
@@ -305,7 +304,7 @@ export const utils = {
},
adminSetup: async (options?: AdminSetupOptions) => {
options ||= { onboarding: true };
options = options || { onboarding: true };
await signUpAdmin({ signUpDto: signupDto.admin });
const response = await login({ loginCredentialDto: loginDto.admin });
@@ -546,7 +545,6 @@ export const utils = {
{
headers: asBearerAuth(accessToken),
fetch: (...args: Parameters<typeof fetch>) =>
// eslint-disable-next-line unicorn/no-invalid-argument-count, unicorn/prefer-await
fetch(...args).then((response) => {
setCookie = response.headers.getSetCookie();
return response;
@@ -676,7 +674,7 @@ export const utils = {
cliLogin: async (accessToken: string) => {
const key = await utils.createApiKey(accessToken, [Permission.All]);
await immichCli(['login', app, key.secret]);
await immichCli(['login', app, `${key.secret}`]);
return key.secret;
},
@@ -708,7 +706,6 @@ export const utils = {
},
};
// eslint-disable-next-line unicorn/no-top-level-side-effects
utils.initSdk();
if (!existsSync(`${testAssetDir}/albums`)) {

View File

@@ -10,7 +10,6 @@
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"target": "es2023",
"lib": ["dom", "ESNext"],
"sourceMap": true,
"outDir": "./dist",
"incremental": true,

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.13.1"
version = "11.11.0"
backend = "aqua:pnpm/pnpm"
[tools.pnpm."platforms.linux-arm64"]
checksum = "sha256:b52db99d215ed7dc9563aed815953c62a6c1ffd7cd75803d3a07ad7e4f246aed"
url = "https://github.com/pnpm/pnpm/releases/download/v11.13.1/pnpm-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563983"
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:2cadd4fc815c591f498a0a84c9e74a836e3e8c1236275f6e1cfd355bae6ae957"
url = "https://github.com/pnpm/pnpm/releases/download/v11.13.1/pnpm-linux-arm64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563985"
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:bd6d4b0e14536207ad76bc838f5980cecd968da15f69aae0b207380cca3f2e98"
url = "https://github.com/pnpm/pnpm/releases/download/v11.13.1/pnpm-linux-x64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563986"
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:ba19690f4ed1b64f1203ade14e9216352b46232d5582468b26a0160e0c9618c5"
url = "https://github.com/pnpm/pnpm/releases/download/v11.13.1/pnpm-linux-x64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563980"
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:765c2bf04e8129cb58c0f946e324262e418370b35a203b50b1f06a0567ef8bc1"
url = "https://github.com/pnpm/pnpm/releases/download/v11.13.1/pnpm-darwin-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563984"
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:d8bebbc71df2702961c1d34a5e61196bc0aa3bbde33c33253f6afa3dd4546a6d"
url = "https://github.com/pnpm/pnpm/releases/download/v11.13.1/pnpm-win32-x64.zip"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563981"
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.13.1"
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 {

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

@@ -23,6 +23,6 @@ class ImmichApp : Application() {
// as the previous start might have been killed without unlocking.
if (BackgroundEngineLock.connectEngines > 0) return@postDelayed
BackgroundWorkerApiImpl.enqueueBackgroundWorker(this)
}, 15000)
}, 5000)
}
}

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

@@ -15,7 +15,6 @@ import androidx.work.ListenableWorker
import androidx.work.WorkerParameters
import app.alextran.immich.MainActivity
import app.alextran.immich.R
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import io.flutter.FlutterInjector
@@ -62,11 +61,6 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
}
override fun startWork(): ListenableFuture<Result> {
if (BackgroundWorkerPreferences(ctx).isLocked() && BackgroundEngineLock.connectEngines > 0) {
Log.i(TAG, "Foreground engine active, skipping background worker")
return Futures.immediateFuture(Result.success())
}
Log.i(TAG, "Starting background upload worker")
if (!loader.initialized()) {
@@ -83,10 +77,6 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
showNotification(notificationConfig.first, notificationConfig.second)
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
if (isStopped || isComplete) {
return@ensureInitializationCompleteAsync
}
engine = FlutterEngine(ctx)
FlutterEngineCache.getInstance().put(BackgroundWorkerApiImpl.ENGINE_CACHE_KEY, engine!!)
@@ -153,17 +143,11 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
return
}
val api = flutterApi
if (api == null) {
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
complete(Result.failure())
}
return
}
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
api.cancel {
complete(Result.failure())
if (flutterApi != null) {
flutterApi?.cancel {
complete(Result.failure())
}
}
}

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

@@ -46,7 +46,7 @@ void main() {
void sendUser(SyncStream stream, String id, String name) {
stream.send(
type: SyncEntityType.userV1.toString(),
type: SyncEntityType.userV1.value,
data: SyncUserV1(
id: id,
name: name,

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

@@ -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 */; };
@@ -131,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 */
@@ -357,7 +355,6 @@
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */,
FE5499F12F1197D8006016CB /* LocalImages.g.swift */,
FE5499F22F1197D8006016CB /* RemoteImages.g.swift */,
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */,
);
path = Images;
sourceTree = "<group>";
@@ -586,7 +583,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/bash \"$SRCROOT/scripts/xcode_flutter_build.sh\"\n";
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n";
};
BAEA01ACA3F5C9CD3D732370 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
@@ -628,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 */,

View File

@@ -32,8 +32,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git",
"state" : {
"revision" : "84a79bc375a301169390ac110c868f06c857b83f",
"version" : "6.27.0"
"revision" : "60d9bb85c94ce6e7fc4406cd32529fd12bdb7809",
"version" : "6.14.0"
}
},
{

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

@@ -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

@@ -1,45 +0,0 @@
#!/usr/bin/env bash
# Makes Flutter's builds through the Xcode GUI properly display errors and warnings
# in the Issue navigator
#
# Flutter's `xcode_backend.dart` runs `flutter assemble` with `allowFail: true`,
# which intentionally does not prefix output with `error:`. Unsure why they do this,
# but this script rebuilds the expected output so Xcode can parse and display the errors
set -o pipefail
# The Immich mobile root (containing the Dart `lib` directory). This is used to make
# absolute paths for Xcode linking
app_root="${FLUTTER_APPLICATION_PATH:-$SRCROOT/..}"
/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" build 2>&1 \
| awk -v app_root="$app_root" '
# Match Dart CFE diagnostics: <path>.dart:<line>:<col>: <Kind>: <message>
# Written for macOS/POSIX/BSD awk
{
# Always pass the original line through to preserve the original build log
print
if ($0 ~ /^.*\.dart:[0-9]+:[0-9]+: (Error|Warning|Context|Info):/) {
# Locate the ": Kind:" separator to split location from message.
rest = $0
if (match(rest, /: Error:/)) { kind = "Error"; keyword = "error" }
else if (match(rest, /: Warning:/)) { kind = "Warning"; keyword = "warning" }
else if (match(rest, /: Context:/)) { kind = "Context"; keyword = "note" }
else if (match(rest, /: Info:/)) { kind = "Info"; keyword = "note" }
# location = everything before ": Kind:" (e.g. "lib/foo.dart:12:5")
location = substr(rest, 1, RSTART - 1)
# message = everything after ": Kind:" (leading space preserved)
message = substr(rest, RSTART + length(": " kind ":"))
# Make the path absolute so Xcode links to it
if (location !~ /^\//)
location = app_root "/" location
printf "%s: %s:%s\n", location, keyword, message
}
}
'
exit "${PIPESTATUS[0]}"

View File

@@ -328,6 +328,8 @@ class SyncStreamService {
return _syncStreamRepository.updateAssetOcrV1(data.cast());
case SyncEntityType.assetOcrDeleteV1:
return _syncStreamRepository.deleteAssetOcrV1(data.cast());
default:
_logger.warning("Unknown sync data type: $type");
}
}

View File

@@ -58,6 +58,7 @@ extension on api.AssetVisibility {
api.AssetVisibility.hidden => AssetVisibility.hidden,
api.AssetVisibility.archive => AssetVisibility.archive,
api.AssetVisibility.locked => AssetVisibility.locked,
_ => AssetVisibility.timeline,
};
}
@@ -67,5 +68,6 @@ extension on api.AssetTypeEnum {
api.AssetTypeEnum.VIDEO => AssetType.video,
api.AssetTypeEnum.AUDIO => AssetType.audio,
api.AssetTypeEnum.OTHER => AssetType.other,
_ => throw Exception('Unknown AssetType value: $this'),
};
}

View File

@@ -210,19 +210,17 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
await _deleteAssets(deletes);
await _upsertAssets(updates);
// Drop every existing album link for each changed asset before re-adding the
// ones the native side reports. A moved asset only reports its new album here,
// so leaving the old link around makes the per-album delete sweep wipe the
// asset entirely (it is still linked to a bucket it no longer lives in).
await _db.batch((batch) async {
for (final assetId in assetAlbums.keys) {
batch.deleteWhere(_db.localAlbumAssetEntity, (f) => f.assetId.equals(assetId));
}
});
// The ugly casting below is required for now because the generated code
// casts the returned values from the platform during decoding them
// and iterating over them causes the type to be List<Object?> instead of
// List<String>
await _db.batch((batch) async {
assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) {
for (final albumId in albumIds.cast<String?>().nonNulls) {
batch.deleteWhere(_db.localAlbumAssetEntity, (f) => f.albumId.equals(albumId) & f.assetId.equals(assetId));
}
});
});
await _db.batch((batch) async {
assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) {
batch.insertAll(

View File

@@ -937,6 +937,7 @@ extension on AssetTypeEnum {
AssetTypeEnum.VIDEO => AssetType.video,
AssetTypeEnum.AUDIO => AssetType.audio,
AssetTypeEnum.OTHER => AssetType.other,
_ => throw Exception('Unknown AssetType value: $this'),
};
}
@@ -944,12 +945,14 @@ extension on AssetOrder {
AlbumAssetOrder toAlbumAssetOrder() => switch (this) {
AssetOrder.asc => AlbumAssetOrder.asc,
AssetOrder.desc => AlbumAssetOrder.desc,
_ => throw Exception('Unknown AssetOrder value: $this'),
};
}
extension on MemoryType {
MemoryTypeEnum toMemoryType() => switch (this) {
MemoryType.onThisDay => MemoryTypeEnum.onThisDay,
_ => throw Exception('Unknown MemoryType value: $this'),
};
}
@@ -958,6 +961,7 @@ extension on api.AlbumUserRole {
api.AlbumUserRole.editor => AlbumUserRole.editor,
api.AlbumUserRole.viewer => AlbumUserRole.viewer,
api.AlbumUserRole.owner => AlbumUserRole.owner,
_ => throw Exception('Unknown AlbumUserRole value: $this'),
};
}
@@ -967,6 +971,7 @@ extension on api.AssetVisibility {
api.AssetVisibility.hidden => AssetVisibility.hidden,
api.AssetVisibility.archive => AssetVisibility.archive,
api.AssetVisibility.locked => AssetVisibility.locked,
_ => throw Exception('Unknown AssetVisibility value: $this'),
};
}
@@ -975,11 +980,12 @@ extension on api.UserMetadataKey {
api.UserMetadataKey.onboarding => UserMetadataKey.onboarding,
api.UserMetadataKey.preferences => UserMetadataKey.preferences,
api.UserMetadataKey.license => UserMetadataKey.license,
_ => throw Exception('Unknown UserMetadataKey value: $this'),
};
}
extension on UserAvatarColor {
AvatarColor? toAvatarColor() => AvatarColor.values.firstWhereOrNull((c) => c.name == toString());
AvatarColor? toAvatarColor() => AvatarColor.values.firstWhereOrNull((c) => c.name == value);
}
extension on api.AssetEditAction {
@@ -987,5 +993,6 @@ extension on api.AssetEditAction {
api.AssetEditAction.crop => AssetEditAction.crop,
api.AssetEditAction.rotate => AssetEditAction.rotate,
api.AssetEditAction.mirror => AssetEditAction.mirror,
_ => AssetEditAction.other,
};
}

View File

@@ -26,7 +26,6 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
FocusNode albumTitleTextFieldFocusNode = FocusNode();
FocusNode albumDescriptionTextFieldFocusNode = FocusNode();
bool isAlbumTitleTextFieldFocus = false;
bool isCreatingAlbum = false;
Set<BaseAsset> selectedAssets = {};
@override
@@ -49,7 +48,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
super.dispose();
}
bool get _canCreateAlbum => albumTitleController.text.trim().isNotEmpty && !isCreatingAlbum;
bool get _canCreateAlbum => albumTitleController.text.trim().isNotEmpty;
String _getEffectiveTitle() {
return albumTitleController.text.isNotEmpty
@@ -168,12 +167,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
}
Future<void> createAlbum() async {
if (isCreatingAlbum) {
return;
}
onBackgroundTapped();
setState(() => isCreatingAlbum = true);
final title = _getEffectiveTitle().trim();
@@ -193,10 +187,6 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
if (context.mounted) {
ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.t());
}
} finally {
if (mounted) {
setState(() => isCreatingAlbum = false);
}
}
}

View File

@@ -174,12 +174,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
return;
}
// The viewer is closing; don't flip the current asset now. Flipping it swaps
// the grid tile hero keys mid pop and animates the close on two tiles (#23779).
if (!mounted || !(ModalRoute.of(context)?.isActive ?? true)) {
return;
}
AssetViewer._setAsset(ref, asset);
_preloader.preload(index, context.sizeData);
_handleCasting();

View File

@@ -91,7 +91,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
}
case AppLifecycleState.paused:
_shouldPlayOnForeground = await _controller?.isPlaying() ?? true;
if (_shouldPlayOnForeground && mounted) {
if (_shouldPlayOnForeground) {
await _notifier.pause();
}
default:
@@ -268,13 +268,10 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
return;
}
// Grab refs to prevent reading after dispose
await _notifier.load(source);
final loopVideo = ref.read(appConfigProvider).viewer.loopVideo;
final localNotifier = _notifier;
await localNotifier.load(source);
await localNotifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
await localNotifier.setVolume(1);
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
await _notifier.setVolume(1);
}
void _initController(NativeVideoPlayerController nc) {

View File

@@ -1,76 +1,23 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class PartnerDetailBottomSheet extends ConsumerStatefulWidget {
class PartnerDetailBottomSheet extends ConsumerWidget {
const PartnerDetailBottomSheet({super.key});
@override
ConsumerState<PartnerDetailBottomSheet> createState() => _PartnerDetailBottomSheetState();
}
class _PartnerDetailBottomSheetState extends ConsumerState<PartnerDetailBottomSheet> {
late final DraggableScrollableController sheetController;
@override
void initState() {
super.initState();
sheetController = DraggableScrollableController();
}
@override
void dispose() {
sheetController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
Future<void> addToAlbum(RemoteAlbum album) async {
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
if (!context.mounted) {
return;
}
if (!result.success) {
ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error);
return;
}
ImmichToast.show(
context: context,
msg: result.count == 0
? 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name})
: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
);
}
Future<void> onKeyboardExpand() {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
return BaseBottomSheet(
controller: sheetController,
Widget build(BuildContext context, WidgetRef ref) {
return const BaseBottomSheet(
initialChildSize: 0.25,
maxChildSize: 0.85,
maxChildSize: 0.4,
shouldCloseOnMinExtent: false,
actions: const [
actions: [
ShareActionButton(source: ActionSource.timeline),
DownloadActionButton(source: ActionSource.timeline),
],
slivers: [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand),
],
);
}
}

View File

@@ -318,7 +318,7 @@ class BackgroundUploadService {
isFavorite: asset.isFavorite,
requiresWiFi: requiresWiFi,
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset
fields: entity.isLivePhoto ? {'visibility': api.AssetVisibility.hidden.toString()} : null,
fields: entity.isLivePhoto ? {'visibility': api.AssetVisibility.hidden.value} : null,
cloudId: entity.isLivePhoto ? null : asset.cloudId,
adjustmentTime: entity.isLivePhoto ? null : asset.adjustmentTime?.toIso8601String(),
latitude: entity.isLivePhoto ? null : asset.latitude?.toString(),

View File

@@ -342,7 +342,7 @@ class ForegroundUploadService {
file: livePhotoFile,
originalFileName: livePhotoTitle,
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset
fields: {...fields, 'visibility': AssetVisibility.hidden.toString()},
fields: {...fields, 'visibility': AssetVisibility.hidden.value},
cancelToken: cancelToken,
onProgress: onProgress != null
? (bytes, totalBytes) => onProgress(asset.localId!, livePhotoTitle, bytes, totalBytes)

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

@@ -12,7 +12,7 @@ String getThumbnailUrlForRemoteId(
bool edited = true,
String? thumbhash,
}) {
final url = '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.toString()}&edited=$edited';
final url = '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}&edited=$edited';
return thumbhash != null ? '$url&c=${Uri.encodeComponent(thumbhash)}' : url;
}

View File

@@ -11,20 +11,17 @@ import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/feature_message.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
const int targetVersion = 26;
Future<void> migrateDatabaseIfNeeded(Drift drift) async {
final int? storedVersion = Store.tryGet(StoreKey.version);
final version = storedVersion ?? targetVersion;
final int version = Store.get(StoreKey.version, targetVersion);
if (version < 25) {
await _migrateTo25();
@@ -34,10 +31,6 @@ Future<void> migrateDatabaseIfNeeded(Drift drift) async {
await _migrateTo26(drift);
}
if (storedVersion == null) {
await FeatureMessageService(SettingsRepository.instance).markSeen();
}
await Store.put(StoreKey.version, targetVersion);
return;
}

View File

@@ -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.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

@@ -1 +1 @@
7.24.0
7.22.0

View File

@@ -4,12 +4,12 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 3.0.3
- Generator version: 7.24.0
- Generator version: 7.22.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements
Dart 2.17 or later
Dart 2.12 or later
## Installation & Usage

View File

@@ -97,9 +97,9 @@ class ApiClient {
if (nullableHeaderParams != null) {
request.headers.addAll(nullableHeaderParams);
}
if (msgBody is String && msgBody.isNotEmpty) {
if (msgBody is String) {
request.body = msgBody;
} else if (msgBody is List<int> && msgBody.isNotEmpty) {
} else if (msgBody is List<int>) {
request.bodyBytes = msgBody;
} else if (msgBody is Map<String, String>) {
request.bodyFields = msgBody;

View File

@@ -11,30 +11,31 @@
part of openapi.api;
/// Album user role
enum AlbumUserRole {
editor._(r'editor'),
owner._(r'owner'),
viewer._(r'viewer'),
;
/// Instantiate a new enum with the provided value.
const AlbumUserRole._(this._value);
class AlbumUserRole {
/// Instantiate a new enum with the provided [value].
const AlbumUserRole._(this.value);
/// The underlying value of this enum member.
final String _value;
final String value;
@override
String toString() => _value;
String toString() => value;
/// Encodes this enum as a value suitable for JSON.
String toJson() => _value;
String toJson() => value;
static const editor = AlbumUserRole._(r'editor');
static const owner = AlbumUserRole._(r'owner');
static const viewer = AlbumUserRole._(r'viewer');
/// List of all possible values in this [enum][AlbumUserRole].
static const values = <AlbumUserRole>[
editor,
owner,
viewer,
];
/// Returns the instance of [AlbumUserRole] that was successfully decoded
/// from the passed [value] on success, null otherwise.
static AlbumUserRole? fromJson(dynamic value) => AlbumUserRoleTypeTransformer().decode(value);
/// Returns a [List] containing instances of [AlbumUserRole]
/// that were successfully decoded from the passed [JSON][json].
static List<AlbumUserRole> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AlbumUserRole>[];
if (json is List && json.isNotEmpty) {
@@ -56,11 +57,9 @@ class AlbumUserRoleTypeTransformer {
const AlbumUserRoleTypeTransformer._();
/// Encodes this enum as a value suitable for JSON.
String encode(AlbumUserRole data) => data._value;
String encode(AlbumUserRole data) => data.value;
/// Returns the instance of [AlbumUserRole] that was successfully decoded
/// from the passed [data] value on success, null otherwise.
/// Decodes a [dynamic value][data] to a AlbumUserRole.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
@@ -69,9 +68,6 @@ class AlbumUserRoleTypeTransformer {
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
AlbumUserRole? decode(dynamic data, {bool allowNull = true}) {
if (data is AlbumUserRole) {
return data;
}
if (data != null) {
switch (data) {
case r'editor': return AlbumUserRole.editor;
@@ -86,7 +82,7 @@ class AlbumUserRoleTypeTransformer {
return null;
}
/// The singleton instance of this transformer.
/// Singleton [AlbumUserRoleTypeTransformer] instance.
static AlbumUserRoleTypeTransformer? _instance;
}

View File

@@ -11,30 +11,31 @@
part of openapi.api;
/// Type of edit action to perform
enum AssetEditAction {
crop._(r'crop'),
rotate._(r'rotate'),
mirror._(r'mirror'),
;
/// Instantiate a new enum with the provided value.
const AssetEditAction._(this._value);
class AssetEditAction {
/// Instantiate a new enum with the provided [value].
const AssetEditAction._(this.value);
/// The underlying value of this enum member.
final String _value;
final String value;
@override
String toString() => _value;
String toString() => value;
/// Encodes this enum as a value suitable for JSON.
String toJson() => _value;
String toJson() => value;
static const crop = AssetEditAction._(r'crop');
static const rotate = AssetEditAction._(r'rotate');
static const mirror = AssetEditAction._(r'mirror');
/// List of all possible values in this [enum][AssetEditAction].
static const values = <AssetEditAction>[
crop,
rotate,
mirror,
];
/// Returns the instance of [AssetEditAction] that was successfully decoded
/// from the passed [value] on success, null otherwise.
static AssetEditAction? fromJson(dynamic value) => AssetEditActionTypeTransformer().decode(value);
/// Returns a [List] containing instances of [AssetEditAction]
/// that were successfully decoded from the passed [JSON][json].
static List<AssetEditAction> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetEditAction>[];
if (json is List && json.isNotEmpty) {
@@ -56,11 +57,9 @@ class AssetEditActionTypeTransformer {
const AssetEditActionTypeTransformer._();
/// Encodes this enum as a value suitable for JSON.
String encode(AssetEditAction data) => data._value;
String encode(AssetEditAction data) => data.value;
/// Returns the instance of [AssetEditAction] that was successfully decoded
/// from the passed [data] value on success, null otherwise.
/// Decodes a [dynamic value][data] to a AssetEditAction.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
@@ -69,9 +68,6 @@ class AssetEditActionTypeTransformer {
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
AssetEditAction? decode(dynamic data, {bool allowNull = true}) {
if (data is AssetEditAction) {
return data;
}
if (data != null) {
switch (data) {
case r'crop': return AssetEditAction.crop;
@@ -86,7 +82,7 @@ class AssetEditActionTypeTransformer {
return null;
}
/// The singleton instance of this transformer.
/// Singleton [AssetEditActionTypeTransformer] instance.
static AssetEditActionTypeTransformer? _instance;
}

View File

@@ -11,30 +11,31 @@
part of openapi.api;
/// Error reason if failed
enum AssetIdErrorReason {
duplicate._(r'duplicate'),
noPermission._(r'no_permission'),
notFound._(r'not_found'),
;
/// Instantiate a new enum with the provided value.
const AssetIdErrorReason._(this._value);
class AssetIdErrorReason {
/// Instantiate a new enum with the provided [value].
const AssetIdErrorReason._(this.value);
/// The underlying value of this enum member.
final String _value;
final String value;
@override
String toString() => _value;
String toString() => value;
/// Encodes this enum as a value suitable for JSON.
String toJson() => _value;
String toJson() => value;
static const duplicate = AssetIdErrorReason._(r'duplicate');
static const noPermission = AssetIdErrorReason._(r'no_permission');
static const notFound = AssetIdErrorReason._(r'not_found');
/// List of all possible values in this [enum][AssetIdErrorReason].
static const values = <AssetIdErrorReason>[
duplicate,
noPermission,
notFound,
];
/// Returns the instance of [AssetIdErrorReason] that was successfully decoded
/// from the passed [value] on success, null otherwise.
static AssetIdErrorReason? fromJson(dynamic value) => AssetIdErrorReasonTypeTransformer().decode(value);
/// Returns a [List] containing instances of [AssetIdErrorReason]
/// that were successfully decoded from the passed [JSON][json].
static List<AssetIdErrorReason> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetIdErrorReason>[];
if (json is List && json.isNotEmpty) {
@@ -56,11 +57,9 @@ class AssetIdErrorReasonTypeTransformer {
const AssetIdErrorReasonTypeTransformer._();
/// Encodes this enum as a value suitable for JSON.
String encode(AssetIdErrorReason data) => data._value;
String encode(AssetIdErrorReason data) => data.value;
/// Returns the instance of [AssetIdErrorReason] that was successfully decoded
/// from the passed [data] value on success, null otherwise.
/// Decodes a [dynamic value][data] to a AssetIdErrorReason.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
@@ -69,9 +68,6 @@ class AssetIdErrorReasonTypeTransformer {
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
AssetIdErrorReason? decode(dynamic data, {bool allowNull = true}) {
if (data is AssetIdErrorReason) {
return data;
}
if (data != null) {
switch (data) {
case r'duplicate': return AssetIdErrorReason.duplicate;
@@ -86,7 +82,7 @@ class AssetIdErrorReasonTypeTransformer {
return null;
}
/// The singleton instance of this transformer.
/// Singleton [AssetIdErrorReasonTypeTransformer] instance.
static AssetIdErrorReasonTypeTransformer? _instance;
}

View File

@@ -11,31 +11,33 @@
part of openapi.api;
/// Job name
enum AssetJobName {
refreshFaces._(r'refresh-faces'),
refreshMetadata._(r'refresh-metadata'),
regenerateThumbnail._(r'regenerate-thumbnail'),
transcodeVideo._(r'transcode-video'),
;
/// Instantiate a new enum with the provided value.
const AssetJobName._(this._value);
class AssetJobName {
/// Instantiate a new enum with the provided [value].
const AssetJobName._(this.value);
/// The underlying value of this enum member.
final String _value;
final String value;
@override
String toString() => _value;
String toString() => value;
/// Encodes this enum as a value suitable for JSON.
String toJson() => _value;
String toJson() => value;
static const refreshFaces = AssetJobName._(r'refresh-faces');
static const refreshMetadata = AssetJobName._(r'refresh-metadata');
static const regenerateThumbnail = AssetJobName._(r'regenerate-thumbnail');
static const transcodeVideo = AssetJobName._(r'transcode-video');
/// List of all possible values in this [enum][AssetJobName].
static const values = <AssetJobName>[
refreshFaces,
refreshMetadata,
regenerateThumbnail,
transcodeVideo,
];
/// Returns the instance of [AssetJobName] that was successfully decoded
/// from the passed [value] on success, null otherwise.
static AssetJobName? fromJson(dynamic value) => AssetJobNameTypeTransformer().decode(value);
/// Returns a [List] containing instances of [AssetJobName]
/// that were successfully decoded from the passed [JSON][json].
static List<AssetJobName> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetJobName>[];
if (json is List && json.isNotEmpty) {
@@ -57,11 +59,9 @@ class AssetJobNameTypeTransformer {
const AssetJobNameTypeTransformer._();
/// Encodes this enum as a value suitable for JSON.
String encode(AssetJobName data) => data._value;
String encode(AssetJobName data) => data.value;
/// Returns the instance of [AssetJobName] that was successfully decoded
/// from the passed [data] value on success, null otherwise.
/// Decodes a [dynamic value][data] to a AssetJobName.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
@@ -70,9 +70,6 @@ class AssetJobNameTypeTransformer {
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
AssetJobName? decode(dynamic data, {bool allowNull = true}) {
if (data is AssetJobName) {
return data;
}
if (data != null) {
switch (data) {
case r'refresh-faces': return AssetJobName.refreshFaces;
@@ -88,7 +85,7 @@ class AssetJobNameTypeTransformer {
return null;
}
/// The singleton instance of this transformer.
/// Singleton [AssetJobNameTypeTransformer] instance.
static AssetJobNameTypeTransformer? _instance;
}

View File

@@ -11,31 +11,33 @@
part of openapi.api;
/// Asset media size
enum AssetMediaSize {
original._(r'original'),
fullsize._(r'fullsize'),
preview._(r'preview'),
thumbnail._(r'thumbnail'),
;
/// Instantiate a new enum with the provided value.
const AssetMediaSize._(this._value);
class AssetMediaSize {
/// Instantiate a new enum with the provided [value].
const AssetMediaSize._(this.value);
/// The underlying value of this enum member.
final String _value;
final String value;
@override
String toString() => _value;
String toString() => value;
/// Encodes this enum as a value suitable for JSON.
String toJson() => _value;
String toJson() => value;
static const original = AssetMediaSize._(r'original');
static const fullsize = AssetMediaSize._(r'fullsize');
static const preview = AssetMediaSize._(r'preview');
static const thumbnail = AssetMediaSize._(r'thumbnail');
/// List of all possible values in this [enum][AssetMediaSize].
static const values = <AssetMediaSize>[
original,
fullsize,
preview,
thumbnail,
];
/// Returns the instance of [AssetMediaSize] that was successfully decoded
/// from the passed [value] on success, null otherwise.
static AssetMediaSize? fromJson(dynamic value) => AssetMediaSizeTypeTransformer().decode(value);
/// Returns a [List] containing instances of [AssetMediaSize]
/// that were successfully decoded from the passed [JSON][json].
static List<AssetMediaSize> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetMediaSize>[];
if (json is List && json.isNotEmpty) {
@@ -57,11 +59,9 @@ class AssetMediaSizeTypeTransformer {
const AssetMediaSizeTypeTransformer._();
/// Encodes this enum as a value suitable for JSON.
String encode(AssetMediaSize data) => data._value;
String encode(AssetMediaSize data) => data.value;
/// Returns the instance of [AssetMediaSize] that was successfully decoded
/// from the passed [data] value on success, null otherwise.
/// Decodes a [dynamic value][data] to a AssetMediaSize.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
@@ -70,9 +70,6 @@ class AssetMediaSizeTypeTransformer {
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
AssetMediaSize? decode(dynamic data, {bool allowNull = true}) {
if (data is AssetMediaSize) {
return data;
}
if (data != null) {
switch (data) {
case r'original': return AssetMediaSize.original;
@@ -88,7 +85,7 @@ class AssetMediaSizeTypeTransformer {
return null;
}
/// The singleton instance of this transformer.
/// Singleton [AssetMediaSizeTypeTransformer] instance.
static AssetMediaSizeTypeTransformer? _instance;
}

View File

@@ -11,29 +11,29 @@
part of openapi.api;
/// Upload status
enum AssetMediaStatus {
created._(r'created'),
duplicate._(r'duplicate'),
;
/// Instantiate a new enum with the provided value.
const AssetMediaStatus._(this._value);
class AssetMediaStatus {
/// Instantiate a new enum with the provided [value].
const AssetMediaStatus._(this.value);
/// The underlying value of this enum member.
final String _value;
final String value;
@override
String toString() => _value;
String toString() => value;
/// Encodes this enum as a value suitable for JSON.
String toJson() => _value;
String toJson() => value;
static const created = AssetMediaStatus._(r'created');
static const duplicate = AssetMediaStatus._(r'duplicate');
/// List of all possible values in this [enum][AssetMediaStatus].
static const values = <AssetMediaStatus>[
created,
duplicate,
];
/// Returns the instance of [AssetMediaStatus] that was successfully decoded
/// from the passed [value] on success, null otherwise.
static AssetMediaStatus? fromJson(dynamic value) => AssetMediaStatusTypeTransformer().decode(value);
/// Returns a [List] containing instances of [AssetMediaStatus]
/// that were successfully decoded from the passed [JSON][json].
static List<AssetMediaStatus> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetMediaStatus>[];
if (json is List && json.isNotEmpty) {
@@ -55,11 +55,9 @@ class AssetMediaStatusTypeTransformer {
const AssetMediaStatusTypeTransformer._();
/// Encodes this enum as a value suitable for JSON.
String encode(AssetMediaStatus data) => data._value;
String encode(AssetMediaStatus data) => data.value;
/// Returns the instance of [AssetMediaStatus] that was successfully decoded
/// from the passed [data] value on success, null otherwise.
/// Decodes a [dynamic value][data] to a AssetMediaStatus.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
@@ -68,9 +66,6 @@ class AssetMediaStatusTypeTransformer {
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
AssetMediaStatus? decode(dynamic data, {bool allowNull = true}) {
if (data is AssetMediaStatus) {
return data;
}
if (data != null) {
switch (data) {
case r'created': return AssetMediaStatus.created;
@@ -84,7 +79,7 @@ class AssetMediaStatusTypeTransformer {
return null;
}
/// The singleton instance of this transformer.
/// Singleton [AssetMediaStatusTypeTransformer] instance.
static AssetMediaStatusTypeTransformer? _instance;
}

View File

@@ -11,29 +11,29 @@
part of openapi.api;
/// Asset sort order
enum AssetOrder {
asc._(r'asc'),
desc._(r'desc'),
;
/// Instantiate a new enum with the provided value.
const AssetOrder._(this._value);
class AssetOrder {
/// Instantiate a new enum with the provided [value].
const AssetOrder._(this.value);
/// The underlying value of this enum member.
final String _value;
final String value;
@override
String toString() => _value;
String toString() => value;
/// Encodes this enum as a value suitable for JSON.
String toJson() => _value;
String toJson() => value;
static const asc = AssetOrder._(r'asc');
static const desc = AssetOrder._(r'desc');
/// List of all possible values in this [enum][AssetOrder].
static const values = <AssetOrder>[
asc,
desc,
];
/// Returns the instance of [AssetOrder] that was successfully decoded
/// from the passed [value] on success, null otherwise.
static AssetOrder? fromJson(dynamic value) => AssetOrderTypeTransformer().decode(value);
/// Returns a [List] containing instances of [AssetOrder]
/// that were successfully decoded from the passed [JSON][json].
static List<AssetOrder> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetOrder>[];
if (json is List && json.isNotEmpty) {
@@ -55,11 +55,9 @@ class AssetOrderTypeTransformer {
const AssetOrderTypeTransformer._();
/// Encodes this enum as a value suitable for JSON.
String encode(AssetOrder data) => data._value;
String encode(AssetOrder data) => data.value;
/// Returns the instance of [AssetOrder] that was successfully decoded
/// from the passed [data] value on success, null otherwise.
/// Decodes a [dynamic value][data] to a AssetOrder.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
@@ -68,9 +66,6 @@ class AssetOrderTypeTransformer {
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
AssetOrder? decode(dynamic data, {bool allowNull = true}) {
if (data is AssetOrder) {
return data;
}
if (data != null) {
switch (data) {
case r'asc': return AssetOrder.asc;
@@ -84,7 +79,7 @@ class AssetOrderTypeTransformer {
return null;
}
/// The singleton instance of this transformer.
/// Singleton [AssetOrderTypeTransformer] instance.
static AssetOrderTypeTransformer? _instance;
}

View File

@@ -11,29 +11,29 @@
part of openapi.api;
/// Asset sorting property
enum AssetOrderBy {
takenAt._(r'takenAt'),
createdAt._(r'createdAt'),
;
/// Instantiate a new enum with the provided value.
const AssetOrderBy._(this._value);
class AssetOrderBy {
/// Instantiate a new enum with the provided [value].
const AssetOrderBy._(this.value);
/// The underlying value of this enum member.
final String _value;
final String value;
@override
String toString() => _value;
String toString() => value;
/// Encodes this enum as a value suitable for JSON.
String toJson() => _value;
String toJson() => value;
static const takenAt = AssetOrderBy._(r'takenAt');
static const createdAt = AssetOrderBy._(r'createdAt');
/// List of all possible values in this [enum][AssetOrderBy].
static const values = <AssetOrderBy>[
takenAt,
createdAt,
];
/// Returns the instance of [AssetOrderBy] that was successfully decoded
/// from the passed [value] on success, null otherwise.
static AssetOrderBy? fromJson(dynamic value) => AssetOrderByTypeTransformer().decode(value);
/// Returns a [List] containing instances of [AssetOrderBy]
/// that were successfully decoded from the passed [JSON][json].
static List<AssetOrderBy> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetOrderBy>[];
if (json is List && json.isNotEmpty) {
@@ -55,11 +55,9 @@ class AssetOrderByTypeTransformer {
const AssetOrderByTypeTransformer._();
/// Encodes this enum as a value suitable for JSON.
String encode(AssetOrderBy data) => data._value;
String encode(AssetOrderBy data) => data.value;
/// Returns the instance of [AssetOrderBy] that was successfully decoded
/// from the passed [data] value on success, null otherwise.
/// Decodes a [dynamic value][data] to a AssetOrderBy.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
@@ -68,9 +66,6 @@ class AssetOrderByTypeTransformer {
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
AssetOrderBy? decode(dynamic data, {bool allowNull = true}) {
if (data is AssetOrderBy) {
return data;
}
if (data != null) {
switch (data) {
case r'takenAt': return AssetOrderBy.takenAt;
@@ -84,7 +79,7 @@ class AssetOrderByTypeTransformer {
return null;
}
/// The singleton instance of this transformer.
/// Singleton [AssetOrderByTypeTransformer] instance.
static AssetOrderByTypeTransformer? _instance;
}

View File

@@ -11,29 +11,29 @@
part of openapi.api;
/// Rejection reason if rejected
enum AssetRejectReason {
duplicate._(r'duplicate'),
unsupportedFormat._(r'unsupported-format'),
;
/// Instantiate a new enum with the provided value.
const AssetRejectReason._(this._value);
class AssetRejectReason {
/// Instantiate a new enum with the provided [value].
const AssetRejectReason._(this.value);
/// The underlying value of this enum member.
final String _value;
final String value;
@override
String toString() => _value;
String toString() => value;
/// Encodes this enum as a value suitable for JSON.
String toJson() => _value;
String toJson() => value;
static const duplicate = AssetRejectReason._(r'duplicate');
static const unsupportedFormat = AssetRejectReason._(r'unsupported-format');
/// List of all possible values in this [enum][AssetRejectReason].
static const values = <AssetRejectReason>[
duplicate,
unsupportedFormat,
];
/// Returns the instance of [AssetRejectReason] that was successfully decoded
/// from the passed [value] on success, null otherwise.
static AssetRejectReason? fromJson(dynamic value) => AssetRejectReasonTypeTransformer().decode(value);
/// Returns a [List] containing instances of [AssetRejectReason]
/// that were successfully decoded from the passed [JSON][json].
static List<AssetRejectReason> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetRejectReason>[];
if (json is List && json.isNotEmpty) {
@@ -55,11 +55,9 @@ class AssetRejectReasonTypeTransformer {
const AssetRejectReasonTypeTransformer._();
/// Encodes this enum as a value suitable for JSON.
String encode(AssetRejectReason data) => data._value;
String encode(AssetRejectReason data) => data.value;
/// Returns the instance of [AssetRejectReason] that was successfully decoded
/// from the passed [data] value on success, null otherwise.
/// Decodes a [dynamic value][data] to a AssetRejectReason.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
@@ -68,9 +66,6 @@ class AssetRejectReasonTypeTransformer {
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
AssetRejectReason? decode(dynamic data, {bool allowNull = true}) {
if (data is AssetRejectReason) {
return data;
}
if (data != null) {
switch (data) {
case r'duplicate': return AssetRejectReason.duplicate;
@@ -84,7 +79,7 @@ class AssetRejectReasonTypeTransformer {
return null;
}
/// The singleton instance of this transformer.
/// Singleton [AssetRejectReasonTypeTransformer] instance.
static AssetRejectReasonTypeTransformer? _instance;
}

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