Compare commits

..

12 Commits

Author SHA1 Message Date
shenlong-tanwen a821dab412 static analysis fix 2026-05-21 11:14:46 +05:30
shenlong-tanwen b70157f85e chore: upgrade flutter to 3.44.0 2026-05-21 10:57:02 +05:30
Caltsic b414b3d32b fix: improve form control focus visibility (#28512)
* Improve form control focus visibility

* fix: align form input focus styles
2026-05-20 15:33:56 -05:00
renovate[bot] 20da7c4267 chore(deps): lock file maintenance (terragrunt) (#28488) 2026-05-20 17:20:50 +02:00
renovate[bot] 92b6778d2d fix(deps): update typescript-projects (#28371)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-05-20 16:56:27 +02:00
Daniel Dietzler 5a61e589e8 chore: always run ci when mise.toml changes and install flutter from aqua (#28521) 2026-05-20 14:43:30 +00:00
renovate[bot] 85192bb110 chore(deps): update ghcr.io/jdx/mise docker tag to v2026.5.11 (#28522) 2026-05-20 14:29:17 +00:00
Timon c7ae97fa2b chore: handle docusaurus deprecation warning (#28516) 2026-05-20 15:27:33 +02:00
Timon 8d02f3625d chore: update mobile makefile command usage instructions (#28514) 2026-05-20 15:26:24 +02:00
bo0tzz a5a7380a26 feat: use lockfile for mise tools (#28503) 2026-05-20 11:37:33 +00:00
renovate[bot] d9ce3d2046 chore(deps): update dependency @types/node to ^24.12.4 (#28490) 2026-05-20 12:41:17 +02:00
renovate[bot] 815ff677fc chore(deps): update github-actions (#28493) 2026-05-19 22:22:44 +00:00
138 changed files with 6385 additions and 24360 deletions
+3 -3
View File
@@ -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 }}
@@ -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@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
with:
ruby-version: '3.3'
bundler-cache: true
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 }}
+3 -3
View File
@@ -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@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
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@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
# ️ 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@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
category: '/language:${{matrix.language}}'
+1 -1
View File
@@ -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 }}
+1 -1
View File
@@ -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 }}
+1 -1
View File
@@ -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 }}
+1 -1
View File
@@ -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 }}
+1 -1
View File
@@ -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 }}
+1
View File
@@ -13,3 +13,4 @@ jobs:
actions: read
contents: read
security-events: write
secrets: inherit
+2 -2
View File
@@ -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 }}
+1 -1
View File
@@ -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 }}
+1 -1
View File
@@ -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 }}
+20 -13
View File
@@ -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 }}
+65
View File
@@ -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"
+3 -1
View File
@@ -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'],
+5
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -32,7 +32,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.12.2",
"@types/node": "^24.12.4",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^7.0.0",
+2 -15
View File
@@ -465,14 +465,10 @@
"advanced_settings_proxy_headers_title": "Custom proxy headers [EXPERIMENTAL]",
"advanced_settings_readonly_mode_subtitle": "Enables the read-only mode where the photos can be only viewed, things like selecting multiple images, sharing, casting, delete are all disabled. Enable/Disable read-only via user avatar from the main screen",
"advanced_settings_readonly_mode_title": "Read-only mode",
"advanced_settings_review_remote_deletions_subtitle": "Manually review cloud trash changes. Restorations are applied automatically.",
"advanced_settings_review_remote_deletions_title": "Review remote deletions",
"advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.",
"advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates [EXPERIMENTAL]",
"advanced_settings_sync_remote_deletions_off_subtitle": "Cloud trash changes are ignored",
"advanced_settings_sync_remote_deletions_selector_title": "Sync remote deletions [EXPERIMENTAL]",
"advanced_settings_sync_remote_deletions_subtitle": "Automatically move assets to trash or restore them on this device when that action is taken on the web.",
"advanced_settings_sync_remote_deletions_title": "Auto sync",
"advanced_settings_sync_remote_deletions_subtitle": "Automatically delete or restore an asset on this device when that action is taken on the web",
"advanced_settings_sync_remote_deletions_title": "Sync remote deletions [EXPERIMENTAL]",
"advanced_settings_tile_subtitle": "Advanced user's settings",
"advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting",
"advanced_settings_troubleshooting_title": "Troubleshooting",
@@ -583,11 +579,6 @@
"asset_not_found_on_icloud": "Asset not found on iCloud. the asset may be inaccessible due to bad file stored on iCloud",
"asset_offline": "Asset Offline",
"asset_offline_description": "This external asset is no longer found on disk. Please contact your Immich administrator for help.",
"asset_out_of_sync_title": "Out-of-sync assets list",
"asset_out_of_sync_trash_confirmation_text": "Move {count, plural, one {asset} other {# assets}} to your device trash?",
"asset_out_of_sync_trash_confirmation_title": "Sync trash change",
"asset_out_of_sync_trash_subtitle": "Assets moved to the Immich cloud trash: choose to move them to local trash or keep them on this device.",
"asset_out_of_sync_trash_subtitle_result": "Nothing left to review — all decisions applied.",
"asset_restored_successfully": "Asset restored successfully",
"asset_skipped": "Skipped",
"asset_skipped_in_trash": "In trash",
@@ -606,7 +597,6 @@
"assets_count": "{count, plural, one {# asset} other {# assets}}",
"assets_deleted_permanently": "{count} asset(s) deleted permanently",
"assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server",
"assets_denied_to_moved_to_trash_count": "Keeping local copies of {count, plural, one {# asset} other {# assets}}",
"assets_downloaded_failed": "{count, plural, one {Downloaded # file - {error} file failed} other {Downloaded # files - {error} files failed}}",
"assets_downloaded_successfully": "{count, plural, one {Downloaded # file successfully} other {Downloaded # files successfully}}",
"assets_moved_to_trash_count": "Moved {count, plural, one {# asset} other {# assets}} to trash",
@@ -1376,7 +1366,6 @@
"keep_all": "Keep All",
"keep_description": "Choose what stays on your device when freeing up space.",
"keep_favorites": "Keep favorites",
"keep_in_trash": "Keep in trash",
"keep_on_device": "Keep on device",
"keep_on_device_hint": "Select items to keep on this device",
"keep_this_delete_others": "Keep this, delete others",
@@ -1696,7 +1685,6 @@
"obtainium_configurator": "Obtainium Configurator",
"obtainium_configurator_instructions": "Use Obtainium to install and update the Android app directly from Immich GitHub's release. Create an API key and select a variant to create your Obtainium configuration link",
"ocr": "OCR",
"off": "Off",
"official_immich_resources": "Official Immich Resources",
"offline": "Offline",
"offset": "Offset",
@@ -1971,7 +1959,6 @@
"retry_upload": "Retry upload",
"review_duplicates": "Review duplicates",
"review_large_files": "Review large files",
"review_out_of_sync_changes": "Review out-of-sync changes",
"role": "Role",
"role_editor": "Editor",
"role_viewer": "Viewer",
+72
View File
@@ -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"
+417
View File
@@ -0,0 +1,417 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools."aqua:flutter/flutter"]]
version = "3.44.0"
backend = "aqua:flutter/flutter"
[tools."aqua:flutter/flutter"."platforms.linux-arm64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-arm64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.macos-arm64"]
checksum = "blake3:fb03aa5d9790205c948922ec3f0751c16e4575b09d6ae9dd4fbeb664a69f0e00"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.0-stable.zip"
[tools."aqua:flutter/flutter"."platforms.macos-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.0-stable.zip"
[tools."aqua:flutter/flutter"."platforms.windows-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.0-stable.zip"
[[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[[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.1"
backend = "aqua:pnpm/pnpm"
[tools.pnpm."platforms.linux-arm64"]
checksum = "sha256:ed8aa7901cf325f4cf5019405bdd6bf988426e4b23d08fe9b12ea4df7046f23e"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-linux-arm64"
[tools.pnpm."platforms.linux-arm64-musl"]
checksum = "sha256:ed8aa7901cf325f4cf5019405bdd6bf988426e4b23d08fe9b12ea4df7046f23e"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-linux-arm64"
[tools.pnpm."platforms.linux-x64"]
checksum = "sha256:fba950842532edd365e949b74643b64e6311089a45532dbe1e8f909a247fe3e9"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-linux-x64"
[tools.pnpm."platforms.linux-x64-musl"]
checksum = "sha256:fba950842532edd365e949b74643b64e6311089a45532dbe1e8f909a247fe3e9"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-linux-x64"
[tools.pnpm."platforms.macos-arm64"]
checksum = "sha256:909ced0038b00881d4d620ba2018c5d9691de373deea8e3c84b722b44324e47c"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-macos-arm64"
[tools.pnpm."platforms.macos-x64"]
checksum = "sha256:afdad60b83f4f482f4c95cc79325f29aef776d0922a324f023a312f40e0cc7d3"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-macos-x64"
[tools.pnpm."platforms.windows-x64"]
checksum = "sha256:67b23fd8c6800566b1cc04c446b170ff6e7977250084e4d8df9bfdbd8e6f4d02"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-win-x64.exe"
[[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"
+6 -5
View File
@@ -16,8 +16,8 @@ config_roots = [
[tools]
node = "24.15.0"
flutter = "3.41.9"
pnpm = "10.33.1"
"aqua:flutter/flutter" = "3.44.0"
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]
@@ -76,8 +77,8 @@ run = [
{ 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]
-1
View File
@@ -1,5 +1,4 @@
{
"dart.flutterSdkPath": ".fvm/versions/3.41.9",
"dart.lineLength": 120,
"[dart]": {
"editor.rulers": [
@@ -1,181 +0,0 @@
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 android.provider.Settings
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.PluginRegistry
private const val TAG = "MediaTrashDelegate"
class MediaTrashDelegate(context: Context) : PluginRegistry.ActivityResultListener {
private val ctx = context.applicationContext
private var activityBinding: ActivityPluginBinding? = null
private var pendingResult: ((Result<Boolean>) -> Unit)? = null
companion object {
private const val PERMISSION_REQUEST_CODE = 1001
private const val TRASH_REQUEST_CODE = 1002
}
fun hasManageMediaPermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaStore.canManageMedia(ctx)
} else {
false
}
}
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit) {
if (hasManageMediaPermission()) {
callback(Result.success(true))
return
}
openManageMediaPermissionSettings(callback)
}
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit) {
openManageMediaPermissionSettings(callback)
}
private fun openManageMediaPermissionSettings(callback: (Result<Boolean>) -> 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()
}
activity.startActivityForResult(intent, PERMISSION_REQUEST_CODE)
}
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> 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
}
restoreUris(listOf(ContentUris.withAppendedId(contentUriForType(type.toInt()), id)), callback)
}
@RequiresApi(Build.VERSION_CODES.R)
private fun restoreUris(uris: List<Uri>, callback: (Result<Boolean>) -> Unit) {
if (uris.isEmpty()) {
callback(Result.failure(FlutterError("TRASH_ERROR", "No URIs to restore", null)))
return
}
toggleTrash(uris, false, callback)
}
@RequiresApi(Build.VERSION_CODES.R)
private fun toggleTrash(
contentUris: List<Uri>,
isTrashed: Boolean,
callback: (Result<Boolean>) -> 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, contentUris, isTrashed)
pendingResult = callback
activity.startIntentSenderForResult(
pendingIntent.intentSender,
TRASH_REQUEST_CODE,
null,
0,
0,
0
)
} catch (e: Exception) {
Log.e(TAG, "Error creating or starting trash request", e)
callback(Result.failure(FlutterError("TRASH_ERROR", "Error creating or starting trash request", null)))
}
}
@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() {
activityBinding?.removeActivityResultListener(this)
activityBinding = null
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == PERMISSION_REQUEST_CODE) {
pendingResult?.invoke(Result.success(hasManageMediaPermission()))
pendingResult = null
return true
}
if (requestCode == TRASH_REQUEST_CODE) {
pendingResult?.invoke(Result.success(resultCode == Activity.RESULT_OK))
pendingResult = null
return true
}
return false
}
}
@@ -553,10 +553,6 @@ interface NativeSyncApi {
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
fun cancelHashing()
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
fun hasManageMediaPermission(): Boolean
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit)
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit)
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
companion object {
@@ -751,78 +747,6 @@ interface NativeSyncApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hasManageMediaPermission$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.hasManageMediaPermission())
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.requestManageMediaPermission$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.requestManageMediaPermission{ result: Result<Boolean> ->
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<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.manageMediaPermission$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.manageMediaPermission{ result: Result<Boolean> ->
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<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val mediaIdArg = args[0] as String
val typeArg = args[1] as Long
api.restoreFromTrashById(mediaIdArg, typeArg) { result: Result<Boolean> ->
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<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
@@ -17,8 +17,6 @@ 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
@@ -41,11 +39,10 @@ sealed class AssetResult {
private const val TAG = "NativeSyncApiImplBase"
@SuppressLint("InlinedApi")
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAware {
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
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
@@ -451,36 +448,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
hashTask = null
}
fun hasManageMediaPermission(): Boolean = mediaTrashDelegate.hasManageMediaPermission()
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit) {
mediaTrashDelegate.requestManageMediaPermission { completeWhenActive(callback, it) }
}
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit) {
mediaTrashDelegate.manageMediaPermission { completeWhenActive(callback, it) }
}
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> 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<String>): List<CloudIdResult> {
+4
View File
@@ -5,3 +5,7 @@ android.nonTransitiveRClass=false
android.nonFinalResIds=false
org.gradle.caching=true
org.gradle.parallel=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
File diff suppressed because it is too large Load Diff
-123
View File
@@ -1,58 +1,23 @@
PODS:
- background_downloader (0.0.1):
- Flutter
- bonsoir_darwin (0.0.1):
- Flutter
- FlutterMacOS
- connectivity_plus (0.0.1):
- Flutter
- cupertino_http (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
- flutter_local_notifications (0.0.1):
- Flutter
- flutter_native_splash (2.4.3):
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- flutter_udid (0.0.1):
- Flutter
- KeychainAccess
- flutter_web_auth_2 (5.0.0):
- Flutter
- fluttertoast (0.0.2):
- Flutter
- geolocator_apple (1.2.0):
- Flutter
- FlutterMacOS
- home_widget (0.0.1):
- Flutter
- image_picker_ios (0.0.1):
- Flutter
- integration_test (0.0.1):
- Flutter
- KeychainAccess (4.2.2)
- local_auth_darwin (0.0.1):
- Flutter
- FlutterMacOS
- MapLibre (6.14.0)
- maplibre_gl (0.0.1):
- Flutter
- MapLibre (= 6.14.0)
- native_video_player (1.0.0):
- Flutter
- network_info_plus (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- permission_handler_apple (9.3.0):
- Flutter
- photo_manager (3.9.0):
- Flutter
- FlutterMacOS
- share_handler_ios (0.0.14):
- Flutter
- share_handler_ios/share_handler_ios_models (= 0.0.14)
@@ -61,144 +26,56 @@ PODS:
- Flutter
- share_handler_ios_models
- share_handler_ios_models (0.0.9)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
DEPENDENCIES:
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
- flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
- home_widget (from `.symlinks/plugins/home_widget/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
- network_info_plus (from `.symlinks/plugins/network_info_plus/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/darwin`)
- share_handler_ios (from `.symlinks/plugins/share_handler_ios/ios`)
- share_handler_ios_models (from `.symlinks/plugins/share_handler_ios/ios/Models`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
trunk:
- KeychainAccess
- MapLibre
EXTERNAL SOURCES:
background_downloader:
:path: ".symlinks/plugins/background_downloader/ios"
bonsoir_darwin:
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
cupertino_http:
:path: ".symlinks/plugins/cupertino_http/darwin"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter:
:path: Flutter
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_udid:
:path: ".symlinks/plugins/flutter_udid/ios"
flutter_web_auth_2:
:path: ".symlinks/plugins/flutter_web_auth_2/ios"
fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios"
geolocator_apple:
:path: ".symlinks/plugins/geolocator_apple/darwin"
home_widget:
:path: ".symlinks/plugins/home_widget/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
integration_test:
:path: ".symlinks/plugins/integration_test/ios"
local_auth_darwin:
:path: ".symlinks/plugins/local_auth_darwin/darwin"
maplibre_gl:
:path: ".symlinks/plugins/maplibre_gl/ios"
native_video_player:
:path: ".symlinks/plugins/native_video_player/ios"
network_info_plus:
:path: ".symlinks/plugins/network_info_plus/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
photo_manager:
:path: ".symlinks/plugins/photo_manager/darwin"
share_handler_ios:
:path: ".symlinks/plugins/share_handler_ios/ios"
share_handler_ios_models:
:path: ".symlinks/plugins/share_handler_ios/ios/Models"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
flutter_udid: 92a5d31fe0526b7b6002a2318df702e12e7eb300
flutter_web_auth_2: 646fc9df97a01c59e5eea99b237da2c6360f8439
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
MapLibre: 69e572367f4ef6287e18246cfafc39c80cdcabcd
maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
photo_manager: 25fd77df14f4f0ba5ef99e2c61814dde77e2bceb
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
PODFILE CHECKSUM: 938abbae4114b9c2140c550a2a0d8f7c674f5dfe
@@ -37,6 +37,7 @@
FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; };
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; };
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -125,6 +126,7 @@
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImagesImpl.swift; sourceTree = "<group>"; };
FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = "<group>"; };
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -189,6 +191,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
FEE084F82EC172460045228E /* SQLiteData in Frameworks */,
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */,
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */,
@@ -243,6 +246,7 @@
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -346,6 +350,9 @@
/* Begin PBXNativeTarget section */
97C146ED1CF9000F007C117D /* Runner */ = {
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
@@ -449,6 +456,7 @@
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */,
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */,
);
@@ -1272,7 +1280,17 @@
package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */;
productName = StructuredFieldValues;
};
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -1,5 +1,4 @@
{
"originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49",
"pins" : [
{
"identity" : "combine-schedulers",
@@ -19,6 +18,24 @@
"version" : "7.8.0"
}
},
{
"identity" : "keychainaccess",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kishikawakatsumi/KeychainAccess",
"state" : {
"revision" : "84e546727d66f1adc5439debad16270d0fdd04e7",
"version" : "4.2.2"
}
},
{
"identity" : "maplibre-gl-native-distribution",
"kind" : "remoteSourceControl",
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git",
"state" : {
"revision" : "60d9bb85c94ce6e7fc4406cd32529fd12bdb7809",
"version" : "6.14.0"
}
},
{
"identity" : "sqlite-data",
"kind" : "remoteSourceControl",
@@ -146,5 +163,5 @@
}
}
],
"version" : 3
"version" : 2
}
@@ -5,6 +5,24 @@
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Immich.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
@@ -1,5 +1,4 @@
{
"originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49",
"pins" : [
{
"identity" : "combine-schedulers",
@@ -19,6 +18,24 @@
"version" : "7.9.0"
}
},
{
"identity" : "keychainaccess",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kishikawakatsumi/KeychainAccess",
"state" : {
"revision" : "84e546727d66f1adc5439debad16270d0fdd04e7",
"version" : "4.2.2"
}
},
{
"identity" : "maplibre-gl-native-distribution",
"kind" : "remoteSourceControl",
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git",
"state" : {
"revision" : "60d9bb85c94ce6e7fc4406cd32529fd12bdb7809",
"version" : "6.14.0"
}
},
{
"identity" : "sqlite-data",
"kind" : "remoteSourceControl",
@@ -146,5 +163,5 @@
}
}
],
"version" : 3
"version" : 2
}
-69
View File
@@ -537,10 +537,6 @@ protocol NativeSyncApi {
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws
func getTrashedAssets() throws -> [String: [PlatformAsset]]
func hasManageMediaPermission() throws -> Bool
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
}
@@ -725,71 +721,6 @@ class NativeSyncApiSetup {
} else {
getTrashedAssetsChannel.setMessageHandler(nil)
}
let hasManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.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.NativeSyncApi.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.NativeSyncApi.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)
}
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)
-16
View File
@@ -382,22 +382,6 @@ 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 hasManageMediaPermission() throws -> Bool {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
}
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
completion(.failure(PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)))
}
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
completion(.failure(PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)))
}
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void) {
completion(.failure(PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)))
}
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
// Ensure to actually getting all assets for the Recents album
@@ -31,7 +31,7 @@ class LocalAsset extends BaseAsset {
this.adjustmentTime,
this.latitude,
this.longitude,
super.isEdited = false,
required super.isEdited,
}) : remoteAssetId = remoteId;
@override
@@ -23,7 +23,6 @@ class RemoteAsset extends BaseAsset {
required super.createdAt,
required super.updatedAt,
this.uploadedAt,
this.deletedAt,
super.width,
super.height,
super.durationMs,
@@ -33,6 +32,7 @@ class RemoteAsset extends BaseAsset {
super.livePhotoVideoId,
this.stackId,
required super.isEdited,
this.deletedAt,
}) : localAssetId = localId;
@override
@@ -62,7 +62,6 @@ class RemoteAsset extends BaseAsset {
createdAt: $createdAt,
updatedAt: $updatedAt,
uploadedAt: ${uploadedAt ?? "<NA>"},
deletedAt: ${deletedAt ?? "<NA>"},
width: ${width ?? "<NA>"},
height: ${height ?? "<NA>"},
durationMs: ${durationMs ?? "<NA>"},
@@ -90,7 +89,6 @@ class RemoteAsset extends BaseAsset {
ownerId == other.ownerId &&
thumbHash == other.thumbHash &&
visibility == other.visibility &&
deletedAt == other.deletedAt &&
stackId == other.stackId &&
uploadedAt == other.uploadedAt &&
deletedAt == other.deletedAt;
@@ -104,7 +102,6 @@ class RemoteAsset extends BaseAsset {
localId.hashCode ^
thumbHash.hashCode ^
visibility.hashCode ^
deletedAt.hashCode ^
stackId.hashCode ^
uploadedAt.hashCode ^
deletedAt.hashCode;
@@ -119,7 +116,6 @@ class RemoteAsset extends BaseAsset {
DateTime? createdAt,
DateTime? updatedAt,
DateTime? uploadedAt,
DateTime? deletedAt,
int? width,
int? height,
int? durationMs,
@@ -129,6 +125,7 @@ class RemoteAsset extends BaseAsset {
String? livePhotoVideoId,
String? stackId,
bool? isEdited,
DateTime? deletedAt,
}) {
return RemoteAsset(
id: id ?? this.id,
@@ -140,7 +137,6 @@ class RemoteAsset extends BaseAsset {
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
uploadedAt: uploadedAt ?? this.uploadedAt,
deletedAt: deletedAt ?? this.deletedAt,
width: width ?? this.width,
height: height ?? this.height,
durationMs: durationMs ?? this.durationMs,
@@ -150,6 +146,7 @@ class RemoteAsset extends BaseAsset {
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId,
isEdited: isEdited ?? this.isEdited,
deletedAt: deletedAt ?? this.deletedAt,
);
}
}
@@ -1,8 +0,0 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class RemoteDeletedLocalAsset {
final LocalAsset asset;
final DateTime remoteDeletedAt;
const RemoteDeletedLocalAsset({required this.asset, required this.remoteDeletedAt});
}
@@ -18,8 +18,6 @@ enum StoreKey<T> {
syncMigrationStatus<String>._(1013),
reviewOutOfSyncChangesAndroid<bool>._(1014),
// Legacy keys that have been migrated to the new metadata store
legacyBackupRequireCharging<bool>._(7),
legacyBackupTriggerDelay<int>._(8),
@@ -8,11 +8,7 @@ class AssetService {
final RemoteAssetRepository _remoteAssetRepository;
final DriftLocalAssetRepository _localAssetRepository;
const AssetService({
required RemoteAssetRepository remoteAssetRepository,
required DriftLocalAssetRepository localAssetRepository,
}) : _remoteAssetRepository = remoteAssetRepository,
_localAssetRepository = localAssetRepository;
const AssetService({required this._remoteAssetRepository, required this._localAssetRepository});
Future<BaseAsset?> getAsset(BaseAsset asset) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
@@ -61,11 +61,9 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
bool _isCleanedUp = false;
BackgroundWorkerBgService({required Drift drift, required DriftLogger driftLogger})
: _drift = drift,
_driftLogger = driftLogger,
_backgroundHostApi = BackgroundWorkerBgHostApi() {
_ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(drift))]);
BackgroundWorkerBgService({required this._drift, required this._driftLogger})
: _backgroundHostApi = BackgroundWorkerBgHostApi() {
_ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(_drift))]);
BackgroundWorkerFlutterApi.setUp(this);
}
+6 -11
View File
@@ -21,18 +21,13 @@ class HashService {
final _log = Logger('HashService');
HashService({
required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required NativeSyncApi nativeSyncApi,
bool Function()? cancelChecker,
required this._localAlbumRepository,
required this._localAssetRepository,
required this._trashedLocalAssetRepository,
required this._nativeSyncApi,
this._cancelChecker,
int? batchSize,
}) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_cancelChecker = cancelChecker,
_nativeSyncApi = nativeSyncApi,
_batchSize = batchSize ?? kBatchHashFileLimit;
}) : _batchSize = batchSize ?? kBatchHashFileLimit;
bool get isCancelled => _cancelChecker?.call() ?? false;
@@ -4,12 +4,15 @@ import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.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_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.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/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart';
@@ -20,29 +23,30 @@ class LocalSyncService {
final DriftLocalAssetRepository _localAssetRepository;
final NativeSyncApi _nativeSyncApi;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final DriftTrashSyncRepository _trashSyncRepository;
final LocalFilesManagerRepository _localFilesManager;
final StorageRepository _storageRepository;
final Logger _log = Logger("DeviceSyncService");
LocalSyncService({
required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required DriftTrashSyncRepository trashSyncRepository,
required NativeSyncApi nativeSyncApi,
}) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_trashSyncRepository = trashSyncRepository,
_nativeSyncApi = nativeSyncApi;
required this._localAlbumRepository,
required this._localAssetRepository,
required this._trashedLocalAssetRepository,
required this._localFilesManager,
required this._storageRepository,
required this._nativeSyncApi,
});
Future<void> sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start();
try {
if (CurrentPlatform.isAndroid) {
await _syncTrashedAssets();
await _trashSyncRepository.syncRestoresForRevivedAssets();
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
final hasPermission = await _localFilesManager.hasManageMediaPermission();
if (hasPermission) {
await _syncTrashedAssets();
} else {
_log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing");
}
}
await _trashSyncRepository.recheckRemoteTrashCandidates();
if (CurrentPlatform.isIOS) {
// final assets = await _localAssetRepository.getEmptyCloudIdAssets();
@@ -51,11 +55,7 @@ class LocalSyncService {
if (full || await _nativeSyncApi.shouldFullSync()) {
_log.fine("Full sync request from ${full ? "user" : "native"}");
await fullSync();
if (CurrentPlatform.isAndroid) {
await _cleanupTrashSync();
}
return;
return await fullSync();
}
final delta = await _nativeSyncApi.getMediaChanges();
@@ -77,13 +77,13 @@ class LocalSyncService {
);
final dbAlbums = await _localAlbumRepository.getAll();
// On Android, we need to sync all albums since it is not possible to
// detect album deletions from the native side
if (CurrentPlatform.isAndroid) {
for (final album in dbAlbums) {
final deviceIds = await _nativeSyncApi.getAssetIdsForAlbum(album.id);
await _localAlbumRepository.syncDeletes(album.id, deviceIds);
}
await _cleanupTrashSync();
}
if (CurrentPlatform.isIOS) {
@@ -110,13 +110,6 @@ class LocalSyncService {
}
}
Future<void> _cleanupTrashSync() async {
final deleted = await _trashSyncRepository.cleanup();
if (deleted > 0) {
_log.fine("cleanup TrashState, deleted: $deleted");
}
}
Future<void> fullSync() async {
try {
final Stopwatch stopwatch = Stopwatch()..start();
@@ -364,7 +357,7 @@ class LocalSyncService {
@visibleForTesting
Future<void> processTrashedAssets(Map<String, List<PlatformAsset>> trashedAssetMap) async {
if (trashedAssetMap.isEmpty) {
_log.fine("syncTrashedAssets, No trashed assets found");
_log.info("syncTrashedAssets, No trashed assets found");
}
final trashedAssets = trashedAssetMap.cast<String, List<Object?>>().entries.expand(
(entry) => entry.value.cast<PlatformAsset>().toTrashedAssets(entry.key),
@@ -372,6 +365,30 @@ class LocalSyncService {
_log.fine("syncTrashedAssets, trashedAssets: ${trashedAssets.map((e) => e.asset.id)}");
await _trashedLocalAssetRepository.processTrashSnapshot(trashedAssets);
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) {
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
} else {
_log.info("syncTrashedAssets, No remote assets found for restoration");
}
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);
}
} else {
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
}
}
}
@@ -416,6 +433,7 @@ extension PlatformToLocalAsset on PlatformAsset {
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
latitude: latitude,
longitude: longitude,
isEdited: false,
);
}
+1 -1
View File
@@ -10,7 +10,7 @@ typedef MapQuery = ({MapMarkerSource markerSource});
class MapFactory {
final DriftMapRepository _mapRepository;
const MapFactory({required DriftMapRepository mapRepository}) : _mapRepository = mapRepository;
const MapFactory({required this._mapRepository});
MapService remote(List<String> ownerIds, TimelineMapOptions options) =>
MapService(_mapRepository.remote(ownerIds, options));
@@ -9,7 +9,7 @@ final AppSetting = SettingsService(storeService: StoreService.I);
class SettingsService {
final StoreService _storeService;
const SettingsService({required StoreService storeService}) : _storeService = storeService;
const SettingsService({required this._storeService});
T get<T>(Setting<T> setting) => _storeService.get(setting.storeKey, setting.defaultValue);
@@ -3,13 +3,18 @@
import 'dart:async';
import 'dart:convert';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
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/trash_sync.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/services/api.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:logging/logging.dart';
@@ -22,31 +27,30 @@ enum SyncMigrationTask {
v20260597_ResetAssetV1AssetV2, // Assets didn't include the uploadedAt column.
}
typedef _RemoteAssetTrashState = ({String id, DateTime? deletedAt, String? checksum});
class SyncStreamService {
final Logger _logger = Logger('SyncStreamService');
final SyncApiRepository _syncApiRepository;
final SyncStreamRepository _syncStreamRepository;
final DriftTrashSyncRepository _trashSyncRepository;
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final LocalFilesManagerRepository _localFilesManager;
final StorageRepository _storageRepository;
final SyncMigrationRepository _syncMigrationRepository;
final ApiService _api;
final bool Function()? _cancelChecker;
SyncStreamService({
required SyncApiRepository syncApiRepository,
required SyncStreamRepository syncStreamRepository,
required DriftTrashSyncRepository trashSyncRepository,
required SyncMigrationRepository syncMigrationRepository,
required ApiService api,
bool Function()? cancelChecker,
}) : _syncApiRepository = syncApiRepository,
_syncStreamRepository = syncStreamRepository,
_trashSyncRepository = trashSyncRepository,
_syncMigrationRepository = syncMigrationRepository,
_api = api,
_cancelChecker = cancelChecker;
required this._syncApiRepository,
required this._syncStreamRepository,
required this._localAssetRepository,
required this._trashedLocalAssetRepository,
required this._localFilesManager,
required this._storageRepository,
required this._syncMigrationRepository,
required this._api,
this._cancelChecker,
});
bool get isCancelled => _cancelChecker?.call() ?? false;
@@ -188,24 +192,22 @@ class SyncStreamService {
case SyncEntityType.assetV1:
final remoteSyncAssets = data.cast<SyncAssetV1>();
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
await _handleRemoteAssetTrashState(
remoteSyncAssets.map((e) => (id: e.id, deletedAt: e.deletedAt, checksum: e.checksum)),
);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
}
return;
case SyncEntityType.assetV2:
final remoteSyncAssets = data.cast<SyncAssetV2>();
await _syncStreamRepository.updateAssetsV2(remoteSyncAssets);
await _handleRemoteAssetTrashState(
remoteSyncAssets.map((e) => (id: e.id, deletedAt: e.deletedAt, checksum: e.checksum)),
);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
}
return;
case SyncEntityType.assetDeleteV1:
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
final now = DateTime.now();
final remoteDeletedAtByRemoteId = Map<String, DateTime>.fromEntries(
remoteSyncAssets.map((e) => MapEntry(e.assetId, now)),
);
await _trashSyncRepository.recordRemoteTrash(remoteDeletedAtByRemoteId);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _syncAssetDeletion(remoteSyncAssets.map((e) => e.assetId).toList());
}
return _syncStreamRepository.deleteAssetsV1(remoteSyncAssets);
case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast());
@@ -476,17 +478,58 @@ class SyncStreamService {
}
}
Future<void> _handleRemoteAssetTrashState(Iterable<_RemoteAssetTrashState> remoteSyncAssets) async {
final deleted = <String, DateTime>{};
final aliveChecksums = <String>[];
for (final e in remoteSyncAssets) {
if (e.deletedAt != null) {
deleted[e.id] = e.deletedAt!;
} else if (e.checksum != null) {
aliveChecksums.add(e.checksum!);
Future<void> _handleRemoteDeleted(Iterable<String> remoteIds) async {
if (remoteIds.isEmpty) {
return Future.value();
} else {
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(remoteIds);
if (localAssetsToTrash.isNotEmpty) {
await _trashLocalAssets(localAssetsToTrash);
} else {
_logger.info("No assets found in backup-enabled albums for remote assets: $remoteIds");
}
}
await _trashSyncRepository.recordRemoteTrash(deleted);
await _trashSyncRepository.recordRemoteRestore(aliveChecksums);
}
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> 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);
}
}
Future<void> _applyRemoteRestoreToLocal() async {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) {
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
} else {
_logger.info("No remote assets found for restoration");
}
}
Future<void> _syncAssetTrashStatus(List<String> remoteIds) async {
if (!(await _localFilesManager.hasManageMediaPermission())) {
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
return;
}
await _handleRemoteDeleted(remoteIds);
await _applyRemoteRestoreToLocal();
}
Future<void> _syncAssetDeletion(List<String> remoteIds) async {
if (!(await _localFilesManager.hasManageMediaPermission())) {
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
return;
}
await _handleRemoteDeleted(remoteIds);
}
}
@@ -35,18 +35,13 @@ enum TimelineOrigin {
albumActivities,
folder,
recentlyAdded,
syncTrash,
}
class TimelineFactory {
final DriftTimelineRepository _timelineRepository;
final MetadataRepository _metadataRepository;
const TimelineFactory({
required DriftTimelineRepository timelineRepository,
required MetadataRepository metadataRepository,
}) : _timelineRepository = timelineRepository,
_metadataRepository = metadataRepository;
const TimelineFactory({required this._timelineRepository, required this._metadataRepository});
GroupAssetsBy get groupBy {
final group = _metadataRepository.appConfig.timeline.groupAssetsBy;
@@ -70,8 +65,6 @@ class TimelineFactory {
TimelineService trash(String userId) => TimelineService(_timelineRepository.trash(userId, groupBy));
TimelineService toTrashSyncReview() => TimelineService(_timelineRepository.toTrashSyncReview(groupBy));
TimelineService archive(String userId) => TimelineService(_timelineRepository.archived(userId, groupBy));
TimelineService lockedFolder(String userId) => TimelineService(_timelineRepository.locked(userId, groupBy));
@@ -111,12 +104,7 @@ class TimelineService {
TimelineService(TimelineQuery query)
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
TimelineService._({
required TimelineAssetSource assetSource,
required TimelineBucketSource bucketSource,
required this.origin,
}) : _assetSource = assetSource,
_bucketSource = bucketSource {
TimelineService._({required this._assetSource, required this._bucketSource, required this.origin}) {
_bucketSubscription = _bucketSource().listen((buckets) {
_mutex.run(() async {
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
+1 -3
View File
@@ -12,9 +12,7 @@ class UserService {
final UserApiRepository _userApiRepository;
final StoreService _storeService;
UserService({required UserApiRepository userApiRepository, required StoreService storeService})
: _userApiRepository = userApiRepository,
_storeService = storeService;
UserService({required this._userApiRepository, required this._storeService});
UserDto getMyUser() {
return _storeService.get(StoreKey.currentUser);
@@ -1,52 +0,0 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
enum TrashStateDecision {
// do not change this order!
pendingReview,
kept,
appTrashed,
}
enum TrashTriggerSource {
// do not change this order!
remoteSync,
localUser,
}
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trash_sync_decision ON trash_sync_entity (decision)')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trash_sync_checksum ON trash_sync_entity (checksum)')
class TrashSyncEntity extends LocalAssetEntity {
const TrashSyncEntity();
IntColumn get decision => intEnum<TrashStateDecision>()();
IntColumn get triggerSource => intEnum<TrashTriggerSource>()();
DateTimeColumn get remoteDeletedAt => dateTime().nullable()();
DateTimeColumn get decidedAt => dateTime().withDefault(currentDateAndTime)();
}
extension TrashSyncEntityDataDomainExtension on TrashSyncEntityData {
LocalAsset toLocalAsset() => LocalAsset(
id: id,
name: name,
checksum: checksum,
type: type,
createdAt: createdAt,
updatedAt: updatedAt,
durationMs: durationMs,
isFavorite: isFavorite,
height: height,
width: width,
orientation: orientation,
playbackStyle: playbackStyle,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
cloudId: iCloudId,
);
}
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,8 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
enum TrashOrigin {
// do not change this order!
@@ -12,13 +13,23 @@ enum TrashOrigin {
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)')
class TrashedLocalAssetEntity extends LocalAssetEntity {
class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
const TrashedLocalAssetEntity();
TextColumn get id => text()();
TextColumn get albumId => text()();
TextColumn get checksum => text().nullable()();
BoolColumn get isFavorite => boolean().withDefault(const Constant(false))();
IntColumn get orientation => integer().withDefault(const Constant(0))();
IntColumn get source => intEnum<TrashOrigin>()();
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {id, albumId};
}
File diff suppressed because it is too large Load Diff
@@ -24,7 +24,6 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
@@ -57,7 +56,6 @@ import 'package:logging/logging.dart';
TrashedLocalAssetEntity,
AssetEditEntity,
MetadataEntity,
TrashSyncEntity,
],
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
)
@@ -100,7 +98,7 @@ class Drift extends $Drift {
}
@override
int get schemaVersion => 27;
int get schemaVersion => 26;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -278,16 +276,6 @@ class Drift extends $Drift {
from25To26: (m, v26) async {
await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt);
},
from26To27: (m, v27) async {
await m.create(v27.trashSyncEntity);
await m.createIndex(v27.idxTrashSyncDecision);
await m.createIndex(v27.idxTrashSyncChecksum);
await m.addColumn(v27.trashedLocalAssetEntity, v27.trashedLocalAssetEntity.iCloudId);
await m.addColumn(v27.trashedLocalAssetEntity, v27.trashedLocalAssetEntity.adjustmentTime);
await m.addColumn(v27.trashedLocalAssetEntity, v27.trashedLocalAssetEntity.latitude);
await m.addColumn(v27.trashedLocalAssetEntity, v27.trashedLocalAssetEntity.longitude);
},
),
);
@@ -45,11 +45,9 @@ import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.da
as i21;
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart'
as i22;
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart'
as i23;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i24;
import 'package:drift/internal/modular.dart' as i25;
as i23;
import 'package:drift/internal/modular.dart' as i24;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@@ -96,11 +94,9 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i22.$MetadataEntityTable metadataEntity = i22.$MetadataEntityTable(
this,
);
late final i23.$TrashSyncEntityTable trashSyncEntity = i23
.$TrashSyncEntityTable(this);
i24.MergedAssetDrift get mergedAssetDrift => i25.ReadDatabaseContainer(
i23.MergedAssetDrift get mergedAssetDrift => i24.ReadDatabaseContainer(
this,
).accessor<i24.MergedAssetDrift>(i24.MergedAssetDrift.new);
).accessor<i23.MergedAssetDrift>(i23.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -137,7 +133,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
trashedLocalAssetEntity,
assetEditEntity,
metadataEntity,
trashSyncEntity,
i10.idxPartnerSharedWithId,
i11.idxLatLng,
i11.idxRemoteExifCity,
@@ -150,8 +145,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
i20.idxTrashedLocalAssetChecksum,
i20.idxTrashedLocalAssetAlbum,
i21.idxAssetEditAssetId,
i23.idxTrashSyncDecision,
i23.idxTrashSyncChecksum,
];
@override
i0.StreamQueryUpdateRules
@@ -404,6 +397,4 @@ class $DriftManager {
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
i22.$$MetadataEntityTableTableManager get metadataEntity =>
i22.$$MetadataEntityTableTableManager(_db, _db.metadataEntity);
i23.$$TrashSyncEntityTableTableManager get trashSyncEntity =>
i23.$$TrashSyncEntityTableTableManager(_db, _db.trashSyncEntity);
}
@@ -13539,714 +13539,6 @@ i1.GeneratedColumn<String> _column_212(String aliasedName) =>
type: i1.DriftSqlType.string,
$customConstraints: 'NULL',
);
final class Schema27 extends i0.VersionedSchema {
Schema27({required super.database}) : super(version: 27);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxStackPrimaryAssetId,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetOwnerVisibilityDeletedCreated,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
metadata,
trashSyncEntity,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteExifCity,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxAssetFaceVisiblePerson,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
idxTrashSyncDecision,
idxTrashSyncChecksum,
];
late final Shape33 userEntity = Shape33(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape50 remoteAssetEntity = Shape50(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_212,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 stackEntity = Shape35(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_130,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape36 localAssetEntity = Shape36(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 remoteAlbumEntity = Shape48(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_138,
_column_114,
_column_115,
_column_139,
_column_140,
_column_141,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 localAlbumEntity = Shape38(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_115,
_column_142,
_column_143,
_column_144,
_column_145,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape39 localAlbumAssetEntity = Shape39(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_146, _column_147, _column_145],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
'idx_remote_asset_owner_visibility_deleted_created',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
);
late final Shape40 authUserEntity = Shape40(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_148,
_column_110,
_column_111,
_column_149,
_column_150,
_column_151,
_column_152,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_153, _column_154, _column_155],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 partnerEntity = Shape41(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_156, _column_157, _column_158],
attachedDatabase: database,
),
alias: null,
);
late final Shape42 remoteExifEntity = Shape42(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_160,
_column_161,
_column_162,
_column_163,
_column_164,
_column_117,
_column_116,
_column_165,
_column_166,
_column_167,
_column_168,
_column_135,
_column_136,
_column_169,
_column_170,
_column_171,
_column_172,
_column_173,
_column_174,
_column_175,
_column_176,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_159, _column_177],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_177, _column_153, _column_178],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 remoteAssetCloudIdEntity = Shape43(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_179,
_column_180,
_column_134,
_column_135,
_column_136,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 memoryEntity = Shape44(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_124,
_column_121,
_column_113,
_column_181,
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_159, _column_187],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 personEntity = Shape45(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_108,
_column_188,
_column_189,
_column_190,
_column_191,
_column_192,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 assetFaceEntity = Shape46(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_193,
_column_194,
_column_195,
_column_196,
_column_197,
_column_198,
_column_199,
_column_200,
_column_201,
_column_124,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_202, _column_203, _column_204],
attachedDatabase: database,
),
alias: null,
);
late final Shape51 trashedLocalAssetEntity = Shape51(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_205,
_column_206,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 assetEditEntity = Shape32(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_207,
_column_208,
_column_209,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape49 metadata = Shape49(
source: i0.VersionedTable(
entityName: 'metadata',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY("key")'],
columns: [_column_210, _column_211, _column_115],
attachedDatabase: database,
),
alias: null,
);
late final Shape52 trashSyncEntity = Shape52(
source: i0.VersionedTable(
entityName: 'trash_sync_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_213,
_column_214,
_column_215,
_column_216,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteExifCity = i1.Index(
'idx_remote_exif_city',
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
'idx_asset_face_visible_person',
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
final i1.Index idxTrashSyncDecision = i1.Index(
'idx_trash_sync_decision',
'CREATE INDEX IF NOT EXISTS idx_trash_sync_decision ON trash_sync_entity (decision)',
);
final i1.Index idxTrashSyncChecksum = i1.Index(
'idx_trash_sync_checksum',
'CREATE INDEX IF NOT EXISTS idx_trash_sync_checksum ON trash_sync_entity (checksum)',
);
}
class Shape51 extends i0.VersionedTable {
Shape51({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get orientation =>
columnsByName['orientation']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get iCloudId =>
columnsByName['i_cloud_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get adjustmentTime =>
columnsByName['adjustment_time']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<double> get latitude =>
columnsByName['latitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get longitude =>
columnsByName['longitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<int> get playbackStyle =>
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationMs =>
columnsByName['duration_ms']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get albumId =>
columnsByName['album_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get source =>
columnsByName['source']! as i1.GeneratedColumn<int>;
}
class Shape52 extends i0.VersionedTable {
Shape52({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get orientation =>
columnsByName['orientation']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get iCloudId =>
columnsByName['i_cloud_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get adjustmentTime =>
columnsByName['adjustment_time']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<double> get latitude =>
columnsByName['latitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get longitude =>
columnsByName['longitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<int> get playbackStyle =>
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationMs =>
columnsByName['duration_ms']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get decision =>
columnsByName['decision']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get triggerSource =>
columnsByName['trigger_source']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get remoteDeletedAt =>
columnsByName['remote_deleted_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get decidedAt =>
columnsByName['decided_at']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<int> _column_213(String aliasedName) =>
i1.GeneratedColumn<int>(
'decision',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<int> _column_214(String aliasedName) =>
i1.GeneratedColumn<int>(
'trigger_source',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<String> _column_215(String aliasedName) =>
i1.GeneratedColumn<String>(
'remote_deleted_at',
aliasedName,
true,
type: i1.DriftSqlType.string,
$customConstraints: 'NULL',
);
i1.GeneratedColumn<String> _column_216(String aliasedName) =>
i1.GeneratedColumn<String>(
'decided_at',
aliasedName,
false,
type: i1.DriftSqlType.string,
$customConstraints: 'NOT NULL DEFAULT CURRENT_TIMESTAMP',
defaultValue: const i1.CustomExpression('CURRENT_TIMESTAMP'),
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -14273,7 +13565,6 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -14402,11 +13693,6 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from25To26(migrator, schema);
return 26;
case 26:
final schema = Schema27(database: database);
final migrator = i1.Migrator(database, schema);
await from26To27(migrator, schema);
return 27;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -14439,7 +13725,6 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -14467,6 +13752,5 @@ i1.OnUpgrade stepByStep({
from23To24: from23To24,
from24To25: from24To25,
from25To26: from25To26,
from26To27: from26To27,
),
);
@@ -6,7 +6,6 @@ import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
@@ -110,70 +109,41 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return query.map((localAlbum) => localAlbum.toDto()).get();
}
Future<List<RemoteDeletedLocalAsset>> getRemoteTrashCandidates(
Map<String, DateTime> remoteDeletedAtByRemoteId,
) async {
if (remoteDeletedAtByRemoteId.isEmpty) {
return const [];
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> remoteIds) async {
if (remoteIds.isEmpty) {
return {};
}
final byLocalId = <String, RemoteDeletedLocalAsset>{};
for (final slice in remoteDeletedAtByRemoteId.keys.toSet().slices(kDriftMaxChunk)) {
final rows = await _remoteTrashCandidatesQuery(slice).get();
final result = <String, List<LocalAsset>>{};
for (final slice in remoteIds.toSet().slices(kDriftMaxChunk)) {
final rows =
await (_db.select(_db.localAlbumAssetEntity).join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
innerJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
])..where(
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
_db.remoteAssetEntity.id.isIn(slice),
))
.get();
for (final row in rows) {
final assetData = row.readTable(_db.localAssetEntity);
final remoteId = row.read(_db.remoteAssetEntity.id)!;
byLocalId.putIfAbsent(
assetData.id,
() => RemoteDeletedLocalAsset(
asset: assetData.toDto(remoteId: remoteId),
remoteDeletedAt: remoteDeletedAtByRemoteId[remoteId]!,
),
);
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
final asset = row.readTable(_db.localAssetEntity).toDto();
(result[albumId] ??= <LocalAsset>[]).add(asset);
}
}
return byLocalId.values.toList();
}
JoinedSelectStatement<HasResultSet, dynamic> _remoteTrashCandidatesQuery(List<String> remoteIdSlice) {
return _db.select(_db.localAssetEntity).join([
innerJoin(
_db.localAlbumAssetEntity,
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
useColumns: false,
),
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
innerJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
leftOuterJoin(
_db.trashSyncEntity,
_db.localAssetEntity.id.equalsExp(_db.trashSyncEntity.id),
useColumns: false,
),
])
..addColumns([_db.remoteAssetEntity.id])
..where(
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
_db.remoteAssetEntity.id.isIn(remoteIdSlice) &
_db.trashSyncEntity.id.isNull(),
);
}
Future<Map<String, DateTime>> getRemotelyDeletedRemoteIds() async {
final rows =
await (_db.selectOnly(_db.remoteAssetEntity)
..addColumns([_db.remoteAssetEntity.id, _db.remoteAssetEntity.deletedAt])
..where(_db.remoteAssetEntity.deletedAt.isNotNull()))
.get();
return {for (final r in rows) r.read(_db.remoteAssetEntity.id)!: r.read(_db.remoteAssetEntity.deletedAt)!};
return result;
}
Future<RemovalCandidatesResult> getRemovalCandidates(
@@ -244,20 +214,6 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get();
}
Future<List<LocalAsset>> getByIds(Iterable<String> ids) async {
final assetIds = ids.toSet();
if (assetIds.isEmpty) {
return const [];
}
final assets = <LocalAsset>[];
for (final slice in assetIds.slices(kDriftMaxChunk)) {
final query = _db.localAssetEntity.select()..where((row) => row.id.isIn(slice));
final rows = await query.map((row) => row.toDto()).get();
assets.addAll(rows);
}
return assets;
}
Future<void> reconcileHashesFromCloudId() async {
await _db.customUpdate(
'''
@@ -4,14 +4,12 @@ import 'package:drift/drift.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@@ -348,12 +346,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
joinLocal: true,
);
TimelineQuery toTrashSyncReview(GroupAssetsBy groupBy) => (
bucketSource: () => _watchTrashSyncBucket(groupBy: groupBy),
assetSource: (offset, count) => _getToTrashSyncBucketAssets(offset: offset, count: count),
origin: TimelineOrigin.syncTrash,
);
TimelineQuery archived(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
filter: (row) =>
row.deletedAt.isNull() & row.ownerId.equals(userId) & row.visibility.equalsValue(AssetVisibility.archive),
@@ -686,58 +678,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get();
}
}
Stream<List<Bucket>> _watchTrashSyncBucket({GroupAssetsBy groupBy = GroupAssetsBy.day}) {
if (groupBy == GroupAssetsBy.none) {
throw UnsupportedError("GroupAssetsBy.none is not supported for watchTrashSyncBucket");
}
final assetCountExp = _db.localAssetEntity.id.count();
final dateExp = _db.localAssetEntity.createdAt.dateFmt(groupBy, toLocal: true);
final query = _db.localAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp])
..where(_db.localAssetEntity.id.isInQuery(_pendingTrashSyncIdsSubquery()))
..groupBy([dateExp])
..orderBy([OrderingTerm.desc(dateExp)]);
return query.map((row) {
final timeline = row.read(dateExp)!.truncateDate(groupBy);
final assetCount = row.read(assetCountExp)!;
return TimeBucket(date: timeline, assetCount: assetCount);
}).watch();
}
Future<List<BaseAsset>> _getToTrashSyncBucketAssets({required int offset, required int count}) {
final query = _db.localAssetEntity.select()
..where((row) => row.id.isInQuery(_pendingTrashSyncIdsSubquery()))
..orderBy([(row) => OrderingTerm.desc(row.createdAt), (row) => OrderingTerm.asc(row.id)])
..limit(count, offset: offset);
return query.map((row) => row.toDto()).get();
}
JoinedSelectStatement<HasResultSet, dynamic> _pendingTrashSyncIdsSubquery() {
final selectedAlbumAssets =
_db.localAlbumAssetEntity.selectOnly().join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
])
..addColumns([_db.localAlbumAssetEntity.assetId])
..where(
_db.localAlbumAssetEntity.assetId.equalsExp(_db.trashSyncEntity.id) &
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected),
);
return _db.trashSyncEntity.selectOnly()
..addColumns([_db.trashSyncEntity.id])
..where(
_db.trashSyncEntity.decision.equalsValue(TrashStateDecision.pendingReview) & existsQuery(selectedAlbumAssets),
);
}
}
List<Bucket> _generateBuckets(int count) {
@@ -1,448 +0,0 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:logging/logging.dart';
enum TrashSyncMode { off, autoSync, review }
typedef RemoteTrashResolveResult = ({int displayCount, bool success});
class TrashSyncCandidate {
final String localAssetId;
final String? checksum;
final DateTime? remoteDeletedAt;
final TrashTriggerSource triggerSource;
final String name;
final AssetType type;
final DateTime createdAt;
final DateTime updatedAt;
final int? width;
final int? height;
final int? durationMs;
final bool isFavorite;
final int orientation;
final AssetPlaybackStyle playbackStyle;
const TrashSyncCandidate({
required this.localAssetId,
required this.checksum,
required this.remoteDeletedAt,
required this.triggerSource,
required this.name,
required this.type,
required this.createdAt,
required this.updatedAt,
required this.width,
required this.height,
required this.durationMs,
required this.isFavorite,
required this.orientation,
required this.playbackStyle,
});
}
class DriftTrashSyncRepository extends DriftDatabaseRepository {
final Logger _logger = Logger('DriftTrashSyncRepository');
final Drift _db;
final DriftLocalAssetRepository _localAssetRepository;
final AssetMediaRepository _assetMediaRepository;
DriftTrashSyncRepository(this._db, this._localAssetRepository, this._assetMediaRepository) : super(_db);
TrashSyncMode get mode {
if (Store.get(StoreKey.reviewOutOfSyncChangesAndroid, false)) {
return TrashSyncMode.review;
}
if (Store.get(StoreKey.manageLocalMediaAndroid, false)) {
return TrashSyncMode.autoSync;
}
return TrashSyncMode.off;
}
Future<void> recordRemoteTrash(Map<String, DateTime> remoteDeletedAtByRemoteId) async {
if (remoteDeletedAtByRemoteId.isEmpty) {
return;
}
final currentMode = mode;
if (currentMode == TrashSyncMode.off) {
return;
}
final candidates = await _localAssetRepository.getRemoteTrashCandidates(remoteDeletedAtByRemoteId);
if (candidates.isEmpty) {
_logger.fine('No local assets matched remote-delete batch of ${remoteDeletedAtByRemoteId.length}');
return;
}
final newCandidates = candidates.map(_candidateFrom).toList();
if (currentMode == TrashSyncMode.autoSync && await _canMoveLocalMediaToTrash()) {
final ids = candidates.map((c) => c.asset.id).toList();
_logger.info('Auto-trashing ${ids.length} local assets');
final movedIds = (await _assetMediaRepository.deleteAll(ids)).toSet();
await upsertCandidates(newCandidates);
if (movedIds.isNotEmpty) {
await markDecision(movedIds, TrashStateDecision.appTrashed);
}
return;
}
await upsertCandidates(newCandidates);
}
Future<void> recheckRemoteTrashCandidates() async {
if (mode == TrashSyncMode.off) {
return;
}
final deleted = await _localAssetRepository.getRemotelyDeletedRemoteIds();
if (deleted.isEmpty) {
return;
}
await recordRemoteTrash(deleted);
}
Future<void> recordRemoteRestore(Iterable<String> aliveRemoteChecksums) async {
final affected = await deleteForRestoredRemotes(aliveRemoteChecksums);
if (affected.isEmpty || mode != TrashSyncMode.autoSync) {
return;
}
final wereAppTrashed = affected.where((r) => r.decision == TrashStateDecision.appTrashed).toList();
if (wereAppTrashed.isEmpty) {
return;
}
if (!CurrentPlatform.isAndroid || !await _hasManageMediaPermission('restore from trash')) {
return;
}
final localAssets = wereAppTrashed.map((r) => r.toLocalAsset()).toList();
await _assetMediaRepository.restoreAssetsFromTrash(localAssets);
}
Future<void> syncRestoresForRevivedAssets() async {
if (mode != TrashSyncMode.autoSync) {
return;
}
if (!CurrentPlatform.isAndroid) {
return;
}
if (!await _hasManageMediaPermission('restore from trash')) {
return;
}
final rows = await getAppTrashedRemotelyRestored();
if (rows.isEmpty) {
return;
}
final localAssets = rows.map((r) => r.toLocalAsset()).toList();
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(localAssets);
if (restoredIds.isEmpty) {
return;
}
await deleteByAssetIds(restoredIds);
}
Future<RemoteTrashResolveResult> applyReviewDecision(Iterable<String> localAssetIds, {required bool keep}) async {
final ids = localAssetIds.toSet();
if (ids.isEmpty) {
return (displayCount: 0, success: true);
}
if (keep) {
await markDecision(ids, TrashStateDecision.kept);
return (displayCount: ids.length, success: true);
}
final movedIds = (await _assetMediaRepository.deleteAll(ids.toList())).toSet();
if (movedIds.isEmpty) {
return (displayCount: 0, success: false);
}
await markDecision(movedIds, TrashStateDecision.appTrashed);
return (displayCount: movedIds.length, success: movedIds.length == ids.length);
}
Future<void> recordUserManualTrash(Iterable<String> localAssetIds) async {
final ids = localAssetIds.toSet();
if (ids.isEmpty) {
return;
}
final snapshots = await _localAssetRepository.getByIds(ids);
if (snapshots.isEmpty) {
return;
}
final manualCandidates = snapshots
.map(
(a) => TrashSyncCandidate(
localAssetId: a.id,
checksum: a.checksum,
remoteDeletedAt: null,
triggerSource: TrashTriggerSource.localUser,
name: a.name,
type: a.type,
createdAt: a.createdAt,
updatedAt: a.updatedAt,
width: a.width,
height: a.height,
durationMs: a.durationMs,
isFavorite: a.isFavorite,
orientation: a.orientation,
playbackStyle: a.playbackStyle,
),
)
.toList();
await upsertCandidates(manualCandidates);
await markDecision(ids, TrashStateDecision.appTrashed);
}
TrashSyncCandidate _candidateFrom(RemoteDeletedLocalAsset candidate) {
final asset = candidate.asset;
return TrashSyncCandidate(
localAssetId: asset.id,
checksum: asset.checksum,
remoteDeletedAt: candidate.remoteDeletedAt,
triggerSource: TrashTriggerSource.remoteSync,
name: asset.name,
type: asset.type,
createdAt: asset.createdAt,
updatedAt: asset.updatedAt,
width: asset.width,
height: asset.height,
durationMs: asset.durationMs,
isFavorite: asset.isFavorite,
orientation: asset.orientation,
playbackStyle: asset.playbackStyle,
);
}
Future<bool> _canMoveLocalMediaToTrash() async {
if (CurrentPlatform.isAndroid) {
return await _hasManageMediaPermission('move to trash');
}
return true;
}
Future<bool> _hasManageMediaPermission(String logContext) async {
if (!CurrentPlatform.isAndroid) {
return true;
}
final hasPermission = await _assetMediaRepository.hasManageMediaPermission();
if (!hasPermission) {
_logger.warning('$logContext blocked: MANAGE_MEDIA permission missing');
}
return hasPermission;
}
Future<void> upsertCandidates(Iterable<TrashSyncCandidate> candidates) async {
if (candidates.isEmpty) {
return;
}
return _db.batch((batch) {
for (final c in candidates) {
batch.insert(
_db.trashSyncEntity,
TrashSyncEntityCompanion.insert(
id: c.localAssetId,
checksum: Value(c.checksum),
decision: TrashStateDecision.pendingReview,
triggerSource: c.triggerSource,
remoteDeletedAt: Value(c.remoteDeletedAt),
name: c.name,
type: c.type,
createdAt: Value(c.createdAt),
updatedAt: Value(c.updatedAt),
width: Value(c.width),
height: Value(c.height),
durationMs: Value(c.durationMs),
isFavorite: Value(c.isFavorite),
orientation: Value(c.orientation),
playbackStyle: Value(c.playbackStyle),
),
mode: InsertMode.insertOrIgnore,
);
}
});
}
Future<void> markDecision(Iterable<String> localAssetIds, TrashStateDecision decision) {
assert(decision != TrashStateDecision.pendingReview, 'Use upsertCandidates for pending rows');
final ids = localAssetIds.toSet();
if (ids.isEmpty) {
return Future.value();
}
return _db.batch((batch) {
for (final slice in ids.slices(kDriftMaxChunk)) {
batch.update(
_db.trashSyncEntity,
TrashSyncEntityCompanion(decision: Value(decision), decidedAt: Value(DateTime.now())),
where: (tbl) => tbl.id.isIn(slice),
);
}
});
}
Future<List<TrashSyncEntityData>> deleteForRestoredRemotes(Iterable<String> remoteAliveChecksums) {
final checksums = remoteAliveChecksums.toSet();
if (checksums.isEmpty) {
return Future.value(const []);
}
return _db.transaction(() async {
final affected = <TrashSyncEntityData>[];
for (final slice in checksums.slices(kDriftMaxChunk)) {
final rows = await (_db.select(
_db.trashSyncEntity,
)..where((t) => t.checksum.isIn(slice) & t.triggerSource.equalsValue(TrashTriggerSource.remoteSync))).get();
affected.addAll(rows);
}
for (final slice in checksums.slices(kDriftMaxChunk)) {
await (_db.delete(
_db.trashSyncEntity,
)..where((t) => t.checksum.isIn(slice) & t.triggerSource.equalsValue(TrashTriggerSource.remoteSync))).go();
}
return affected;
});
}
Future<void> deleteByAssetIds(Iterable<String> localAssetIds) {
final ids = localAssetIds.toSet();
if (ids.isEmpty) {
return Future.value();
}
return _db.batch((batch) {
for (final slice in ids.slices(kDriftMaxChunk)) {
batch.deleteWhere(_db.trashSyncEntity, (t) => t.id.isIn(slice));
}
});
}
Future<List<TrashSyncEntityData>> getAppTrashedRemotelyRestored() async {
final selectedTrashedAlbums =
_db.trashedLocalAssetEntity.selectOnly().join([
innerJoin(
_db.localAlbumEntity,
_db.trashedLocalAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
])
..addColumns([_db.trashedLocalAssetEntity.id])
..where(
_db.trashedLocalAssetEntity.id.equalsExp(_db.trashSyncEntity.id) &
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected),
);
final rows =
await (_db.select(_db.trashSyncEntity).join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.checksum.equalsExp(_db.trashSyncEntity.checksum)),
])..where(
_db.trashSyncEntity.decision.equalsValue(TrashStateDecision.appTrashed) &
_db.trashSyncEntity.triggerSource.equalsValue(TrashTriggerSource.remoteSync) &
_db.remoteAssetEntity.deletedAt.isNull() &
existsQuery(selectedTrashedAlbums),
))
.get();
return rows.map((r) => r.readTable(_db.trashSyncEntity)).toList();
}
Future<Set<String>> getAppTrashedAssetIds() async {
final rows =
await (_db.selectOnly(_db.trashSyncEntity)
..addColumns([_db.trashSyncEntity.id])
..where(_db.trashSyncEntity.decision.equalsValue(TrashStateDecision.appTrashed)))
.get();
return rows.map((r) => r.read(_db.trashSyncEntity.id)!).toSet();
}
Stream<int> watchPendingReviewCount() {
final countExpr = _db.trashSyncEntity.id.count();
final q = _db.selectOnly(_db.trashSyncEntity)
..addColumns([countExpr])
..where(
_db.trashSyncEntity.decision.equalsValue(TrashStateDecision.pendingReview) &
_isLocalAssetInBackupSelectedAlbum(),
);
return q.watchSingle().map((row) => row.read(countExpr) ?? 0).distinct();
}
Stream<bool> watchIsAssetPendingById(String localAssetId) {
final q = _db.selectOnly(_db.trashSyncEntity)
..addColumns([_db.trashSyncEntity.id])
..where(
_db.trashSyncEntity.id.equals(localAssetId) &
_db.trashSyncEntity.decision.equalsValue(TrashStateDecision.pendingReview) &
_isLocalAssetInBackupSelectedAlbum(),
)
..limit(1);
return q.watchSingleOrNull().map((row) => row != null).distinct();
}
Stream<bool> watchIsAssetPendingByChecksum(String checksum) {
final q = _db.selectOnly(_db.trashSyncEntity)
..addColumns([_db.trashSyncEntity.id])
..where(
_db.trashSyncEntity.checksum.equals(checksum) &
_db.trashSyncEntity.decision.equalsValue(TrashStateDecision.pendingReview) &
_isLocalAssetInBackupSelectedAlbum(),
)
..limit(1);
return q.watchSingleOrNull().map((row) => row != null).distinct();
}
Expression<bool> _isLocalAssetInBackupSelectedAlbum() {
final selectedAlbumQ =
_db.localAlbumAssetEntity.selectOnly().join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
])
..addColumns([_db.localAlbumAssetEntity.assetId])
..where(
_db.localAlbumAssetEntity.assetId.equalsExp(_db.trashSyncEntity.id) &
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected),
);
return existsQuery(selectedAlbumQ);
}
Future<int> cleanup() async {
return _db.transaction(() async {
final aliveChecksums = _db.selectOnly(_db.remoteAssetEntity)
..addColumns([_db.remoteAssetEntity.checksum])
..where(_db.remoteAssetEntity.deletedAt.isNull());
final rule1 = await (_db.delete(_db.trashSyncEntity)..where((t) => t.checksum.isInQuery(aliveChecksums))).go();
final liveLocalIds = _db.selectOnly(_db.localAssetEntity)..addColumns([_db.localAssetEntity.id]);
final rule2 =
await (_db.delete(_db.trashSyncEntity)..where(
(t) => t.id.isNotInQuery(liveLocalIds) & t.decision.equalsValue(TrashStateDecision.appTrashed).not(),
))
.go();
return rule1 + rule2;
});
}
}
@@ -3,7 +3,7 @@ import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
@@ -57,6 +57,9 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
return rows.map((result) => result.readTable(_db.trashedLocalAssetEntity).toLocalAsset());
}
/// Applies resulted snapshot of trashed assets:
/// - upserts incoming rows
/// - deletes rows that are not present in the snapshot
Future<void> processTrashSnapshot(Iterable<TrashedAsset> trashedAssets) async {
if (trashedAssets.isEmpty) {
await _db.delete(_db.trashedLocalAssetEntity).go();
@@ -83,7 +86,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
isFavorite: Value(item.asset.isFavorite),
orientation: Value(item.asset.orientation),
playbackStyle: Value(item.asset.playbackStyle),
source: .localSync,
source: TrashOrigin.localSync,
);
batch.insert<$TrashedLocalAssetEntityTable, TrashedLocalAssetEntityData>(
@@ -101,11 +104,9 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
_db.trashedLocalAssetEntity,
)..addColumns([_db.trashedLocalAssetEntity.id])).map((r) => r.read(_db.trashedLocalAssetEntity.id)!).get();
final idToDelete = existingIds.where((id) => !assetIds.contains(id));
await _db.batch((batch) {
for (final slice in idToDelete.slices(kDriftMaxChunk)) {
(_db.delete(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice))).go();
}
});
for (final slice in idToDelete.slices(kDriftMaxChunk)) {
await (_db.delete(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice))).go();
}
}
});
}
@@ -124,7 +125,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
.map((row) => row.read<int>(_db.trashedLocalAssetEntity.id.count()) ?? 0);
}
Future<void> trashLocalAssets(Map<String, Iterable<RemoteDeletedLocalAsset>> assetsByAlbums) async {
Future<void> trashLocalAsset(Map<String, List<LocalAsset>> assetsByAlbums) async {
if (assetsByAlbums.isEmpty) {
return Future.value();
}
@@ -133,8 +134,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
final idToDelete = <String>{};
for (final entry in assetsByAlbums.entries) {
for (final record in entry.value) {
final asset = record.asset;
for (final asset in entry.value) {
idToDelete.add(asset.id);
companions.add(
TrashedLocalAssetEntityCompanion(
@@ -264,6 +264,32 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
});
}
Future<Map<String, List<LocalAsset>>> getToTrash() async {
final result = <String, List<LocalAsset>>{};
final rows =
await (_db.select(_db.localAlbumAssetEntity).join([
innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
leftOuterJoin(
_db.remoteAssetEntity,
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
),
])..where(
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
_db.remoteAssetEntity.deletedAt.isNotNull(),
))
.get();
for (final row in rows) {
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
final asset = row.readTable(_db.localAssetEntity).toDto();
(result[albumId] ??= <LocalAsset>[]).add(asset);
}
return result;
}
//attempt to reuse existing checksums
Future<Map<String, String>> _getCachedChecksums(Set<String> assetIds) async {
final localChecksumById = <String, String>{};
-76
View File
@@ -654,82 +654,6 @@ class NativeSyncApi {
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
}
Future<bool> hasManageMediaPermission() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hasManageMediaPermission$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as bool;
}
Future<bool> requestManageMediaPermission() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.requestManageMediaPermission$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as bool;
}
Future<bool> manageMediaPermission() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.manageMediaPermission$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as bool;
}
Future<bool> restoreFromTrashById(String mediaId, int type) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[mediaId, type]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as bool;
}
Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
@@ -1,60 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/trash_sync_bottom_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@RoutePage()
class DriftTrashSyncReviewPage extends ConsumerWidget {
const DriftTrashSyncReviewPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access trash');
}
final timelineService = ref.watch(timelineFactoryProvider).toTrashSyncReview();
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(
appBar: SliverAppBar(
title: Text('asset_out_of_sync_title'.tr()),
floating: true,
snap: true,
pinned: true,
centerTitle: true,
elevation: 0,
),
topSliverWidgetHeight: 24,
topSliverWidget: SliverPadding(
padding: const EdgeInsets.all(16.0),
sliver: SliverToBoxAdapter(
child: SizedBox(
height: 72.0,
child: Consumer(
builder: (context, ref, _) {
final outOfSyncCount = ref.watch(outOfSyncAssetsCountProvider).value ?? 0;
return outOfSyncCount > 0
? const Text('asset_out_of_sync_trash_subtitle').tr()
: Center(
child: Text('asset_out_of_sync_trash_subtitle_result', style: context.textTheme.bodyLarge).tr(),
);
},
),
),
),
),
bottomSheet: const TrashSyncBottomBar(),
),
);
}
@@ -1,65 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
void showKeepResultToast(BuildContext context, ActionResult result) {
if (!context.mounted) {
return;
}
final message = result.success
? 'assets_denied_to_moved_to_trash_count'.t(args: {'count': '${result.count}'})
: 'scaffold_body_error_occurred'.t();
ImmichToast.show(
context: context,
msg: message,
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
/// This deny move to trash action has the following behavior:
/// - Deny moving to the local trash those assets that are in the remote trash.
///
/// This action is used when the asset is selected in multi-selection mode in the trash page
class KeepOnDeviceActionButton extends ConsumerWidget {
final ActionSource source;
final void Function(ActionResult result) onResult;
const KeepOnDeviceActionButton({super.key, required this.source, required this.onResult});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
ref.read(assetViewerProvider.notifier).setControls(false);
final actionNotifier = ref.read(actionProvider.notifier);
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
final result = await actionNotifier.resolveRemoteTrash(source, keep: true);
onResult.call(result);
multiSelectNotifier.reset();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
const iconData = Icons.cloud_off_outlined;
return source == ActionSource.viewer
? BaseActionButton(
maxWidth: 110.0,
iconData: iconData,
label: 'keep'.t(),
onPressed: () => _onTap(context, ref),
)
: TextButton.icon(
icon: const Icon(iconData),
label: Text('keep_on_device'.t(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,98 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
void showTrashResultToast(BuildContext context, ActionResult result) {
if (!context.mounted) {
return;
}
final message = result.success
? 'assets_moved_to_trash_count'.t(args: {'count': '${result.count}'})
: 'errors.something_went_wrong'.t();
ImmichToast.show(
context: context,
msg: message,
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.info : ToastType.error,
);
}
/// This move to trash action has the following behavior:
/// - Allows moving to the local trash those assets that are in the remote trash.
///
/// This action is used when the asset is selected in multi-selection mode in the review out-of-sync changes
class MoveToTrashActionButton extends ConsumerWidget {
final ActionSource source;
final void Function(ActionResult result) onResult;
const MoveToTrashActionButton({super.key, required this.source, required this.onResult});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final selectedCount = source == ActionSource.viewer ? 1 : ref.read(multiSelectProvider).selectedAssets.length;
final assetViewerNotifier = ref.read(assetViewerProvider.notifier);
assetViewerNotifier.setControls(false);
final confirmed = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('asset_out_of_sync_trash_confirmation_title'.tr()),
content: Text('asset_out_of_sync_trash_confirmation_text'.t(args: {'count': '$selectedCount'})),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('cancel'.t(context: context)),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error),
child: Text('control_bottom_app_bar_trash_from_immich'.tr()),
),
],
);
},
);
if (confirmed != true) {
assetViewerNotifier.setControls(true);
return;
}
final actionNotifier = ref.read(actionProvider.notifier);
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
final result = await actionNotifier.resolveRemoteTrash(source, keep: false);
onResult.call(result);
multiSelectNotifier.reset();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
const iconData = Icons.delete_forever_outlined;
return (source == ActionSource.viewer)
? BaseActionButton(
maxWidth: 100.0,
iconData: iconData,
label: 'delete'.tr(),
onPressed: () => _onTap(context, ref),
)
: TextButton.icon(
icon: Icon(iconData, color: Colors.red[400]),
label: Text(
'control_bottom_app_bar_trash_from_immich'.tr(),
style: TextStyle(fontSize: 14, color: Colors.red[400], fontWeight: FontWeight.bold),
),
onPressed: () => _onTap(context, ref),
);
}
}
@@ -2,22 +2,17 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/keep_on_device_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
@@ -44,50 +39,29 @@ class ViewerBottomBar extends ConsumerWidget {
final serverInfo = ref.watch(serverInfoProvider);
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final isSyncTrashTimeline = timelineOrigin == TimelineOrigin.syncTrash;
final originalTheme = context.themeData;
final actions = <Widget>[
if (isInTrash && isOwner && asset.hasRemote && !isSyncTrashTimeline)
if (isInTrash && isOwner && asset.hasRemote)
const RestoreActionButton(source: ActionSource.viewer)
else
const ShareActionButton(source: ActionSource.viewer),
if (isSyncTrashTimeline) ...[
KeepOnDeviceActionButton(
source: ActionSource.viewer,
onResult: (result) {
showKeepResultToast(context, result);
_updateView(result, ref);
},
),
MoveToTrashActionButton(
source: ActionSource.viewer,
onResult: (result) {
showTrashResultToast(context, result);
_updateView(result, ref);
},
),
] else ...[
const ShareActionButton(source: ActionSource.viewer),
if (!isInLockedView) ...[
if (!isInTrash) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
// edit sync was added in 2.6.0
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
],
if (isOwner) ...[
if (asset.isLocalOnly)
const DeleteLocalActionButton(source: ActionSource.viewer)
else if (asset.isTrashed)
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
else
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
],
if (!isInLockedView) ...[
if (!isInTrash) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
// edit sync was added in 2.6.0
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
],
if (isOwner) ...[
if (asset.isLocalOnly)
const DeleteLocalActionButton(source: ActionSource.viewer)
else if (asset.isTrashed)
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
else
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
],
],
];
@@ -138,15 +112,4 @@ class ViewerBottomBar extends ConsumerWidget {
),
);
}
void _updateView(ActionResult result, WidgetRef ref) {
Future.delayed(Durations.extralong4, () {
if (result.success) {
EventStream.shared.emit(const TimelineReloadEvent());
}
if (ref.context.mounted) {
ref.read(assetViewerProvider.notifier).setControls(true);
}
});
}
}
@@ -9,7 +9,6 @@ import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -36,7 +35,6 @@ class ViewerKebabMenu extends ConsumerWidget {
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final isWaitingForTrashApproval = ref.watch(isWaitingForTrashApprovalProvider(asset.checksum)).value == true;
final actionContext = ActionButtonContext(
asset: asset,
@@ -50,7 +48,6 @@ class ViewerKebabMenu extends ConsumerWidget {
source: ActionSource.viewer,
isCasting: isCasting,
timelineOrigin: timelineOrigin,
isWaitingForTrashApproval: isWaitingForTrashApproval,
);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
@@ -15,8 +14,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -50,10 +47,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final originalTheme = context.themeData;
final isWaitingForSyncApproval =
ref.read(timelineServiceProvider).origin == TimelineOrigin.syncTrash ||
ref.watch(isWaitingForTrashApprovalProvider(asset.checksum)).value == true;
final actions = <Widget>[
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
if (album != null && album.isActivityEnabled && album.isShared)
@@ -70,9 +63,9 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
},
),
if (asset.hasRemote && isOwner && !asset.isFavorite && !isWaitingForSyncApproval)
if (asset.hasRemote && isOwner && !asset.isFavorite)
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
if (asset.hasRemote && isOwner && asset.isFavorite && !isWaitingForSyncApproval)
if (asset.hasRemote && isOwner && asset.isFavorite)
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
ViewerKebabMenu(originalTheme: originalTheme),
@@ -1,38 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/keep_on_device_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_trash_action_button.widget.dart';
class TrashSyncBottomBar extends ConsumerWidget {
const TrashSyncBottomBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 64,
child: Container(
color: context.themeData.canvasColor,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
KeepOnDeviceActionButton(
source: ActionSource.timeline,
onResult: (result) => showKeepResultToast(context, result),
),
MoveToTrashActionButton(
source: ActionSource.timeline,
onResult: (result) => showTrashResultToast(context, result),
),
],
),
),
),
),
);
}
}
@@ -296,16 +296,12 @@ class _ThumbnailRenderBox extends RenderBox {
bool isRepaintBoundary = true;
_ThumbnailRenderBox({
required ui.Image? image,
required ui.Image? previousImage,
required double fadeValue,
required BoxFit fit,
required Gradient placeholderGradient,
}) : _image = image,
_previousImage = previousImage,
_fadeValue = fadeValue,
_fit = fit,
_placeholderGradient = placeholderGradient;
required this._image,
required this._previousImage,
required this._fadeValue,
required this._fit,
required this._placeholderGradient,
});
@override
void paint(PaintingContext context, Offset offset) {
@@ -62,14 +62,11 @@ class RenderFixedRow extends RenderBox
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
RenderFixedRow({
List<RenderBox>? children,
required double height,
required List<double> widths,
required double spacing,
required TextDirection textDirection,
}) : _height = height,
_widths = widths,
_spacing = spacing,
_textDirection = textDirection {
required this._height,
required this._widths,
required this._spacing,
required this._textDirection,
}) {
addAll(children);
}
@@ -578,9 +578,7 @@ class _SlideFadeTransition extends StatelessWidget {
final Animation<double> _animation;
final Widget _child;
const _SlideFadeTransition({required Animation<double> animation, required Widget child})
: _animation = animation,
_child = child;
const _SlideFadeTransition({required this._animation, required this._child});
@override
Widget build(BuildContext context) {
@@ -397,7 +397,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final grid = CustomScrollView(
primary: true,
physics: _scrollPhysics,
cacheExtent: maxHeight * 2,
scrollCacheExtent: .pixels(maxHeight * 2),
slivers: [
if (isSelectionMode) const SelectionSliverAppBar() else if (widget.appBar != null) widget.appBar!,
if (widget.topSliverWidget != null) widget.topSliverWidget!,
@@ -503,7 +503,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
class _SliverSegmentedList extends SliverMultiBoxAdaptorWidget {
final List<Segment> _segments;
const _SliverSegmentedList({required List<Segment> segments, required super.delegate}) : _segments = segments;
const _SliverSegmentedList({required this._segments, required super.delegate});
@override
_RenderSliverTimelineBoxAdaptor createRenderObject(BuildContext context) =>
@@ -527,8 +527,7 @@ class _RenderSliverTimelineBoxAdaptor extends RenderSliverMultiBoxAdaptor {
markNeedsLayout();
}
_RenderSliverTimelineBoxAdaptor({required super.childManager, required List<Segment> segments})
: _segments = segments;
_RenderSliverTimelineBoxAdaptor({required super.childManager, required this._segments});
int getMinChildIndexForScrollOffset(double offset) =>
_segments.findByOffset(offset)?.getMinChildIndexForScrollOffset(offset) ?? 0;
@@ -2,8 +2,3 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
final appSettingsServiceProvider = Provider((_) => const AppSettingsService());
final appSettingStreamProvider = StreamProvider.family.autoDispose<bool, AppSettingsEnum<bool>>((ref, setting) {
final service = ref.watch(appSettingsServiceProvider);
return service.watchSetting(setting);
});
@@ -550,21 +550,6 @@ class ActionNotifier extends Notifier<void> {
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> resolveRemoteTrash(ActionSource source, {required bool keep}) async {
final selectedLocalIds = _getAssets(source).map((a) => a.localId).nonNulls.toSet();
_logger.info('resolveRemoteTrash, selectedLocalIds: $selectedLocalIds, keep: $keep');
if (selectedLocalIds.isEmpty) {
return const ActionResult(count: 0, success: false, error: 'Failed to select asset(s)');
}
try {
final result = await _service.resolveRemoteTrash(selectedLocalIds, keep: keep);
return ActionResult(count: result.displayCount, success: result.success);
} catch (error, stack) {
_logger.severe('Failed to ${keep ? 'keep' : 'trash'} assets', error, stack);
return ActionResult(count: selectedLocalIds.length, success: false, error: error.toString());
}
}
}
extension on Iterable<RemoteAsset> {
@@ -11,7 +11,8 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
@@ -19,7 +20,10 @@ final syncStreamServiceProvider = Provider(
(ref) => SyncStreamService(
syncApiRepository: ref.watch(syncApiRepositoryProvider),
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
trashSyncRepository: ref.watch(trashSyncRepositoryProvider),
localAssetRepository: ref.watch(localAssetRepository),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
storageRepository: ref.watch(storageRepositoryProvider),
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
api: ref.watch(apiServiceProvider),
cancelChecker: ref.watch(cancellationProvider),
@@ -35,7 +39,8 @@ final localSyncServiceProvider = Provider(
localAlbumRepository: ref.watch(localAlbumRepository),
localAssetRepository: ref.watch(localAssetRepository),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
trashSyncRepository: ref.watch(trashSyncRepositoryProvider),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
storageRepository: ref.watch(storageRepositoryProvider),
nativeSyncApi: ref.watch(nativeSyncApiProvider),
),
);
@@ -1,45 +1,12 @@
import 'package:async/async.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
typedef TrashedAssetsCount = ({int total, int hashed});
final trashSyncRepositoryProvider = Provider<DriftTrashSyncRepository>((ref) {
return DriftTrashSyncRepository(
ref.watch(driftProvider),
ref.watch(localAssetRepository),
ref.watch(assetMediaRepositoryProvider),
);
});
final trashedAssetsCountProvider = StreamProvider<TrashedAssetsCount>((ref) {
final repo = ref.watch(trashedLocalAssetRepository);
final total$ = repo.watchCount();
final hashed$ = repo.watchHashedCount();
return StreamZip<int>([total$, hashed$]).map((values) => (total: values[0], hashed: values[1]));
});
final outOfSyncAssetsCountProvider = StreamProvider<int>((ref) {
final enabledReviewMode = ref.watch(appSettingStreamProvider(AppSettingsEnum.reviewOutOfSyncChangesAndroid));
final repo = ref.watch(trashSyncRepositoryProvider);
return enabledReviewMode.when(
data: (enabled) => enabled ? repo.watchPendingReviewCount() : Stream<int>.value(0),
loading: () => Stream<int>.value(0),
error: (_, __) => Stream<int>.value(0),
);
});
final isWaitingForTrashApprovalProvider = StreamProvider.family<bool, String?>((ref, checksum) {
final enabledReviewMode = ref.watch(appSettingStreamProvider(AppSettingsEnum.reviewOutOfSyncChangesAndroid));
final repo = ref.watch(trashSyncRepositoryProvider);
return enabledReviewMode.when(
data: (enabled) => enabled && checksum != null ? repo.watchIsAssetPendingByChecksum(checksum) : Stream.value(false),
loading: () => Stream.value(false),
error: (_, __) => Stream.value(false),
);
});
@@ -8,24 +8,19 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:share_plus/share_plus.dart';
final assetMediaRepositoryProvider = Provider(
(ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider), ref.watch(nativeSyncApiProvider)),
);
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider)));
class AssetMediaRepository {
final AssetApiRepository _assetApiRepository;
final NativeSyncApi _nativeSyncApi;
static final Logger _log = Logger("AssetMediaRepository");
const AssetMediaRepository(this._assetApiRepository, this._nativeSyncApi);
const AssetMediaRepository(this._assetApiRepository);
Future<bool> _androidSupportsTrash() async {
if (Platform.isAndroid) {
@@ -50,54 +45,6 @@ class AssetMediaRepository {
return PhotoManager.editor.deleteWithIds(ids);
}
Future<bool> requestManageMediaPermission() async {
try {
return await _nativeSyncApi.requestManageMediaPermission();
} catch (e, s) {
_log.warning('Error requesting manage media permission', e, s);
return false;
}
}
Future<bool> hasManageMediaPermission() async {
try {
return await _nativeSyncApi.hasManageMediaPermission();
} catch (e, s) {
_log.warning('Error requesting manage media permission state', e, s);
return false;
}
}
Future<bool> manageMediaPermission() async {
try {
return await _nativeSyncApi.manageMediaPermission();
} catch (e, s) {
_log.warning('Error requesting manage media permission settings', e, s);
return false;
}
}
Future<bool> _restoreFromTrashById(String mediaId, int type) async {
try {
return await _nativeSyncApi.restoreFromTrashById(mediaId, type);
} catch (e, s) {
_log.warning('Error restore file from trash by Id', e, s);
return false;
}
}
Future<List<String>> restoreAssetsFromTrash(Iterable<LocalAsset> assets) async {
final restoredIds = <String>[];
for (final asset in assets) {
_log.info("Restoring from trash, localId: ${asset.id}, checksum: ${asset.checksum}");
final result = await _restoreFromTrashById(asset.id, asset.type.index);
if (result) {
restoredIds.add(asset.id);
}
}
return restoredIds;
}
Future<AssetEntity?> get(String id) async {
final entity = await AssetEntity.fromId(id);
return entity;
@@ -0,0 +1,51 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/services/local_files_manager.service.dart';
import 'package:logging/logging.dart';
final localFilesManagerRepositoryProvider = Provider(
(ref) => LocalFilesManagerRepository(ref.watch(localFileManagerServiceProvider)),
);
class LocalFilesManagerRepository {
LocalFilesManagerRepository(this._service);
final Logger _logger = Logger('LocalFilesManagerRepo');
final LocalFilesManagerService _service;
Future<bool> moveToTrash(List<String> mediaUrls) async {
return await _service.moveToTrash(mediaUrls);
}
Future<bool> restoreFromTrash(String fileName, int type) async {
return await _service.restoreFromTrash(fileName, type);
}
Future<bool> requestManageMediaPermission() async {
return await _service.requestManageMediaPermission();
}
Future<bool> hasManageMediaPermission() async {
return await _service.hasManageMediaPermission();
}
Future<bool> manageMediaPermission() async {
return await _service.manageMediaPermission();
}
Future<List<String>> restoreAssetsFromTrash(Iterable<LocalAsset> assets) async {
final restoredIds = <String>[];
for (final asset in assets) {
_logger.info("Restoring from trash, localId: ${asset.id}, remoteId: ${asset.checksum}");
try {
final result = await _service.restoreFromTrashById(asset.id, asset.type.index);
if (result) {
restoredIds.add(asset.id);
}
} catch (e) {
_logger.warning("Restoring failure: $e");
}
}
return restoredIds;
}
}
-2
View File
@@ -63,7 +63,6 @@ import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_slideshow.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash_sync_review.page.dart';
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
import 'package:immich_mobile/presentation/pages/edit/drift_edit.page.dart';
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
@@ -164,7 +163,6 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftMemoryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftFavoriteRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftTrashRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftTrashSyncReviewRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftArchiveRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftLockedFolderRoute.page, guards: [_authGuard, _lockedGuard, _duplicateGuard]),
AutoRoute(page: DriftVideoRoute.page, guards: [_authGuard, _duplicateGuard]),
-16
View File
@@ -1158,22 +1158,6 @@ class DriftTrashRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [DriftTrashSyncReviewPage]
class DriftTrashSyncReviewRoute extends PageRouteInfo<void> {
const DriftTrashSyncReviewRoute({List<PageRouteInfo>? children})
: super(DriftTrashSyncReviewRoute.name, initialChildren: children);
static const String name = 'DriftTrashSyncReviewRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const DriftTrashSyncReviewPage();
},
);
}
/// generated route for
/// [DriftUploadDetailPage]
class DriftUploadDetailRoute extends PageRouteInfo<void> {
+7 -14
View File
@@ -8,15 +8,14 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.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/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
@@ -35,7 +34,7 @@ final actionServiceProvider = Provider<ActionService>(
ref.watch(localAssetRepository),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(remoteAlbumRepository),
ref.watch(trashSyncRepositoryProvider),
ref.watch(trashedLocalAssetRepository),
ref.watch(assetMediaRepositoryProvider),
ref.watch(downloadRepositoryProvider),
ref.watch(tagServiceProvider),
@@ -48,7 +47,7 @@ class ActionService {
final DriftLocalAssetRepository _localAssetRepository;
final DriftAlbumApiRepository _albumApiRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftTrashSyncRepository _trashSyncRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final DownloadRepository _downloadRepository;
final TagService _tagService;
@@ -59,7 +58,7 @@ class ActionService {
this._localAssetRepository,
this._albumApiRepository,
this._remoteAlbumRepository,
this._trashSyncRepository,
this._trashedLocalAssetRepository,
this._assetMediaRepository,
this._downloadRepository,
this._tagService,
@@ -299,16 +298,10 @@ class ActionService {
return 0;
}
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _trashSyncRepository.recordUserManualTrash(deletedIds);
await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds);
} else {
await _localAssetRepository.delete(deletedIds);
}
await _localAssetRepository.delete(deletedIds);
return deletedIds.length;
}
/// Apply a user review decision. The HIGH atomicity bug from the
/// original PR cannot recur — `DriftTrashSyncRepository` owns the single
/// transactional surface.
Future<RemoteTrashResolveResult> resolveRemoteTrash(Iterable<String> localAssetIds, {required bool keep}) {
return _trashSyncRepository.applyReviewDecision(localAssetIds, keep: keep);
}
}
@@ -4,7 +4,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
enum AppSettingsEnum<T> {
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
reviewOutOfSyncChangesAndroid<bool>(StoreKey.reviewOutOfSyncChangesAndroid, null, false),
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);
@@ -24,11 +23,4 @@ class AppSettingsService {
Future<void> setSetting<T>(AppSettingsEnum<T> setting, T value) {
return Store.put(setting.storeKey, value);
}
Stream<T> watchSetting<T>(AppSettingsEnum<T> setting) async* {
yield getSetting<T>(setting);
await for (final dynamic value in Store.watch(setting.storeKey)) {
yield (value as T?) ?? setting.defaultValue;
}
}
}
@@ -0,0 +1,66 @@
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logging/logging.dart';
final localFileManagerServiceProvider = Provider<LocalFilesManagerService>((ref) => const LocalFilesManagerService());
class LocalFilesManagerService {
const LocalFilesManagerService();
static final Logger _logger = Logger('LocalFilesManager');
static const MethodChannel _channel = MethodChannel('file_trash');
Future<bool> moveToTrash(List<String> mediaUrls) async {
try {
return await _channel.invokeMethod('moveToTrash', {'mediaUrls': mediaUrls});
} catch (e, s) {
_logger.warning('Error moving file to trash', e, s);
return false;
}
}
Future<bool> restoreFromTrash(String fileName, int type) async {
try {
return await _channel.invokeMethod('restoreFromTrash', {'fileName': fileName, 'type': type});
} catch (e, s) {
_logger.warning('Error restore file from trash', e, s);
return false;
}
}
Future<bool> restoreFromTrashById(String mediaId, int type) async {
try {
return await _channel.invokeMethod('restoreFromTrash', {'mediaId': mediaId, 'type': type});
} catch (e, s) {
_logger.warning('Error restore file from trash by Id', e, s);
return false;
}
}
Future<bool> requestManageMediaPermission() async {
try {
return await _channel.invokeMethod('requestManageMediaPermission');
} catch (e, s) {
_logger.warning('Error requesting manage media permission', e, s);
return false;
}
}
Future<bool> hasManageMediaPermission() async {
try {
return await _channel.invokeMethod('hasManageMediaPermission');
} catch (e, s) {
_logger.warning('Error requesting manage media permission state', e, s);
return false;
}
}
Future<bool> manageMediaPermission() async {
try {
return await _channel.invokeMethod('manageMediaPermission');
} catch (e, s) {
_logger.warning('Error requesting manage media permission settings', e, s);
return false;
}
}
}
+1 -3
View File
@@ -12,13 +12,11 @@ class ImmichTheme {
ThemeData getThemeData({required ColorScheme colorScheme, required Locale locale}) {
final isDark = colorScheme.brightness == Brightness.dark;
final warningColor = isDark ? const Color(0xFFF3BC6A) : const Color(0xFFC47A00);
final onWarningColor = isDark ? Colors.black : Colors.white;
return ThemeData(
useMaterial3: true,
brightness: colorScheme.brightness,
colorScheme: colorScheme.copyWith(tertiary: warningColor, onTertiary: onWarningColor),
colorScheme: colorScheme,
primaryColor: colorScheme.primary,
hintColor: colorScheme.onSurfaceSecondary,
focusColor: colorScheme.primary,
+7 -17
View File
@@ -47,7 +47,6 @@ class ActionButtonContext {
final bool isCasting;
final TimelineOrigin timelineOrigin;
final int selectedCount;
final bool isWaitingForTrashApproval;
const ActionButtonContext({
required this.asset,
@@ -62,7 +61,6 @@ class ActionButtonContext {
this.isCasting = false,
this.timelineOrigin = TimelineOrigin.main,
this.selectedCount = 1,
this.isWaitingForTrashApproval = false,
});
}
@@ -104,8 +102,7 @@ enum ActionButtonType {
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
!context.isArchived &&
!context.isWaitingForTrashApproval,
!context.isArchived,
ActionButtonType.unarchive =>
context.isOwner && //
!context.isInLockedView && //
@@ -120,37 +117,31 @@ enum ActionButtonType {
!context.isInLockedView && //
context.asset.hasRemote && //
context.isTrashEnabled && //
context.timelineOrigin != TimelineOrigin.trash &&
!context.isWaitingForTrashApproval,
context.timelineOrigin != TimelineOrigin.trash,
ActionButtonType.restoreTrash =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
context.timelineOrigin == TimelineOrigin.trash &&
!context.isWaitingForTrashApproval,
context.timelineOrigin == TimelineOrigin.trash,
ActionButtonType.deletePermanent =>
context.isOwner && //
context.asset.hasRemote && //
(!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView) &&
!context.isWaitingForTrashApproval,
(!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView),
ActionButtonType.delete =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote &&
!context.isWaitingForTrashApproval,
context.asset.hasRemote,
ActionButtonType.moveToLockFolder =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote &&
!context.isWaitingForTrashApproval,
context.asset.hasRemote,
ActionButtonType.removeFromLockFolder =>
context.isOwner && //
context.isInLockedView && //
context.asset.hasRemote,
ActionButtonType.deleteLocal =>
!context.isInLockedView && //
context.asset.hasLocal &&
!context.isWaitingForTrashApproval,
context.asset.hasLocal,
ActionButtonType.upload =>
!context.isInLockedView && //
context.asset.storage == AssetState.local,
@@ -188,7 +179,6 @@ enum ActionButtonType {
context.timelineOrigin != TimelineOrigin.lockedFolder &&
context.timelineOrigin != TimelineOrigin.archive &&
context.timelineOrigin != TimelineOrigin.localAlbum &&
context.timelineOrigin != TimelineOrigin.syncTrash &&
context.isOwner,
ActionButtonType.cast => context.isCasting || context.asset.hasRemote,
ActionButtonType.slideshow => true,
@@ -6,13 +6,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
import 'package:immich_mobile/pages/common/settings.page.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/locale_provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
@@ -70,24 +68,19 @@ class ImmichAppBarDialog extends HookConsumerWidget {
);
}
buildActionButton(IconData icon, String text, Function() onTap, {Widget? trailing, Color? btnColor}) {
buildActionButton(IconData icon, String text, Function() onTap, {Widget? trailing}) {
return ListTile(
dense: true,
visualDensity: VisualDensity.standard,
contentPadding: const EdgeInsets.only(left: 30, right: 30),
minLeadingWidth: 40,
leading: SizedBox(
child: Icon(icon, color: btnColor ?? theme.textTheme.labelLarge?.color?.withAlpha(250), size: 20),
),
leading: SizedBox(child: Icon(icon, color: theme.textTheme.labelLarge?.color?.withAlpha(250), size: 20)),
title: Text(
text,
style: theme.textTheme.labelLarge?.copyWith(
color: btnColor ?? theme.textTheme.labelLarge?.color?.withAlpha(250),
),
style: theme.textTheme.labelLarge?.copyWith(color: theme.textTheme.labelLarge?.color?.withAlpha(250)),
).tr(),
onTap: onTap,
trailing: trailing,
iconColor: btnColor,
);
}
@@ -103,25 +96,6 @@ class ImmichAppBarDialog extends HookConsumerWidget {
);
}
Widget buildOutOfSyncButton() {
return Consumer(
builder: (context, ref, _) {
final outOfSyncCount = ref.watch(outOfSyncAssetsCountProvider).value ?? 0;
if (outOfSyncCount == 0) {
return const SizedBox.shrink();
}
final btnColor = theme.colorScheme.tertiary;
return buildActionButton(
Icons.warning_amber_rounded,
'review_out_of_sync_changes'.t(),
() => context.pushRoute(const DriftTrashSyncReviewRoute()),
trailing: Text('($outOfSyncCount)', style: theme.textTheme.labelLarge?.copyWith(color: btnColor)),
btnColor: btnColor,
);
},
);
}
buildAppLogButton() {
return buildActionButton(
Icons.assignment_outlined,
@@ -295,7 +269,6 @@ class ImmichAppBarDialog extends HookConsumerWidget {
],
),
),
buildOutOfSyncButton(),
if (isReadonlyModeEnabled) buildReadonlyMessage(),
buildAppLogButton(),
buildFreeUpSpaceButton(),
@@ -12,7 +12,6 @@ import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@@ -112,7 +111,6 @@ class _ProfileIndicator extends ConsumerWidget {
// TODO: remove this when update Flutter version newer than 3.35.7
final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile;
final outOfSyncCount = ref.watch(outOfSyncAssetsCountProvider).value ?? 0;
void toggleReadonlyMode() {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
ref.read(readonlyModeProvider.notifier).toggleReadonlyMode();
@@ -149,7 +147,7 @@ class _ProfileIndicator extends ConsumerWidget {
),
backgroundColor: Colors.transparent,
alignment: Alignment.bottomRight,
isLabelVisible: versionWarningPresent || outOfSyncCount > 0,
isLabelVisible: versionWarningPresent,
offset: const Offset(-2, -12),
child: user == null
? const Icon(Icons.face_outlined, size: widgetSize)
@@ -22,7 +22,7 @@ import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/provider_utils.dart';
import 'package:immich_mobile/utils/url_helper.dart';
@@ -193,7 +193,7 @@ class LoginForm extends HookConsumerWidget {
}
getManageMediaPermission() async {
final hasPermission = await ref.read(assetMediaRepositoryProvider).hasManageMediaPermission();
final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission();
if (!hasPermission) {
await showDialog(
context: context,
@@ -224,7 +224,7 @@ class LoginForm extends HookConsumerWidget {
),
TextButton(
onPressed: () {
ref.read(assetMediaRepositoryProvider).requestManageMediaPermission();
ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
Navigator.of(context).pop();
},
child: Text(
@@ -7,20 +7,17 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/app_settings.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/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_action_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/widgets/settings/ssl_client_cert_settings.dart';
import 'package:logging/logging.dart';
@@ -31,7 +28,9 @@ class AdvancedSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final isManageMediaSupported = useState(false);
final manageMediaAndroidPermission = useState(false);
final levelId = useState<int>(ref.read(systemConfigProvider).logLevel.index);
final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote);
useValueChanged(
@@ -57,6 +56,11 @@ class AdvancedSettings extends HookConsumerWidget {
useEffect(() {
() async {
isManageMediaSupported.value = await checkAndroidVersion();
if (isManageMediaSupported.value) {
manageMediaAndroidPermission.value = await ref
.read(localFilesManagerRepositoryProvider)
.hasManageMediaPermission();
}
}();
return null;
}, []);
@@ -68,11 +72,36 @@ class AdvancedSettings extends HookConsumerWidget {
title: "advanced_settings_troubleshooting_title".tr(),
subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
),
// Android 12+: full selector (Off / Auto sync / Review) + MANAGE_MEDIA tile.
// iOS: reduced selector (Off / Review) no MANAGE_MEDIA on this
// platform; auto-sync is dropped because PhotoKit prompts on
// every batch, which would defeat the "set and forget" intent.
if (isManageMediaSupported.value || Platform.isIOS) const _TrashSyncModeSelector(),
if (isManageMediaSupported.value)
Column(
children: [
SettingsSwitchListTile(
enabled: true,
valueNotifier: manageLocalMediaAndroid,
title: "advanced_settings_sync_remote_deletions_title".tr(),
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
onChanged: (value) async {
if (value) {
final result = await ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
manageLocalMediaAndroid.value = result;
manageMediaAndroidPermission.value = result;
}
},
),
SettingsActionTile(
title: "manage_media_access_title".tr(),
statusText: manageMediaAndroidPermission.value ? "allowed".tr() : "not_allowed".tr(),
subtitle: "manage_media_access_rationale".tr(),
statusColor: manageLocalMediaAndroid.value && !manageMediaAndroidPermission.value
? const Color.fromARGB(255, 243, 188, 106)
: null,
onActionTap: () async {
final result = await ref.read(localFilesManagerRepositoryProvider).manageMediaPermission();
manageMediaAndroidPermission.value = result;
},
),
],
),
SettingsSliderListTile(
text: "advanced_settings_log_level_title".tr(namedArgs: {'level': logLevel}),
valueNotifier: levelId,
@@ -149,135 +178,3 @@ class AdvancedSettings extends HookConsumerWidget {
return SettingsSubPageScaffold(settings: advancedSettings);
}
}
enum _TrashSyncMode { none, auto, review }
final _manageMediaPermissionProvider = FutureProvider<bool>((ref) async {
return ref.watch(assetMediaRepositoryProvider).hasManageMediaPermission();
});
class _TrashSyncModeSelector extends HookConsumerWidget {
const _TrashSyncModeSelector();
@override
Widget build(BuildContext context, WidgetRef ref) {
final autoSyncChanges = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final reviewOutOfSyncChanges = useAppSettingsState(AppSettingsEnum.reviewOutOfSyncChangesAndroid);
final manageMediaAndroidPermission = ref.watch(_manageMediaPermissionProvider);
final manageMediaAndroidPermissionValue = manageMediaAndroidPermission.valueOrNull;
final selectedTrashSyncMode = autoSyncChanges.value
? _TrashSyncMode.auto
: reviewOutOfSyncChanges.value
? _TrashSyncMode.review
: _TrashSyncMode.none;
Future<void> attemptToEnableSetting(AppSettingsEnum key) async {
if (Platform.isIOS) {
// No MANAGE_MEDIA on iOS; review is the only mode the user can pick.
if (key == AppSettingsEnum.reviewOutOfSyncChangesAndroid) {
reviewOutOfSyncChanges.value = true;
autoSyncChanges.value = false;
}
ref.invalidate(appSettingsServiceProvider);
return;
}
final result = await ref.read(assetMediaRepositoryProvider).requestManageMediaPermission();
ref.invalidate(_manageMediaPermissionProvider);
if (key == AppSettingsEnum.manageLocalMediaAndroid) {
autoSyncChanges.value = result;
if (result) {
reviewOutOfSyncChanges.value = false;
}
}
if (key == AppSettingsEnum.reviewOutOfSyncChangesAndroid) {
reviewOutOfSyncChanges.value = result;
if (result) {
autoSyncChanges.value = false;
}
}
ref.invalidate(appSettingsServiceProvider);
}
Future<void> handleTrashSyncModeChange(_TrashSyncMode? mode) async {
if (mode == null) {
return;
}
switch (mode) {
case _TrashSyncMode.none:
if (!autoSyncChanges.value && !reviewOutOfSyncChanges.value) {
break;
}
autoSyncChanges.value = false;
reviewOutOfSyncChanges.value = false;
ref.invalidate(appSettingsServiceProvider);
break;
case _TrashSyncMode.auto:
if (autoSyncChanges.value) {
break;
}
await attemptToEnableSetting(AppSettingsEnum.manageLocalMediaAndroid);
break;
case _TrashSyncMode.review:
if (reviewOutOfSyncChanges.value) {
break;
}
await attemptToEnableSetting(AppSettingsEnum.reviewOutOfSyncChangesAndroid);
break;
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingsSubTitle(title: "advanced_settings_sync_remote_deletions_selector_title".tr()),
SettingsRadioListTile(
groups: [
SettingsRadioGroup(
title: 'off'.tr(),
subtitle: 'advanced_settings_sync_remote_deletions_off_subtitle'.tr(),
value: _TrashSyncMode.none,
),
// Auto-sync requires MANAGE_MEDIA to run silently. iOS has no
// equivalent permission and every batch would trigger a PhotoKit
// prompt so the auto mode is intentionally hidden there.
if (!Platform.isIOS)
SettingsRadioGroup(
title: 'advanced_settings_sync_remote_deletions_title'.tr(),
subtitle: 'advanced_settings_sync_remote_deletions_subtitle'.tr(),
value: _TrashSyncMode.auto,
),
SettingsRadioGroup(
title: 'advanced_settings_review_remote_deletions_title'.tr(),
subtitle: 'advanced_settings_review_remote_deletions_subtitle'.tr(),
value: _TrashSyncMode.review,
),
],
groupBy: selectedTrashSyncMode,
onRadioChanged: (mode) => handleTrashSyncModeChange(mode),
),
// MANAGE_MEDIA permission tile is Android-only; iOS has no equivalent.
if (!Platform.isIOS)
SettingsActionTile(
title: "manage_media_access_title".tr(),
statusText: manageMediaAndroidPermissionValue == null
? null
: manageMediaAndroidPermissionValue == true
? "allowed".tr()
: "not_allowed".tr(),
subtitle: "manage_media_access_rationale".tr(),
statusColor:
manageMediaAndroidPermissionValue == false && (autoSyncChanges.value || reviewOutOfSyncChanges.value)
? const Color.fromARGB(255, 243, 188, 106)
: null,
onActionTap: () async {
await ref.read(assetMediaRepositoryProvider).manageMediaPermission();
ref.invalidate(_manageMediaPermissionProvider);
},
),
],
);
}
}
@@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -355,10 +354,8 @@ class _SyncStatsCounts extends ConsumerWidget {
),
),
// To be removed once the experimental feature is stable
if ((kDebugMode || kProfileMode) &&
CurrentPlatform.isAndroid &&
(appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid) ||
appSettingsService.getSetting<bool>(AppSettingsEnum.reviewOutOfSyncChangesAndroid))) ...[
if (CurrentPlatform.isAndroid &&
appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid)) ...[
SettingGroupTitle(title: "trash".t(context: context)),
Consumer(
builder: (context, ref, _) {
@@ -1,12 +1,13 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
class LanguageSettings extends HookConsumerWidget {
@@ -84,7 +85,7 @@ class LanguageSettings extends HookConsumerWidget {
padding: const EdgeInsets.all(8),
itemCount: filteredLocaleEntries.value.length,
itemExtent: 64.0,
cacheExtent: 100,
scrollCacheExtent: const .pixels(100),
itemBuilder: (context, index) {
final countryName = filteredLocaleEntries.value[index].key;
final localeValue = filteredLocaleEntries.value[index].value;
@@ -36,10 +36,6 @@ class ExternalNetworkPreference extends HookConsumerWidget {
}
handleReorder(int oldIndex, int newIndex) {
if (oldIndex < newIndex) {
newIndex -= 1;
}
final entry = entries.value.removeAt(oldIndex);
entries.value.insert(newIndex, entry);
entries.value = [...entries.value];
@@ -113,7 +109,7 @@ class ExternalNetworkPreference extends HookConsumerWidget {
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: entries.value.length,
onReorder: handleReorder,
onReorderItem: handleReorder,
itemBuilder: (context, index) {
return EndpointInput(
key: Key(index.toString()),
@@ -1,13 +1,11 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
class SettingsRadioGroup<T> {
final String title;
final String? subtitle;
final T value;
const SettingsRadioGroup({required this.title, this.subtitle, required this.value});
const SettingsRadioGroup({required this.title, required this.value});
}
class SettingsRadioListTile<T> extends StatelessWidget {
@@ -30,12 +28,6 @@ class SettingsRadioListTile<T> extends StatelessWidget {
dense: true,
activeColor: context.primaryColor,
title: Text(g.title, style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
subtitle: g.subtitle != null
? Text(
g.subtitle!,
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
)
: null,
value: g.value,
controlAffinity: ListTileControlAffinity.trailing,
),
+8 -8
View File
@@ -1,26 +1,26 @@
.PHONY: build watch create_app_icon create_splash build_release_android pigeon test analyze format migration translation
build:
@printf "This command has been removed. Please use:\n\n mise codegen # or mise //:mobile:codegen:dart from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise codegen # or mise //mobile:codegen:dart from another directory\n\n" >&2 && exit 1
pigeon:
@printf "This command has been removed. Please use:\n\n mise pigeon # or mise //:mobile:codegen:pigeon from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise pigeon # or mise //mobile:codegen:pigeon from another directory\n\n" >&2 && exit 1
build_release_android:
@printf "This command has been removed. Please use:\n\n mise run build:android # or mise //:mobile:build:android from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise run build:android # or mise //mobile:build:android from another directory\n\n" >&2 && exit 1
migration:
@printf "This command has been removed. Please use:\n\n mise migration # or mise //:mobile:drift:migration from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise migration # or mise //mobile:drift:migration from another directory\n\n" >&2 && exit 1
translation:
@printf "This command has been removed. Please use:\n\n mise translation # or mise //:mobile:codegen:translation from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise translation # or mise //mobile:codegen:translation from another directory\n\n" >&2 && exit 1
analyze:
@printf "This command has been removed. Please use:\n\n mise analyze # or mise //:mobile:lint from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise analyze # or mise //mobile:lint from another directory\n\n" >&2 && exit 1
format:
@printf "This command has been removed. Please use:\n\n mise format # or mise //:mobile:format from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise format # or mise //mobile:format from another directory\n\n" >&2 && exit 1
test:
@printf "This command has been removed. Please use:\n\n mise test # or mise //:mobile:test from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise test # or mise //mobile:test from another directory\n\n" >&2 && exit 1
+10 -10
View File
@@ -5,10 +5,10 @@ packages:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.0"
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
@@ -103,10 +103,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.18.0"
path:
dependency: transitive
description:
@@ -124,10 +124,10 @@ packages:
dependency: transitive
description:
name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.1"
version: "1.10.2"
stack_trace:
dependency: transitive
description:
@@ -164,10 +164,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.11"
vector_math:
dependency: transitive
description:
@@ -180,10 +180,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.0.2"
version: "15.2.0"
sdks:
dart: ">=3.11.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
+16 -16
View File
@@ -5,10 +5,10 @@ packages:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.0"
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
@@ -77,10 +77,10 @@ packages:
dependency: transitive
description:
name: ffi
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
version: "2.2.0"
file:
dependency: transitive
description:
@@ -124,10 +124,10 @@ packages:
dependency: "direct main"
description:
name: go_router
sha256: "5540e4a3f416dd4a93458257b908eb88353cbd0fb5b0a3d1bd7d849ba1e88735"
sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f"
url: "https://pub.dev"
source: hosted
version: "17.2.1"
version: "17.2.3"
immich_ui:
dependency: "direct main"
description:
@@ -211,10 +211,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.18.0"
path:
dependency: transitive
description:
@@ -248,10 +248,10 @@ packages:
dependency: transitive
description:
name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.1"
version: "1.10.2"
stack_trace:
dependency: transitive
description:
@@ -312,10 +312,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.11"
typed_data:
dependency: transitive
description:
@@ -328,10 +328,10 @@ packages:
dependency: transitive
description:
name: uuid
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.2"
version: "4.5.3"
vector_math:
dependency: transitive
description:
@@ -344,10 +344,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.0.2"
version: "15.2.0"
web:
dependency: transitive
description:
+8 -12
View File
@@ -11,7 +11,14 @@ import 'package:pigeon/pigeon.dart';
dartPackageName: 'immich_mobile',
),
)
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
enum PlatformAssetPlaybackStyle {
unknown,
image,
video,
imageAnimated,
livePhoto,
videoLooping,
}
class PlatformAsset {
final String id;
@@ -135,17 +142,6 @@ abstract class NativeSyncApi {
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
Map<String, List<PlatformAsset>> getTrashedAssets();
bool hasManageMediaPermission();
@async
bool requestManageMediaPermission();
@async
bool manageMediaPermission();
@async
bool restoreFromTrashById(String mediaId, int type);
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
}
+56 -48
View File
@@ -5,18 +5,18 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc"
url: "https://pub.dev"
source: hosted
version: "93.0.0"
version: "96.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0"
url: "https://pub.dev"
source: hosted
version: "10.0.1"
version: "10.2.0"
ansicolor:
dependency: transitive
description:
@@ -133,10 +133,10 @@ packages:
dependency: transitive
description:
name: build
sha256: aadd943f4f8cc946882c954c187e6115a84c98c81ad1d9c6cbf0895a8c85da9c
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
url: "https://pub.dev"
source: hosted
version: "4.0.5"
version: "4.0.6"
build_config:
dependency: transitive
description:
@@ -157,10 +157,10 @@ packages:
dependency: "direct dev"
description:
name: build_runner
sha256: "521daf8d189deb79ba474e43a696b41c49fb3987818dbacf3308f1e03673a75e"
sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"
url: "https://pub.dev"
source: hosted
version: "2.13.1"
version: "2.15.0"
built_collection:
dependency: transitive
description:
@@ -173,10 +173,10 @@ packages:
dependency: transitive
description:
name: built_value
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56"
url: "https://pub.dev"
source: hosted
version: "8.12.5"
version: "8.12.6"
cast:
dependency: "direct main"
description:
@@ -358,18 +358,18 @@ packages:
dependency: "direct main"
description:
name: drift
sha256: "055c249d1f91be5a47fe447f88afc24c4ca6f4cd6c5ed66767b4797d48acc2e5"
sha256: "8033500116b24398fba0cca0369cc31678cd627c01e41753a61186911cea743e"
url: "https://pub.dev"
source: hosted
version: "2.32.1"
version: "2.33.0"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: "88a9de3af8571518148a6d8a513b57779fd1e60a026d3ab8a481a878fba01d91"
sha256: b3dd5b75e30522a91da8abda9f5bb17230cb038097f6d15fa75d42bb563428aa
url: "https://pub.dev"
source: hosted
version: "2.32.1"
version: "2.33.0"
drift_flutter:
dependency: "direct main"
description:
@@ -613,10 +613,10 @@ packages:
dependency: "direct main"
description:
name: flutter_svg
sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9"
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
url: "https://pub.dev"
source: hosted
version: "2.2.4"
version: "2.3.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -772,10 +772,10 @@ packages:
dependency: transitive
description:
name: hooks
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
version: "1.0.3"
hooks_riverpod:
dependency: "direct main"
description:
@@ -844,18 +844,18 @@ packages:
dependency: "direct main"
description:
name: image_picker
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
version: "1.2.2"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "66810af8e99b2657ee98e5c6f02064f69bb63f7a70e343937f70946c5f8c6622"
sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f
url: "https://pub.dev"
source: hosted
version: "0.8.13+16"
version: "0.8.13+17"
image_picker_for_web:
dependency: transitive
description:
@@ -952,10 +952,10 @@ packages:
dependency: transitive
description:
name: json_annotation
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
url: "https://pub.dev"
source: hosted
version: "4.11.0"
version: "4.12.0"
leak_tracker:
dependency: transitive
description:
@@ -984,10 +984,10 @@ packages:
dependency: transitive
description:
name: lean_builder
sha256: ee4117b03e93a4eb83e1a78c8e7a1dc22188d43bb142309982be48673a1b3a53
sha256: c16e95ddf7b2d49dd551357b7212fe2ce9f13ec7ad1b1e660c157184031e96c0
url: "https://pub.dev"
source: hosted
version: "0.1.7"
version: "0.1.10"
lints:
dependency: transitive
description:
@@ -1088,10 +1088,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.18.0"
mime:
dependency: transitive
description:
@@ -1429,6 +1429,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
riverpod:
dependency: transitive
description:
@@ -1607,10 +1615,10 @@ packages:
dependency: transitive
description:
name: source_gen
sha256: "732792cfd197d2161a65bb029606a46e0a18ff30ef9e141a7a82172b05ea8ecd"
sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
url: "https://pub.dev"
source: hosted
version: "4.2.2"
version: "4.2.3"
source_span:
dependency: transitive
description:
@@ -1647,10 +1655,10 @@ packages:
dependency: transitive
description:
name: sqlparser
sha256: ab2b467425f1d4f3acfa5fd11a08226f7d6c26ff102c06be1807e1dff34e050b
sha256: ecdc06d4a7d79dcbc928d99afd2f7f5b0f98a637c46f89be83d911617f759978
url: "https://pub.dev"
source: hosted
version: "0.44.3"
version: "0.44.4"
stack_trace:
dependency: transitive
description:
@@ -1711,10 +1719,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.11"
thumbhash:
dependency: "direct main"
description:
@@ -1767,10 +1775,10 @@ packages:
dependency: transitive
description:
name: url_launcher_android
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
url: "https://pub.dev"
source: hosted
version: "6.3.29"
version: "6.3.30"
url_launcher_ios:
dependency: transitive
description:
@@ -1807,10 +1815,10 @@ packages:
dependency: transitive
description:
name: url_launcher_web
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
@@ -1831,10 +1839,10 @@ packages:
dependency: transitive
description:
name: vector_graphics
sha256: "81da85e9ca8885ade47f9685b953cb098970d11be4821ac765580a6607ea4373"
sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d"
url: "https://pub.dev"
source: hosted
version: "1.1.21"
version: "1.2.2"
vector_graphics_codec:
dependency: transitive
description:
@@ -1847,10 +1855,10 @@ packages:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74"
sha256: b9b3f391857781aa96acacef96066f2f49b4cd03cf9fce3ca4d8da2ef5ea129e
url: "https://pub.dev"
source: hosted
version: "1.2.0"
version: "1.2.3"
vector_math:
dependency: transitive
description:
@@ -1863,10 +1871,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499"
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.1.0"
version: "15.2.0"
wakelock_plus:
dependency: "direct main"
description:
@@ -1879,10 +1887,10 @@ packages:
dependency: transitive
description:
name: wakelock_plus_platform_interface
sha256: "14b2e5b9e35c2631e656913c47adecdd71633ae92896a27a64c8f1fcfabc21cc"
sha256: b13f99e992e7ae6a152e16c5559d3c07ff445b13330192662494e614ca3e7d7b
url: "https://pub.dev"
source: hosted
version: "1.5.0"
version: "1.5.1"
watcher:
dependency: transitive
description:
@@ -1988,5 +1996,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.11.0 <4.0.0"
flutter: "3.41.9"
dart: ">=3.12.0 <4.0.0"
flutter: "3.44.0"
+2 -2
View File
@@ -5,8 +5,8 @@ publish_to: 'none'
version: 3.0.0+3047
environment:
sdk: '>=3.11.0 <4.0.0'
flutter: 3.41.9
sdk: '>=3.12.0 <4.0.0'
flutter: 3.44.0
dependencies:
async: ^2.13.1
@@ -2,7 +2,6 @@ import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/local_sync.service.dart';
@@ -11,32 +10,32 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.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/store.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:mocktail/mocktail.dart';
import '../../domain/service.mock.dart';
import '../../fixtures/asset.stub.dart';
import '../../infrastructure/repository.mock.dart';
import '../../mocks/asset_entity.mock.dart';
import '../../repository.mocks.dart';
void main() {
late LocalSyncService sut;
late DriftLocalAlbumRepository mockLocalAlbumRepository;
late DriftLocalAssetRepository mockLocalAssetRepository;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
late MockDriftTrashSyncRepository mockDriftTrashSyncRepository;
late LocalFilesManagerRepository mockLocalFilesManager;
late StorageRepository mockStorageRepository;
late MockNativeSyncApi mockNativeSyncApi;
late Drift db;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
debugDefaultTargetPlatformOverride = TargetPlatform.android;
registerFallbackValue(LocalAssetStub.image1);
registerFallbackValue(<LocalAsset>[]);
registerFallbackValue(<LocalAlbum>[]);
registerFallbackValue(<String>[]);
registerFallbackValue(<String, List<String>>{});
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
@@ -49,12 +48,11 @@ void main() {
});
setUp(() async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
mockLocalAlbumRepository = MockLocalAlbumRepository();
mockLocalAssetRepository = MockLocalAssetRepository();
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
mockDriftTrashSyncRepository = MockDriftTrashSyncRepository();
mockLocalFilesManager = MockLocalFilesManagerRepository();
mockStorageRepository = MockStorageRepository();
mockNativeSyncApi = MockNativeSyncApi();
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
@@ -62,58 +60,70 @@ void main() {
(_) async => SyncDelta(hasChanges: false, updates: const [], deletes: const [], assetAlbums: const {}),
);
when(() => mockNativeSyncApi.getTrashedAssets()).thenAnswer((_) async => {});
when(() => mockNativeSyncApi.checkpointSync()).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).thenAnswer((_) async {});
when(() => mockDriftTrashSyncRepository.cleanup()).thenAnswer((_) async => 0);
when(() => mockDriftTrashSyncRepository.syncRestoresForRevivedAssets()).thenAnswer((_) async {});
when(() => mockDriftTrashSyncRepository.recheckRemoteTrashCandidates()).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {});
when(() => mockLocalFilesManager.moveToTrash(any<List<String>>())).thenAnswer((_) async => true);
sut = LocalSyncService(
localAlbumRepository: mockLocalAlbumRepository,
localAssetRepository: mockLocalAssetRepository,
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
trashSyncRepository: mockDriftTrashSyncRepository,
localFilesManager: mockLocalFilesManager,
storageRepository: mockStorageRepository,
nativeSyncApi: mockNativeSyncApi,
);
await Store.clear();
await Store.put(StoreKey.manageLocalMediaAndroid, false);
await Store.put(StoreKey.reviewOutOfSyncChangesAndroid, false);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
});
// After the refactor, LocalSyncService is just the OS-trash mirror
// updater plus a delegating cleanup hook. The restore branch that
// used to live here is now owned by DriftTrashSyncRepository (tested in
// trash_sync_service_test.dart / trash_sync_repository_test.dart).
group('LocalSyncService - OS trash mirror', () {
test('updates mirror on Android regardless of store flags', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, false);
group('LocalSyncService - syncTrashedAssets gating', () {
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
await sut.sync();
verify(() => mockNativeSyncApi.getTrashedAssets()).called(1);
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1);
});
test('invokes catch-up restore on Android', () async {
// Regression: my refactor initially dropped this catch-up. The
// original PR ran restore detection in `processTrashedAssets`
// every sync. We preserve that periodic check via
// DriftTrashSyncRepository.syncRestoresForRevivedAssets.
await sut.sync();
verify(() => mockDriftTrashSyncRepository.syncRestoresForRevivedAssets()).called(1);
});
test('skips mirror and catch-up on non-Android platforms', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
test('skips syncTrashedAssets when store flag disabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, false);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
await sut.sync();
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
verifyNever(() => mockDriftTrashSyncRepository.syncRestoresForRevivedAssets());
});
test('processTrashedAssets writes the OS mirror and no longer calls restore', () async {
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
await sut.sync();
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
});
test('skips syncTrashedAssets on non-Android platforms', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
await sut.sync();
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
});
});
group('LocalSyncService - syncTrashedAssets behavior', () {
test('processes trashed snapshot, restores assets, and trashes local files', () async {
final platformAsset = PlatformAsset(
id: 'remote-id',
name: 'remote.jpg',
@@ -121,9 +131,29 @@ void main() {
durationMs: 0,
orientation: 0,
isFavorite: false,
playbackStyle: PlatformAssetPlaybackStyle.image,
playbackStyle: PlatformAssetPlaybackStyle.image
);
final assetsToRestore = [LocalAssetStub.image1];
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => assetsToRestore);
final restoredIds = ['image1'];
when(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
final Iterable<LocalAsset> requested = invocation.positionalArguments.first as Iterable<LocalAsset>;
expect(requested, orderedEquals(assetsToRestore));
return restoredIds;
});
final localAssetToTrash = LocalAssetStub.image2.copyWith(id: 'local-trash', checksum: 'checksum-trash');
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer(
(_) async => {
'album-a': [localAssetToTrash],
},
);
final assetEntity = MockAssetEntity();
when(() => assetEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-trash');
when(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).thenAnswer((_) async => assetEntity);
await sut.processTrashedAssets({
'album-a': [platformAsset],
});
@@ -136,47 +166,41 @@ void main() {
expect(trashedEntry.albumId, 'album-a');
expect(trashedEntry.asset.id, platformAsset.id);
expect(trashedEntry.asset.name, platformAsset.name);
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
verify(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).called(1);
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
verify(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).called(1);
final moveArgs = verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List<String>;
expect(moveArgs, ['content://local-trash']);
final trashArgs =
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
as Map<String, List<LocalAsset>>;
expect(trashArgs.keys, ['album-a']);
expect(trashArgs['album-a'], [localAssetToTrash]);
});
test('processTrashedAssets handles empty snapshot without errors', () async {
test('does not attempt restore when repository has no assets to restore', () async {
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
await sut.processTrashedAssets({});
final trashedSnapshot =
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single
as Iterable<TrashedAsset>;
expect(trashedSnapshot, isEmpty);
});
});
group('LocalSyncService - cleanup delegation', () {
test('cleans trash state after Android full sync', () async {
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => true);
when(() => mockNativeSyncApi.getAlbums()).thenAnswer((_) async => []);
when(() => mockLocalAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id})).thenAnswer((_) async => []);
await sut.sync();
verify(() => mockDriftTrashSyncRepository.cleanup()).called(1);
verifyNever(() => mockLocalFilesManager.restoreAssetsFromTrash(any()));
verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any()));
});
test('cleans trash state after Android delta sync with changes', () async {
when(() => mockNativeSyncApi.getMediaChanges()).thenAnswer(
(_) async => SyncDelta(hasChanges: true, updates: const [], deletes: const [], assetAlbums: const {}),
);
when(() => mockNativeSyncApi.getAlbums()).thenAnswer((_) async => []);
when(() => mockLocalAlbumRepository.updateAll(any())).thenAnswer((_) async {});
when(
() => mockLocalAlbumRepository.processDelta(
updates: any(named: 'updates'),
deletes: any(named: 'deletes'),
assetAlbums: any(named: 'assetAlbums'),
),
).thenAnswer((_) async {});
when(() => mockLocalAlbumRepository.getAll()).thenAnswer((_) async => []);
test('does not move local assets when repository finds nothing to trash', () async {
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
await sut.sync();
await sut.processTrashedAssets({});
verify(() => mockDriftTrashSyncRepository.cleanup()).called(1);
verifyNever(() => mockLocalFilesManager.moveToTrash(any()));
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
});
});
@@ -191,7 +215,7 @@ void main() {
isFavorite: false,
createdAt: 1700000000,
updatedAt: 1732000000,
playbackStyle: PlatformAssetPlaybackStyle.image,
playbackStyle: PlatformAssetPlaybackStyle.image
);
final localAsset = platformAsset.toLocalAsset();

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