mirror of
https://github.com/immich-app/immich.git
synced 2026-07-22 21:34:19 +03:00
Compare commits
63 Commits
feat/nativ
...
refactor/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f0c662965 | ||
|
|
b9f6c4aaf2 | ||
|
|
23778551f7 | ||
|
|
457da17c5d | ||
|
|
12f741d911 | ||
|
|
a0c7b81142 | ||
|
|
20997823d8 | ||
|
|
8a875f5978 | ||
|
|
8435d219e4 | ||
|
|
8bbc252703 | ||
|
|
2d3cf8ffaa | ||
|
|
33cba2f9af | ||
|
|
75053ebfba | ||
|
|
ba9ec7c80e | ||
|
|
99370b2ac4 | ||
|
|
0f5bf6071f | ||
|
|
998fd5f6a8 | ||
|
|
f413c446e3 | ||
|
|
c9539a4aa8 | ||
|
|
a560e08693 | ||
|
|
49c0baf076 | ||
|
|
3485dbffda | ||
|
|
e508781125 | ||
|
|
a30ebab70c | ||
|
|
5d7283e44d | ||
|
|
73329a8ce8 | ||
|
|
d5adfb97dd | ||
|
|
b70cb58bc8 | ||
|
|
943bfafb7a | ||
|
|
ee4bd3f833 | ||
|
|
7a7303aceb | ||
|
|
0a7c067b20 | ||
|
|
c8de56c422 | ||
|
|
0e4572c37b | ||
|
|
14a2776453 | ||
|
|
59bc81423c | ||
|
|
e6fff3b15e | ||
|
|
755df23b22 | ||
|
|
899f547053 | ||
|
|
4938fd4c89 | ||
|
|
e81a79224c | ||
|
|
7b0f58b2c1 | ||
|
|
ae8398ffe4 | ||
|
|
aa08dad1f5 | ||
|
|
ce022233ae | ||
|
|
1b4d41324e | ||
|
|
8365cdd59e | ||
|
|
1cec7021e8 | ||
|
|
1b0f473457 | ||
|
|
e918658cc1 | ||
|
|
2e587fc7e8 | ||
|
|
df970da59e | ||
|
|
8061a2e5ff | ||
|
|
77091b0107 | ||
|
|
4c754f2999 | ||
|
|
6fa3f2feac | ||
|
|
3f6897ef80 | ||
|
|
89d0a9d59f | ||
|
|
45a6ea84af | ||
|
|
bf64f3867b | ||
|
|
4a4d468aa2 | ||
|
|
522def1ed6 | ||
|
|
3adc3920fb |
@@ -83,7 +83,7 @@
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:4": {
|
||||
// https://github.com/devcontainers/features/issues/1466
|
||||
"moby": false
|
||||
}
|
||||
|
||||
2
.github/workflows/auto-close.yml
vendored
2
.github/workflows/auto-close.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
outputs:
|
||||
uses_template: ${{ steps.check.outputs.uses_template }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
sparse-checkout: .github/pull_request_template.md
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
68
.github/workflows/build-mobile.yml
vendored
68
.github/workflows/build-mobile.yml
vendored
@@ -65,7 +65,6 @@ jobs:
|
||||
filters: |
|
||||
mobile:
|
||||
- 'mobile/**'
|
||||
- 'native/**'
|
||||
force-filters: |
|
||||
- '.github/workflows/build-mobile.yml'
|
||||
force-events: 'workflow_call,workflow_dispatch'
|
||||
@@ -88,7 +87,7 @@ jobs:
|
||||
permission-contents: read
|
||||
permission-pull-requests: write
|
||||
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
@@ -107,14 +106,14 @@ jobs:
|
||||
working-directory: ./mobile
|
||||
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
|
||||
|
||||
- uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
|
||||
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: Restore Gradle Cache
|
||||
id: cache-gradle-restore
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -155,59 +154,6 @@ 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
|
||||
@@ -238,7 +184,7 @@ jobs:
|
||||
|
||||
- name: Save Gradle Cache
|
||||
id: cache-gradle-save
|
||||
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
if: github.ref == 'refs/heads/main'
|
||||
with:
|
||||
path: |
|
||||
@@ -255,7 +201,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-15
|
||||
runs-on: macos-26
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
@@ -269,7 +215,7 @@ jobs:
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
persist-credentials: false
|
||||
@@ -295,7 +241,7 @@ jobs:
|
||||
run: flutter build ios --config-only --no-codesign
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
|
||||
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
bundler-cache: true
|
||||
|
||||
2
.github/workflows/cache-cleanup.yml
vendored
2
.github/workflows/cache-cleanup.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
permission-actions: write
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
6
.github/workflows/check-openapi.yml
vendored
6
.github/workflows/check-openapi.yml
vendored
@@ -20,12 +20,12 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
uses: oasdiff/oasdiff-action/breaking@291b6594ce617f22f49af5898ee038ed0b46e8fe # v0.1.5
|
||||
uses: oasdiff/oasdiff-action/breaking@024f6c399f9a21ada1addb0f9a36ce1bfac995f1 # v0.1.6
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
4
.github/workflows/cli.yml
vendored
4
.github/workflows/cli.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
2
.github/workflows/close-duplicates.yml
vendored
2
.github/workflows/close-duplicates.yml
vendored
@@ -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:e3fe456f346b02c2f479462100763aee3ba8245768dfeac3c4262acb068e5c62
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:d603f5618c1fc9f2b4d506ffed54ba6bcfec83d56b893b5fa8b3f899a402fa0a
|
||||
outputs:
|
||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||
steps:
|
||||
|
||||
8
.github/workflows/codeql-analysis.yml
vendored
8
.github/workflows/codeql-analysis.yml
vendored
@@ -51,14 +51,14 @@ jobs:
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
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@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
|
||||
# ℹ️ 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@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
2
.github/workflows/docs-build.yml
vendored
2
.github/workflows/docs-build.yml
vendored
@@ -62,7 +62,7 @@ jobs:
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
2
.github/workflows/docs-deploy.yml
vendored
2
.github/workflows/docs-deploy.yml
vendored
@@ -137,7 +137,7 @@ jobs:
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
2
.github/workflows/docs-destroy.yml
vendored
2
.github/workflows/docs-destroy.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
2
.github/workflows/fdroid.yml
vendored
2
.github/workflows/fdroid.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout pubspec for versionCode
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
persist-credentials: false
|
||||
|
||||
2
.github/workflows/fix-format.yml
vendored
2
.github/workflows/fix-format.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
persist-credentials: true
|
||||
|
||||
6
.github/workflows/prepare-release.yml
vendored
6
.github/workflows/prepare-release.yml
vendored
@@ -61,7 +61,7 @@ jobs:
|
||||
permission-contents: write
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
persist-credentials: true
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
permission-actions: read
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
with:
|
||||
draft: true
|
||||
prerelease: ${{ needs.bump_version.outputs.rc }}
|
||||
|
||||
2
.github/workflows/sdk.yml
vendored
2
.github/workflows/sdk.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
3
.github/workflows/static_analysis.yml
vendored
3
.github/workflows/static_analysis.yml
vendored
@@ -35,7 +35,6 @@ jobs:
|
||||
mobile:
|
||||
- 'mobile/**'
|
||||
- 'i18n/en.json'
|
||||
- 'native/**'
|
||||
force-filters: |
|
||||
- '.github/workflows/static_analysis.yml'
|
||||
force-events: 'workflow_dispatch,release'
|
||||
@@ -59,7 +58,7 @@ jobs:
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
81
.github/workflows/test.yml
vendored
81
.github/workflows/test.yml
vendored
@@ -59,10 +59,6 @@ jobs:
|
||||
- 'mise.toml'
|
||||
mobile:
|
||||
- 'mobile/**'
|
||||
- 'native/**'
|
||||
- 'mise.toml'
|
||||
native:
|
||||
- 'native/**'
|
||||
- 'mise.toml'
|
||||
machine-learning:
|
||||
- 'machine-learning/**'
|
||||
@@ -73,45 +69,6 @@ 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
|
||||
@@ -128,7 +85,7 @@ jobs:
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -157,7 +114,7 @@ jobs:
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -189,7 +146,7 @@ jobs:
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -221,7 +178,7 @@ jobs:
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -266,7 +223,7 @@ jobs:
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -305,7 +262,7 @@ jobs:
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -334,7 +291,7 @@ jobs:
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -385,7 +342,7 @@ jobs:
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -418,7 +375,7 @@ jobs:
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
@@ -455,7 +412,7 @@ jobs:
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
@@ -465,7 +422,7 @@ jobs:
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -533,7 +490,7 @@ jobs:
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
@@ -543,7 +500,7 @@ jobs:
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -641,7 +598,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
permission-contents: read
|
||||
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -680,7 +637,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
permission-contents: read
|
||||
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -712,7 +669,7 @@ jobs:
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -742,7 +699,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
permission-contents: read
|
||||
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -765,7 +722,7 @@ jobs:
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -824,7 +781,7 @@ jobs:
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
38
.vscode/settings.json
vendored
38
.vscode/settings.json
vendored
@@ -29,9 +29,6 @@
|
||||
"editor.formatOnSave": true,
|
||||
"tailwindCSS.lint.suggestCanonicalClasses": "ignore"
|
||||
},
|
||||
"svelte.plugin.svelte.compilerWarnings": {
|
||||
"state_referenced_locally": "ignore"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
@@ -43,37 +40,40 @@
|
||||
"eslint.useFlatConfig": true,
|
||||
"eslint.validate": ["javascript", "typescript", "svelte"],
|
||||
"eslint.workingDirectories": [
|
||||
{ "directory": "cli", "changeProcessCWD": true },
|
||||
{ "directory": "e2e", "changeProcessCWD": true },
|
||||
{ "directory": "server", "changeProcessCWD": true },
|
||||
{ "directory": "web", "changeProcessCWD": true }
|
||||
{ "changeProcessCWD": true, "directory": "cli" },
|
||||
{ "changeProcessCWD": true, "directory": "e2e" },
|
||||
{ "changeProcessCWD": true, "directory": "server" },
|
||||
{ "changeProcessCWD": true, "directory": "web" }
|
||||
],
|
||||
"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",
|
||||
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
|
||||
"*.js": "${capture}.spec.js,${capture}.mock.js",
|
||||
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
|
||||
"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": {
|
||||
"**/node_modules": true,
|
||||
"**/.svelte-kit": true,
|
||||
"**/build": true,
|
||||
"**/dist": true,
|
||||
"**/.svelte-kit": true,
|
||||
"**/node_modules": 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
|
||||
}
|
||||
|
||||
@@ -5,4 +5,3 @@
|
||||
/machine-learning/ @mertalev
|
||||
/e2e/ @danieldietzler
|
||||
/mobile/ @shenlong-tanwen @santoshakil @agg23
|
||||
/native/ @santoshakil @mertalev
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tools]
|
||||
terragrunt = "1.0.3"
|
||||
opentofu = "1.11.6"
|
||||
terragrunt = "1.1.1"
|
||||
opentofu = "1.12.4"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
run = "terragrunt hclfmt"
|
||||
|
||||
@@ -2,37 +2,35 @@
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||
version = "4.52.7"
|
||||
constraints = "4.52.7"
|
||||
version = "4.52.8"
|
||||
constraints = "4.52.8"
|
||||
hashes = [
|
||||
"h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
|
||||
"h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
|
||||
"h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
|
||||
"h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
|
||||
"h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
|
||||
"h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
|
||||
"h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
|
||||
"h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
|
||||
"h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
|
||||
"h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
|
||||
"h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
|
||||
"h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
|
||||
"h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
|
||||
"h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
|
||||
"zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
|
||||
"zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
|
||||
"zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
|
||||
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
|
||||
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
|
||||
"h1:2uxVaMaGPDJljXqYzuD6xtyfOKdNudtGIgawRTkFJIs=",
|
||||
"h1:34HL1GDpqYdn2iEC+85pCi+NddZf30qH3fJ8y3P6CAM=",
|
||||
"h1:4Z+uD+YY/7HrqdypM7DyUK+RtWepoLBc9fqtRQYZx4I=",
|
||||
"h1:8Ss6wlJL2BHvkIZU1iaHvyUntqNhPFZ0fl6mjsxhctc=",
|
||||
"h1:AYeWWMTWaNg01N3hivxr/6YlG41j1RietMnft9vLjUI=",
|
||||
"h1:BGRNOzo8NUgbx5RwpWkWmr38f/s3txb7mzQhqfM5blI=",
|
||||
"h1:EEXRMdC8bPlpLuOU8u+dE8mFJe3BpquFdH6vXcKseY4=",
|
||||
"h1:ENp29e6O6OnaHJlbtmRlyjlAmm3Wt4mGctWUpBY3d0g=",
|
||||
"h1:IAtBAbGVe1npj/n1NvCIZkSYGl7qaPYT+FnVdZ2CZ00=",
|
||||
"h1:QRGGKngrxLORr4CImKXLDDJL5s67zUNBDAgk6FIIFNk=",
|
||||
"h1:bNql6cC+MfDbaJK3q7a8C1m3A21o7e5mrjBIGtk0RdI=",
|
||||
"h1:gmNv+PEOkYDdu+c15bfpcoAE3EI7pgmov9RMf3GZLsI=",
|
||||
"h1:yieZ7NWRYQcLYHUevzDDrrmDkoQICM+3ZeX4i/mw9ME=",
|
||||
"zh:08b305329a680a9213b2d8e642fbce7e4d97a524b1d2cef59e190ba9d678c477",
|
||||
"zh:47975bd711ee18a46e589822171fa87474a552b332bfc8dea8fd1a64504eed8d",
|
||||
"zh:5640d0d226bbafff3395542456c29feb942ca9c55ac01b4245a34c0590a33363",
|
||||
"zh:5b0ad839fafba938c60a95d6b4a865843643591e97573ed2b9c4af3754064f74",
|
||||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
|
||||
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
|
||||
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
|
||||
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
|
||||
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
|
||||
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
|
||||
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
|
||||
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
|
||||
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
|
||||
"zh:93a9bc1139f5c02a44fdbf51fb2ce0891e2a42033b66febb928ccf72e870408f",
|
||||
"zh:977f75cdf365686aa16ae02dcc0fc1769bae6f86be6e165393756c940bfb4af0",
|
||||
"zh:9afcda2660b3dc6ee6329acea532a719225bd1f6cf46f695feb2d77160847c1b",
|
||||
"zh:9e3da67b1b05b03d1f0c18b8677edf3797815b5dd49629f10eb2f457dccbed30",
|
||||
"zh:b8d7da230f5266367c1b6b1cf31aa39087e231a87d55b71bb9d5c854ca1fcfe6",
|
||||
"zh:cc96e7cd7350456b7a11a53d1c76c73a542de8359f9750f637ff865e8e77be91",
|
||||
"zh:da1f58d067def243047bb7178cb197e2b9c3a791a9eb380ad92b73290615ae29",
|
||||
"zh:ed5f3e1f59a338bcdce0a2537a8a2f299cfe18c168626799b2a9c97af3d8d3cb",
|
||||
"zh:f16cc31f73a58a26ffe2d93223eddb7c340623a4e2ef38c6091766fd611e3e11",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ terraform {
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "4.52.7"
|
||||
version = "4.52.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,37 +2,35 @@
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||
version = "4.52.7"
|
||||
constraints = "4.52.7"
|
||||
version = "4.52.8"
|
||||
constraints = "4.52.8"
|
||||
hashes = [
|
||||
"h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
|
||||
"h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
|
||||
"h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
|
||||
"h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
|
||||
"h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
|
||||
"h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
|
||||
"h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
|
||||
"h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
|
||||
"h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
|
||||
"h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
|
||||
"h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
|
||||
"h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
|
||||
"h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
|
||||
"h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
|
||||
"zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
|
||||
"zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
|
||||
"zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
|
||||
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
|
||||
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
|
||||
"h1:2uxVaMaGPDJljXqYzuD6xtyfOKdNudtGIgawRTkFJIs=",
|
||||
"h1:34HL1GDpqYdn2iEC+85pCi+NddZf30qH3fJ8y3P6CAM=",
|
||||
"h1:4Z+uD+YY/7HrqdypM7DyUK+RtWepoLBc9fqtRQYZx4I=",
|
||||
"h1:8Ss6wlJL2BHvkIZU1iaHvyUntqNhPFZ0fl6mjsxhctc=",
|
||||
"h1:AYeWWMTWaNg01N3hivxr/6YlG41j1RietMnft9vLjUI=",
|
||||
"h1:BGRNOzo8NUgbx5RwpWkWmr38f/s3txb7mzQhqfM5blI=",
|
||||
"h1:EEXRMdC8bPlpLuOU8u+dE8mFJe3BpquFdH6vXcKseY4=",
|
||||
"h1:ENp29e6O6OnaHJlbtmRlyjlAmm3Wt4mGctWUpBY3d0g=",
|
||||
"h1:IAtBAbGVe1npj/n1NvCIZkSYGl7qaPYT+FnVdZ2CZ00=",
|
||||
"h1:QRGGKngrxLORr4CImKXLDDJL5s67zUNBDAgk6FIIFNk=",
|
||||
"h1:bNql6cC+MfDbaJK3q7a8C1m3A21o7e5mrjBIGtk0RdI=",
|
||||
"h1:gmNv+PEOkYDdu+c15bfpcoAE3EI7pgmov9RMf3GZLsI=",
|
||||
"h1:yieZ7NWRYQcLYHUevzDDrrmDkoQICM+3ZeX4i/mw9ME=",
|
||||
"zh:08b305329a680a9213b2d8e642fbce7e4d97a524b1d2cef59e190ba9d678c477",
|
||||
"zh:47975bd711ee18a46e589822171fa87474a552b332bfc8dea8fd1a64504eed8d",
|
||||
"zh:5640d0d226bbafff3395542456c29feb942ca9c55ac01b4245a34c0590a33363",
|
||||
"zh:5b0ad839fafba938c60a95d6b4a865843643591e97573ed2b9c4af3754064f74",
|
||||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
|
||||
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
|
||||
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
|
||||
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
|
||||
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
|
||||
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
|
||||
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
|
||||
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
|
||||
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
|
||||
"zh:93a9bc1139f5c02a44fdbf51fb2ce0891e2a42033b66febb928ccf72e870408f",
|
||||
"zh:977f75cdf365686aa16ae02dcc0fc1769bae6f86be6e165393756c940bfb4af0",
|
||||
"zh:9afcda2660b3dc6ee6329acea532a719225bd1f6cf46f695feb2d77160847c1b",
|
||||
"zh:9e3da67b1b05b03d1f0c18b8677edf3797815b5dd49629f10eb2f457dccbed30",
|
||||
"zh:b8d7da230f5266367c1b6b1cf31aa39087e231a87d55b71bb9d5c854ca1fcfe6",
|
||||
"zh:cc96e7cd7350456b7a11a53d1c76c73a542de8359f9750f637ff865e8e77be91",
|
||||
"zh:da1f58d067def243047bb7178cb197e2b9c3a791a9eb380ad92b73290615ae29",
|
||||
"zh:ed5f3e1f59a338bcdce0a2537a8a2f299cfe18c168626799b2a9c97af3d8d3cb",
|
||||
"zh:f16cc31f73a58a26ffe2d93223eddb7c340623a4e2ef38c6091766fd611e3e11",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ terraform {
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "4.52.7"
|
||||
version = "4.52.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
|
||||
image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
|
||||
image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
@@ -97,7 +97,7 @@ services:
|
||||
command: ['./run.sh', '-disable-reporting']
|
||||
ports:
|
||||
- 3000:3000
|
||||
image: grafana/grafana:12.4.4-ubuntu@sha256:df2e7ef5f32f771794cf76bad5f2bceac227036460a2cc269a9045e5662abc58
|
||||
image: grafana/grafana:12.4.5-ubuntu@sha256:00396460e499415c828b7c298f19287c8a0f95e72412ee37ac11723655c2d6b9
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
|
||||
image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
|
||||
user: '1000:1000'
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
|
||||
image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.9 KiB |
@@ -35,6 +35,7 @@ Found Admin:
|
||||
- Email=admin@example.com
|
||||
- Name=Immich Admin
|
||||
? Please choose a new password (optional) immich-is-cool
|
||||
? Invalidate existing sessions? Yes
|
||||
The admin password has been updated.
|
||||
```
|
||||
|
||||
|
||||
@@ -28,4 +28,4 @@ run = "prettier --write ."
|
||||
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
|
||||
|
||||
[tools]
|
||||
wrangler = "4.110.0"
|
||||
wrangler = "4.111.0"
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"@docusaurus/tsconfig": "^3.10.0",
|
||||
"@docusaurus/types": "^3.10.0",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "^6.0.0"
|
||||
"@typescript/native": "npm:typescript@^7.0.2",
|
||||
"typescript": "npm:@typescript/typescript6@^6.0.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -45,7 +45,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich-e2e-redis
|
||||
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
|
||||
image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
||||
@@ -41,9 +41,18 @@ 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/prevent-abbreviations': 'off',
|
||||
'unicorn/name-replacements': 'off',
|
||||
'unicorn/filename-case': 'off',
|
||||
'unicorn/no-null': 'off',
|
||||
'unicorn/prefer-top-level-await': 'off',
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^64.0.0",
|
||||
"eslint-plugin-unicorn": "^72.0.0",
|
||||
"exiftool-vendored": "^35.0.0",
|
||||
"globals": "^17.0.0",
|
||||
"luxon": "^3.4.4",
|
||||
@@ -51,7 +51,8 @@
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"supertest": "^7.0.0",
|
||||
"typescript": "^6.0.0",
|
||||
"@typescript/native": "npm:typescript@^7.0.2",
|
||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"utimes": "^5.2.1",
|
||||
"vitest": "^4.0.0"
|
||||
|
||||
@@ -118,7 +118,7 @@ describe('/admin/database-backups', () => {
|
||||
|
||||
expect(status).toBe(201);
|
||||
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
cookie = headers['set-cookie'][0].split(';', 1)[0];
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
@@ -224,7 +224,7 @@ describe('/admin/database-backups', () => {
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
cookie = headers['set-cookie'][0].split(';', 1)[0];
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
@@ -295,7 +295,7 @@ describe('/admin/database-backups', () => {
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
cookie = headers['set-cookie'][0].split(';', 1)[0];
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('/admin/maintenance', () => {
|
||||
|
||||
expect(status).toBe(201);
|
||||
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
cookie = headers['set-cookie'][0].split(';', 1)[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('=')[1].trim(),
|
||||
token: cookie!.split('=', 2)[1].trim(),
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
expect(body).toEqual(
|
||||
|
||||
@@ -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(';')[0].split('='))
|
||||
?.map((cookie) => cookie.split(';', 1)[0].split('='))
|
||||
?.find(([name]) => name === 'immich_maintenance_token');
|
||||
|
||||
expect(cookie).toBeTruthy();
|
||||
|
||||
@@ -120,6 +120,7 @@ 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) }),
|
||||
|
||||
@@ -781,7 +781,7 @@ describe('/asset', () => {
|
||||
exifImageWidth: 4032,
|
||||
exifImageHeight: 3024,
|
||||
latitude: 41.2203,
|
||||
longitude: -96.071_625,
|
||||
longitude: -96.071625,
|
||||
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.613_24,
|
||||
latitude: 36.61324,
|
||||
lensModel: '18.3mm F2.8',
|
||||
longitude: -121.897_85,
|
||||
longitude: -121.89785,
|
||||
make: 'RICOH IMAGING COMPANY, LTD.',
|
||||
model: 'RICOH GR III',
|
||||
orientation: '1',
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('/map', () => {
|
||||
country: 'United States of America',
|
||||
id: expect.any(String),
|
||||
lat: expect.closeTo(39.115),
|
||||
lon: expect.closeTo(-108.400_968),
|
||||
lon: expect.closeTo(-108.400968),
|
||||
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.071_625),
|
||||
lon: expect.closeTo(-96.071625),
|
||||
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.400_968),
|
||||
lon: expect.closeTo(-108.400968),
|
||||
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.071_625),
|
||||
lon: expect.closeTo(-96.071625),
|
||||
state: 'Nebraska',
|
||||
},
|
||||
]);
|
||||
@@ -188,20 +188,20 @@ describe('/map', () => {
|
||||
const reverseGeocodeTestCases = [
|
||||
{
|
||||
name: 'Vaucluse',
|
||||
lat: -33.858_977_058_663_13,
|
||||
lon: 151.278_490_730_270_48,
|
||||
lat: -33.85897705866313,
|
||||
lon: 151.27849073027048,
|
||||
results: [{ city: 'Vaucluse', state: 'New South Wales', country: 'Australia' }],
|
||||
},
|
||||
{
|
||||
name: 'Ravenhall',
|
||||
lat: -37.765_732_399_174_75,
|
||||
lon: 144.752_453_164_883_3,
|
||||
lat: -37.76573239917475,
|
||||
lon: 144.7524531648833,
|
||||
results: [{ city: 'Ravenhall', state: 'Victoria', country: 'Australia' }],
|
||||
},
|
||||
{
|
||||
name: 'Scarborough',
|
||||
lat: -31.894_346_156_789_997,
|
||||
lon: 115.757_617_103_904_64,
|
||||
lat: -31.894346156789997,
|
||||
lon: 115.75761710390464,
|
||||
results: [{ city: 'Scarborough', state: 'Western Australia', country: 'Australia' }],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.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: 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: 38.9711, longitude: -109.7137 }, // thompson springs
|
||||
{ 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: 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: 0, longitude: 0 }, // null island
|
||||
];
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('/search', () => {
|
||||
);
|
||||
|
||||
await Promise.all(updates);
|
||||
for (const [i] of coordinates.entries()) {
|
||||
for (const i of coordinates.keys()) {
|
||||
await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: assets[i].id });
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -119,7 +119,9 @@ describe(`immich upload`, () => {
|
||||
const baseDir = `/tmp/upload/`;
|
||||
|
||||
const testPaths = Object.keys(files).map((filePath) => `${baseDir}/${filePath}`);
|
||||
testPaths.map((filePath) => utils.createImageFile(filePath));
|
||||
for (const filePath of testPaths) {
|
||||
utils.createImageFile(filePath);
|
||||
}
|
||||
|
||||
const commandLine = paths.map((argument) => `${baseDir}/${argument}`);
|
||||
|
||||
@@ -135,7 +137,9 @@ describe(`immich upload`, () => {
|
||||
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||
expect(assets.total).toBe(expectedCount);
|
||||
|
||||
testPaths.map((filePath) => utils.removeImageFile(filePath));
|
||||
for (const filePath of testPaths) {
|
||||
utils.removeImageFile(filePath);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -646,7 +650,7 @@ describe(`immich upload`, () => {
|
||||
]);
|
||||
|
||||
expect(stdout).toBe('');
|
||||
expect(stderr).toEqual(`error: option '-n, --dry-run' cannot be used with option '-h, --skip-hash'`);
|
||||
expect(stderr).toEqual(`error: option '-n, --dry-run' cannot be used with option '--skip-hash'`);
|
||||
expect(exitCode).not.toBe(0);
|
||||
|
||||
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||
|
||||
@@ -65,6 +65,10 @@ describe(`immich-admin`, () => {
|
||||
child.stdout.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
if (data.includes('Please choose a new password (optional)')) {
|
||||
child.stdin.write('\n');
|
||||
}
|
||||
|
||||
if (data.includes('Invalidate existing sessions?')) {
|
||||
child.stdin.end('\n');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 & seedNumber; // Convert to 32bit integer
|
||||
seedNumber &= seedNumber; // Convert to 32bit integer
|
||||
}
|
||||
return randomImage(new SeededRandom(Math.abs(seedNumber)), { width, height });
|
||||
};
|
||||
|
||||
@@ -64,7 +64,7 @@ export function generateAsset(
|
||||
const asset: MockTimelineAsset = {
|
||||
id: assetId,
|
||||
ownerId,
|
||||
ratio: Number.parseFloat(ratio.split(':')[0]) / Number.parseFloat(ratio.split(':')[1]),
|
||||
ratio: Number(ratio.split(':', 1)[0]) / Number(ratio.split(':', 2)[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()].flat();
|
||||
const allAssets = buckets.values().toArray().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));
|
||||
|
||||
@@ -172,11 +172,7 @@ function shouldIncludeAsset(
|
||||
if (isArchived !== undefined && actuallyArchived !== isArchived) {
|
||||
return false;
|
||||
}
|
||||
if (isFavorite !== undefined && actuallyFavorited !== isFavorite) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return isFavorite === undefined || actuallyFavorited === isFavorite;
|
||||
}
|
||||
/**
|
||||
* Get summary for all buckets (mimics getTimeBuckets API)
|
||||
@@ -361,7 +357,7 @@ export function getAsset(
|
||||
owner?: UserResponseDto,
|
||||
): AssetResponseDto | undefined {
|
||||
// Search through all buckets for the asset
|
||||
const buckets = [...timelineData.buckets.values()];
|
||||
const buckets = timelineData.buckets.values().toArray();
|
||||
for (const assets of buckets) {
|
||||
const asset = assets.find((a) => a.id === assetId);
|
||||
if (asset) {
|
||||
@@ -395,7 +391,7 @@ export function getAlbum(
|
||||
|
||||
// Get the actual asset objects from the timeline data
|
||||
const albumAssets: AssetResponseDto[] = [];
|
||||
const allAssets = [...timelineData.buckets.values()].flat();
|
||||
const allAssets = timelineData.buckets.values().toArray().flat();
|
||||
|
||||
for (const assetId of album.assetIds) {
|
||||
const assetConfig = allAssets.find((a) => a.id === assetId);
|
||||
|
||||
@@ -143,7 +143,7 @@ export function validateTimelineConfig(config: TimelineConfig): void {
|
||||
}
|
||||
|
||||
// Validate seed if provided
|
||||
if (config.seed !== undefined && (config.seed < 0 || !Number.isInteger(config.seed))) {
|
||||
if (config.seed !== undefined && (config.seed < 0 || !Number.isSafeInteger(config.seed))) {
|
||||
throw new Error('Seed must be a non-negative integer');
|
||||
}
|
||||
|
||||
|
||||
@@ -153,11 +153,8 @@ 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') {
|
||||
|
||||
@@ -40,7 +40,8 @@ export const setupTimelineMockApiRoutes = async (
|
||||
contentType: 'application/json',
|
||||
json: getTimeBuckets(timelineRestData, isTrashed, isArchived, isFavorite, albumId, changes),
|
||||
});
|
||||
} else if (pathname === '/api/timeline/bucket') {
|
||||
}
|
||||
if (pathname === '/api/timeline/bucket') {
|
||||
const timeBucket = url.searchParams.get('timeBucket');
|
||||
if (!timeBucket) {
|
||||
return route.continue();
|
||||
|
||||
@@ -38,7 +38,7 @@ test.describe('asset-viewer', () => {
|
||||
assets.push(...timeBucket);
|
||||
}
|
||||
for (const yearMonth of timelineRestData.buckets.keys()) {
|
||||
const [year, month] = yearMonth.split('-');
|
||||
const [year, month] = yearMonth.split('-', 2);
|
||||
yearMonths.push(`${year}-${Number(month)}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable unicorn/no-this-outside-of-class */
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { expect, Page } from '@playwright/test';
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ test.describe('Timeline', () => {
|
||||
assets.push(...timeBucket);
|
||||
}
|
||||
for (const yearMonth of timelineRestData.buckets.keys()) {
|
||||
const [year, month] = yearMonth.split('-');
|
||||
const [year, month] = yearMonth.split('-', 2);
|
||||
yearMonths.push(`${year}-${Number(month)}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ export const sleep = (ms: number) => {
|
||||
};
|
||||
|
||||
export const padYearMonth = (yearMonth: string) => {
|
||||
const [year, month] = yearMonth.split('-');
|
||||
const [year, month] = yearMonth.split('-', 2);
|
||||
return `${year}-${month.padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
@@ -72,6 +72,7 @@ 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) {
|
||||
@@ -151,6 +152,7 @@ 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);
|
||||
@@ -177,6 +179,7 @@ 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);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable unicorn/no-top-level-assignment-in-function */
|
||||
import {
|
||||
AssetMediaCreateDto,
|
||||
AssetMediaResponseDto,
|
||||
@@ -177,7 +178,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',
|
||||
@@ -304,7 +305,7 @@ export const utils = {
|
||||
},
|
||||
|
||||
adminSetup: async (options?: AdminSetupOptions) => {
|
||||
options = options || { onboarding: true };
|
||||
options ||= { onboarding: true };
|
||||
|
||||
await signUpAdmin({ signUpDto: signupDto.admin });
|
||||
const response = await login({ loginCredentialDto: loginDto.admin });
|
||||
@@ -545,6 +546,7 @@ 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;
|
||||
@@ -674,7 +676,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;
|
||||
},
|
||||
|
||||
@@ -706,6 +708,7 @@ export const utils = {
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line unicorn/no-top-level-side-effects
|
||||
utils.initSdk();
|
||||
|
||||
if (!existsSync(`${testAssetDir}/albums`)) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"target": "es2023",
|
||||
"lib": ["dom", "ESNext"],
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"incremental": true,
|
||||
|
||||
170
mise.lock
170
mise.lock
@@ -94,6 +94,23 @@ url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets
|
||||
version = "7.1.3-6"
|
||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
|
||||
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
||||
|
||||
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
|
||||
version = "7.1.3-6"
|
||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz"
|
||||
|
||||
@@ -112,17 +129,12 @@ version = "7.1.3-6"
|
||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
|
||||
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"]
|
||||
checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889"
|
||||
|
||||
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
|
||||
version = "7.1.3-6"
|
||||
@@ -133,18 +145,6 @@ checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_win64-clang-gpl.zip"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409036094"
|
||||
|
||||
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
|
||||
version = "7.1.3-6"
|
||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"]
|
||||
checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889"
|
||||
|
||||
[[tools."github:webassembly/binaryen"]]
|
||||
version = "version_124"
|
||||
backend = "github:webassembly/binaryen"
|
||||
@@ -248,119 +248,119 @@ version = "7.5.0"
|
||||
backend = "npm:oazapfts"
|
||||
|
||||
[[tools.opentofu]]
|
||||
version = "1.11.6"
|
||||
version = "1.12.4"
|
||||
backend = "aqua:opentofu/opentofu"
|
||||
|
||||
[tools.opentofu."platforms.linux-arm64"]
|
||||
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382"
|
||||
checksum = "sha256:dc7bfcd93ce9795a86c58fbf71efd013c39dcd1febb13c9cd3555c43b9c2403a"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646678"
|
||||
|
||||
[tools.opentofu."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382"
|
||||
checksum = "sha256:dc7bfcd93ce9795a86c58fbf71efd013c39dcd1febb13c9cd3555c43b9c2403a"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646678"
|
||||
|
||||
[tools.opentofu."platforms.linux-x64"]
|
||||
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401"
|
||||
checksum = "sha256:81836d0f12b4fe9013b85586349f993def9429b6383bb77cdd6c2f3a9d9aac24"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646677"
|
||||
|
||||
[tools.opentofu."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401"
|
||||
checksum = "sha256:81836d0f12b4fe9013b85586349f993def9429b6383bb77cdd6c2f3a9d9aac24"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646677"
|
||||
|
||||
[tools.opentofu."platforms.macos-arm64"]
|
||||
checksum = "sha256:62d7fa8539e13b444827aa0a3b90c5972da5c47e8f8882d9dcf2e430e78840c1"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536399"
|
||||
checksum = "sha256:7c06e4390d9ccd467773e37ff1c3d833c7ca0c24742cd9e9ad47284bea472247"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646544"
|
||||
|
||||
[tools.opentofu."platforms.macos-x64"]
|
||||
checksum = "sha256:1408cdef1c380f914565e6b4bb70794c6b163f195fcb233357f3d6c5745906b6"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536384"
|
||||
checksum = "sha256:ead1d2ce643addb4ffeb93240b9377ae1c2fd793a6bd22d65922ac37adfdf546"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646688"
|
||||
|
||||
[tools.opentofu."platforms.windows-x64"]
|
||||
checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c7077367e"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536406"
|
||||
checksum = "sha256:a4d86a07755c8d151f20f945e6cfb5b40deeed942af36a9bd385c5c2e965d5dd"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_windows_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646543"
|
||||
|
||||
[[tools.pnpm]]
|
||||
version = "11.11.0"
|
||||
version = "11.13.1"
|
||||
backend = "aqua:pnpm/pnpm"
|
||||
|
||||
[tools.pnpm."platforms.linux-arm64"]
|
||||
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"
|
||||
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"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.linux-arm64-musl"]
|
||||
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"
|
||||
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"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.linux-x64"]
|
||||
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"
|
||||
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"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.linux-x64-musl"]
|
||||
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"
|
||||
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"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.macos-arm64"]
|
||||
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"
|
||||
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"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.windows-x64"]
|
||||
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"
|
||||
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"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[[tools.terragrunt]]
|
||||
version = "1.0.3"
|
||||
version = "1.1.1"
|
||||
backend = "aqua:gruntwork-io/terragrunt"
|
||||
|
||||
[tools.terragrunt."platforms.linux-arm64"]
|
||||
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253"
|
||||
checksum = "sha256:a374a7993ff3d99665a7e014007d3647ec7f0465c9d55c85e9f94c932e73cea2"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786148"
|
||||
|
||||
[tools.terragrunt."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253"
|
||||
checksum = "sha256:a374a7993ff3d99665a7e014007d3647ec7f0465c9d55c85e9f94c932e73cea2"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786148"
|
||||
|
||||
[tools.terragrunt."platforms.linux-x64"]
|
||||
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196"
|
||||
checksum = "sha256:ce90077ac31ef17a2ba10d11d45f36c6501997a8f4f79d703bb7daba37032f53"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785968"
|
||||
|
||||
[tools.terragrunt."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196"
|
||||
checksum = "sha256:ce90077ac31ef17a2ba10d11d45f36c6501997a8f4f79d703bb7daba37032f53"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785968"
|
||||
|
||||
[tools.terragrunt."platforms.macos-arm64"]
|
||||
checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654084"
|
||||
checksum = "sha256:9ec8f678b9ae6c81d5d9d77b94cbf6349ce639d5938694b4adc9dea73e416794"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785628"
|
||||
|
||||
[tools.terragrunt."platforms.macos-x64"]
|
||||
checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406653970"
|
||||
checksum = "sha256:73e768a69fa44a60f9d9174f54aaf179327e08706f633c9c79d5f5c9622c91c2"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785520"
|
||||
|
||||
[tools.terragrunt."platforms.windows-x64"]
|
||||
checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_windows_amd64.exe.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654412"
|
||||
checksum = "sha256:dd50a324691e072a3ac879e9ec43d6310e2936c929fda658eb2811fefcc75115"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_windows_amd64.exe.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786421"
|
||||
|
||||
10
mise.toml
10
mise.toml
@@ -1,4 +1,4 @@
|
||||
experimental_monorepo_root = true
|
||||
monorepo_root = true
|
||||
|
||||
[monorepo]
|
||||
config_roots = [
|
||||
@@ -12,14 +12,13 @@ config_roots = [
|
||||
"docs",
|
||||
".github",
|
||||
"machine-learning",
|
||||
"native",
|
||||
]
|
||||
|
||||
[tools]
|
||||
node = "24.15.0"
|
||||
pnpm = "11.11.0"
|
||||
terragrunt = "1.0.3"
|
||||
opentofu = "1.11.6"
|
||||
pnpm = "11.13.1"
|
||||
terragrunt = "1.1.1"
|
||||
opentofu = "1.12.4"
|
||||
"npm:oazapfts" = "7.5.0"
|
||||
"github:extism/cli" = "1.6.3"
|
||||
"github:webassembly/binaryen" = "version_124"
|
||||
@@ -36,7 +35,6 @@ macos-x64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz" }
|
||||
macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" }
|
||||
|
||||
[settings]
|
||||
experimental = true
|
||||
pin = true
|
||||
lockfile = true
|
||||
|
||||
|
||||
13
mobile/android/app/CMakeLists.txt
Normal file
13
mobile/android/app/CMakeLists.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
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)
|
||||
@@ -77,6 +77,11 @@ android {
|
||||
}
|
||||
namespace 'app.alextran.immich'
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path "CMakeLists.txt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
|
||||
52
mobile/android/app/src/main/cpp/native_buffer.c
Normal file
52
mobile/android/app/src/main/cpp/native_buffer.c
Normal file
@@ -0,0 +1,52 @@
|
||||
#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;
|
||||
}
|
||||
173
mobile/android/app/src/main/cpp/native_image.c
Normal file
173
mobile/android/app/src/main/cpp/native_image.c
Normal file
@@ -0,0 +1,173 @@
|
||||
#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;
|
||||
}
|
||||
@@ -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)
|
||||
}, 5000)
|
||||
}, 15000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ const val INITIAL_BUFFER_SIZE = 32 * 1024
|
||||
|
||||
object NativeBuffer {
|
||||
init {
|
||||
System.loadLibrary("immich_core_ffi")
|
||||
System.loadLibrary("native_buffer")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@@ -21,6 +21,9 @@ 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
|
||||
}
|
||||
@@ -32,12 +35,8 @@ class NativeByteBuffer(initialCapacity: Int) {
|
||||
|
||||
inline fun ensureHeadroom() {
|
||||
if (offset == 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
|
||||
capacity *= 2
|
||||
pointer = NativeBuffer.realloc(pointer, capacity)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,27 +4,26 @@ import android.graphics.Bitmap
|
||||
|
||||
object NativeImage {
|
||||
init {
|
||||
System.loadLibrary("immich_core_ffi")
|
||||
// rotate() is compiled into the native_buffer shared lib (which already links jnigraphics).
|
||||
System.loadLibrary("native_buffer")
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates an RGBA_8888 [bitmap] and returns a malloc'd buffer, or 0 on failure.
|
||||
* [outInfo] receives width, height, and row bytes.
|
||||
* 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.
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun rotate(bitmap: Bitmap, orientation: Int, outInfo: IntArray): Long
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
@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
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ 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
|
||||
@@ -61,6 +62,11 @@ 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()) {
|
||||
@@ -77,6 +83,10 @@ 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!!)
|
||||
|
||||
@@ -143,11 +153,17 @@ 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 {
|
||||
if (flutterApi != null) {
|
||||
flutterApi?.cancel {
|
||||
complete(Result.failure())
|
||||
}
|
||||
api.cancel {
|
||||
complete(Result.failure())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ 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.*
|
||||
@@ -50,23 +49,17 @@ 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.
|
||||
val source1010102 =
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && config == Bitmap.Config.RGBA_1010102
|
||||
if (source1010102) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && config == Bitmap.Config.RGBA_1010102) {
|
||||
val info = IntArray(3)
|
||||
val pointer = NativeImage.convert1010102(this, info)
|
||||
if (pointer != 0L) {
|
||||
recycle()
|
||||
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)
|
||||
}
|
||||
}
|
||||
return mapOf(
|
||||
"pointer" to pointer,
|
||||
"width" to info[0].toLong(),
|
||||
"height" to info[1].toLong(),
|
||||
"rowBytes" to info[2].toLong()
|
||||
)
|
||||
}
|
||||
// native convert declined (OOM/lock) -> fall through to the Skia copy path below.
|
||||
}
|
||||
@@ -77,16 +70,12 @@ fun Bitmap.toNativeBuffer(): Map<String, Long> {
|
||||
try {
|
||||
val buffer = NativeBuffer.wrap(pointer, size)
|
||||
bitmap.copyPixelsToBuffer(buffer)
|
||||
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)
|
||||
}
|
||||
}
|
||||
return mapOf(
|
||||
"pointer" to pointer,
|
||||
"width" to bitmap.width.toLong(),
|
||||
"height" to bitmap.height.toLong(),
|
||||
"rowBytes" to (bitmap.width * 4).toLong()
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
NativeBuffer.free(pointer)
|
||||
throw e
|
||||
@@ -112,14 +101,12 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
|
||||
threadPool.execute {
|
||||
try {
|
||||
val bytes = Base64.getDecoder().decode(thumbhash)
|
||||
val info = IntArray(3)
|
||||
val pointer = NativeImage.thumbhash(bytes, info)
|
||||
require(pointer != 0L) { "Invalid thumbhash" }
|
||||
val image = ThumbHash.thumbHashToRGBA(bytes)
|
||||
val res = mapOf(
|
||||
"pointer" to pointer,
|
||||
"width" to info[0].toLong(),
|
||||
"height" to info[1].toLong(),
|
||||
"rowBytes" to info[2].toLong()
|
||||
"pointer" to image.pointer,
|
||||
"width" to image.width.toLong(),
|
||||
"height" to image.height.toLong(),
|
||||
"rowBytes" to (image.width * 4).toLong()
|
||||
)
|
||||
callback(Result.success(res))
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ void main() {
|
||||
|
||||
void sendUser(SyncStream stream, String id, String name) {
|
||||
stream.send(
|
||||
type: SyncEntityType.userV1.value,
|
||||
type: SyncEntityType.userV1.toString(),
|
||||
data: SyncUserV1(
|
||||
id: id,
|
||||
name: name,
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
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)');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -36,6 +36,7 @@
|
||||
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 */; };
|
||||
@@ -130,6 +131,7 @@
|
||||
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 */
|
||||
@@ -355,6 +357,7 @@
|
||||
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */,
|
||||
FE5499F12F1197D8006016CB /* LocalImages.g.swift */,
|
||||
FE5499F22F1197D8006016CB /* RemoteImages.g.swift */,
|
||||
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */,
|
||||
);
|
||||
path = Images;
|
||||
sourceTree = "<group>";
|
||||
@@ -583,7 +586,7 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n";
|
||||
shellScript = "/bin/bash \"$SRCROOT/scripts/xcode_flutter_build.sh\"\n";
|
||||
};
|
||||
BAEA01ACA3F5C9CD3D732370 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
@@ -625,6 +628,7 @@
|
||||
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 */,
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git",
|
||||
"state" : {
|
||||
"revision" : "60d9bb85c94ce6e7fc4406cd32529fd12bdb7809",
|
||||
"version" : "6.14.0"
|
||||
"revision" : "84a79bc375a301169390ac110c868f06c857b83f",
|
||||
"version" : "6.27.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -43,15 +43,16 @@ class ConnectivityApiImpl: ConnectivityApi {
|
||||
capabilities.append(.vpn)
|
||||
}
|
||||
|
||||
// Determine if connection is unmetered:
|
||||
// - Must be on WiFi (not cellular)
|
||||
// - Must not be expensive (rules out personal hotspot)
|
||||
// - Must not be constrained (Low Data Mode)
|
||||
// Note: VPN over cellular should still be considered metered
|
||||
// Determine if connection is unmetered from the OS metered flags rather than
|
||||
// the interface type, so wired ethernet (iPhone USB adapters, Apple Silicon
|
||||
// Macs) is treated as unmetered like Wi-Fi:
|
||||
// - Not on cellular
|
||||
// - Not expensive (also rules out cellular and personal hotspot)
|
||||
// - Not constrained (Low Data Mode)
|
||||
// Note: VPN over cellular stays metered because the path is still expensive.
|
||||
let isOnCellular = path.usesInterfaceType(.cellular)
|
||||
let isOnWifi = path.usesInterfaceType(.wifi)
|
||||
|
||||
if isOnWifi && !isOnCellular && !path.isExpensive && !path.isConstrained {
|
||||
|
||||
if !isOnCellular && !path.isExpensive && !path.isConstrained {
|
||||
capabilities.append(.unmetered)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -38,26 +38,15 @@ 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: "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)))
|
||||
}
|
||||
guard let data = Data(base64Encoded: thumbhash)
|
||||
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
|
||||
|
||||
let (width, height, pointer) = thumbHashToRGBA(hash: data)
|
||||
completion(.success([
|
||||
"pointer": Int64(Int(bitPattern: pointer)),
|
||||
"width": Int64(info[0]),
|
||||
"height": Int64(info[1]),
|
||||
"rowBytes": Int64(info[2])
|
||||
"pointer": Int64(Int(bitPattern: pointer.baseAddress)),
|
||||
"width": Int64(width),
|
||||
"height": Int64(height),
|
||||
"rowBytes": Int64(width * 4)
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
225
mobile/ios/Runner/Images/Thumbhash.swift
Normal file
225
mobile/ios/Runner/Images/Thumbhash.swift
Normal file
@@ -0,0 +1,225 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -203,7 +203,10 @@ class ImmichAPI {
|
||||
|
||||
func fetchMemory(for date: Date) async throws -> [MemoryResult] {
|
||||
// get URL
|
||||
let memoryParams = [URLQueryItem(name: "for", value: date.ISO8601Format())]
|
||||
let localDay = date.formatted(
|
||||
Date.ISO8601FormatStyle(timeZone: .current).year().month().day().dateSeparator(.dash)
|
||||
)
|
||||
let memoryParams = [URLQueryItem(name: "for", value: localDay)]
|
||||
guard
|
||||
let searchURL = buildRequestURL(
|
||||
serverConfig: serverConfig,
|
||||
|
||||
@@ -34,13 +34,6 @@ 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"
|
||||
@@ -109,8 +102,6 @@ end
|
||||
)
|
||||
app_identifier = base_bundle_id
|
||||
|
||||
trust_mise_configs
|
||||
|
||||
# Set version number if provided
|
||||
if version_number
|
||||
increment_version_number(version_number: version_number)
|
||||
@@ -267,8 +258,6 @@ 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)
|
||||
@@ -295,7 +284,6 @@ 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: {
|
||||
|
||||
45
mobile/ios/scripts/xcode_flutter_build.sh
Executable file
45
mobile/ios/scripts/xcode_flutter_build.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/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]}"
|
||||
@@ -24,6 +24,12 @@ class ViewerReloadAssetEvent extends Event {
|
||||
const ViewerReloadAssetEvent();
|
||||
}
|
||||
|
||||
class ViewerStackAssetDeletedEvent extends Event {
|
||||
final int stackIndex;
|
||||
|
||||
const ViewerStackAssetDeletedEvent({required this.stackIndex});
|
||||
}
|
||||
|
||||
// Multi-Select Events
|
||||
class MultiSelectToggleEvent extends Event {
|
||||
final bool isEnabled;
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/colors.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/models/value_codec.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
enum SettingsKey<T> {
|
||||
// Theme
|
||||
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
|
||||
themeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
|
||||
themePrimaryColor<ImmichColorPreset>(codec: EnumCodec(ImmichColorPreset.values)),
|
||||
themeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
|
||||
themeDynamic<bool>(),
|
||||
themeColorfulInterface<bool>(),
|
||||
|
||||
@@ -27,13 +26,13 @@ enum SettingsKey<T> {
|
||||
|
||||
// Network
|
||||
networkAutoEndpointSwitching<bool>(),
|
||||
networkExternalEndpointList<List<String>>(codec: ListCodec(PrimitiveCodec.string)),
|
||||
networkCustomHeaders<Map<String, String>>(codec: MapCodec(PrimitiveCodec.string, PrimitiveCodec.string)),
|
||||
networkPreferredWifiName<String?>(),
|
||||
networkLocalEndpoint<String?>(),
|
||||
networkExternalEndpointList<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
|
||||
networkCustomHeaders<Map<String, String>>(codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string)),
|
||||
|
||||
// Album
|
||||
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
|
||||
albumSortMode<AlbumSortMode>(codec: EnumCodec(AlbumSortMode.values)),
|
||||
albumIsReverse<bool>(),
|
||||
albumIsGrid<bool>(),
|
||||
|
||||
@@ -47,195 +46,45 @@ enum SettingsKey<T> {
|
||||
|
||||
// Timeline
|
||||
timelineTilesPerRow<int>(),
|
||||
timelineGroupAssetsBy<GroupAssetsBy>(codec: _EnumCodec(GroupAssetsBy.values)),
|
||||
timelineGroupAssetsBy<GroupAssetsBy>(codec: EnumCodec(GroupAssetsBy.values)),
|
||||
timelineStorageIndicator<bool>(),
|
||||
|
||||
// Log
|
||||
logLevel<LogLevel>(codec: _EnumCodec(LogLevel.values)),
|
||||
logLevel<LogLevel>(codec: EnumCodec(LogLevel.values)),
|
||||
|
||||
// Map
|
||||
mapShowFavoriteOnly<bool>(),
|
||||
mapRelativeDate<int>(),
|
||||
mapIncludeArchived<bool>(),
|
||||
mapThemeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
|
||||
mapThemeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
|
||||
mapWithPartners<bool>(),
|
||||
|
||||
// Cleanup
|
||||
cleanupKeepFavorites<bool>(),
|
||||
cleanupKeepMediaType<AssetKeepType>(codec: _EnumCodec(AssetKeepType.values)),
|
||||
cleanupKeepAlbumIds<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
|
||||
cleanupKeepMediaType<AssetKeepType>(codec: EnumCodec(AssetKeepType.values)),
|
||||
cleanupKeepAlbumIds<List<String>>(codec: ListCodec(PrimitiveCodec.string)),
|
||||
cleanupCutoffDaysAgo<int>(),
|
||||
cleanupDefaultsInitialized<bool>(),
|
||||
|
||||
// Share
|
||||
shareFileType<ShareAssetType>(codec: _EnumCodec(ShareAssetType.values)),
|
||||
shareFileType<ShareAssetType>(codec: EnumCodec(ShareAssetType.values)),
|
||||
|
||||
// Slideshow
|
||||
slideshowRepeat<bool>(),
|
||||
slideshowDuration<int>(),
|
||||
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
|
||||
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values)),
|
||||
slideshowLook<SlideshowLook>(codec: EnumCodec(SlideshowLook.values)),
|
||||
slideshowDirection<SlideshowDirection>(codec: EnumCodec(SlideshowDirection.values)),
|
||||
|
||||
// Feature message
|
||||
featureMessageSeenRelease<SemVer>(codec: _SemVerCodec());
|
||||
featureMessageSeenRelease<SemVer>(codec: SemVerCodec());
|
||||
|
||||
final _SettingsCodec<T>? _codecOverride;
|
||||
final ValueCodec<T>? _codecOverride;
|
||||
|
||||
const SettingsKey({_SettingsCodec<T>? codec}) : _codecOverride = codec;
|
||||
const SettingsKey({ValueCodec<T>? codec}) : _codecOverride = codec;
|
||||
|
||||
_SettingsCodec<T> get _codec => _codecOverride ?? _SettingsCodec.forType(T);
|
||||
ValueCodec<T> get _codec => _codecOverride ?? ValueCodec.forType(T);
|
||||
|
||||
String encode(T value) => _codec.encode(value);
|
||||
|
||||
T decode(String raw) => _codec.decode(raw);
|
||||
}
|
||||
|
||||
sealed class _SettingsCodec<T> {
|
||||
const _SettingsCodec();
|
||||
|
||||
String encode(T value);
|
||||
T decode(String raw);
|
||||
|
||||
static final Map<Type, _SettingsCodec<Object>> _primitives = {
|
||||
..._register<int>(_PrimitiveCodec.integer),
|
||||
..._register<double>(_PrimitiveCodec.real),
|
||||
..._register<bool>(_PrimitiveCodec.boolean),
|
||||
..._register<String>(_PrimitiveCodec.string),
|
||||
..._register<DateTime>(const _DateTimeCodec()),
|
||||
};
|
||||
|
||||
static Map<Type, _SettingsCodec<Object>> _register<T>(_SettingsCodec<Object> codec) => {
|
||||
T: codec,
|
||||
// Reifies the nullable type T so it can be used as a key in the _primitives map
|
||||
_typeOf<T?>(): codec,
|
||||
};
|
||||
|
||||
static Type _typeOf<T>() => T;
|
||||
|
||||
static _SettingsCodec<T> forType<T>(Type runtimeType) {
|
||||
final codec = _primitives[runtimeType];
|
||||
if (codec == null) {
|
||||
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the SettingsKey.');
|
||||
}
|
||||
return codec as _SettingsCodec<T>;
|
||||
}
|
||||
}
|
||||
|
||||
final class _EnumCodec<T extends Enum> extends _SettingsCodec<T> {
|
||||
final List<T> values;
|
||||
|
||||
const _EnumCodec(this.values);
|
||||
|
||||
@override
|
||||
String encode(T value) => value.name;
|
||||
|
||||
@override
|
||||
T decode(String raw) => values.firstWhere((v) => v.name == raw);
|
||||
}
|
||||
|
||||
final class _DateTimeCodec extends _SettingsCodec<DateTime> {
|
||||
const _DateTimeCodec();
|
||||
|
||||
@override
|
||||
String encode(DateTime value) => value.toIso8601String();
|
||||
|
||||
@override
|
||||
DateTime decode(String raw) => DateTime.parse(raw);
|
||||
}
|
||||
|
||||
final class _SemVerCodec extends _SettingsCodec<SemVer> {
|
||||
const _SemVerCodec();
|
||||
|
||||
@override
|
||||
String encode(SemVer value) => value.toString();
|
||||
|
||||
@override
|
||||
SemVer decode(String raw) => SemVer.fromString(raw);
|
||||
}
|
||||
|
||||
final class _MapCodec<K extends Object, V extends Object> extends _SettingsCodec<Map<K, V>> {
|
||||
final _SettingsCodec<K> _keyCodec;
|
||||
final _SettingsCodec<V> _valueCodec;
|
||||
|
||||
const _MapCodec(this._keyCodec, this._valueCodec);
|
||||
|
||||
@override
|
||||
String encode(Map<K, V> value) {
|
||||
final entries = <String, String>{};
|
||||
value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v));
|
||||
return jsonEncode(entries);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<K, V> decode(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) {
|
||||
return {};
|
||||
}
|
||||
final result = <K, V>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final rawKey = entry.key;
|
||||
final rawValue = entry.value;
|
||||
if (rawKey is! String || rawValue is! String) {
|
||||
return {};
|
||||
}
|
||||
final k = _keyCodec.decode(rawKey);
|
||||
final v = _valueCodec.decode(rawValue);
|
||||
result[k] = v;
|
||||
}
|
||||
return result;
|
||||
} on FormatException {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class _ListCodec<T extends Object> extends _SettingsCodec<List<T>> {
|
||||
final _SettingsCodec<T> _elementCodec;
|
||||
|
||||
const _ListCodec(this._elementCodec);
|
||||
|
||||
@override
|
||||
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
|
||||
|
||||
@override
|
||||
List<T> decode(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) {
|
||||
return [];
|
||||
}
|
||||
final result = <T>[];
|
||||
for (final item in decoded) {
|
||||
if (item is! String) {
|
||||
return [];
|
||||
}
|
||||
final element = _elementCodec.decode(item);
|
||||
result.add(element);
|
||||
}
|
||||
return result;
|
||||
} on FormatException {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class _PrimitiveCodec<T extends Object> extends _SettingsCodec<T> {
|
||||
final T Function(String) _parse;
|
||||
|
||||
const _PrimitiveCodec._(this._parse);
|
||||
|
||||
@override
|
||||
String encode(T value) => value.toString();
|
||||
|
||||
@override
|
||||
T decode(String raw) => _parse(raw);
|
||||
|
||||
static const integer = _PrimitiveCodec<int>._(int.parse);
|
||||
static const real = _PrimitiveCodec<double>._(double.parse);
|
||||
static const boolean = _PrimitiveCodec<bool>._(bool.parse);
|
||||
static const string = _PrimitiveCodec<String>._(_identity);
|
||||
|
||||
static String _identity(String s) => s;
|
||||
}
|
||||
|
||||
153
mobile/lib/domain/models/value_codec.dart
Normal file
153
mobile/lib/domain/models/value_codec.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
sealed class ValueCodec<T> {
|
||||
const ValueCodec();
|
||||
|
||||
String encode(T value);
|
||||
T decode(String raw);
|
||||
|
||||
static final Map<Type, ValueCodec<Object>> _primitives = {
|
||||
..._register<int>(PrimitiveCodec.integer),
|
||||
..._register<double>(PrimitiveCodec.real),
|
||||
..._register<bool>(PrimitiveCodec.boolean),
|
||||
..._register<String>(PrimitiveCodec.string),
|
||||
..._register<DateTime>(const DateTimeCodec()),
|
||||
};
|
||||
|
||||
static Map<Type, ValueCodec<Object>> _register<T>(ValueCodec<Object> codec) => {
|
||||
T: codec,
|
||||
// Reifies the nullable type T so it can be used as a key in the _primitives map
|
||||
_typeOf<T?>(): codec,
|
||||
};
|
||||
|
||||
static Type _typeOf<T>() => T;
|
||||
|
||||
static ValueCodec<T> forType<T>(Type runtimeType) {
|
||||
final codec = _primitives[runtimeType];
|
||||
if (codec == null) {
|
||||
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the key.');
|
||||
}
|
||||
return codec as ValueCodec<T>;
|
||||
}
|
||||
}
|
||||
|
||||
final class EnumCodec<T extends Enum> extends ValueCodec<T> {
|
||||
final List<T> values;
|
||||
|
||||
const EnumCodec(this.values);
|
||||
|
||||
@override
|
||||
String encode(T value) => value.name;
|
||||
|
||||
@override
|
||||
T decode(String raw) => values.firstWhere((v) => v.name == raw);
|
||||
}
|
||||
|
||||
final class DateTimeCodec extends ValueCodec<DateTime> {
|
||||
const DateTimeCodec();
|
||||
|
||||
@override
|
||||
String encode(DateTime value) => value.toIso8601String();
|
||||
|
||||
@override
|
||||
DateTime decode(String raw) => DateTime.parse(raw);
|
||||
}
|
||||
|
||||
final class SemVerCodec extends ValueCodec<SemVer> {
|
||||
const SemVerCodec();
|
||||
|
||||
@override
|
||||
String encode(SemVer value) => value.toString();
|
||||
|
||||
@override
|
||||
SemVer decode(String raw) => SemVer.fromString(raw);
|
||||
}
|
||||
|
||||
final class MapCodec<K extends Object, V extends Object> extends ValueCodec<Map<K, V>> {
|
||||
final ValueCodec<K> _keyCodec;
|
||||
final ValueCodec<V> _valueCodec;
|
||||
|
||||
const MapCodec(this._keyCodec, this._valueCodec);
|
||||
|
||||
@override
|
||||
String encode(Map<K, V> value) {
|
||||
final entries = <String, String>{};
|
||||
value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v));
|
||||
return jsonEncode(entries);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<K, V> decode(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) {
|
||||
return {};
|
||||
}
|
||||
final result = <K, V>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final rawKey = entry.key;
|
||||
final rawValue = entry.value;
|
||||
if (rawKey is! String || rawValue is! String) {
|
||||
continue;
|
||||
}
|
||||
final k = _keyCodec.decode(rawKey);
|
||||
final v = _valueCodec.decode(rawValue);
|
||||
result[k] = v;
|
||||
}
|
||||
return result;
|
||||
} on FormatException {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class ListCodec<T extends Object> extends ValueCodec<List<T>> {
|
||||
final ValueCodec<T> _elementCodec;
|
||||
|
||||
const ListCodec(this._elementCodec);
|
||||
|
||||
@override
|
||||
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
|
||||
|
||||
@override
|
||||
List<T> decode(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) {
|
||||
return [];
|
||||
}
|
||||
final result = <T>[];
|
||||
for (final item in decoded) {
|
||||
if (item is! String) {
|
||||
return [];
|
||||
}
|
||||
final element = _elementCodec.decode(item);
|
||||
result.add(element);
|
||||
}
|
||||
return result;
|
||||
} on FormatException {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class PrimitiveCodec<T extends Object> extends ValueCodec<T> {
|
||||
final T Function(String) _parse;
|
||||
|
||||
const PrimitiveCodec._(this._parse);
|
||||
|
||||
@override
|
||||
String encode(T value) => value.toString();
|
||||
|
||||
@override
|
||||
T decode(String raw) => _parse(raw);
|
||||
|
||||
static const integer = PrimitiveCodec<int>._(int.parse);
|
||||
static const real = PrimitiveCodec<double>._(double.parse);
|
||||
static const boolean = PrimitiveCodec<bool>._(bool.parse);
|
||||
static const string = PrimitiveCodec<String>._(_identity);
|
||||
|
||||
static String _identity(String s) => s;
|
||||
}
|
||||
@@ -159,8 +159,8 @@ class RemoteAlbumService {
|
||||
return updatedAlbum;
|
||||
}
|
||||
|
||||
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
|
||||
return _repository.getDateRange(albumId);
|
||||
Stream<(DateTime, DateTime)> watchDateRange(String albumId) {
|
||||
return _repository.watchDateRange(albumId);
|
||||
}
|
||||
|
||||
Future<List<UserDto>> getSharedUsers(String albumId) {
|
||||
@@ -175,12 +175,12 @@ class RemoteAlbumService {
|
||||
return _repository.getAssets(albumId);
|
||||
}
|
||||
|
||||
Future<int> addAssets({required String albumId, required List<String> assetIds}) async {
|
||||
Future<({int added, int failed})> addAssets({required String albumId, required List<String> assetIds}) async {
|
||||
final album = await _albumApiRepository.addAssets(albumId, assetIds);
|
||||
|
||||
await _repository.addAssets(albumId, album.added);
|
||||
|
||||
return album.added.length;
|
||||
return (added: album.added.length, failed: album.failed.length);
|
||||
}
|
||||
|
||||
/// !TODO The name here is not clear as we have addAssets method above,
|
||||
@@ -196,7 +196,7 @@ class RemoteAlbumService {
|
||||
}) async {
|
||||
int addedCount = 0;
|
||||
if (candidates.remoteAssetIds.isNotEmpty) {
|
||||
addedCount += await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds);
|
||||
addedCount += (await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds)).added;
|
||||
}
|
||||
if (candidates.localAssetsToUpload.isNotEmpty) {
|
||||
addedCount += await _uploadAndAddLocals(
|
||||
|
||||
@@ -328,8 +328,6 @@ class SyncStreamService {
|
||||
return _syncStreamRepository.updateAssetOcrV1(data.cast());
|
||||
case SyncEntityType.assetOcrDeleteV1:
|
||||
return _syncStreamRepository.deleteAssetOcrV1(data.cast());
|
||||
default:
|
||||
_logger.warning("Unknown sync data type: $type");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ extension on api.AssetVisibility {
|
||||
api.AssetVisibility.hidden => AssetVisibility.hidden,
|
||||
api.AssetVisibility.archive => AssetVisibility.archive,
|
||||
api.AssetVisibility.locked => AssetVisibility.locked,
|
||||
_ => AssetVisibility.timeline,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,6 +67,5 @@ 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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
abstract class CachedKeyValueRepository<K extends Enum, S> {
|
||||
CachedKeyValueRepository(this._snapshot);
|
||||
|
||||
S _snapshot;
|
||||
S get snapshot => _snapshot;
|
||||
@protected
|
||||
set snapshot(S value) => _snapshot = value;
|
||||
|
||||
List<K> get keys;
|
||||
|
||||
Object decodeValue(K key, String raw);
|
||||
|
||||
S buildSnapshot(Map<K, Object?> overrides);
|
||||
|
||||
Selectable<({String key, String? value})> selectable();
|
||||
|
||||
Future<void> refresh() async => _snapshot = _build(await selectable().get());
|
||||
|
||||
Stream<S> watchSnapshot() => selectable().watch().map((rows) => _snapshot = _build(rows));
|
||||
|
||||
S _build(List<({String key, String? value})> rows) => buildSnapshot(
|
||||
rows.fold({}, (overrides, row) {
|
||||
final key = keys.firstWhereOrNull((key) => key.name == row.key);
|
||||
if (key == null) {
|
||||
return overrides;
|
||||
}
|
||||
|
||||
Object? decodedValue;
|
||||
if (row.value != null) {
|
||||
decodedValue = decodeValue(key, row.value!);
|
||||
}
|
||||
|
||||
return {...overrides, key: decodedValue};
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -210,17 +210,19 @@ 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(
|
||||
|
||||
@@ -217,7 +217,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||
});
|
||||
}
|
||||
|
||||
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
|
||||
Stream<(DateTime, DateTime)> watchDateRange(String albumId) {
|
||||
final query = _db.remoteAlbumAssetEntity.selectOnly()
|
||||
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
|
||||
..addColumns([_db.remoteAssetEntity.createdAt.min(), _db.remoteAssetEntity.createdAt.max()])
|
||||
@@ -229,7 +229,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||
final minDate = row.read(_db.remoteAssetEntity.createdAt.min());
|
||||
final maxDate = row.read(_db.remoteAssetEntity.createdAt.max());
|
||||
return (minDate ?? DateTime.now(), maxDate ?? DateTime.now());
|
||||
}).getSingle();
|
||||
}).watchSingle();
|
||||
}
|
||||
|
||||
Future<List<UserDto>> getSharedUsers(String albumId) async {
|
||||
|
||||
@@ -71,7 +71,13 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
|
||||
final query = _db.remoteAssetEntity.select()
|
||||
..where((row) => row.stackId.equals(stackId) & row.id.equals(asset.id).not())
|
||||
..where(
|
||||
(row) =>
|
||||
row.stackId.equals(stackId) &
|
||||
row.id.equals(asset.id).not() &
|
||||
row.deletedAt.isNull() &
|
||||
row.visibility.equalsValue(AssetVisibility.timeline),
|
||||
)
|
||||
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]);
|
||||
|
||||
return query.map((row) => row.toDto()).get();
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/settings_key.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/cached_key_value_repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class SettingsRepository extends DriftDatabaseRepository {
|
||||
class SettingsRepository extends CachedKeyValueRepository<SettingsKey, AppConfig> {
|
||||
final Drift _db;
|
||||
|
||||
SettingsRepository._(this._db) : super(_db);
|
||||
SettingsRepository._(this._db) : super(const .new());
|
||||
|
||||
static SettingsRepository? _instance;
|
||||
|
||||
@@ -19,9 +20,6 @@ class SettingsRepository extends DriftDatabaseRepository {
|
||||
return instance;
|
||||
}
|
||||
|
||||
AppConfig _appConfig = const .new();
|
||||
AppConfig get appConfig => _appConfig;
|
||||
|
||||
static Future<SettingsRepository> ensureInitialized(Drift db) async {
|
||||
if (_instance == null) {
|
||||
final instance = SettingsRepository._(db);
|
||||
@@ -31,7 +29,20 @@ class SettingsRepository extends DriftDatabaseRepository {
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
Future<void> refresh() async => _applyOverrides(await _db.select(_db.settingsEntity).get());
|
||||
@override
|
||||
List<SettingsKey> get keys => SettingsKey.values;
|
||||
|
||||
@override
|
||||
Object decodeValue(SettingsKey key, String raw) => key.decode(raw);
|
||||
|
||||
@override
|
||||
AppConfig buildSnapshot(Map<SettingsKey, Object?> overrides) => AppConfig.fromEntries(overrides);
|
||||
|
||||
@override
|
||||
Selectable<({String key, String? value})> selectable() =>
|
||||
_db.select(_db.settingsEntity).map((row) => (key: row.key, value: row.value));
|
||||
|
||||
AppConfig get appConfig => snapshot;
|
||||
|
||||
Future<void> clear(Iterable<SettingsKey> keys) async {
|
||||
if (keys.isEmpty) {
|
||||
@@ -41,13 +52,15 @@ class SettingsRepository extends DriftDatabaseRepository {
|
||||
final names = keys.map((key) => key.name).toList();
|
||||
await (_db.delete(_db.settingsEntity)..where((row) => row.key.isIn(names))).go();
|
||||
|
||||
var config = snapshot;
|
||||
for (final key in keys) {
|
||||
_appConfig = _appConfig.write(key, defaultConfig.read(key));
|
||||
config = config.write(key, defaultConfig.read(key));
|
||||
}
|
||||
snapshot = config;
|
||||
}
|
||||
|
||||
Future<void> write<T, U extends T>(SettingsKey<T> key, U value) async {
|
||||
if (value == _appConfig.read(key)) {
|
||||
if (value == snapshot.read(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,29 +78,8 @@ class SettingsRepository extends DriftDatabaseRepository {
|
||||
.insertOnConflictUpdate(
|
||||
SettingsEntityCompanion.insert(key: key.name, value: .new(resolvedValue), updatedAt: .new(DateTime.now())),
|
||||
);
|
||||
_appConfig = _appConfig.write(key, value);
|
||||
snapshot = snapshot.write(key, value);
|
||||
}
|
||||
|
||||
Stream<AppConfig> watchConfig() => _db.select(_db.settingsEntity).watch().map((rows) {
|
||||
_applyOverrides(rows);
|
||||
return _appConfig;
|
||||
});
|
||||
|
||||
void _applyOverrides(List<SettingsEntityData> rows) {
|
||||
_appConfig = AppConfig.fromEntries(
|
||||
rows.fold({}, (overrides, row) {
|
||||
final metadataKey = SettingsKey.values.firstWhereOrNull((key) => key.name == row.key);
|
||||
if (metadataKey == null) {
|
||||
return overrides;
|
||||
}
|
||||
|
||||
Object? decodedValue;
|
||||
if (row.value != null) {
|
||||
decodedValue = metadataKey.decode(row.value!);
|
||||
}
|
||||
|
||||
return {...overrides, metadataKey: decodedValue};
|
||||
}),
|
||||
);
|
||||
}
|
||||
Stream<AppConfig> watchConfig() => watchSnapshot();
|
||||
}
|
||||
|
||||
@@ -937,7 +937,6 @@ extension on AssetTypeEnum {
|
||||
AssetTypeEnum.VIDEO => AssetType.video,
|
||||
AssetTypeEnum.AUDIO => AssetType.audio,
|
||||
AssetTypeEnum.OTHER => AssetType.other,
|
||||
_ => throw Exception('Unknown AssetType value: $this'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -945,14 +944,12 @@ 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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -961,7 +958,6 @@ 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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -971,7 +967,6 @@ 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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -980,12 +975,11 @@ 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 == value);
|
||||
AvatarColor? toAvatarColor() => AvatarColor.values.firstWhereOrNull((c) => c.name == toString());
|
||||
}
|
||||
|
||||
extension on api.AssetEditAction {
|
||||
@@ -993,6 +987,5 @@ extension on api.AssetEditAction {
|
||||
api.AssetEditAction.crop => AssetEditAction.crop,
|
||||
api.AssetEditAction.rotate => AssetEditAction.rotate,
|
||||
api.AssetEditAction.mirror => AssetEditAction.mirror,
|
||||
_ => AssetEditAction.other,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/partner.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
@@ -33,7 +33,7 @@ class PartnerPage extends ConsumerWidget {
|
||||
title: Text(context.t.partners),
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
actions: const [ActionIconButtonWidget(action: PartnerAddAction())],
|
||||
actions: const [PartnerAddAction(display: ActionDisplay.iconButton)],
|
||||
),
|
||||
body: sharedByAsync.when(
|
||||
data: (partners) => PartnerSharedByList(partners: partners.toList(growable: false)),
|
||||
@@ -60,7 +60,7 @@ class _EmptyPartners extends StatelessWidget {
|
||||
),
|
||||
const Align(
|
||||
alignment: .center,
|
||||
child: ActionButtonWidget(action: PartnerAddAction()),
|
||||
child: PartnerAddAction(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -88,8 +88,10 @@ class PartnerSharedByList extends StatelessWidget {
|
||||
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
|
||||
title: Text(partner.name),
|
||||
subtitle: Text(partner.email),
|
||||
trailing: ActionIconButtonWidget(
|
||||
action: PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
|
||||
trailing: PartnerRemoveAction(
|
||||
sharedWithId: partner.id,
|
||||
partnerName: partner.name,
|
||||
display: ActionDisplay.iconButton,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,32 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:flutter/widgets.dart' hide Action;
|
||||
|
||||
class ActionScope {
|
||||
final BuildContext context;
|
||||
final WidgetRef ref;
|
||||
final UserDto authUser;
|
||||
enum ActionDisplay { button, iconButton, menuItem, columnButton }
|
||||
|
||||
const ActionScope({required this.context, required this.ref, required this.authUser});
|
||||
}
|
||||
|
||||
abstract class BaseAction {
|
||||
const BaseAction();
|
||||
|
||||
IconData get icon;
|
||||
|
||||
String label(ActionScope scope);
|
||||
|
||||
bool isVisible(ActionScope scope) => true;
|
||||
|
||||
Future<void> onAction(ActionScope scope);
|
||||
}
|
||||
|
||||
abstract class AssetAction<T extends BaseAsset> extends BaseAction {
|
||||
final Iterable<BaseAsset> assets;
|
||||
|
||||
const AssetAction({required this.assets});
|
||||
|
||||
Iterable<T> filter(ActionScope scope) => assets.whereType<T>();
|
||||
class Action {
|
||||
final IconData icon;
|
||||
final bool isVisible;
|
||||
final String label;
|
||||
final Future<void> Function() onAction;
|
||||
|
||||
const Action({
|
||||
required this.icon,
|
||||
this.isVisible = true,
|
||||
required this.label,
|
||||
required this.onAction,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,95 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/material.dart' hide Action;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/utils/error_handler.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class _ActionWidgetScope {
|
||||
final String label;
|
||||
final VoidCallback onAction;
|
||||
abstract class ActionWidget extends ConsumerWidget {
|
||||
final ActionDisplay display;
|
||||
|
||||
const _ActionWidgetScope({required this.label, required this.onAction});
|
||||
}
|
||||
const ActionWidget({super.key, this.display = ActionDisplay.button});
|
||||
|
||||
class _ActionWidget extends ConsumerWidget {
|
||||
final BaseAction action;
|
||||
final Widget Function(_ActionWidgetScope context) builder;
|
||||
|
||||
const _ActionWidget({required this.action, required this.builder});
|
||||
|
||||
Future<void> _onAction(ActionScope scope) async {
|
||||
try {
|
||||
await action.onAction(scope);
|
||||
} catch (error, stackTrace) {
|
||||
handleError(scope.context, error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
|
||||
}
|
||||
}
|
||||
Action resolve(BuildContext context, WidgetRef ref);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final authUser = ref.watch(currentUserProvider);
|
||||
if (authUser == null) {
|
||||
final action = resolve(context, ref);
|
||||
if (!action.isVisible) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final scope = ActionScope(context: context, ref: ref, authUser: authUser);
|
||||
if (!action.isVisible(scope)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return builder(.new(label: action.label(scope), onAction: () => _onAction(scope)));
|
||||
return switch (display) {
|
||||
ActionDisplay.button =>
|
||||
ImmichTextButton(labelText: action.label, icon: action.icon, onPressed: action.onAction),
|
||||
ActionDisplay.iconButton => ImmichIconButton(icon: action.icon, onPressed: action.onAction),
|
||||
ActionDisplay.menuItem =>
|
||||
ImmichMenuItem(icon: action.icon, label: action.label, onPressed: action.onAction),
|
||||
ActionDisplay.columnButton =>
|
||||
ImmichColumnButton(icon: action.icon, label: action.label, onPressed: action.onAction),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ActionIconButtonWidget extends StatelessWidget {
|
||||
final BaseAction action;
|
||||
final ImmichVariant variant;
|
||||
|
||||
const ActionIconButtonWidget({super.key, required this.action, this.variant = .ghost});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _ActionWidget(
|
||||
action: action,
|
||||
builder: (ctx) => ImmichIconButton(icon: action.icon, onPressed: ctx.onAction, variant: variant),
|
||||
);
|
||||
}
|
||||
|
||||
class ActionButtonWidget extends StatelessWidget {
|
||||
final BaseAction action;
|
||||
final ImmichVariant variant;
|
||||
|
||||
const ActionButtonWidget({super.key, required this.action, this.variant = .ghost});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _ActionWidget(
|
||||
action: action,
|
||||
builder: (ctx) =>
|
||||
ImmichTextButton(labelText: ctx.label, icon: action.icon, onPressed: ctx.onAction, variant: variant),
|
||||
);
|
||||
}
|
||||
|
||||
class ActionColumnButtonWidget extends StatelessWidget {
|
||||
final BaseAction action;
|
||||
|
||||
const ActionColumnButtonWidget({super.key, required this.action});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _ActionWidget(
|
||||
action: action,
|
||||
builder: (ctx) => ImmichColumnButton(icon: action.icon, label: ctx.label, onPressed: ctx.onAction),
|
||||
);
|
||||
}
|
||||
|
||||
class ActionMenuItemWidget extends StatelessWidget {
|
||||
final BaseAction action;
|
||||
|
||||
const ActionMenuItemWidget({super.key, required this.action});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _ActionWidget(
|
||||
action: action,
|
||||
builder: (ctx) => ImmichMenuItem(icon: action.icon, label: ctx.label, onPressed: ctx.onAction),
|
||||
);
|
||||
}
|
||||
|
||||
33
mobile/lib/presentation/actions/asset_action.dart
Normal file
33
mobile/lib/presentation/actions/asset_action.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
typedef AssetActionSelection = ({Set<BaseAsset> assets, void Function() clearSelect});
|
||||
|
||||
final assetsActionProvider = Provider.family<AssetActionSelection, ActionSource>((ref, source) {
|
||||
final assets = switch (source) {
|
||||
ActionSource.timeline => ref.watch(multiSelectProvider.select((s) => s.selectedAssets)),
|
||||
ActionSource.viewer => switch (ref.watch(assetViewerProvider.select((s) => s.currentAsset))) {
|
||||
BaseAsset asset => {asset},
|
||||
null => const <BaseAsset>{},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
assets: assets,
|
||||
clearSelect: () {
|
||||
if (source == ActionSource.timeline) {
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
abstract class AssetAction extends ActionWidget {
|
||||
final ActionSource source;
|
||||
|
||||
const AssetAction({super.key, super.display, this.source = ActionSource.timeline});
|
||||
}
|
||||
@@ -1,27 +1,30 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:flutter/material.dart' hide Action;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class AssetDebugAction extends AssetAction<BaseAsset> {
|
||||
const AssetDebugAction({required super.assets});
|
||||
class AssetDebugAction extends AssetAction {
|
||||
const AssetDebugAction({super.key, super.display, super.source});
|
||||
|
||||
@override
|
||||
IconData get icon => Icons.help_outline_rounded;
|
||||
Action resolve(BuildContext context, WidgetRef ref) {
|
||||
final (:assets, :clearSelect) = ref.watch(assetsActionProvider(source));
|
||||
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
|
||||
|
||||
@override
|
||||
String label(ActionScope scope) => scope.context.t.troubleshoot;
|
||||
|
||||
@override
|
||||
bool isVisible(ActionScope scope) =>
|
||||
assets.length == 1 && scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
|
||||
|
||||
@override
|
||||
Future<void> onAction(ActionScope scope) async =>
|
||||
unawaited(scope.context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
|
||||
return Action(
|
||||
icon: Icons.help_outline_rounded,
|
||||
isVisible: assets.length == 1 && advancedTroubleshooting,
|
||||
label: context.t.troubleshoot,
|
||||
onAction: () async {
|
||||
unawaited(context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
|
||||
clearSelect();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user