diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml
index bfbc7bd2e2..cc77bb3183 100644
--- a/.github/workflows/build-mobile.yml
+++ b/.github/workflows/build-mobile.yml
@@ -91,7 +91,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -159,14 +159,14 @@ jobs:
- name: Comment APK download link on PR
if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork }}
- uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
+ uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
APK_URL: ${{ steps.upload-apk.outputs.artifact-url }}
with:
- github-token: ${{ steps.token.outputs.token }}
- message-id: 'mobile-android-apk'
- message: |
+ id: mobile-android-apk
+ token: ${{ steps.token.outputs.token }}
+ body: |
đą **Android release APK (universal)** â `${{ env.HEAD_SHA }}`
Download: ${{ env.APK_URL }}
@@ -216,7 +216,7 @@ jobs:
persist-credentials: false
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -231,7 +231,7 @@ jobs:
run: mise //mobile:codegen:pigeon
- name: Setup Ruby
- uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
+ uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
with:
ruby-version: '3.3'
bundler-cache: true
@@ -288,7 +288,6 @@ jobs:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
ENVIRONMENT: ${{ inputs.environment || 'development' }}
- BUNDLE_ID_SUFFIX: ${{ inputs.environment == 'production' && '' || 'development' }}
GITHUB_REF: ${{ github.ref }}
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 120
FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 6
diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml
index 07c7505762..0ce156a0bc 100644
--- a/.github/workflows/check-openapi.yml
+++ b/.github/workflows/check-openapi.yml
@@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
- uses: oasdiff/oasdiff-action/breaking@26ccb332c67a45ca649de9faf60552ef1b8260d9 # v0.0.46
+ uses: oasdiff/oasdiff-action/breaking@6147a58e5d1249a12f42fc864ab791d571a30015 # v0.0.47
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml
index fd4b7f1abe..42adf5c72a 100644
--- a/.github/workflows/cli.yml
+++ b/.github/workflows/cli.yml
@@ -43,7 +43,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index f9e6dbfa2d..cebd9b1747 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
+ uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,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@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
+ uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
# âšī¸ 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
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
+ uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
category: '/language:${{matrix.language}}'
diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml
index a85435ea5a..23f16c4c47 100644
--- a/.github/workflows/docs-build.yml
+++ b/.github/workflows/docs-build.yml
@@ -66,7 +66,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml
index 083fa009eb..3b789e810d 100644
--- a/.github/workflows/docs-deploy.yml
+++ b/.github/workflows/docs-deploy.yml
@@ -131,7 +131,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -213,12 +213,11 @@ jobs:
run: 'mise run //deployment:tf apply'
- name: Comment
- uses: actions-cool/maintain-one-comment@909842216bc8e8658364c572ec52100f4c2cc50a # v3.3.0
+ uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
if: ${{ steps.parameters.outputs.event == 'pr' }}
with:
+ id: docs-pr-url
token: ${{ steps.token.outputs.token }}
number: ${{ fromJson(needs.checks.outputs.parameters).pr_number }}
body: |
đ Documentation deployed to [${{ steps.docs-output.outputs.subdomain }}](https://${{ steps.docs-output.outputs.subdomain }})
- emojis: 'rocket'
- body-include: ''
diff --git a/.github/workflows/docs-destroy.yml b/.github/workflows/docs-destroy.yml
index 4186438d43..b1d75f241c 100644
--- a/.github/workflows/docs-destroy.yml
+++ b/.github/workflows/docs-destroy.yml
@@ -29,7 +29,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -44,9 +44,8 @@ jobs:
run: 'mise run //deployment:tf destroy -- -refresh=false'
- name: Comment
- uses: actions-cool/maintain-one-comment@909842216bc8e8658364c572ec52100f4c2cc50a # v3.3.0
+ uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
with:
+ id: docs-pr-url
token: ${{ steps.token.outputs.token }}
- number: ${{ github.event.number }}
delete: true
- body-include: ''
diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml
index e718c13792..23f23c1f4c 100644
--- a/.github/workflows/fix-format.yml
+++ b/.github/workflows/fix-format.yml
@@ -28,7 +28,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
diff --git a/.github/workflows/merge-translations.yml b/.github/workflows/merge-translations.yml
index 685dfc6abe..ff00a16b6e 100644
--- a/.github/workflows/merge-translations.yml
+++ b/.github/workflows/merge-translations.yml
@@ -31,7 +31,7 @@ jobs:
- name: Generate a token
id: generate_token
if: ${{ inputs.skip != true }}
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
diff --git a/.github/workflows/org-zizmor.yml b/.github/workflows/org-zizmor.yml
index 8510fd85b4..050e69b496 100644
--- a/.github/workflows/org-zizmor.yml
+++ b/.github/workflows/org-zizmor.yml
@@ -13,3 +13,4 @@ jobs:
actions: read
contents: read
security-events: write
+ secrets: inherit
diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml
index d4fe794913..4f6a7dc75b 100644
--- a/.github/workflows/prepare-release.yml
+++ b/.github/workflows/prepare-release.yml
@@ -62,7 +62,7 @@ jobs:
ref: main
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -119,7 +119,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
diff --git a/.github/workflows/preview-label.yaml b/.github/workflows/preview-label.yaml
index f4a03c3013..94fb9f797c 100644
--- a/.github/workflows/preview-label.yaml
+++ b/.github/workflows/preview-label.yaml
@@ -19,11 +19,11 @@ jobs:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- - uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
+ - uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
with:
- github-token: ${{ steps.token.outputs.token }}
- message-id: 'preview-status'
- message: 'Deploying preview environment to https://pr-${{ github.event.pull_request.number }}.preview.internal.immich.build/'
+ id: preview-status
+ token: ${{ steps.token.outputs.token }}
+ body: 'Deploying preview environment to https://pr-${{ github.event.pull_request.number }}.preview.internal.immich.build/'
remove-label:
runs-on: ubuntu-latest
@@ -48,16 +48,16 @@ jobs:
name: 'preview'
})
- - uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
+ - uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
if: ${{ github.event.pull_request.head.repo.fork }}
with:
- github-token: ${{ steps.token.outputs.token }}
- message-id: 'preview-status'
- message: 'PRs from forks cannot have preview environments.'
+ id: preview-status
+ token: ${{ steps.token.outputs.token }}
+ body: 'PRs from forks cannot have preview environments.'
- - uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
+ - uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
- github-token: ${{ steps.token.outputs.token }}
- message-id: 'preview-status'
- message: 'Preview environment has been removed.'
+ id: preview-status
+ token: ${{ steps.token.outputs.token }}
+ body: 'Preview environment has been removed.'
diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml
index 9502d940ea..ce632fafb7 100644
--- a/.github/workflows/sdk.yml
+++ b/.github/workflows/sdk.yml
@@ -28,7 +28,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml
index 10642fbd11..d347317793 100644
--- a/.github/workflows/static_analysis.yml
+++ b/.github/workflows/static_analysis.yml
@@ -61,7 +61,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 323a572757..e16e6f059d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -30,25 +30,32 @@ jobs:
filters: |
i18n:
- 'i18n/**'
+ - 'mise.toml'
web:
- 'web/**'
- 'i18n/**'
- 'packages/sdk/**'
- 'pnpm-lock.yaml'
+ - 'mise.toml'
server:
- 'server/**'
- 'pnpm-lock.yaml'
+ - 'mise.toml'
cli:
- 'packages/cli/**'
- 'packages/sdk/**'
- 'pnpm-lock.yaml'
+ - 'mise.toml'
e2e:
- 'e2e/**'
- 'pnpm-lock.yaml'
+ - 'mise.toml'
mobile:
- 'mobile/**'
+ - 'mise.toml'
machine-learning:
- 'machine-learning/**'
+ - 'mise.toml'
.github:
- '.github/**'
force-filters: |
@@ -76,7 +83,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -107,7 +114,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -138,7 +145,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -182,7 +189,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -220,7 +227,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -248,7 +255,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -298,7 +305,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -331,7 +338,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -550,7 +557,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -587,7 +594,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -618,7 +625,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -669,7 +676,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -727,7 +734,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
diff --git a/deployment/mise.lock b/deployment/mise.lock
new file mode 100644
index 0000000000..fb7be27b99
--- /dev/null
+++ b/deployment/mise.lock
@@ -0,0 +1,65 @@
+# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
+
+[[tools.opentofu]]
+version = "1.11.6"
+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"
+
+[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"
+
+[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"
+
+[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"
+
+[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"
+
+[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"
+
+[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"
+
+[[tools.terragrunt]]
+version = "1.0.3"
+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"
+
+[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"
+
+[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"
+
+[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"
+
+[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"
+
+[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"
+
+[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"
diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md
index ca22c5ad34..0932fa2855 100644
--- a/docs/docs/install/environment-variables.md
+++ b/docs/docs/install/environment-variables.md
@@ -154,33 +154,33 @@ Redis (Sentinel) URL example JSON before encoding:
## Machine Learning
-| Variable | Description | Default | Containers |
-| :---------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----------------------------: | :--------------- |
-| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
-| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
-| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
-| `MACHINE_LEARNING_REQUEST_THREADS`\*1 | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning |
-| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
-| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
-| `MACHINE_LEARNING_WORKERS`\*2 | Number of worker processes to spawn | `1` | machine learning |
-| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`\*3 | HTTP Keep-alive time in seconds | `2` | machine learning |
-| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO) | machine learning |
-| `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Comma-separated list of (textual) CLIP model(s) to preload and cache | | machine learning |
-| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
-| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
-| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
-| `MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION` | Comma-separated list of (recognition) OCR model(s) to preload and cache | | machine learning |
-| `MACHINE_LEARNING_PRELOAD__OCR__DETECTION` | Comma-separated list of (detection) OCR model(s) to preload and cache | | machine learning |
-| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
-| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
-| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
-| `MACHINE_LEARNING_DEVICE_IDS`\*4 | Device IDs to use in multi-GPU environments | `0` | machine learning |
-| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
-| `MACHINE_LEARNING_MAX_BATCH_SIZE__OCR` | Set the maximum number of boxes that will be processed at once by the OCR model | `6` | machine learning |
-| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
-| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spun up while inferencing. | `1` | machine learning |
-| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
-| `MACHINE_LEARNING_OPENVINO_PRECISION` | If set to FP16, uses half-precision floating-point operations for faster inference with reduced accuracy (one of [`FP16`, `FP32`], applies only to OpenVINO) | `FP32` | machine learning |
+| Variable | Description | Default | Containers |
+| :---------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------: | :--------------- |
+| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
+| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
+| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
+| `MACHINE_LEARNING_REQUEST_THREADS`\*1 | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning |
+| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
+| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
+| `MACHINE_LEARNING_WORKERS`\*2 | Number of worker processes to spawn | `1` | machine learning |
+| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`\*3 | HTTP Keep-alive time in seconds | `2` | machine learning |
+| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `300` (`900` if using ROCm) | machine learning |
+| `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Comma-separated list of (textual) CLIP model(s) to preload and cache | | machine learning |
+| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
+| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
+| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
+| `MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION` | Comma-separated list of (recognition) OCR model(s) to preload and cache | | machine learning |
+| `MACHINE_LEARNING_PRELOAD__OCR__DETECTION` | Comma-separated list of (detection) OCR model(s) to preload and cache | | machine learning |
+| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
+| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
+| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
+| `MACHINE_LEARNING_DEVICE_IDS`\*4 | Device IDs to use in multi-GPU environments | `0` | machine learning |
+| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
+| `MACHINE_LEARNING_MAX_BATCH_SIZE__OCR` | Set the maximum number of boxes that will be processed at once by the OCR model | `6` | machine learning |
+| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
+| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spun up while inferencing. | `1` | machine learning |
+| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
+| `MACHINE_LEARNING_OPENVINO_PRECISION` | If set to FP16, uses half-precision floating-point operations for faster inference with reduced accuracy (one of [`FP16`, `FP32`], applies only to OpenVINO) | `FP32` | machine learning |
\*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones.
diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js
index 00a120b8b6..734317a302 100644
--- a/docs/docusaurus.config.js
+++ b/docs/docusaurus.config.js
@@ -10,7 +10,6 @@ const config = {
url: 'https://docs.immich.app',
baseUrl: '/',
onBrokenLinks: 'throw',
- onBrokenMarkdownLinks: 'warn',
favicon: 'img/favicon.png',
// GitHub pages deployment config.
@@ -29,6 +28,9 @@ const config = {
// Mermaid diagrams
markdown: {
mermaid: true,
+ hooks: {
+ onBrokenMarkdownLinks: 'warn',
+ },
},
themes: ['@docusaurus/theme-mermaid'],
diff --git a/docs/mise.lock b/docs/mise.lock
new file mode 100644
index 0000000000..dee476c431
--- /dev/null
+++ b/docs/mise.lock
@@ -0,0 +1,5 @@
+# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
+
+[[tools.wrangler]]
+version = "4.66.0"
+backend = "npm:wrangler"
diff --git a/docs/mise.toml b/docs/mise.toml
index 32fcac5578..4c95e36173 100644
--- a/docs/mise.toml
+++ b/docs/mise.toml
@@ -28,4 +28,4 @@ run = "prettier --write ."
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
[tools]
-wrangler = "4.66.0"
+wrangler = "4.91.0"
diff --git a/e2e/package.json b/e2e/package.json
index 00868d001d..77003c03d7 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -32,7 +32,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
- "@types/node": "^24.12.2",
+ "@types/node": "^24.12.4",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^7.0.0",
diff --git a/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts b/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts
index c2a3b8e724..6f986df84f 100644
--- a/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts
+++ b/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts
@@ -536,7 +536,7 @@ test.describe('Timeline', () => {
force: false,
ids: [assetToTrash.id],
});
- await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
+ await page.locator('#control-bar').getByLabel('Close').click();
await page.getByText('Trash', { exact: true }).click();
await timelineUtils.waitForTimelineLoad(page);
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
@@ -676,7 +676,7 @@ test.describe('Timeline', () => {
ids: [assetToArchive.id],
});
await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
- await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
+ await page.locator('#control-bar').getByLabel('Close').click();
await page.getByRole('link').getByText('Archive').click();
await timelineUtils.waitForTimelineLoad(page);
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
@@ -823,7 +823,7 @@ test.describe('Timeline', () => {
});
// ensure thumbnail still exists and has favorite icon
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
- await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
+ await page.locator('#control-bar').getByLabel('Close').click();
await page.getByRole('link').getByText('Favorites').click();
await timelineUtils.waitForTimelineLoad(page);
await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt);
diff --git a/machine-learning/immich_ml/config.py b/machine-learning/immich_ml/config.py
index c5ba0bdf0a..d1e44fd70a 100644
--- a/machine-learning/immich_ml/config.py
+++ b/machine-learning/immich_ml/config.py
@@ -6,7 +6,7 @@ from pathlib import Path
from socket import socket
from gunicorn.arbiter import Arbiter
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from rich.console import Console
from rich.logging import RichHandler
@@ -42,6 +42,10 @@ class MaxBatchSize(BaseModel):
ocr: int | None = None
+def default_worker_timeout() -> int:
+ return 900 if os.environ.get("DEVICE") == "rocm" else 300
+
+
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="MACHINE_LEARNING_",
@@ -54,7 +58,7 @@ class Settings(BaseSettings):
model_ttl: int = 300
model_ttl_poll_s: int = 10
workers: int = 1
- worker_timeout: int = 300
+ worker_timeout: int = Field(default_factory=default_worker_timeout)
http_keepalive_timeout_s: int = 2
test_full: bool = False
request_threads: int = os.cpu_count() or 4
diff --git a/machine-learning/immich_ml/models/facial_recognition/recognition.py b/machine-learning/immich_ml/models/facial_recognition/recognition.py
index ed1897c9f9..c144bd3eaa 100644
--- a/machine-learning/immich_ml/models/facial_recognition/recognition.py
+++ b/machine-learning/immich_ml/models/facial_recognition/recognition.py
@@ -89,4 +89,10 @@ class FaceRecognizer(InferenceModel):
@property
def _batch_size_default(self) -> int | None:
providers = ort.get_available_providers()
- return None if self.model_format == ModelFormat.ONNX and "OpenVINOExecutionProvider" not in providers else 1
+ if (
+ self.model_format == ModelFormat.ONNX
+ and "MIGraphXExecutionProvider" not in providers
+ and "OpenVINOExecutionProvider" not in providers
+ ):
+ return None
+ return 1
diff --git a/machine-learning/immich_ml/sessions/ort.py b/machine-learning/immich_ml/sessions/ort.py
index bebd235970..579cafc1cd 100644
--- a/machine-learning/immich_ml/sessions/ort.py
+++ b/machine-learning/immich_ml/sessions/ort.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from pathlib import Path
+from threading import Lock
from typing import Any
import numpy as np
@@ -12,6 +13,37 @@ from immich_ml.schemas import ModelPrecision, SessionNode
from ..config import log, settings
+MigraphxInputSignature = tuple[tuple[str, str, tuple[int, ...]], ...]
+
+_migraphx_registry_lock = Lock()
+_migraphx_model_locks: dict[str, Lock] = {}
+_migraphx_compiled_inputs: set[tuple[str, MigraphxInputSignature]] = set()
+
+
+def _migraphx_get_model_lock(model_key: str) -> Lock:
+ with _migraphx_registry_lock:
+ lock = _migraphx_model_locks.get(model_key)
+ if lock is None:
+ lock = Lock()
+ _migraphx_model_locks[model_key] = lock
+ return lock
+
+
+def _migraphx_has_compiled_input(key: tuple[str, MigraphxInputSignature]) -> bool:
+ with _migraphx_registry_lock:
+ return key in _migraphx_compiled_inputs
+
+
+def _migraphx_mark_compiled_input(key: tuple[str, MigraphxInputSignature]) -> None:
+ with _migraphx_registry_lock:
+ _migraphx_compiled_inputs.add(key)
+
+
+def _migraphx_input_signature(
+ input_feed: dict[str, NDArray[np.float32]] | dict[str, NDArray[np.int32]],
+) -> MigraphxInputSignature:
+ return tuple((name, str(value.dtype), tuple(value.shape)) for name, value in sorted(input_feed.items()))
+
class OrtSession:
session: ort.InferenceSession
@@ -48,7 +80,21 @@ class OrtSession:
input_feed: dict[str, NDArray[np.float32]] | dict[str, NDArray[np.int32]],
run_options: Any = None,
) -> list[NDArray[np.float32]]:
- outputs: list[NDArray[np.float32]] = self.session.run(output_names, input_feed, run_options)
+ if "MIGraphXExecutionProvider" in self.providers:
+ model_key = self.model_path.resolve().as_posix()
+ input_key = (model_key, _migraphx_input_signature(input_feed))
+ if not _migraphx_has_compiled_input(input_key):
+ model_lock = _migraphx_get_model_lock(model_key)
+ with model_lock:
+ if not _migraphx_has_compiled_input(input_key):
+ outputs: list[NDArray[np.float32]] = self.session.run(output_names, input_feed, run_options)
+ _migraphx_mark_compiled_input(input_key)
+ return outputs
+
+ outputs = self.session.run(output_names, input_feed, run_options)
+ return outputs
+
+ outputs = self.session.run(output_names, input_feed, run_options)
return outputs
@property
diff --git a/machine-learning/mise.lock b/machine-learning/mise.lock
new file mode 100644
index 0000000000..b9788e603b
--- /dev/null
+++ b/machine-learning/mise.lock
@@ -0,0 +1,72 @@
+# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
+
+[[tools.python]]
+version = "3.11.15"
+backend = "core:python"
+
+[tools.python."platforms.linux-arm64"]
+checksum = "sha256:243f794278eff6adba96ed3677ec6877175df84c25f140e17f09f9be82d0f12a"
+url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
+provenance = "github-attestations"
+
+[tools.python."platforms.linux-arm64-musl"]
+checksum = "sha256:52b4c52094ff8b383a45c694acf4c5c0e883152be6d5229a35a8186ce907c6eb"
+url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-aarch64-unknown-linux-musl-install_only_stripped.tar.gz"
+provenance = "github-attestations"
+
+[tools.python."platforms.linux-x64"]
+checksum = "sha256:171dffd8c0f66e8a0725364a7428015b22fc18dd298b24f541392e17dd0e561f"
+url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
+provenance = "github-attestations"
+
+[tools.python."platforms.linux-x64-musl"]
+checksum = "sha256:2ac90fef8917ebd14826a6d667593a06cf0ae5f745ba9b1147dc086dd35f5284"
+url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-unknown-linux-musl-install_only_stripped.tar.gz"
+provenance = "github-attestations"
+
+[tools.python."platforms.macos-arm64"]
+checksum = "sha256:fdfc363b538662eb7441a14e06f72c4a992c56af7f401f5730ea5081f8f8ad6e"
+url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-aarch64-apple-darwin-install_only_stripped.tar.gz"
+provenance = "github-attestations"
+
+[tools.python."platforms.macos-x64"]
+checksum = "sha256:5f1eb247cbca2c0ad5ccbf6d299a4f54b31b5c63b492d74c3531dc4344a42f88"
+url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-apple-darwin-install_only_stripped.tar.gz"
+provenance = "github-attestations"
+
+[tools.python."platforms.windows-x64"]
+checksum = "sha256:756d7f148498b8822f6aedf44a020613576f09983161f346ad36dcef6238cdc3"
+url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
+provenance = "github-attestations"
+
+[[tools.uv]]
+version = "0.8.15"
+backend = "aqua:astral-sh/uv"
+
+[tools.uv."platforms.linux-arm64"]
+checksum = "sha256:23ea21a05c62c4c307ce691f29bff2f15c94c4f07f2b83d9b356f0664bc8b3a2"
+url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-aarch64-unknown-linux-musl.tar.gz"
+
+[tools.uv."platforms.linux-arm64-musl"]
+checksum = "sha256:23ea21a05c62c4c307ce691f29bff2f15c94c4f07f2b83d9b356f0664bc8b3a2"
+url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-aarch64-unknown-linux-musl.tar.gz"
+
+[tools.uv."platforms.linux-x64"]
+checksum = "sha256:d0fec58f3124e05e0a1af0f6541abfce4333253cdaf23c7b6bb2e6128bf138ea"
+url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-unknown-linux-musl.tar.gz"
+
+[tools.uv."platforms.linux-x64-musl"]
+checksum = "sha256:d0fec58f3124e05e0a1af0f6541abfce4333253cdaf23c7b6bb2e6128bf138ea"
+url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-unknown-linux-musl.tar.gz"
+
+[tools.uv."platforms.macos-arm64"]
+checksum = "sha256:103367962c5cb00bf7370d84cbaa3fec5a9807be9cc833ea9d8eea400c119fa2"
+url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-aarch64-apple-darwin.tar.gz"
+
+[tools.uv."platforms.macos-x64"]
+checksum = "sha256:2bbef70982e97dfc36454de173f35ec1a5e83ae11e3885df6a50db3fd76171cb"
+url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-apple-darwin.tar.gz"
+
+[tools.uv."platforms.windows-x64"]
+checksum = "sha256:459d95892a5cc5c21779532f4f41b9238594b79e312a5142da2148ecfa10e705"
+url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-pc-windows-msvc.zip"
diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml
index f706a1f125..4a74c6405e 100644
--- a/machine-learning/pyproject.toml
+++ b/machine-learning/pyproject.toml
@@ -10,7 +10,7 @@ dependencies = [
"fastapi>=0.95.2,<1.0",
"gunicorn>=21.1.0",
"huggingface-hub>=1.0,<2.0",
- "insightface>=0.7.3,<1.0",
+ "insightface>=0.7.3,<2.0",
"numpy>=2.4.0,<3.0",
"opencv-python-headless>=4.7.0.72,<5.0",
"orjson>=3.9.5",
diff --git a/machine-learning/test_main.py b/machine-learning/test_main.py
index cce334e40e..b281c0d417 100644
--- a/machine-learning/test_main.py
+++ b/machine-learning/test_main.py
@@ -35,7 +35,37 @@ from immich_ml.sessions.ort import OrtSession
from immich_ml.sessions.rknn import RknnSession, run_inference
+class FakeLock:
+ def __init__(self) -> None:
+ self.enter = mock.Mock()
+ self.exit = mock.Mock()
+
+ def __enter__(self) -> None:
+ self.enter()
+
+ def __exit__(self, *args: object) -> None:
+ self.exit(*args)
+
+
class TestBase:
+ def test_sets_default_worker_timeout(self, monkeypatch: MonkeyPatch) -> None:
+ monkeypatch.delenv("DEVICE", raising=False)
+ monkeypatch.delenv("MACHINE_LEARNING_WORKER_TIMEOUT", raising=False)
+
+ assert Settings().worker_timeout == 300
+
+ def test_sets_rocm_default_worker_timeout(self, monkeypatch: MonkeyPatch) -> None:
+ monkeypatch.setenv("DEVICE", "rocm")
+ monkeypatch.delenv("MACHINE_LEARNING_WORKER_TIMEOUT", raising=False)
+
+ assert Settings().worker_timeout == 900
+
+ def test_worker_timeout_env_override(self, monkeypatch: MonkeyPatch) -> None:
+ monkeypatch.setenv("DEVICE", "rocm")
+ monkeypatch.setenv("MACHINE_LEARNING_WORKER_TIMEOUT", "1200")
+
+ assert Settings().worker_timeout == 1200
+
def test_sets_default_cache_dir(self) -> None:
encoder = OpenClipTextualEncoder("ViT-B-32__openai")
@@ -413,6 +443,52 @@ class TestOrtSession:
assert sess_options is session.sess_options
+ def test_serializes_rocm_first_run_for_new_input_signature(self, mocker: MockerFixture) -> None:
+ lock = FakeLock()
+ get_model_lock = mocker.patch("immich_ml.sessions.ort._migraphx_get_model_lock", return_value=lock)
+ mocker.patch("immich_ml.sessions.ort._migraphx_compiled_inputs", set())
+ mocker.patch("immich_ml.sessions.ort.Path.mkdir")
+ session = OrtSession("/cache/ViT-B-32__openai/model.onnx", providers=["MIGraphXExecutionProvider"])
+ input_feed = {"input": np.random.rand(1, 3, 224, 224).astype(np.float32)}
+
+ session.run(None, input_feed)
+ session.run(None, input_feed)
+
+ lock.enter.assert_called_once()
+ lock.exit.assert_called_once()
+ get_model_lock.assert_called_once()
+ session.session.run.assert_has_calls([mock.call(None, input_feed, None), mock.call(None, input_feed, None)])
+
+ def test_serializes_rocm_run_for_each_new_input_signature(self, mocker: MockerFixture) -> None:
+ lock = FakeLock()
+ mocker.patch("immich_ml.sessions.ort._migraphx_get_model_lock", return_value=lock)
+ mocker.patch("immich_ml.sessions.ort._migraphx_compiled_inputs", set())
+ mocker.patch("immich_ml.sessions.ort.Path.mkdir")
+ session = OrtSession("/cache/ViT-B-32__openai/model.onnx", providers=["MIGraphXExecutionProvider"])
+ input_feed = {"input": np.random.rand(1, 3, 224, 224).astype(np.float32)}
+ new_shape_input_feed = {"input": np.random.rand(2, 3, 224, 224).astype(np.float32)}
+
+ session.run(None, input_feed)
+ session.run(None, new_shape_input_feed)
+
+ assert lock.enter.call_count == 2
+ assert lock.exit.call_count == 2
+ session.session.run.assert_has_calls(
+ [mock.call(None, input_feed, None), mock.call(None, new_shape_input_feed, None)]
+ )
+
+ def test_does_not_serialize_non_rocm_run(self, mocker: MockerFixture) -> None:
+ lock = FakeLock()
+ get_model_lock = mocker.patch("immich_ml.sessions.ort._migraphx_get_model_lock", return_value=lock)
+ session = OrtSession("/cache/ViT-B-32__openai/model.onnx", providers=["CPUExecutionProvider"])
+ input_feed = {"input": np.random.rand(1, 3, 224, 224).astype(np.float32)}
+
+ session.run(None, input_feed)
+
+ get_model_lock.assert_not_called()
+ lock.enter.assert_not_called()
+ session.session.run.assert_called_once_with(None, input_feed, None)
+
class TestAnnSession:
def test_creates_ann_session(self, ann_session: mock.Mock, info: mock.Mock) -> None:
@@ -883,6 +959,34 @@ class TestFaceRecognition:
onnx.load.assert_not_called()
onnx.save.assert_not_called()
+ def test_recognition_does_not_add_batch_axis_for_migraphx(
+ self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture
+ ) -> None:
+ onnx = mocker.patch("immich_ml.models.facial_recognition.recognition.onnx", autospec=True)
+ update_dims = mocker.patch(
+ "immich_ml.models.facial_recognition.recognition.update_inputs_outputs_dims", autospec=True
+ )
+ mocker.patch("immich_ml.models.base.InferenceModel.download")
+ mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX")
+ mocker.patch(
+ "immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
+ return_value=["MIGraphXExecutionProvider", "CPUExecutionProvider"],
+ )
+ path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
+
+ inputs = [SimpleNamespace(name="input.1", shape=(1, 3, 224, 224))]
+ outputs = [SimpleNamespace(name="output.1", shape=(1, 800))]
+ ort_session.return_value.get_inputs.return_value = inputs
+ ort_session.return_value.get_outputs.return_value = outputs
+
+ face_recognizer = FaceRecognizer("buffalo_s", cache_dir=path)
+ face_recognizer.load()
+
+ assert face_recognizer.batch_size == 1
+ update_dims.assert_not_called()
+ onnx.load.assert_not_called()
+ onnx.save.assert_not_called()
+
def test_set_custom_max_batch_size(self, mocker: MockerFixture) -> None:
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=2))
diff --git a/machine-learning/uv.lock b/machine-learning/uv.lock
index 5623c553df..145ee5f9e5 100644
--- a/machine-learning/uv.lock
+++ b/machine-learning/uv.lock
@@ -1004,7 +1004,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.95.2,<1.0" },
{ name = "gunicorn", specifier = ">=21.1.0" },
{ name = "huggingface-hub", specifier = ">=1.0,<2.0" },
- { name = "insightface", specifier = ">=0.7.3,<1.0" },
+ { name = "insightface", specifier = ">=0.7.3,<2.0" },
{ name = "numpy", specifier = ">=2.4.0,<3.0" },
{ name = "onnxruntime", marker = "extra == 'armnn'", specifier = ">=1.23.2,<2" },
{ name = "onnxruntime", marker = "extra == 'cpu'", specifier = ">=1.23.2,<2" },
diff --git a/mise.lock b/mise.lock
new file mode 100644
index 0000000000..440177aab2
--- /dev/null
+++ b/mise.lock
@@ -0,0 +1,332 @@
+# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
+
+[[tools."aqua:flutter/flutter"]]
+version = "3.41.9"
+backend = "aqua:flutter/flutter"
+
+[[tools.flutter]]
+version = "3.41.9-stable"
+backend = "asdf:flutter"
+
+[[tools."github:CQLabs/homebrew-dcm"]]
+version = "1.37.0"
+backend = "github:CQLabs/homebrew-dcm"
+
+[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64"]
+checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
+url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
+url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
+
+[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64-musl"]
+checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
+url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
+url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
+
+[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64"]
+checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
+url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
+url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
+
+[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64-musl"]
+checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
+url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
+url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
+
+[tools."github:CQLabs/homebrew-dcm"."platforms.macos-arm64"]
+checksum = "sha256:30bede64367d09067093cc57af6ec9496d7717898138ded5cb98a16ac8dd9d93"
+url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-arm-release.zip"
+url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543757"
+
+[tools."github:CQLabs/homebrew-dcm"."platforms.macos-x64"]
+checksum = "sha256:e56cb99872be7445a4de1d37e5438ca70e3bcd83be7a2b9b385e3538881f8068"
+url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-x64-release.zip"
+url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543727"
+
+[tools."github:CQLabs/homebrew-dcm"."platforms.windows-x64"]
+checksum = "sha256:f133470daa3fb0427f039b424392af7e917d7e7db6b556aa2a968ab0e31587da"
+url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-windows-release.zip"
+url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543660"
+
+[[tools."github:extism/cli"]]
+version = "1.6.3"
+backend = "github:extism/cli"
+
+[tools."github:extism/cli"."platforms.linux-arm64"]
+checksum = "sha256:d92f830c9be39637569feacb04e9750c28848df6d9a219db94152a9b4eb9452b"
+url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-arm64.tar.gz"
+url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694030"
+
+[tools."github:extism/cli"."platforms.linux-arm64-musl"]
+checksum = "sha256:d92f830c9be39637569feacb04e9750c28848df6d9a219db94152a9b4eb9452b"
+url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-arm64.tar.gz"
+url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694030"
+
+[tools."github:extism/cli"."platforms.linux-x64"]
+checksum = "sha256:34e7ae9bfded6e2c32dee83f70a4e50d34f9d3e80d1762b09625fe82e214d02d"
+url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-amd64.tar.gz"
+url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694025"
+
+[tools."github:extism/cli"."platforms.linux-x64-musl"]
+checksum = "sha256:34e7ae9bfded6e2c32dee83f70a4e50d34f9d3e80d1762b09625fe82e214d02d"
+url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-amd64.tar.gz"
+url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694025"
+
+[tools."github:extism/cli"."platforms.macos-arm64"]
+checksum = "sha256:b4ddbc575b5ac000115247f781723f9b9f284ed87b29c600539d72161b5b29fc"
+url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-darwin-arm64.tar.gz"
+url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694029"
+
+[tools."github:extism/cli"."platforms.macos-x64"]
+checksum = "sha256:9a2f71b6e6009685a622cc3084e52d2a1a8e23c98d29ffa72e666e9dc699855f"
+url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-darwin-amd64.tar.gz"
+url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694026"
+
+[tools."github:extism/cli"."platforms.windows-x64"]
+checksum = "sha256:47e4ed2782445b2b08a4d1ac127211588f8b4d1fc25fd6481d4cb65151b5213c"
+url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-windows-amd64.zip"
+url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694035"
+
+[[tools."github:extism/js-pdk"]]
+version = "1.6.0"
+backend = "github:extism/js-pdk"
+
+[tools."github:extism/js-pdk"."platforms.linux-arm64"]
+checksum = "sha256:15a186250e68d6bff4ec839fff275d45a90e383a69209dcc1239eb9e3aee6e1b"
+url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-aarch64-linux-v1.6.0.gz"
+url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223214"
+
+[tools."github:extism/js-pdk"."platforms.linux-arm64-musl"]
+checksum = "sha256:15a186250e68d6bff4ec839fff275d45a90e383a69209dcc1239eb9e3aee6e1b"
+url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-aarch64-linux-v1.6.0.gz"
+url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223214"
+
+[tools."github:extism/js-pdk"."platforms.linux-x64"]
+checksum = "sha256:4ded271ccf465031ccd0dc35e7a140e134d7f30721671cc4a8e1ff805d4aad68"
+url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-linux-v1.6.0.gz"
+url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223119"
+
+[tools."github:extism/js-pdk"."platforms.linux-x64-musl"]
+checksum = "sha256:4ded271ccf465031ccd0dc35e7a140e134d7f30721671cc4a8e1ff805d4aad68"
+url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-linux-v1.6.0.gz"
+url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223119"
+
+[tools."github:extism/js-pdk"."platforms.macos-arm64"]
+checksum = "sha256:548e25bda3971a07c32d78a249135cf8cb7b3eede101e878e06e53e01ac2e0ce"
+url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-aarch64-macos-v1.6.0.gz"
+url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223215"
+
+[tools."github:extism/js-pdk"."platforms.macos-x64"]
+checksum = "sha256:d85a875c2a071f0c29fe572764c52c3a499f157ab7f9efac8939a4364390e29b"
+url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-macos-v1.6.0.gz"
+url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223239"
+
+[tools."github:extism/js-pdk"."platforms.windows-x64"]
+checksum = "sha256:97b7b746141e4777e1ca2b76febdeb16dc9d314ff6a4257df05a476b67228acc"
+url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-windows-v1.6.0.gz"
+url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353224133"
+
+[[tools."github:jellyfin/jellyfin-ffmpeg"]]
+version = "7.1.3-6"
+backend = "github:jellyfin/jellyfin-ffmpeg"
+
+[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.linux-x64"]
+checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
+url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
+url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
+
+[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64-musl"]
+checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
+url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
+url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
+
+[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"]
+checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820"
+url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz"
+url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838"
+
+[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"."platforms.windows-x64"]
+checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1cc1"
+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:webassembly/binaryen"]]
+version = "version_124"
+backend = "github:webassembly/binaryen"
+
+[tools."github:webassembly/binaryen"."platforms.linux-arm64"]
+checksum = "sha256:6291bd9a57d8e046f3bc099a4db386c147433a87f71c783a901c5b1792e38de3"
+url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-aarch64-linux.tar.gz"
+url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288927659"
+
+[tools."github:webassembly/binaryen"."platforms.linux-arm64-musl"]
+checksum = "sha256:6291bd9a57d8e046f3bc099a4db386c147433a87f71c783a901c5b1792e38de3"
+url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-aarch64-linux.tar.gz"
+url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288927659"
+
+[tools."github:webassembly/binaryen"."platforms.linux-x64"]
+checksum = "sha256:0290c3779fedf592b8da0ded3032ff55c41a2b7bfa2d6bf7b7bac6f0e6e28963"
+url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-linux.tar.gz"
+url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926769"
+
+[tools."github:webassembly/binaryen"."platforms.linux-x64-musl"]
+checksum = "sha256:0290c3779fedf592b8da0ded3032ff55c41a2b7bfa2d6bf7b7bac6f0e6e28963"
+url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-linux.tar.gz"
+url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926769"
+
+[tools."github:webassembly/binaryen"."platforms.macos-arm64"]
+checksum = "sha256:86a2c960ff62c6d2ea6009d1f89745c22c70100d394a095eab45eb941bdaa24c"
+url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-arm64-macos.tar.gz"
+url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926134"
+
+[tools."github:webassembly/binaryen"."platforms.macos-x64"]
+checksum = "sha256:b389bb0731758d86c3cb266d01d28a12725c23bd3cabc3df34faa162af0887e9"
+url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-macos.tar.gz"
+url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926135"
+
+[tools."github:webassembly/binaryen"."platforms.windows-x64"]
+checksum = "sha256:b5e1d2a1ad3c03229ddc89823848f4a1c11f9c6402a51fa26f0aaa5f1d7a2203"
+url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-windows.tar.gz"
+url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288925833"
+
+[[tools.java]]
+version = "21.0.2"
+backend = "core:java"
+
+[tools.java."platforms.linux-arm64"]
+checksum = "sha256:08db1392a48d4eb5ea5315cf8f18b89dbaf36cda663ba882cf03c704c9257ec2"
+url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-aarch64_bin.tar.gz"
+
+[tools.java."platforms.linux-x64"]
+checksum = "sha256:a2def047a73941e01a73739f92755f86b895811afb1f91243db214cff5bdac3f"
+url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz"
+
+[tools.java."platforms.macos-arm64"]
+checksum = "sha256:b3d588e16ec1e0ef9805d8a696591bd518a5cea62567da8f53b5ce32d11d22e4"
+url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-aarch64_bin.tar.gz"
+
+[tools.java."platforms.macos-x64"]
+checksum = "sha256:8fd09e15dc406387a0aba70bf5d99692874e999bf9cd9208b452b5d76ac922d3"
+url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-x64_bin.tar.gz"
+
+[tools.java."platforms.windows-x64"]
+checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
+url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
+
+[[tools.node]]
+version = "24.15.0"
+backend = "core:node"
+
+[tools.node."platforms.linux-arm64"]
+checksum = "sha256:73afc234d558c24919875f51c2d1ea002a2ada4ea6f83601a383869fefa64eed"
+url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-linux-arm64.tar.gz"
+
+[tools.node."platforms.linux-arm64-musl"]
+checksum = "sha256:31e98aa960a067da91edffd5d93bc46657b5d2a8029612c359f5f2ac0060152a"
+url = "https://unofficial-builds.nodejs.org/download/release/v24.15.0/node-v24.15.0-linux-arm64-musl.tar.gz"
+
+[tools.node."platforms.linux-x64"]
+checksum = "sha256:44836872d9aec49f1e6b52a9a922872db9a2b02d235a616a5681b6a85fec8d89"
+url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-linux-x64.tar.gz"
+
+[tools.node."platforms.linux-x64-musl"]
+checksum = "sha256:f55af5bd489c5347b113ca6594cae00a54b30ba57ac5875324311bfc6f4762e3"
+url = "https://unofficial-builds.nodejs.org/download/release/v24.15.0/node-v24.15.0-linux-x64-musl.tar.gz"
+
+[tools.node."platforms.macos-arm64"]
+checksum = "sha256:372331b969779ab5d15b949884fc6eaf88d5afe87bde8ba881d6400b9100ffc4"
+url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-darwin-arm64.tar.gz"
+
+[tools.node."platforms.macos-x64"]
+checksum = "sha256:ffd5ee293467927f3ee731a553eb88fd1f48cf74eebc2d74a6babe4af228673b"
+url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-darwin-x64.tar.gz"
+
+[tools.node."platforms.windows-x64"]
+checksum = "sha256:cc5149eabd53779ce1e7bdc5401643622d0c7e6800ade18928a767e940bb0e62"
+url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-win-x64.zip"
+
+[[tools."npm:oazapfts"]]
+version = "7.5.0"
+backend = "npm:oazapfts"
+
+[[tools.opentofu]]
+version = "1.11.6"
+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"
+
+[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"
+
+[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"
+
+[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"
+
+[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"
+
+[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"
+
+[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"
+
+[[tools.pnpm]]
+version = "10.33.4"
+backend = "aqua:pnpm/pnpm"
+
+[[tools.terragrunt]]
+version = "1.0.3"
+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"
+
+[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"
+
+[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"
+
+[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"
+
+[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"
+
+[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"
+
+[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"
diff --git a/mise.toml b/mise.toml
index 08e6b7fd78..5f9446fef0 100644
--- a/mise.toml
+++ b/mise.toml
@@ -16,8 +16,8 @@ config_roots = [
[tools]
node = "24.15.0"
-flutter = "3.41.9"
-pnpm = "10.33.1"
+"aqua:flutter/flutter" = "3.41.9"
+pnpm = "10.33.4"
terragrunt = "1.0.3"
opentofu = "1.11.6"
java = "21.0.2"
@@ -50,11 +50,12 @@ macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz"
[settings]
experimental = true
pin = true
+lockfile = true
[tasks.plugins]
run = [
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",
- "pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core build"
+ "pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core build",
]
[tasks.open-api-typescript]
@@ -72,12 +73,11 @@ run = "bash ./bin/generate-dart-sdk.sh"
env = { SHARP_IGNORE_GLOBAL_LIBVIPS = true }
run = [
{ task = "//:plugins" },
- { task = "//server:build" },
{ task = "//server:install" },
{ task = "//server:build" },
{ task = "//server:sync-open-api" },
- { task = ":open-api-typescript"},
- { task = ":open-api-dart"},
+ { task = ":open-api-typescript" },
+ { task = ":open-api-dart" },
]
[tasks.sql]
diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle
index 7e3d67fa81..1f22eeaf52 100644
--- a/mobile/android/app/build.gradle
+++ b/mobile/android/app/build.gradle
@@ -89,6 +89,13 @@ flutter {
}
dependencies {
+ constraints {
+ implementation("androidx.glance:glance-appwidget") {
+ version { strictly libs.versions.glance.get() }
+ because 'home_widget requests 1.+ which can resolve to pre-releases incompatible with our compileSdk/AGP'
+ }
+ }
+
implementation libs.okhttp
implementation libs.cronet.embedded
implementation libs.media3.datasource.okhttp
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
index 2c80b8d2bd..b4cd705b05 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
@@ -17,6 +17,8 @@ import app.alextran.immich.images.LocalImageApi
import app.alextran.immich.images.LocalImagesImpl
import app.alextran.immich.images.RemoteImageApi
import app.alextran.immich.images.RemoteImagesImpl
+import app.alextran.immich.permission.PermissionApi
+import app.alextran.immich.permission.PermissionApiImpl
import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30
@@ -44,7 +46,9 @@ class MainActivity : FlutterFragmentActivity() {
} else {
NativeSyncApiImpl30(ctx)
}
+ val permissionApiImpl = PermissionApiImpl(ctx)
NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
+ PermissionApi.setUp(messenger, permissionApiImpl)
LocalImageApi.setUp(messenger, LocalImagesImpl(ctx))
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx))
@@ -53,6 +57,7 @@ class MainActivity : FlutterFragmentActivity() {
flutterEngine.plugins.add(backgroundEngineLockImpl)
flutterEngine.plugins.add(nativeSyncApiImpl)
+ flutterEngine.plugins.add(permissionApiImpl)
}
fun cancelPlugins(flutterEngine: FlutterEngine) {
@@ -60,6 +65,8 @@ class MainActivity : FlutterFragmentActivity() {
flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin?
?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin?
nativeApi?.detachFromEngine()
+ val permissionApi = flutterEngine.plugins.get(PermissionApiImpl::class.java) as ImmichPlugin?
+ permissionApi?.detachFromEngine()
}
}
}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt
index 1687a7ba95..c380a0a6a5 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt
@@ -315,6 +315,7 @@ interface NetworkApi {
fun hasCertificate(): Boolean
fun getClientPointer(): Long
fun setRequestHeaders(headers: Map, serverUrls: List, token: String?)
+ fun getAppGroupId(): String
companion object {
/** The codec used by NetworkApi. */
@@ -430,6 +431,21 @@ interface NetworkApi {
channel.setMessageHandler(null)
}
}
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.getAppGroupId$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List = try {
+ listOf(api.getAppGroupId())
+ } catch (exception: Throwable) {
+ NetworkPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
}
}
}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt
index 85b7a6c730..4479ec7701 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt
@@ -13,7 +13,7 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
private var networkApi: NetworkApiImpl? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
- networkApi = NetworkApiImpl()
+ networkApi = NetworkApiImpl(binding.applicationContext)
NetworkApi.setUp(binding.binaryMessenger, networkApi)
}
@@ -39,9 +39,11 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
}
}
-private class NetworkApiImpl : NetworkApi {
+private class NetworkApiImpl(private val context: Context) : NetworkApi {
var activity: Activity? = null
+ override fun getAppGroupId(): String = context.packageName
+
override fun addCertificate(clientData: ClientCertData, callback: (Result) -> Unit) {
try {
HttpClientManager.setKeyEntry(clientData.data, clientData.password.toCharArray())
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/ManageMediaPermissionDelegate.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/ManageMediaPermissionDelegate.kt
new file mode 100644
index 0000000000..ddabfbabd8
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/ManageMediaPermissionDelegate.kt
@@ -0,0 +1,96 @@
+package app.alextran.immich.permission
+
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.provider.MediaStore
+import android.provider.Settings
+import androidx.core.net.toUri
+import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
+import io.flutter.plugin.common.PluginRegistry
+
+class ManageMediaPermissionDelegate(
+ context: Context,
+ private val requestCode: Int = 1003,
+) : PluginRegistry.ActivityResultListener {
+ private val ctx = context.applicationContext
+ private var activityBinding: ActivityPluginBinding? = null
+ private var pendingResult: ((Result) -> Unit)? = null
+
+ fun hasManageMediaPermission(): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ MediaStore.canManageMedia(ctx)
+ } else {
+ false
+ }
+ }
+
+ fun requestManageMediaPermission(callback: (Result) -> Unit) {
+ if (hasManageMediaPermission()) {
+ callback(Result.success(true))
+ return
+ }
+
+ openManageMediaPermissionSettings(callback)
+ }
+
+ fun manageMediaPermission(callback: (Result) -> Unit) {
+ openManageMediaPermissionSettings(callback)
+ }
+
+ private fun openManageMediaPermissionSettings(callback: (Result) -> Unit) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ callback(Result.success(false))
+ return
+ }
+
+ val activity = activityBinding?.activity
+ if (activity == null) {
+ callback(Result.failure(FlutterError("NO_ACTIVITY", "Activity not available", null)))
+ return
+ }
+
+ pendingResult = callback
+ val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA).apply {
+ data = "package:${activity.packageName}".toUri()
+ }
+ try {
+ activity.startActivityForResult(intent, requestCode)
+ } catch (e: Exception) {
+ pendingResult = null
+ callback(
+ Result.failure(
+ FlutterError("ACTIVITY_LAUNCH_FAILED", "Failed to launch MANAGE_MEDIA settings", e.toString())
+ )
+ )
+ }
+ }
+
+ fun onAttachedToActivity(binding: ActivityPluginBinding) {
+ activityBinding = binding
+ binding.addActivityResultListener(this)
+ }
+
+ fun onDetachedFromActivity() {
+ failPending("ACTIVITY_DETACHED", "Activity detached before MANAGE_MEDIA result")
+ activityBinding?.removeActivityResultListener(this)
+ activityBinding = null
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
+ if (requestCode == this.requestCode) {
+ val callback = pendingResult
+ pendingResult = null
+ callback?.invoke(Result.success(hasManageMediaPermission()))
+ return true
+ }
+
+ return false
+ }
+
+ private fun failPending(code: String, message: String) {
+ val callback = pendingResult ?: return
+ pendingResult = null
+ callback(Result.failure(FlutterError(code, message, null)))
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt
new file mode 100644
index 0000000000..48a1a72037
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt
@@ -0,0 +1,128 @@
+// Autogenerated from Pigeon (v26.3.4), do not edit directly.
+// See also: https://pub.dev/packages/pigeon
+@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
+
+package app.alextran.immich.permission
+
+import android.util.Log
+import io.flutter.plugin.common.BasicMessageChannel
+import io.flutter.plugin.common.BinaryMessenger
+import io.flutter.plugin.common.EventChannel
+import io.flutter.plugin.common.MessageCodec
+import io.flutter.plugin.common.StandardMethodCodec
+import io.flutter.plugin.common.StandardMessageCodec
+import java.io.ByteArrayOutputStream
+import java.nio.ByteBuffer
+private object PermissionApiPigeonUtils {
+
+ fun wrapResult(result: Any?): List {
+ return listOf(result)
+ }
+
+ fun wrapError(exception: Throwable): List {
+ return if (exception is FlutterError) {
+ listOf(
+ exception.code,
+ exception.message,
+ exception.details
+ )
+ } else {
+ listOf(
+ exception.javaClass.simpleName,
+ exception.toString(),
+ "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
+ )
+ }
+ }
+}
+
+/**
+ * Error class for passing custom error details to Flutter via a thrown PlatformException.
+ * @property code The error code.
+ * @property message The error message.
+ * @property details The error details. Must be a datatype supported by the api codec.
+ */
+class FlutterError (
+ val code: String,
+ override val message: String? = null,
+ val details: Any? = null
+) : RuntimeException()
+private open class PermissionApiPigeonCodec : StandardMessageCodec() {
+ override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
+ return super.readValueOfType(type, buffer)
+ }
+ override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
+ super.writeValue(stream, value)
+ }
+}
+
+
+/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
+interface PermissionApi {
+ fun hasManageMediaPermission(): Boolean
+ fun requestManageMediaPermission(callback: (Result) -> Unit)
+ fun manageMediaPermission(callback: (Result) -> Unit)
+
+ companion object {
+ /** The codec used by PermissionApi. */
+ val codec: MessageCodec by lazy {
+ PermissionApiPigeonCodec()
+ }
+ /** Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`. */
+ @JvmOverloads
+ fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
+ val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List = try {
+ listOf(api.hasManageMediaPermission())
+ } catch (exception: Throwable) {
+ PermissionApiPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ api.requestManageMediaPermission{ result: Result ->
+ val error = result.exceptionOrNull()
+ if (error != null) {
+ reply.reply(PermissionApiPigeonUtils.wrapError(error))
+ } else {
+ val data = result.getOrNull()
+ reply.reply(PermissionApiPigeonUtils.wrapResult(data))
+ }
+ }
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ api.manageMediaPermission{ result: Result ->
+ val error = result.exceptionOrNull()
+ if (error != null) {
+ reply.reply(PermissionApiPigeonUtils.wrapError(error))
+ } else {
+ val data = result.getOrNull()
+ reply.reply(PermissionApiPigeonUtils.wrapResult(data))
+ }
+ }
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ }
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApiImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApiImpl.kt
new file mode 100644
index 0000000000..c3443bb06d
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApiImpl.kt
@@ -0,0 +1,37 @@
+package app.alextran.immich.permission
+
+import android.content.Context
+import app.alextran.immich.core.ImmichPlugin
+import io.flutter.embedding.engine.plugins.activity.ActivityAware
+import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
+
+class PermissionApiImpl(context: Context) : ImmichPlugin(), PermissionApi, ActivityAware {
+ private val manageMediaPermissionDelegate = ManageMediaPermissionDelegate(context)
+
+ override fun hasManageMediaPermission(): Boolean =
+ manageMediaPermissionDelegate.hasManageMediaPermission()
+
+ override fun requestManageMediaPermission(callback: (Result) -> Unit) {
+ manageMediaPermissionDelegate.requestManageMediaPermission { completeWhenActive(callback, it) }
+ }
+
+ override fun manageMediaPermission(callback: (Result) -> Unit) {
+ manageMediaPermissionDelegate.manageMediaPermission { completeWhenActive(callback, it) }
+ }
+
+ override fun onAttachedToActivity(binding: ActivityPluginBinding) {
+ manageMediaPermissionDelegate.onAttachedToActivity(binding)
+ }
+
+ override fun onDetachedFromActivityForConfigChanges() {
+ manageMediaPermissionDelegate.onDetachedFromActivity()
+ }
+
+ override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
+ manageMediaPermissionDelegate.onAttachedToActivity(binding)
+ }
+
+ override fun onDetachedFromActivity() {
+ manageMediaPermissionDelegate.onDetachedFromActivity()
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MediaTrashDelegate.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MediaTrashDelegate.kt
new file mode 100644
index 0000000000..fbf4d7e919
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MediaTrashDelegate.kt
@@ -0,0 +1,133 @@
+package app.alextran.immich.sync
+
+import android.app.Activity
+import android.content.ContentResolver
+import android.content.ContentUris
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.provider.MediaStore
+import androidx.annotation.RequiresApi
+import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
+import io.flutter.plugin.common.PluginRegistry
+
+class MediaTrashDelegate(
+ context: Context,
+ private val trashRequestCode: Int = 1002,
+) : PluginRegistry.ActivityResultListener {
+ private val ctx = context.applicationContext
+ private var activityBinding: ActivityPluginBinding? = null
+ private var pendingResult: ((Result) -> Unit)? = null
+
+ private fun hasManageMediaPermission(): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ MediaStore.canManageMedia(ctx)
+ } else {
+ false
+ }
+ }
+
+ fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result) -> Unit) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || !hasManageMediaPermission()) {
+ callback(Result.failure(FlutterError("PERMISSION_DENIED", "Media permission required", null)))
+ return
+ }
+
+ val id = mediaId.toLongOrNull()
+ if (id == null) {
+ callback(Result.failure(FlutterError("INVALID_ID", "The file id is not a valid number: $mediaId", null)))
+ return
+ }
+
+ if (!isInTrash(id)) {
+ callback(Result.failure(FlutterError("TRASH_NOT_FOUND", "Item with id=$id not found in trash", null)))
+ return
+ }
+
+ restoreUri(ContentUris.withAppendedId(contentUriForType(type.toInt()), id), callback)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.R)
+ private fun restoreUri(
+ contentUri: Uri,
+ callback: (Result) -> Unit,
+ ) {
+ val activity = activityBinding?.activity
+ if (activity == null) {
+ callback(Result.failure(FlutterError("NO_ACTIVITY", "Activity not available", null)))
+ return
+ }
+
+ try {
+ val pendingIntent = MediaStore.createTrashRequest(ctx.contentResolver, listOf(contentUri), false)
+ pendingResult = callback
+ activity.startIntentSenderForResult(
+ pendingIntent.intentSender,
+ trashRequestCode,
+ null,
+ 0,
+ 0,
+ 0,
+ )
+ } catch (e: Exception) {
+ pendingResult = null
+ callback(
+ Result.failure(
+ FlutterError("TRASH_ERROR", "Error creating or starting trash request", e.toString())
+ )
+ )
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.R)
+ private fun isInTrash(id: Long): Boolean {
+ val filesUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
+ val args = Bundle().apply {
+ putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns._ID}=?")
+ putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(id.toString()))
+ putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
+ putInt(ContentResolver.QUERY_ARG_LIMIT, 1)
+ }
+ return ctx.contentResolver.query(filesUri, arrayOf(MediaStore.Files.FileColumns._ID), args, null)
+ ?.use { it.moveToFirst() } == true
+ }
+
+ private fun contentUriForType(type: Int): Uri =
+ when (type) {
+ // Same order as AssetType from Dart.
+ 1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
+ 2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
+ 3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
+ else -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
+ }
+
+ fun onAttachedToActivity(binding: ActivityPluginBinding) {
+ activityBinding = binding
+ binding.addActivityResultListener(this)
+ }
+
+ fun onDetachedFromActivity() {
+ failPending("ACTIVITY_DETACHED", "Activity detached before trash result")
+ activityBinding?.removeActivityResultListener(this)
+ activityBinding = null
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
+ if (requestCode == trashRequestCode) {
+ val callback = pendingResult
+ pendingResult = null
+ callback?.invoke(Result.success(resultCode == Activity.RESULT_OK))
+ return true
+ }
+
+ return false
+ }
+
+ private fun failPending(code: String, message: String) {
+ val callback = pendingResult ?: return
+ pendingResult = null
+ callback(Result.failure(FlutterError(code, message, null)))
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt
index b49664dea5..345302026d 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt
@@ -553,6 +553,7 @@ interface NativeSyncApi {
fun hashAssets(assetIds: List, allowNetworkAccess: Boolean, callback: (Result>) -> Unit)
fun cancelHashing()
fun getTrashedAssets(): Map>
+ fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result) -> Unit)
fun getCloudIdForAssetIds(assetIds: List): List
companion object {
@@ -747,6 +748,27 @@ interface NativeSyncApi {
channel.setMessageHandler(null)
}
}
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val mediaIdArg = args[0] as String
+ val typeArg = args[1] as Long
+ api.restoreFromTrashById(mediaIdArg, typeArg) { result: Result ->
+ val error = result.exceptionOrNull()
+ if (error != null) {
+ reply.reply(MessagesPigeonUtils.wrapError(error))
+ } else {
+ val data = result.getOrNull()
+ reply.reply(MessagesPigeonUtils.wrapResult(data))
+ }
+ }
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
run {
val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt
index 777a565fe3..1f5ff2529e 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt
@@ -17,6 +17,8 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.ImageHeaderParser
import com.bumptech.glide.load.ImageHeaderParserUtils
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
+import io.flutter.embedding.engine.plugins.activity.ActivityAware
+import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -39,10 +41,11 @@ sealed class AssetResult {
private const val TAG = "NativeSyncApiImplBase"
@SuppressLint("InlinedApi")
-open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
+open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAware {
private val ctx: Context = context.applicationContext
private var hashTask: Job? = null
+ private val mediaTrashDelegate = MediaTrashDelegate(ctx)
companion object {
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
@@ -448,6 +451,26 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
hashTask = null
}
+ fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result) -> Unit) {
+ mediaTrashDelegate.restoreFromTrashById(mediaId, type) { completeWhenActive(callback, it) }
+ }
+
+ override fun onAttachedToActivity(binding: ActivityPluginBinding) {
+ mediaTrashDelegate.onAttachedToActivity(binding)
+ }
+
+ override fun onDetachedFromActivityForConfigChanges() {
+ mediaTrashDelegate.onDetachedFromActivity()
+ }
+
+ override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
+ mediaTrashDelegate.onAttachedToActivity(binding)
+ }
+
+ override fun onDetachedFromActivity() {
+ mediaTrashDelegate.onDetachedFromActivity()
+ }
+
// This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs
@Suppress("unused", "UNUSED_PARAMETER")
fun getCloudIdForAssetIds(assetIds: List): List {
diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
index fbffa69ba5..65cc5ec433 100644
--- a/mobile/ios/Runner.xcodeproj/project.pbxproj
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -19,6 +19,8 @@
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */; };
+ B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */; };
+ B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */; };
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; };
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; };
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
@@ -105,6 +107,8 @@
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = ""; };
B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.g.swift; sourceTree = ""; };
B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityApiImpl.swift; sourceTree = ""; };
+ B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApi.g.swift; sourceTree = ""; };
+ B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApiImpl.swift; sourceTree = ""; };
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = ""; };
E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; };
F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -283,6 +287,7 @@
B25D37792E72CA15008B6CA7 /* Connectivity */,
B21E34A62E5AF9760031FDB9 /* Background */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
+ B2EE00052E72CA15008B6CA7 /* Permission */,
FA9973382CF6DF4B000EF859 /* Runner.entitlements */,
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */,
@@ -317,6 +322,15 @@
path = Connectivity;
sourceTree = "";
};
+ B2EE00052E72CA15008B6CA7 /* Permission */ = {
+ isa = PBXGroup;
+ children = (
+ B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */,
+ B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */,
+ );
+ path = Permission;
+ sourceTree = "";
+ };
FAC6F8B62D287F120078CB2F /* ShareExtension */ = {
isa = PBXGroup;
children = (
@@ -619,6 +633,8 @@
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */,
FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */,
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
+ 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 */,
@@ -718,6 +734,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
+ CUSTOM_GROUP_ID = group.app.immich.share.profile;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -750,7 +767,6 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
- CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -801,6 +817,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
+ CUSTOM_GROUP_ID = group.app.immich.share.debug;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@@ -860,6 +877,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
+ CUSTOM_GROUP_ID = group.app.immich.share;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -894,7 +912,6 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
- CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -924,7 +941,6 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
- CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -1080,7 +1096,6 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
- CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1124,7 +1139,6 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
- CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1165,7 +1179,6 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
- CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift
index 216146a6f3..e1ff3b1f6e 100644
--- a/mobile/ios/Runner/AppDelegate.swift
+++ b/mobile/ios/Runner/AppDelegate.swift
@@ -26,6 +26,7 @@ import native_video_player
public static func registerPlugins(with registry: FlutterPluginRegistry, messenger: FlutterBinaryMessenger) {
NativeSyncApiImpl.register(with: registry.registrar(forPlugin: NativeSyncApiImpl.name)!)
+ PermissionApiSetup.setUp(binaryMessenger: messenger, api: PermissionApiImpl())
LocalImageApiSetup.setUp(binaryMessenger: messenger, api: LocalImageApiImpl())
RemoteImageApiSetup.setUp(binaryMessenger: messenger, api: RemoteImageApiImpl())
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: messenger, api: BackgroundWorkerApiImpl())
diff --git a/mobile/ios/Runner/Core/Network.g.swift b/mobile/ios/Runner/Core/Network.g.swift
index 7d9b9f14be..265923d165 100644
--- a/mobile/ios/Runner/Core/Network.g.swift
+++ b/mobile/ios/Runner/Core/Network.g.swift
@@ -288,6 +288,7 @@ protocol NetworkApi {
func hasCertificate() throws -> Bool
func getClientPointer() throws -> Int64
func setRequestHeaders(headers: [String: String], serverUrls: [String], token: String?) throws
+ func getAppGroupId() throws -> String
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -388,5 +389,18 @@ class NetworkApiSetup {
} else {
setRequestHeadersChannel.setMessageHandler(nil)
}
+ let getAppGroupIdChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.getAppGroupId\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+ if let api = api {
+ getAppGroupIdChannel.setMessageHandler { _, reply in
+ do {
+ let result = try api.getAppGroupId()
+ reply(wrapResult(result))
+ } catch {
+ reply(wrapError(error))
+ }
+ }
+ } else {
+ getAppGroupIdChannel.setMessageHandler(nil)
+ }
}
}
diff --git a/mobile/ios/Runner/Core/NetworkApiImpl.swift b/mobile/ios/Runner/Core/NetworkApiImpl.swift
index 82a913d837..288b8e9539 100644
--- a/mobile/ios/Runner/Core/NetworkApiImpl.swift
+++ b/mobile/ios/Runner/Core/NetworkApiImpl.swift
@@ -61,6 +61,10 @@ class NetworkApiImpl: NetworkApi {
return Int64(Int(bitPattern: pointer))
}
+ func getAppGroupId() throws -> String {
+ return Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as! String
+ }
+
func setRequestHeaders(headers: [String : String], serverUrls: [String], token: String?) throws {
URLSessionManager.setServerUrls(serverUrls)
diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Runner/Core/URLSessionManager.swift
index e9d65d3113..48963aa577 100644
--- a/mobile/ios/Runner/Core/URLSessionManager.swift
+++ b/mobile/ios/Runner/Core/URLSessionManager.swift
@@ -4,7 +4,7 @@ import native_video_player
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
let HEADERS_KEY = "immich.request_headers"
let SERVER_URLS_KEY = "immich.server_urls"
-let APP_GROUP = "group.app.immich.share"
+let APP_GROUP = Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as! String
let COOKIE_EXPIRY_DAYS: TimeInterval = 400
enum AuthCookie: CaseIterable {
diff --git a/mobile/ios/Runner/Permission/PermissionApi.g.swift b/mobile/ios/Runner/Permission/PermissionApi.g.swift
new file mode 100644
index 0000000000..53ad9e5b11
--- /dev/null
+++ b/mobile/ios/Runner/Permission/PermissionApi.g.swift
@@ -0,0 +1,106 @@
+// Autogenerated from Pigeon (v26.3.4), do not edit directly.
+// See also: https://pub.dev/packages/pigeon
+
+import Foundation
+
+#if os(iOS)
+ import Flutter
+#elseif os(macOS)
+ import FlutterMacOS
+#else
+ #error("Unsupported platform.")
+#endif
+
+private func wrapResult(_ result: Any?) -> [Any?] {
+ return [result]
+}
+
+private func wrapError(_ error: Any) -> [Any?] {
+ if let pigeonError = error as? PigeonError {
+ return [
+ pigeonError.code,
+ pigeonError.message,
+ pigeonError.details,
+ ]
+ }
+ if let flutterError = error as? FlutterError {
+ return [
+ flutterError.code,
+ flutterError.message,
+ flutterError.details,
+ ]
+ }
+ return [
+ "\(error)",
+ "\(Swift.type(of: error))",
+ "Stacktrace: \(Thread.callStackSymbols)",
+ ]
+}
+
+private func isNullish(_ value: Any?) -> Bool {
+ return value is NSNull || value == nil
+}
+
+private func nilOrValue(_ value: Any?) -> T? {
+ if value is NSNull { return nil }
+ return value as! T?
+}
+
+/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
+protocol PermissionApi {
+ func hasManageMediaPermission() throws -> Bool
+ func requestManageMediaPermission(completion: @escaping (Result) -> Void)
+ func manageMediaPermission(completion: @escaping (Result) -> Void)
+}
+
+/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
+class PermissionApiSetup {
+ static var codec: FlutterStandardMessageCodec { FlutterStandardMessageCodec.sharedInstance() }
+ /// Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`.
+ static func setUp(binaryMessenger: FlutterBinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
+ let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
+ let hasManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+ if let api = api {
+ hasManageMediaPermissionChannel.setMessageHandler { _, reply in
+ do {
+ let result = try api.hasManageMediaPermission()
+ reply(wrapResult(result))
+ } catch {
+ reply(wrapError(error))
+ }
+ }
+ } else {
+ hasManageMediaPermissionChannel.setMessageHandler(nil)
+ }
+ let requestManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+ if let api = api {
+ requestManageMediaPermissionChannel.setMessageHandler { _, reply in
+ api.requestManageMediaPermission { result in
+ switch result {
+ case .success(let res):
+ reply(wrapResult(res))
+ case .failure(let error):
+ reply(wrapError(error))
+ }
+ }
+ }
+ } else {
+ requestManageMediaPermissionChannel.setMessageHandler(nil)
+ }
+ let manageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+ if let api = api {
+ manageMediaPermissionChannel.setMessageHandler { _, reply in
+ api.manageMediaPermission { result in
+ switch result {
+ case .success(let res):
+ reply(wrapResult(res))
+ case .failure(let error):
+ reply(wrapError(error))
+ }
+ }
+ }
+ } else {
+ manageMediaPermissionChannel.setMessageHandler(nil)
+ }
+ }
+}
diff --git a/mobile/ios/Runner/Permission/PermissionApiImpl.swift b/mobile/ios/Runner/Permission/PermissionApiImpl.swift
new file mode 100644
index 0000000000..e725b742fd
--- /dev/null
+++ b/mobile/ios/Runner/Permission/PermissionApiImpl.swift
@@ -0,0 +1,15 @@
+import Foundation
+
+class PermissionApiImpl: PermissionApi {
+ func hasManageMediaPermission() throws -> Bool {
+ return false
+ }
+
+ func requestManageMediaPermission(completion: @escaping (Result) -> Void) {
+ completion(.success(false))
+ }
+
+ func manageMediaPermission(completion: @escaping (Result) -> Void) {
+ completion(.success(false))
+ }
+}
diff --git a/mobile/ios/Runner/Runner.entitlements b/mobile/ios/Runner/Runner.entitlements
index e5862cb213..c8a6be9cbb 100644
--- a/mobile/ios/Runner/Runner.entitlements
+++ b/mobile/ios/Runner/Runner.entitlements
@@ -10,7 +10,7 @@
com.apple.security.application-groups
- group.app.immich.share
+ $(CUSTOM_GROUP_ID)
diff --git a/mobile/ios/Runner/RunnerProfile.entitlements b/mobile/ios/Runner/RunnerProfile.entitlements
index 6a5c086baf..93a4aab552 100644
--- a/mobile/ios/Runner/RunnerProfile.entitlements
+++ b/mobile/ios/Runner/RunnerProfile.entitlements
@@ -12,7 +12,7 @@
com.apple.security.application-groups
- group.app.immich.share
+ $(CUSTOM_GROUP_ID)
diff --git a/mobile/ios/Runner/Sync/Messages.g.swift b/mobile/ios/Runner/Sync/Messages.g.swift
index 2933fc89af..d18a153bb7 100644
--- a/mobile/ios/Runner/Sync/Messages.g.swift
+++ b/mobile/ios/Runner/Sync/Messages.g.swift
@@ -537,6 +537,7 @@ protocol NativeSyncApi {
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws
func getTrashedAssets() throws -> [String: [PlatformAsset]]
+ func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result) -> Void)
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
}
@@ -721,6 +722,24 @@ class NativeSyncApiSetup {
} else {
getTrashedAssetsChannel.setMessageHandler(nil)
}
+ let restoreFromTrashByIdChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+ if let api = api {
+ restoreFromTrashByIdChannel.setMessageHandler { message, reply in
+ let args = message as! [Any?]
+ let mediaIdArg = args[0] as! String
+ let typeArg = args[1] as! Int64
+ api.restoreFromTrashById(mediaId: mediaIdArg, type: typeArg) { result in
+ switch result {
+ case .success(let res):
+ reply(wrapResult(res))
+ case .failure(let error):
+ reply(wrapError(error))
+ }
+ }
+ }
+ } else {
+ restoreFromTrashByIdChannel.setMessageHandler(nil)
+ }
let getCloudIdForAssetIdsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift
index 40b71bd6c2..e6903defeb 100644
--- a/mobile/ios/Runner/Sync/MessagesImpl.swift
+++ b/mobile/ios/Runner/Sync/MessagesImpl.swift
@@ -382,6 +382,10 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
}
+
+ func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result) -> Void) {
+ completion(.success(false))
+ }
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult {
// Ensure to actually getting all assets for the Recents album
diff --git a/mobile/ios/ShareExtension/ShareExtension.entitlements b/mobile/ios/ShareExtension/ShareExtension.entitlements
index d16dcca065..1328658832 100644
--- a/mobile/ios/ShareExtension/ShareExtension.entitlements
+++ b/mobile/ios/ShareExtension/ShareExtension.entitlements
@@ -4,7 +4,7 @@
com.apple.security.application-groups
- group.app.immich.share
+ $(CUSTOM_GROUP_ID)
\ No newline at end of file
diff --git a/mobile/ios/WidgetExtension/ImmichAPI.swift b/mobile/ios/WidgetExtension/ImmichAPI.swift
index 6ae2d502f8..c5d7971aba 100644
--- a/mobile/ios/WidgetExtension/ImmichAPI.swift
+++ b/mobile/ios/WidgetExtension/ImmichAPI.swift
@@ -2,7 +2,7 @@ import Foundation
import SwiftUI
import WidgetKit
-let IMMICH_SHARE_GROUP = "group.app.immich.share"
+let IMMICH_SHARE_GROUP = Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as! String
enum WidgetError: Error, Codable {
case noLogin
diff --git a/mobile/ios/WidgetExtension/Info.plist b/mobile/ios/WidgetExtension/Info.plist
index d4e598ee31..15b00baa20 100644
--- a/mobile/ios/WidgetExtension/Info.plist
+++ b/mobile/ios/WidgetExtension/Info.plist
@@ -2,6 +2,8 @@
+ AppGroupId
+ $(CUSTOM_GROUP_ID)
NSAppTransportSecurity
NSAllowsArbitraryLoads
diff --git a/mobile/ios/WidgetExtension/WidgetExtension.entitlements b/mobile/ios/WidgetExtension/WidgetExtension.entitlements
index d16dcca065..1328658832 100644
--- a/mobile/ios/WidgetExtension/WidgetExtension.entitlements
+++ b/mobile/ios/WidgetExtension/WidgetExtension.entitlements
@@ -4,7 +4,7 @@
com.apple.security.application-groups
- group.app.immich.share
+ $(CUSTOM_GROUP_ID)
\ No newline at end of file
diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile
index ff9fc4580f..f5f7f7887a 100644
--- a/mobile/ios/fastlane/Fastfile
+++ b/mobile/ios/fastlane/Fastfile
@@ -21,6 +21,7 @@ platform :ios do
CODE_SIGN_IDENTITY = "Apple Distribution: FUTO Holdings, Inc. (#{TEAM_ID})"
BASE_BUNDLE_ID = "app.alextran.immich"
DEV_BUNDLE_ID = "tech.futo.immich.testflight"
+ DEV_GROUP_ID = "group.app.immich.share.testflight"
# Helper method to get App Store Connect API key
def get_api_key
@@ -33,6 +34,13 @@ platform :ios do
)
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"
+ args += " CUSTOM_GROUP_ID='#{group_id}'" if group_id
+ args
+ end
+
# Helper method to get version from pubspec.yaml
def get_version_from_pubspec
require 'yaml'
@@ -89,7 +97,8 @@ end
version_number: nil,
profile_name_main:,
profile_name_share:,
- profile_name_widget:
+ profile_name_widget:,
+ group_id: nil
)
app_identifier = base_bundle_id
@@ -97,7 +106,7 @@ end
if version_number
increment_version_number(version_number: version_number)
end
-
+
# Increment build number
increment_build_number(
build_number: latest_testflight_build_number(
@@ -106,14 +115,14 @@ end
) + 1,
xcodeproj: "./Runner.xcodeproj"
)
-
+
# Build the app
build_app(
scheme: "Runner",
workspace: "Runner.xcworkspace",
configuration: configuration,
export_method: "app-store",
- xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
+ xcargs: build_xcargs(group_id: group_id),
export_options: {
provisioningProfiles: {
"#{app_identifier}" => profile_name_main,
@@ -165,7 +174,8 @@ end
distribute_external: false,
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
- profile_name_widget: widget_profile_name
+ profile_name_widget: widget_profile_name,
+ group_id: DEV_GROUP_ID
)
end
@@ -274,7 +284,7 @@ end
configuration: "Release",
export_method: "app-store",
skip_package_ipa: true,
- xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
+ xcargs: build_xcargs(group_id: DEV_GROUP_ID),
export_options: {
provisioningProfiles: {
DEV_BUNDLE_ID => main_profile_name,
diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart
index 1748a2a57d..409cd38431 100644
--- a/mobile/lib/constants/constants.dart
+++ b/mobile/lib/constants/constants.dart
@@ -30,7 +30,6 @@ const int kTimelineAssetLoadBatchSize = 1024;
const int kTimelineAssetLoadOppositeSize = 64;
// Widget keys
-const String appShareGroupId = "group.app.immich.share";
const String kWidgetAuthToken = "widget_auth_token";
const String kWidgetServerEndpoint = "widget_server_url";
const String kWidgetCustomHeaders = "widget_custom_headers";
diff --git a/mobile/lib/domain/models/config/app_config.dart b/mobile/lib/domain/models/config/app_config.dart
index b988983ef1..1baa368df4 100644
--- a/mobile/lib/domain/models/config/app_config.dart
+++ b/mobile/lib/domain/models/config/app_config.dart
@@ -1,4 +1,5 @@
import 'package:immich_mobile/domain/models/config/album_config.dart';
+import 'package:immich_mobile/domain/models/config/backup_config.dart';
import 'package:immich_mobile/domain/models/config/cleanup_config.dart';
import 'package:immich_mobile/domain/models/config/image_config.dart';
import 'package:immich_mobile/domain/models/config/map_config.dart';
@@ -16,6 +17,7 @@ class AppConfig {
final ViewerConfig viewer;
final SlideshowConfig slideshow;
final AlbumConfig album;
+ final BackupConfig backup;
const AppConfig({
this.theme = const .new(),
@@ -26,6 +28,7 @@ class AppConfig {
this.viewer = const .new(),
this.slideshow = const .new(),
this.album = const .new(),
+ this.backup = const .new(),
});
AppConfig copyWith({
@@ -37,6 +40,7 @@ class AppConfig {
ViewerConfig? viewer,
SlideshowConfig? slideshow,
AlbumConfig? album,
+ BackupConfig? backup,
}) => .new(
theme: theme ?? this.theme,
cleanup: cleanup ?? this.cleanup,
@@ -46,6 +50,7 @@ class AppConfig {
viewer: viewer ?? this.viewer,
slideshow: slideshow ?? this.slideshow,
album: album ?? this.album,
+ backup: backup ?? this.backup,
);
@override
@@ -59,12 +64,13 @@ class AppConfig {
other.image == image &&
other.viewer == viewer &&
other.slideshow == slideshow &&
- other.album == album);
+ other.album == album &&
+ other.backup == backup);
@override
- int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow, album);
+ int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow, album, backup);
@override
String toString() =>
- 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album)';
+ 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup)';
}
diff --git a/mobile/lib/domain/models/config/backup_config.dart b/mobile/lib/domain/models/config/backup_config.dart
new file mode 100644
index 0000000000..19f91a4ed7
--- /dev/null
+++ b/mobile/lib/domain/models/config/backup_config.dart
@@ -0,0 +1,52 @@
+class BackupConfig {
+ final bool enabled;
+ final bool useCellularForVideos;
+ final bool useCellularForPhotos;
+ final bool requireCharging;
+ final int triggerDelay;
+ final bool syncAlbums;
+
+ const BackupConfig({
+ this.enabled = false,
+ this.useCellularForVideos = false,
+ this.useCellularForPhotos = false,
+ this.requireCharging = false,
+ this.triggerDelay = 30,
+ this.syncAlbums = false,
+ });
+
+ BackupConfig copyWith({
+ bool? enabled,
+ bool? useCellularForVideos,
+ bool? useCellularForPhotos,
+ bool? requireCharging,
+ int? triggerDelay,
+ bool? syncAlbums,
+ }) => BackupConfig(
+ enabled: enabled ?? this.enabled,
+ useCellularForVideos: useCellularForVideos ?? this.useCellularForVideos,
+ useCellularForPhotos: useCellularForPhotos ?? this.useCellularForPhotos,
+ requireCharging: requireCharging ?? this.requireCharging,
+ triggerDelay: triggerDelay ?? this.triggerDelay,
+ syncAlbums: syncAlbums ?? this.syncAlbums,
+ );
+
+ @override
+ bool operator ==(Object other) =>
+ identical(this, other) ||
+ (other is BackupConfig &&
+ other.enabled == enabled &&
+ other.useCellularForVideos == useCellularForVideos &&
+ other.useCellularForPhotos == useCellularForPhotos &&
+ other.requireCharging == requireCharging &&
+ other.triggerDelay == triggerDelay &&
+ other.syncAlbums == syncAlbums);
+
+ @override
+ int get hashCode =>
+ Object.hash(enabled, useCellularForVideos, useCellularForPhotos, requireCharging, triggerDelay, syncAlbums);
+
+ @override
+ String toString() =>
+ 'BackupConfig(enabled: $enabled, useCellularForVideos: $useCellularForVideos, useCellularForPhotos: $useCellularForPhotos, requireCharging: $requireCharging, triggerDelay: $triggerDelay, syncAlbums: $syncAlbums)';
+}
diff --git a/mobile/lib/domain/models/metadata_key.dart b/mobile/lib/domain/models/metadata_key.dart
index 67af27ecbc..541c538169 100644
--- a/mobile/lib/domain/models/metadata_key.dart
+++ b/mobile/lib/domain/models/metadata_key.dart
@@ -62,6 +62,14 @@ enum MetadataKey {
albumIsReverse(.appConfig, 'album.isReverse', true),
albumIsGrid(.appConfig, 'album.isGrid', false),
+ // Backup
+ backupEnabled(.appConfig, 'backup.enabled', false),
+ backupUseCellularForVideos(.appConfig, 'backup.useCellularForVideos', false),
+ backupUseCellularForPhotos(.appConfig, 'backup.useCellularForPhotos', false),
+ backupRequireCharging(.appConfig, 'backup.requireCharging', false),
+ backupTriggerDelay(.appConfig, 'backup.triggerDelay', 30),
+ backupSyncAlbums(.appConfig, 'backup.syncAlbums', false),
+
// Timeline
timelineTilesPerRow(.appConfig, 'timeline.tilesPerRow', 4),
timelineGroupAssetsBy(
diff --git a/mobile/lib/domain/models/setting.model.dart b/mobile/lib/domain/models/setting.model.dart
index 0dc48de3b1..d6d9e2902b 100644
--- a/mobile/lib/domain/models/setting.model.dart
+++ b/mobile/lib/domain/models/setting.model.dart
@@ -1,8 +1,7 @@
import 'package:immich_mobile/domain/models/store.model.dart';
enum Setting {
- advancedTroubleshooting(StoreKey.advancedTroubleshooting, false),
- enableBackup(StoreKey.enableBackup, false);
+ advancedTroubleshooting(StoreKey.advancedTroubleshooting, false);
const Setting(this.storeKey, this.defaultValue);
diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart
index 5562e43869..be1b0c5fb8 100644
--- a/mobile/lib/domain/models/store.model.dart
+++ b/mobile/lib/domain/models/store.model.dart
@@ -6,26 +6,25 @@ enum StoreKey {
version._(0),
currentUser._(2),
deviceId._(4),
- backupRequireCharging._(7),
- backupTriggerDelay._(8),
serverUrl._(10),
accessToken._(11),
serverEndpoint._(12),
advancedTroubleshooting._(114),
enableHapticFeedback._(126),
- syncAlbums._(131),
manageLocalMediaAndroid._(137),
// Read-only Mode settings
readonlyModeEnabled._(138),
- // Experimental stuff
- enableBackup._(1003),
- useWifiForUploadVideos._(1004),
- useWifiForUploadPhotos._(1005),
syncMigrationStatus._(1013),
// Legacy keys that have been migrated to the new metadata store
+ legacyBackupRequireCharging._(7),
+ legacyBackupTriggerDelay._(8),
+ legacySyncAlbums._(131),
+ legacyEnableBackup._(1003),
+ legacyUseWifiForUploadVideos._(1004),
+ legacyUseWifiForUploadPhotos._(1005),
legacySelectedAlbumSortOrder._(113),
legacySelectedAlbumSortReverse._(123),
legacyAlbumGridView._(140),
diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart
index 0c8746700c..bcefadaf0e 100644
--- a/mobile/lib/domain/services/background_worker.service.dart
+++ b/mobile/lib/domain/services/background_worker.service.dart
@@ -11,15 +11,14 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
+import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/platform/background_worker_api.g.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
-import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart' show nativeSyncApiProvider;
import 'package:immich_mobile/providers/user.provider.dart';
-import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/localization.service.dart';
@@ -39,16 +38,15 @@ class BackgroundWorkerFgService {
Future saveNotificationMessage(String title, String body) =>
_foregroundHostApi.saveNotificationMessage(title, body);
- Future configure({int? minimumDelaySeconds, bool? requireCharging}) => _foregroundHostApi.configure(
- BackgroundWorkerSettings(
- minimumDelaySeconds:
- minimumDelaySeconds ??
- Store.get(AppSettingsEnum.backupTriggerDelay.storeKey, AppSettingsEnum.backupTriggerDelay.defaultValue),
- requiresCharging:
- requireCharging ??
- Store.get(AppSettingsEnum.backupRequireCharging.storeKey, AppSettingsEnum.backupRequireCharging.defaultValue),
- ),
- );
+ Future configure({int? minimumDelaySeconds, bool? requireCharging}) {
+ final backup = MetadataRepository.instance.appConfig.backup;
+ return _foregroundHostApi.configure(
+ BackgroundWorkerSettings(
+ minimumDelaySeconds: minimumDelaySeconds ?? backup.triggerDelay,
+ requiresCharging: requireCharging ?? backup.requireCharging,
+ ),
+ );
+ }
Future disable() => _foregroundHostApi.disable();
}
@@ -71,7 +69,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
BackgroundWorkerFlutterApi.setUp(this);
}
- bool get _isBackupEnabled => _ref?.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup) ?? false;
+ bool get _isBackupEnabled => MetadataRepository.instance.appConfig.backup.enabled;
Future init() async {
try {
diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart
index 34300dee3d..23f9e3f78d 100644
--- a/mobile/lib/domain/services/local_sync.service.dart
+++ b/mobile/lib/domain/services/local_sync.service.dart
@@ -9,10 +9,10 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
-import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
-import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
+import 'package:immich_mobile/repositories/asset_media.repository.dart';
+import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart';
@@ -23,29 +23,29 @@ class LocalSyncService {
final DriftLocalAssetRepository _localAssetRepository;
final NativeSyncApi _nativeSyncApi;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
- final LocalFilesManagerRepository _localFilesManager;
- final StorageRepository _storageRepository;
+ final AssetMediaRepository _assetMediaRepository;
+ final IPermissionRepository _permissionRepository;
final Logger _log = Logger("DeviceSyncService");
LocalSyncService({
required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
- required LocalFilesManagerRepository localFilesManager,
- required StorageRepository storageRepository,
+ required AssetMediaRepository assetMediaRepository,
+ required IPermissionRepository permissionRepository,
required NativeSyncApi nativeSyncApi,
}) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
- _localFilesManager = localFilesManager,
- _storageRepository = storageRepository,
+ _assetMediaRepository = assetMediaRepository,
+ _permissionRepository = permissionRepository,
_nativeSyncApi = nativeSyncApi;
Future sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start();
try {
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
- final hasPermission = await _localFilesManager.hasManageMediaPermission();
+ final hasPermission = await _permissionRepository.hasManageMediaPermission();
if (hasPermission) {
await _syncTrashedAssets();
} else {
@@ -373,7 +373,7 @@ class LocalSyncService {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) {
- final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
+ final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
} else {
_log.info("syncTrashedAssets, No remote assets found for restoration");
@@ -381,15 +381,15 @@ class LocalSyncService {
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
if (localAssetsToTrash.isNotEmpty) {
- final mediaUrls = await Future.wait(
- localAssetsToTrash.values
- .expand((e) => e)
- .map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
- );
- _log.info("Moving to trash ${mediaUrls.join(", ")} assets");
- final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
- if (result) {
- await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
+ final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
+ _log.info("Moving to trash ${localIds.join(", ")} assets");
+ final movedIds = await _assetMediaRepository.deleteAll(localIds);
+ if (movedIds.isNotEmpty) {
+ final movedAssetsByAlbum = localAssetsToTrash.map(
+ (albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
+ )..removeWhere((_, assets) => assets.isEmpty);
+
+ await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
}
} else {
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart
index 9c8bac4c92..862d4c165c 100644
--- a/mobile/lib/domain/services/sync_stream.service.dart
+++ b/mobile/lib/domain/services/sync_stream.service.dart
@@ -9,12 +9,12 @@ import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
-import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
-import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
+import 'package:immich_mobile/repositories/asset_media.repository.dart';
+import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:logging/logging.dart';
@@ -34,8 +34,8 @@ class SyncStreamService {
final SyncStreamRepository _syncStreamRepository;
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
- final LocalFilesManagerRepository _localFilesManager;
- final StorageRepository _storageRepository;
+ final AssetMediaRepository _assetMediaRepository;
+ final IPermissionRepository _permissionRepository;
final SyncMigrationRepository _syncMigrationRepository;
final ApiService _api;
final bool Function()? _cancelChecker;
@@ -45,8 +45,8 @@ class SyncStreamService {
required SyncStreamRepository syncStreamRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
- required LocalFilesManagerRepository localFilesManager,
- required StorageRepository storageRepository,
+ required AssetMediaRepository assetMediaRepository,
+ required IPermissionRepository permissionRepository,
required SyncMigrationRepository syncMigrationRepository,
required ApiService api,
bool Function()? cancelChecker,
@@ -54,8 +54,8 @@ class SyncStreamService {
_syncStreamRepository = syncStreamRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
- _localFilesManager = localFilesManager,
- _storageRepository = storageRepository,
+ _assetMediaRepository = assetMediaRepository,
+ _permissionRepository = permissionRepository,
_syncMigrationRepository = syncMigrationRepository,
_api = api,
_cancelChecker = cancelChecker;
@@ -500,22 +500,22 @@ class SyncStreamService {
}
Future _trashLocalAssets(Map> localAssetsToTrash) async {
- final mediaUrls = await Future.wait(
- localAssetsToTrash.values
- .expand((e) => e)
- .map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
- );
- _logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
- final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
- if (result) {
- await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
+ final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
+ _logger.info("Moving to trash ${localIds.join(", ")} assets");
+ final movedIds = await _assetMediaRepository.deleteAll(localIds);
+ if (movedIds.isNotEmpty) {
+ final movedAssetsByAlbum = localAssetsToTrash.map(
+ (albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
+ )..removeWhere((_, assets) => assets.isEmpty);
+
+ await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
}
}
Future _applyRemoteRestoreToLocal() async {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) {
- final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
+ final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
} else {
_logger.info("No remote assets found for restoration");
@@ -523,7 +523,7 @@ class SyncStreamService {
}
Future _syncAssetTrashStatus(List remoteIds) async {
- if (!(await _localFilesManager.hasManageMediaPermission())) {
+ if (!(await _permissionRepository.hasManageMediaPermission())) {
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
return;
}
@@ -533,7 +533,7 @@ class SyncStreamService {
}
Future _syncAssetDeletion(List remoteIds) async {
- if (!(await _localFilesManager.hasManageMediaPermission())) {
+ if (!(await _permissionRepository.hasManageMediaPermission())) {
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
return;
}
diff --git a/mobile/lib/infrastructure/repositories/metadata.repository.dart b/mobile/lib/infrastructure/repositories/metadata.repository.dart
index 86dcf220c4..fa1d275026 100644
--- a/mobile/lib/infrastructure/repositories/metadata.repository.dart
+++ b/mobile/lib/infrastructure/repositories/metadata.repository.dart
@@ -152,6 +152,14 @@ extension on MetadataDomain {
isReverse: repo._read(.albumIsReverse),
isGrid: repo._read(.albumIsGrid),
),
+ backup: .new(
+ enabled: repo._read(.backupEnabled),
+ useCellularForVideos: repo._read(.backupUseCellularForVideos),
+ useCellularForPhotos: repo._read(.backupUseCellularForPhotos),
+ requireCharging: repo._read(.backupRequireCharging),
+ triggerDelay: repo._read(.backupTriggerDelay),
+ syncAlbums: repo._read(.backupSyncAlbums),
+ ),
);
case .systemConfig:
repo._systemConfig = .new(
diff --git a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart
index 93a1d629b8..de37437326 100644
--- a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart
+++ b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart
@@ -8,13 +8,13 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
-import 'package:immich_mobile/providers/app_settings.provider.dart';
+import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
+import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
-import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:logging/logging.dart';
@@ -43,7 +43,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState p.totalCount));
@@ -55,7 +55,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState a.backupSelection == BackupSelection.selected)
@@ -103,7 +103,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState p.totalCount));
final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
diff --git a/mobile/lib/pages/backup/drift_backup_options.page.dart b/mobile/lib/pages/backup/drift_backup_options.page.dart
index 79891d7002..4e8a185955 100644
--- a/mobile/lib/pages/backup/drift_backup_options.page.dart
+++ b/mobile/lib/pages/backup/drift_backup_options.page.dart
@@ -3,14 +3,12 @@ import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/domain/models/store.model.dart';
-import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
-import 'package:immich_mobile/providers/app_settings.provider.dart';
+import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
+import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
-import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart';
import 'package:logging/logging.dart';
@@ -21,18 +19,20 @@ class DriftBackupOptionsPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
bool hasPopped = false;
- final previousWifiReqForVideos = Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false;
- final previousWifiReqForPhotos = Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false;
+ final previousBackup = ref.read(metadataProvider).appConfig.backup;
+ final previousCellularForVideos = previousBackup.useCellularForVideos;
+ final previousCellularForPhotos = previousBackup.useCellularForPhotos;
return PopScope(
onPopInvokedWithResult: (didPop, result) async {
// There is an issue with Flutter where the pop event
// can be triggered multiple times, so we guard it with _hasPopped
- final currentWifiReqForVideos = Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false;
- final currentWifiReqForPhotos = Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false;
+ final currentBackup = ref.read(metadataProvider).appConfig.backup;
+ final currentCellularForVideos = currentBackup.useCellularForVideos;
+ final currentCellularForPhotos = currentBackup.useCellularForPhotos;
- if (currentWifiReqForVideos == previousWifiReqForVideos &&
- currentWifiReqForPhotos == previousWifiReqForPhotos) {
+ if (currentCellularForVideos == previousCellularForVideos &&
+ currentCellularForPhotos == previousCellularForPhotos) {
return;
}
@@ -45,7 +45,7 @@ class DriftBackupOptionsPage extends ConsumerWidget {
}
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
- final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
+ final isBackupEnabled = MetadataRepository.instance.appConfig.backup.enabled;
if (!isBackupEnabled) {
return;
}
diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart
index 594f47999c..7b49d98307 100644
--- a/mobile/lib/pages/common/splash_screen.page.dart
+++ b/mobile/lib/pages/common/splash_screen.page.dart
@@ -12,6 +12,7 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/generated/translations.g.dart';
+import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
@@ -340,7 +341,7 @@ class SplashScreenPageState extends ConsumerState {
await backgroundManager.hashAssets();
}
- if (Store.get(StoreKey.syncAlbums, false)) {
+ if (MetadataRepository.instance.appConfig.backup.syncAlbums) {
await backgroundManager.syncLinkedAlbum();
}
} catch (e) {
@@ -369,7 +370,7 @@ class SplashScreenPageState extends ConsumerState {
}
Future _resumeBackup(DriftBackupNotifier notifier) async {
- final isEnableBackup = Store.get(StoreKey.enableBackup, false);
+ final isEnableBackup = MetadataRepository.instance.appConfig.backup.enabled;
if (isEnableBackup) {
final currentUser = Store.tryGet(StoreKey.currentUser);
diff --git a/mobile/lib/platform/native_sync_api.g.dart b/mobile/lib/platform/native_sync_api.g.dart
index e7095663b0..ff6ca7bf9d 100644
--- a/mobile/lib/platform/native_sync_api.g.dart
+++ b/mobile/lib/platform/native_sync_api.g.dart
@@ -654,6 +654,25 @@ class NativeSyncApi {
return (pigeonVar_replyValue! as Map