mirror of
https://github.com/immich-app/immich.git
synced 2026-07-23 13:54:05 +03:00
Compare commits
12 Commits
refactor/a
...
renovate/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e736719dd8 | ||
|
|
8a875f5978 | ||
|
|
8435d219e4 | ||
|
|
8bbc252703 | ||
|
|
2d3cf8ffaa | ||
|
|
33cba2f9af | ||
|
|
75053ebfba | ||
|
|
ba9ec7c80e | ||
|
|
99370b2ac4 | ||
|
|
0f5bf6071f | ||
|
|
998fd5f6a8 | ||
|
|
f413c446e3 |
4
.github/workflows/build-mobile.yml
vendored
4
.github/workflows/build-mobile.yml
vendored
@@ -106,7 +106,7 @@ jobs:
|
||||
working-directory: ./mobile
|
||||
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
|
||||
|
||||
- uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
|
||||
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
@@ -241,7 +241,7 @@ jobs:
|
||||
run: flutter build ios --config-only --no-codesign
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
|
||||
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
bundler-cache: true
|
||||
|
||||
2
.github/workflows/check-openapi.yml
vendored
2
.github/workflows/check-openapi.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
uses: oasdiff/oasdiff-action/breaking@291b6594ce617f22f49af5898ee038ed0b46e8fe # v0.1.5
|
||||
uses: oasdiff/oasdiff-action/breaking@024f6c399f9a21ada1addb0f9a36ce1bfac995f1 # v0.1.6
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
|
||||
2
.github/workflows/close-duplicates.yml
vendored
2
.github/workflows/close-duplicates.yml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
needs: [get_body, should_run]
|
||||
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
||||
container:
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:e3fe456f346b02c2f479462100763aee3ba8245768dfeac3c4262acb068e5c62
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:d603f5618c1fc9f2b4d506ffed54ba6bcfec83d56b893b5fa8b3f899a402fa0a
|
||||
outputs:
|
||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||
steps:
|
||||
|
||||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -58,7 +58,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -84,6 +84,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
2
.github/workflows/prepare-release.yml
vendored
2
.github/workflows/prepare-release.yml
vendored
@@ -152,7 +152,7 @@ jobs:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Create draft release
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
with:
|
||||
draft: true
|
||||
prerelease: ${{ needs.bump_version.outputs.rc }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tools]
|
||||
terragrunt = "1.0.3"
|
||||
opentofu = "1.11.6"
|
||||
terragrunt = "1.1.1"
|
||||
opentofu = "1.12.4"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
run = "terragrunt hclfmt"
|
||||
|
||||
@@ -2,37 +2,35 @@
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||
version = "4.52.7"
|
||||
constraints = "4.52.7"
|
||||
version = "4.52.8"
|
||||
constraints = "4.52.8"
|
||||
hashes = [
|
||||
"h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
|
||||
"h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
|
||||
"h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
|
||||
"h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
|
||||
"h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
|
||||
"h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
|
||||
"h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
|
||||
"h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
|
||||
"h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
|
||||
"h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
|
||||
"h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
|
||||
"h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
|
||||
"h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
|
||||
"h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
|
||||
"zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
|
||||
"zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
|
||||
"zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
|
||||
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
|
||||
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
|
||||
"h1:2uxVaMaGPDJljXqYzuD6xtyfOKdNudtGIgawRTkFJIs=",
|
||||
"h1:34HL1GDpqYdn2iEC+85pCi+NddZf30qH3fJ8y3P6CAM=",
|
||||
"h1:4Z+uD+YY/7HrqdypM7DyUK+RtWepoLBc9fqtRQYZx4I=",
|
||||
"h1:8Ss6wlJL2BHvkIZU1iaHvyUntqNhPFZ0fl6mjsxhctc=",
|
||||
"h1:AYeWWMTWaNg01N3hivxr/6YlG41j1RietMnft9vLjUI=",
|
||||
"h1:BGRNOzo8NUgbx5RwpWkWmr38f/s3txb7mzQhqfM5blI=",
|
||||
"h1:EEXRMdC8bPlpLuOU8u+dE8mFJe3BpquFdH6vXcKseY4=",
|
||||
"h1:ENp29e6O6OnaHJlbtmRlyjlAmm3Wt4mGctWUpBY3d0g=",
|
||||
"h1:IAtBAbGVe1npj/n1NvCIZkSYGl7qaPYT+FnVdZ2CZ00=",
|
||||
"h1:QRGGKngrxLORr4CImKXLDDJL5s67zUNBDAgk6FIIFNk=",
|
||||
"h1:bNql6cC+MfDbaJK3q7a8C1m3A21o7e5mrjBIGtk0RdI=",
|
||||
"h1:gmNv+PEOkYDdu+c15bfpcoAE3EI7pgmov9RMf3GZLsI=",
|
||||
"h1:yieZ7NWRYQcLYHUevzDDrrmDkoQICM+3ZeX4i/mw9ME=",
|
||||
"zh:08b305329a680a9213b2d8e642fbce7e4d97a524b1d2cef59e190ba9d678c477",
|
||||
"zh:47975bd711ee18a46e589822171fa87474a552b332bfc8dea8fd1a64504eed8d",
|
||||
"zh:5640d0d226bbafff3395542456c29feb942ca9c55ac01b4245a34c0590a33363",
|
||||
"zh:5b0ad839fafba938c60a95d6b4a865843643591e97573ed2b9c4af3754064f74",
|
||||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
|
||||
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
|
||||
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
|
||||
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
|
||||
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
|
||||
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
|
||||
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
|
||||
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
|
||||
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
|
||||
"zh:93a9bc1139f5c02a44fdbf51fb2ce0891e2a42033b66febb928ccf72e870408f",
|
||||
"zh:977f75cdf365686aa16ae02dcc0fc1769bae6f86be6e165393756c940bfb4af0",
|
||||
"zh:9afcda2660b3dc6ee6329acea532a719225bd1f6cf46f695feb2d77160847c1b",
|
||||
"zh:9e3da67b1b05b03d1f0c18b8677edf3797815b5dd49629f10eb2f457dccbed30",
|
||||
"zh:b8d7da230f5266367c1b6b1cf31aa39087e231a87d55b71bb9d5c854ca1fcfe6",
|
||||
"zh:cc96e7cd7350456b7a11a53d1c76c73a542de8359f9750f637ff865e8e77be91",
|
||||
"zh:da1f58d067def243047bb7178cb197e2b9c3a791a9eb380ad92b73290615ae29",
|
||||
"zh:ed5f3e1f59a338bcdce0a2537a8a2f299cfe18c168626799b2a9c97af3d8d3cb",
|
||||
"zh:f16cc31f73a58a26ffe2d93223eddb7c340623a4e2ef38c6091766fd611e3e11",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ terraform {
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "4.52.7"
|
||||
version = "4.52.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,37 +2,35 @@
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||
version = "4.52.7"
|
||||
constraints = "4.52.7"
|
||||
version = "4.52.8"
|
||||
constraints = "4.52.8"
|
||||
hashes = [
|
||||
"h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
|
||||
"h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
|
||||
"h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
|
||||
"h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
|
||||
"h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
|
||||
"h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
|
||||
"h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
|
||||
"h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
|
||||
"h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
|
||||
"h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
|
||||
"h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
|
||||
"h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
|
||||
"h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
|
||||
"h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
|
||||
"zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
|
||||
"zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
|
||||
"zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
|
||||
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
|
||||
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
|
||||
"h1:2uxVaMaGPDJljXqYzuD6xtyfOKdNudtGIgawRTkFJIs=",
|
||||
"h1:34HL1GDpqYdn2iEC+85pCi+NddZf30qH3fJ8y3P6CAM=",
|
||||
"h1:4Z+uD+YY/7HrqdypM7DyUK+RtWepoLBc9fqtRQYZx4I=",
|
||||
"h1:8Ss6wlJL2BHvkIZU1iaHvyUntqNhPFZ0fl6mjsxhctc=",
|
||||
"h1:AYeWWMTWaNg01N3hivxr/6YlG41j1RietMnft9vLjUI=",
|
||||
"h1:BGRNOzo8NUgbx5RwpWkWmr38f/s3txb7mzQhqfM5blI=",
|
||||
"h1:EEXRMdC8bPlpLuOU8u+dE8mFJe3BpquFdH6vXcKseY4=",
|
||||
"h1:ENp29e6O6OnaHJlbtmRlyjlAmm3Wt4mGctWUpBY3d0g=",
|
||||
"h1:IAtBAbGVe1npj/n1NvCIZkSYGl7qaPYT+FnVdZ2CZ00=",
|
||||
"h1:QRGGKngrxLORr4CImKXLDDJL5s67zUNBDAgk6FIIFNk=",
|
||||
"h1:bNql6cC+MfDbaJK3q7a8C1m3A21o7e5mrjBIGtk0RdI=",
|
||||
"h1:gmNv+PEOkYDdu+c15bfpcoAE3EI7pgmov9RMf3GZLsI=",
|
||||
"h1:yieZ7NWRYQcLYHUevzDDrrmDkoQICM+3ZeX4i/mw9ME=",
|
||||
"zh:08b305329a680a9213b2d8e642fbce7e4d97a524b1d2cef59e190ba9d678c477",
|
||||
"zh:47975bd711ee18a46e589822171fa87474a552b332bfc8dea8fd1a64504eed8d",
|
||||
"zh:5640d0d226bbafff3395542456c29feb942ca9c55ac01b4245a34c0590a33363",
|
||||
"zh:5b0ad839fafba938c60a95d6b4a865843643591e97573ed2b9c4af3754064f74",
|
||||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
|
||||
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
|
||||
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
|
||||
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
|
||||
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
|
||||
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
|
||||
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
|
||||
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
|
||||
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
|
||||
"zh:93a9bc1139f5c02a44fdbf51fb2ce0891e2a42033b66febb928ccf72e870408f",
|
||||
"zh:977f75cdf365686aa16ae02dcc0fc1769bae6f86be6e165393756c940bfb4af0",
|
||||
"zh:9afcda2660b3dc6ee6329acea532a719225bd1f6cf46f695feb2d77160847c1b",
|
||||
"zh:9e3da67b1b05b03d1f0c18b8677edf3797815b5dd49629f10eb2f457dccbed30",
|
||||
"zh:b8d7da230f5266367c1b6b1cf31aa39087e231a87d55b71bb9d5c854ca1fcfe6",
|
||||
"zh:cc96e7cd7350456b7a11a53d1c76c73a542de8359f9750f637ff865e8e77be91",
|
||||
"zh:da1f58d067def243047bb7178cb197e2b9c3a791a9eb380ad92b73290615ae29",
|
||||
"zh:ed5f3e1f59a338bcdce0a2537a8a2f299cfe18c168626799b2a9c97af3d8d3cb",
|
||||
"zh:f16cc31f73a58a26ffe2d93223eddb7c340623a4e2ef38c6091766fd611e3e11",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ terraform {
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "4.52.7"
|
||||
version = "4.52.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ services:
|
||||
command: ['./run.sh', '-disable-reporting']
|
||||
ports:
|
||||
- 3000:3000
|
||||
image: grafana/grafana:12.4.4-ubuntu@sha256:df2e7ef5f32f771794cf76bad5f2bceac227036460a2cc269a9045e5662abc58
|
||||
image: grafana/grafana:12.4.5-ubuntu@sha256:00396460e499415c828b7c298f19287c8a0f95e72412ee37ac11723655c2d6b9
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
ARG DEVICE=cpu
|
||||
|
||||
FROM python:3.11-bookworm@sha256:20ec607c68642c64c73269ce245aa0f060913f4129d15d9687850aa158e6269c AS builder-cpu
|
||||
FROM python:3.11-bookworm@sha256:5c34b355088846dddc8afb7442c20b9433dccdc8d66192dc52c616adeaa106a3 AS builder-cpu
|
||||
|
||||
FROM python:3.13-slim-trixie@sha256:f82c96458eedc847b233e582eb31336f4954b39cae020b6dcf5b3ed0e5cbcd74 AS builder-openvino
|
||||
FROM python:3.13-slim-trixie@sha256:6771159cd4fa5d9bba1258caf0b82e6b73458c694d178ad97c5e925c2d0e1a91 AS builder-openvino
|
||||
|
||||
FROM builder-cpu AS builder-cuda
|
||||
|
||||
@@ -39,12 +39,12 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy
|
||||
|
||||
FROM python:3.11-slim-bookworm@sha256:e2d3af735aff6eeee600b1933bedd99da6645fedf572cc12ef4cc1331f2ceebe AS prod-cpu
|
||||
FROM python:3.11-slim-bookworm@sha256:b18992999dbe963a45a8a4da40ac2b1975be1a776d939d098c647482bcad5cba AS prod-cpu
|
||||
|
||||
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
|
||||
MACHINE_LEARNING_MODEL_ARENA=false
|
||||
|
||||
FROM python:3.13-slim-trixie@sha256:f82c96458eedc847b233e582eb31336f4954b39cae020b6dcf5b3ed0e5cbcd74 AS prod-openvino
|
||||
FROM python:3.13-slim-trixie@sha256:6771159cd4fa5d9bba1258caf0b82e6b73458c694d178ad97c5e925c2d0e1a91 AS prod-openvino
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||
|
||||
725
machine-learning/uv.lock
generated
725
machine-learning/uv.lock
generated
File diff suppressed because it is too large
Load Diff
88
mise.lock
88
mise.lock
@@ -248,43 +248,43 @@ version = "7.5.0"
|
||||
backend = "npm:oazapfts"
|
||||
|
||||
[[tools.opentofu]]
|
||||
version = "1.11.6"
|
||||
version = "1.12.4"
|
||||
backend = "aqua:opentofu/opentofu"
|
||||
|
||||
[tools.opentofu."platforms.linux-arm64"]
|
||||
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382"
|
||||
checksum = "sha256:dc7bfcd93ce9795a86c58fbf71efd013c39dcd1febb13c9cd3555c43b9c2403a"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646678"
|
||||
|
||||
[tools.opentofu."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382"
|
||||
checksum = "sha256:dc7bfcd93ce9795a86c58fbf71efd013c39dcd1febb13c9cd3555c43b9c2403a"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646678"
|
||||
|
||||
[tools.opentofu."platforms.linux-x64"]
|
||||
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401"
|
||||
checksum = "sha256:81836d0f12b4fe9013b85586349f993def9429b6383bb77cdd6c2f3a9d9aac24"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646677"
|
||||
|
||||
[tools.opentofu."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401"
|
||||
checksum = "sha256:81836d0f12b4fe9013b85586349f993def9429b6383bb77cdd6c2f3a9d9aac24"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646677"
|
||||
|
||||
[tools.opentofu."platforms.macos-arm64"]
|
||||
checksum = "sha256:62d7fa8539e13b444827aa0a3b90c5972da5c47e8f8882d9dcf2e430e78840c1"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536399"
|
||||
checksum = "sha256:7c06e4390d9ccd467773e37ff1c3d833c7ca0c24742cd9e9ad47284bea472247"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646544"
|
||||
|
||||
[tools.opentofu."platforms.macos-x64"]
|
||||
checksum = "sha256:1408cdef1c380f914565e6b4bb70794c6b163f195fcb233357f3d6c5745906b6"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536384"
|
||||
checksum = "sha256:ead1d2ce643addb4ffeb93240b9377ae1c2fd793a6bd22d65922ac37adfdf546"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646688"
|
||||
|
||||
[tools.opentofu."platforms.windows-x64"]
|
||||
checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c7077367e"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536406"
|
||||
checksum = "sha256:a4d86a07755c8d151f20f945e6cfb5b40deeed942af36a9bd385c5c2e965d5dd"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_windows_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646543"
|
||||
|
||||
[[tools.pnpm]]
|
||||
version = "11.13.1"
|
||||
@@ -327,40 +327,40 @@ url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563981"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[[tools.terragrunt]]
|
||||
version = "1.0.3"
|
||||
version = "1.1.1"
|
||||
backend = "aqua:gruntwork-io/terragrunt"
|
||||
|
||||
[tools.terragrunt."platforms.linux-arm64"]
|
||||
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253"
|
||||
checksum = "sha256:a374a7993ff3d99665a7e014007d3647ec7f0465c9d55c85e9f94c932e73cea2"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786148"
|
||||
|
||||
[tools.terragrunt."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253"
|
||||
checksum = "sha256:a374a7993ff3d99665a7e014007d3647ec7f0465c9d55c85e9f94c932e73cea2"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786148"
|
||||
|
||||
[tools.terragrunt."platforms.linux-x64"]
|
||||
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196"
|
||||
checksum = "sha256:ce90077ac31ef17a2ba10d11d45f36c6501997a8f4f79d703bb7daba37032f53"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785968"
|
||||
|
||||
[tools.terragrunt."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196"
|
||||
checksum = "sha256:ce90077ac31ef17a2ba10d11d45f36c6501997a8f4f79d703bb7daba37032f53"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785968"
|
||||
|
||||
[tools.terragrunt."platforms.macos-arm64"]
|
||||
checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654084"
|
||||
checksum = "sha256:9ec8f678b9ae6c81d5d9d77b94cbf6349ce639d5938694b4adc9dea73e416794"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785628"
|
||||
|
||||
[tools.terragrunt."platforms.macos-x64"]
|
||||
checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406653970"
|
||||
checksum = "sha256:73e768a69fa44a60f9d9174f54aaf179327e08706f633c9c79d5f5c9622c91c2"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785520"
|
||||
|
||||
[tools.terragrunt."platforms.windows-x64"]
|
||||
checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_windows_amd64.exe.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654412"
|
||||
checksum = "sha256:dd50a324691e072a3ac879e9ec43d6310e2936c929fda658eb2811fefcc75115"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_windows_amd64.exe.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786421"
|
||||
|
||||
@@ -17,8 +17,8 @@ config_roots = [
|
||||
[tools]
|
||||
node = "24.15.0"
|
||||
pnpm = "11.13.1"
|
||||
terragrunt = "1.0.3"
|
||||
opentofu = "1.11.6"
|
||||
terragrunt = "1.1.1"
|
||||
opentofu = "1.12.4"
|
||||
"npm:oazapfts" = "7.5.0"
|
||||
"github:extism/cli" = "1.6.3"
|
||||
"github:webassembly/binaryen" = "version_124"
|
||||
|
||||
@@ -18,7 +18,6 @@ import app.alextran.immich.images.LocalImageApi
|
||||
import app.alextran.immich.images.LocalImagesImpl
|
||||
import app.alextran.immich.images.RemoteImageApi
|
||||
import app.alextran.immich.images.RemoteImagesImpl
|
||||
import app.alextran.immich.media.AssetMediaApiImpl
|
||||
import app.alextran.immich.permission.PermissionApi
|
||||
import app.alextran.immich.permission.PermissionApiImpl
|
||||
import app.alextran.immich.sync.NativeSyncApi
|
||||
@@ -67,7 +66,6 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
||||
flutterEngine.plugins.add(nativeSyncApiImpl)
|
||||
flutterEngine.plugins.add(permissionApiImpl)
|
||||
flutterEngine.plugins.add(AssetMediaApiImpl(ctx))
|
||||
}
|
||||
|
||||
fun cancelPlugins(flutterEngine: FlutterEngine) {
|
||||
@@ -77,8 +75,6 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
nativeApi?.detachFromEngine()
|
||||
val permissionApi = flutterEngine.plugins.get(PermissionApiImpl::class.java) as ImmichPlugin?
|
||||
permissionApi?.detachFromEngine()
|
||||
val assetMediaApi = flutterEngine.plugins.get(AssetMediaApiImpl::class.java) as ImmichPlugin?
|
||||
assetMediaApi?.detachFromEngine()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,30 +2,17 @@ package app.alextran.immich.core
|
||||
|
||||
import androidx.annotation.CallSuper
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
abstract class ImmichPlugin : FlutterPlugin {
|
||||
private var detached: Boolean = false
|
||||
|
||||
private var supervisor = SupervisorJob()
|
||||
protected var scope = CoroutineScope(supervisor + Dispatchers.Default)
|
||||
private set
|
||||
private var detached: Boolean = false;
|
||||
|
||||
@CallSuper
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
detached = false
|
||||
if (!scope.isActive) {
|
||||
supervisor = SupervisorJob()
|
||||
scope = CoroutineScope(supervisor + Dispatchers.Default)
|
||||
}
|
||||
detached = false;
|
||||
}
|
||||
|
||||
fun detachFromEngine() {
|
||||
detached = true
|
||||
supervisor.cancel()
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
@@ -35,9 +22,8 @@ abstract class ImmichPlugin : FlutterPlugin {
|
||||
|
||||
fun <T> completeWhenActive(callback: (T) -> Unit, value: T) {
|
||||
if (detached) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
callback(value)
|
||||
callback(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||
|
||||
package app.alextran.immich.media
|
||||
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.BasicMessageChannel
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MessageCodec
|
||||
import io.flutter.plugin.common.StandardMethodCodec
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
private object AssetMediaPigeonUtils {
|
||||
|
||||
fun wrapResult(result: Any?): List<Any?> {
|
||||
return listOf(result)
|
||||
}
|
||||
|
||||
fun wrapError(exception: Throwable): List<Any?> {
|
||||
return if (exception is FlutterError) {
|
||||
listOf(
|
||||
exception.code,
|
||||
exception.message,
|
||||
exception.details
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
exception.javaClass.simpleName,
|
||||
exception.toString(),
|
||||
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
fun doubleEquals(a: Double, b: Double): Boolean {
|
||||
// Normalize -0.0 to 0.0 and handle NaN equality.
|
||||
return (if (a == 0.0) 0.0 else a) == (if (b == 0.0) 0.0 else b) || (a.isNaN() && b.isNaN())
|
||||
}
|
||||
|
||||
fun floatEquals(a: Float, b: Float): Boolean {
|
||||
// Normalize -0.0 to 0.0 and handle NaN equality.
|
||||
return (if (a == 0.0f) 0.0f else a) == (if (b == 0.0f) 0.0f else b) || (a.isNaN() && b.isNaN())
|
||||
}
|
||||
|
||||
fun doubleHash(d: Double): Int {
|
||||
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
|
||||
val normalized = if (d == 0.0) 0.0 else d
|
||||
val bits = java.lang.Double.doubleToLongBits(normalized)
|
||||
return (bits xor (bits ushr 32)).toInt()
|
||||
}
|
||||
|
||||
fun floatHash(f: Float): Int {
|
||||
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
|
||||
val normalized = if (f == 0.0f) 0.0f else f
|
||||
return java.lang.Float.floatToIntBits(normalized)
|
||||
}
|
||||
|
||||
fun deepEquals(a: Any?, b: Any?): Boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
if (a == null || b == null) {
|
||||
return false
|
||||
}
|
||||
if (a is ByteArray && b is ByteArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is IntArray && b is IntArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is LongArray && b is LongArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is DoubleArray && b is DoubleArray) {
|
||||
if (a.size != b.size) return false
|
||||
for (i in a.indices) {
|
||||
if (!doubleEquals(a[i], b[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is FloatArray && b is FloatArray) {
|
||||
if (a.size != b.size) return false
|
||||
for (i in a.indices) {
|
||||
if (!floatEquals(a[i], b[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is Array<*> && b is Array<*>) {
|
||||
if (a.size != b.size) return false
|
||||
for (i in a.indices) {
|
||||
if (!deepEquals(a[i], b[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is List<*> && b is List<*>) {
|
||||
if (a.size != b.size) return false
|
||||
val iterA = a.iterator()
|
||||
val iterB = b.iterator()
|
||||
while (iterA.hasNext() && iterB.hasNext()) {
|
||||
if (!deepEquals(iterA.next(), iterB.next())) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is Map<*, *> && b is Map<*, *>) {
|
||||
if (a.size != b.size) return false
|
||||
for (entry in a) {
|
||||
val key = entry.key
|
||||
var found = false
|
||||
for (bEntry in b) {
|
||||
if (deepEquals(key, bEntry.key)) {
|
||||
if (deepEquals(entry.value, bEntry.value)) {
|
||||
found = true
|
||||
break
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is Double && b is Double) {
|
||||
return doubleEquals(a, b)
|
||||
}
|
||||
if (a is Float && b is Float) {
|
||||
return floatEquals(a, b)
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
fun deepHash(value: Any?): Int {
|
||||
return when (value) {
|
||||
null -> 0
|
||||
is ByteArray -> value.contentHashCode()
|
||||
is IntArray -> value.contentHashCode()
|
||||
is LongArray -> value.contentHashCode()
|
||||
is DoubleArray -> {
|
||||
var result = 1
|
||||
for (item in value) {
|
||||
result = 31 * result + doubleHash(item)
|
||||
}
|
||||
result
|
||||
}
|
||||
is FloatArray -> {
|
||||
var result = 1
|
||||
for (item in value) {
|
||||
result = 31 * result + floatHash(item)
|
||||
}
|
||||
result
|
||||
}
|
||||
is Array<*> -> {
|
||||
var result = 1
|
||||
for (item in value) {
|
||||
result = 31 * result + deepHash(item)
|
||||
}
|
||||
result
|
||||
}
|
||||
is List<*> -> {
|
||||
var result = 1
|
||||
for (item in value) {
|
||||
result = 31 * result + deepHash(item)
|
||||
}
|
||||
result
|
||||
}
|
||||
is Map<*, *> -> {
|
||||
var result = 0
|
||||
for (entry in value) {
|
||||
result += ((deepHash(entry.key) * 31) xor deepHash(entry.value))
|
||||
}
|
||||
result
|
||||
}
|
||||
is Double -> doubleHash(value)
|
||||
is Float -> floatHash(value)
|
||||
else -> value.hashCode()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for passing custom error details to Flutter via a thrown PlatformException.
|
||||
* @property code The error code.
|
||||
* @property message The error message.
|
||||
* @property details The error details. Must be a datatype supported by the api codec.
|
||||
*/
|
||||
class FlutterError (
|
||||
val code: String,
|
||||
override val message: String? = null,
|
||||
val details: Any? = null
|
||||
) : RuntimeException()
|
||||
|
||||
enum class AssetMediaActionStatus(val raw: Int) {
|
||||
DONE(0),
|
||||
ALREADY_IN_STATE(1),
|
||||
NOT_FOUND(2),
|
||||
FAILED(3);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): AssetMediaActionStatus? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class AssetMediaActionResult (
|
||||
val id: String,
|
||||
val status: AssetMediaActionStatus
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): AssetMediaActionResult {
|
||||
val id = pigeonVar_list[0] as String
|
||||
val status = pigeonVar_list[1] as AssetMediaActionStatus
|
||||
return AssetMediaActionResult(id, status)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
id,
|
||||
status,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as AssetMediaActionResult
|
||||
return AssetMediaPigeonUtils.deepEquals(this.id, other.id) && AssetMediaPigeonUtils.deepEquals(this.status, other.status)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + AssetMediaPigeonUtils.deepHash(this.id)
|
||||
result = 31 * result + AssetMediaPigeonUtils.deepHash(this.status)
|
||||
return result
|
||||
}
|
||||
}
|
||||
private open class AssetMediaPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
129.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
AssetMediaActionStatus.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
130.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
AssetMediaActionResult.fromList(it)
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
when (value) {
|
||||
is AssetMediaActionStatus -> {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.raw.toLong())
|
||||
}
|
||||
is AssetMediaActionResult -> {
|
||||
stream.write(130)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface AssetMediaApi {
|
||||
fun trash(ids: List<String>, callback: (Result<List<AssetMediaActionResult>>) -> Unit)
|
||||
fun restore(ids: List<String>, callback: (Result<List<AssetMediaActionResult>>) -> Unit)
|
||||
fun trashedAmong(ids: List<String>, callback: (Result<List<String>>) -> Unit)
|
||||
|
||||
companion object {
|
||||
/** The codec used by AssetMediaApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
AssetMediaPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `AssetMediaApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: AssetMediaApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.AssetMediaApi.trash$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val idsArg = args[0] as List<String>
|
||||
api.trash(idsArg) { result: Result<List<AssetMediaActionResult>> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(AssetMediaPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(AssetMediaPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.AssetMediaApi.restore$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val idsArg = args[0] as List<String>
|
||||
api.restore(idsArg) { result: Result<List<AssetMediaActionResult>> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(AssetMediaPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(AssetMediaPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.AssetMediaApi.trashedAmong$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val idsArg = args[0] as List<String>
|
||||
api.trashedAmong(idsArg) { result: Result<List<String>> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(AssetMediaPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(AssetMediaPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
package app.alextran.immich.media
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.IntentSenderRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import app.alextran.immich.core.ImmichPlugin
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.UUID
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
private enum class MediaAction { TRASH, RESTORE }
|
||||
|
||||
private data class MediaItem(val uri: Uri, val isTrashed: Boolean)
|
||||
|
||||
private const val MAX_QUERY_ARGS = 900
|
||||
|
||||
private const val MAX_CONSENT_URIS = 500
|
||||
|
||||
@SuppressLint("NewApi", "InlinedApi")
|
||||
class AssetMediaApiImpl(context: Context) : ImmichPlugin(), AssetMediaApi, ActivityAware {
|
||||
private val ctx: Context = context.applicationContext
|
||||
private var binaryMessenger: BinaryMessenger? = null
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
|
||||
private val supportsMediaRequest: Boolean
|
||||
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
super.onAttachedToEngine(binding)
|
||||
binaryMessenger = binding.binaryMessenger
|
||||
AssetMediaApi.setUp(binding.binaryMessenger, this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
super.onDetachedFromEngine(binding)
|
||||
binaryMessenger?.let { AssetMediaApi.setUp(it, null) }
|
||||
binaryMessenger = null
|
||||
activityBinding = null
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activityBinding = binding
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
activityBinding = null
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
activityBinding = binding
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
activityBinding = null
|
||||
}
|
||||
|
||||
override fun trash(ids: List<String>, callback: (Result<List<AssetMediaActionResult>>) -> Unit) =
|
||||
runAction(ids, MediaAction.TRASH, callback)
|
||||
|
||||
override fun restore(
|
||||
ids: List<String>,
|
||||
callback: (Result<List<AssetMediaActionResult>>) -> Unit
|
||||
) =
|
||||
runAction(ids, MediaAction.RESTORE, callback)
|
||||
|
||||
override fun trashedAmong(ids: List<String>, callback: (Result<List<String>>) -> Unit) =
|
||||
respond(callback, "TRASHED_AMONG_ERROR") {
|
||||
if (!supportsMediaRequest) return@respond emptyList()
|
||||
val items = queryMediaItems(ids)
|
||||
ids.filter { items[it]?.isTrashed == true }
|
||||
}
|
||||
|
||||
private fun runAction(
|
||||
ids: List<String>,
|
||||
action: MediaAction,
|
||||
callback: (Result<List<AssetMediaActionResult>>) -> Unit,
|
||||
) = respond(callback, "MEDIA_ACTION_ERROR") {
|
||||
if (ids.isEmpty()) return@respond emptyList()
|
||||
if (!supportsMediaRequest) {
|
||||
return@respond ids.map { AssetMediaActionResult(it, AssetMediaActionStatus.FAILED) }
|
||||
}
|
||||
|
||||
val items = queryMediaItems(ids)
|
||||
|
||||
val targets = ids.mapNotNull { id ->
|
||||
val item = items[id] ?: return@mapNotNull null
|
||||
val needsAction = when (action) {
|
||||
MediaAction.TRASH -> !item.isTrashed
|
||||
MediaAction.RESTORE -> item.isTrashed
|
||||
}
|
||||
|
||||
if (needsAction) id to item.uri else null
|
||||
}.toMap()
|
||||
|
||||
val applied = buildMap {
|
||||
for (batch in targets.entries.chunked(MAX_CONSENT_URIS)) {
|
||||
val result = if (requestConsent(action, batch.map { it.value })) {
|
||||
AssetMediaActionStatus.DONE
|
||||
} else {
|
||||
AssetMediaActionStatus.FAILED
|
||||
}
|
||||
batch.forEach { put(it.key, result) }
|
||||
}
|
||||
}
|
||||
|
||||
ids.map { id ->
|
||||
val status = when (id) {
|
||||
in applied -> applied.getValue(id)
|
||||
!in items -> AssetMediaActionStatus.NOT_FOUND
|
||||
else -> AssetMediaActionStatus.ALREADY_IN_STATE
|
||||
}
|
||||
AssetMediaActionResult(id, status)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun <T> respond(callback: (Result<T>) -> Unit, errorCode: String, work: suspend () -> T) {
|
||||
scope.launch {
|
||||
try {
|
||||
completeWhenActive(callback, Result.success(work()))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
completeWhenActive(callback, Result.failure(FlutterError(errorCode, e.message, null)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun requestConsent(action: MediaAction, uris: List<Uri>): Boolean =
|
||||
withContext(Dispatchers.Main) {
|
||||
val activity = activityBinding?.activity as? ComponentActivity ?: return@withContext false
|
||||
val resolver = ctx.contentResolver
|
||||
val request = when (action) {
|
||||
MediaAction.TRASH -> MediaStore.createTrashRequest(resolver, uris, true)
|
||||
MediaAction.RESTORE -> MediaStore.createTrashRequest(resolver, uris, false)
|
||||
}
|
||||
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val key = "immich_asset_media_api_${UUID.randomUUID()}"
|
||||
var launcher: ActivityResultLauncher<IntentSenderRequest>? = null
|
||||
launcher = activity.activityResultRegistry.register(
|
||||
key,
|
||||
ActivityResultContracts.StartIntentSenderForResult()
|
||||
) { result ->
|
||||
launcher?.unregister()
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(result.resultCode == Activity.RESULT_OK)
|
||||
}
|
||||
}
|
||||
continuation.invokeOnCancellation { launcher.unregister() }
|
||||
try {
|
||||
launcher.launch(IntentSenderRequest.Builder(request.intentSender).build())
|
||||
} catch (_: Exception) {
|
||||
launcher.unregister()
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun queryMediaItems(ids: List<String>): Map<String, MediaItem> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val numeric = ids.filter { it.toLongOrNull() != null }
|
||||
if (numeric.isEmpty()) {
|
||||
return@withContext emptyMap()
|
||||
}
|
||||
|
||||
buildMap {
|
||||
for (chunk in numeric.chunked(MAX_QUERY_ARGS)) {
|
||||
ensureActive()
|
||||
val placeholders = chunk.joinToString(",") { "?" }
|
||||
val args = Bundle().apply {
|
||||
putString(
|
||||
ContentResolver.QUERY_ARG_SQL_SELECTION,
|
||||
"${MediaStore.Files.FileColumns._ID} IN ($placeholders)"
|
||||
)
|
||||
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, chunk.toTypedArray())
|
||||
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE)
|
||||
}
|
||||
|
||||
ctx.contentResolver.query(
|
||||
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
|
||||
arrayOf(
|
||||
MediaStore.Files.FileColumns._ID,
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE,
|
||||
MediaStore.Files.FileColumns.IS_TRASHED,
|
||||
),
|
||||
args,
|
||||
null,
|
||||
)?.use { cursor ->
|
||||
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
|
||||
val typeColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)
|
||||
val trashedColumn =
|
||||
cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.IS_TRASHED)
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(idColumn)
|
||||
val uri = mediaContentUri(cursor.getInt(typeColumn), id) ?: continue
|
||||
put(id.toString(), MediaItem(uri, cursor.getInt(trashedColumn) == 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun mediaContentUri(mediaType: Int, id: Long): Uri? = when (mediaType) {
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE ->
|
||||
ContentUris.withAppendedId(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id
|
||||
)
|
||||
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO ->
|
||||
ContentUris.withAppendedId(
|
||||
MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL),
|
||||
id
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package app.alextran.immich.sync
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
|
||||
class MediaTrashDelegate(
|
||||
context: Context,
|
||||
private val trashRequestCode: Int = 1002,
|
||||
) : PluginRegistry.ActivityResultListener {
|
||||
private val ctx = context.applicationContext
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
private var pendingResult: ((Result<Boolean>) -> Unit)? = null
|
||||
|
||||
private fun hasManageMediaPermission(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaStore.canManageMedia(ctx)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<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
|
||||
}
|
||||
|
||||
restoreUri(ContentUris.withAppendedId(contentUriForType(type.toInt()), id), callback)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun restoreUri(
|
||||
contentUri: Uri,
|
||||
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, listOf(contentUri), false)
|
||||
pendingResult = callback
|
||||
activity.startIntentSenderForResult(
|
||||
pendingIntent.intentSender,
|
||||
trashRequestCode,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
pendingResult = null
|
||||
callback(
|
||||
Result.failure(
|
||||
FlutterError("TRASH_ERROR", "Error creating or starting trash request", e.toString())
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun isInTrash(id: Long): Boolean {
|
||||
val filesUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
val args = Bundle().apply {
|
||||
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns._ID}=?")
|
||||
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(id.toString()))
|
||||
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
|
||||
putInt(ContentResolver.QUERY_ARG_LIMIT, 1)
|
||||
}
|
||||
return ctx.contentResolver.query(filesUri, arrayOf(MediaStore.Files.FileColumns._ID), args, null)
|
||||
?.use { it.moveToFirst() } == true
|
||||
}
|
||||
|
||||
private fun contentUriForType(type: Int): Uri =
|
||||
when (type) {
|
||||
// Same order as AssetType from Dart.
|
||||
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||
else -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
}
|
||||
|
||||
fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activityBinding = binding
|
||||
binding.addActivityResultListener(this)
|
||||
}
|
||||
|
||||
fun onDetachedFromActivity() {
|
||||
failPending("ACTIVITY_DETACHED", "Activity detached before trash result")
|
||||
activityBinding?.removeActivityResultListener(this)
|
||||
activityBinding = null
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||
if (requestCode == trashRequestCode) {
|
||||
val callback = pendingResult
|
||||
pendingResult = null
|
||||
callback?.invoke(Result.success(resultCode == Activity.RESULT_OK))
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun failPending(code: String, message: String) {
|
||||
val callback = pendingResult ?: return
|
||||
pendingResult = null
|
||||
callback(Result.failure(FlutterError(code, message, null)))
|
||||
}
|
||||
}
|
||||
@@ -553,6 +553,8 @@ interface NativeSyncApi {
|
||||
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
||||
fun cancelHashing()
|
||||
fun cancelSync()
|
||||
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
|
||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
|
||||
|
||||
companion object {
|
||||
@@ -763,6 +765,42 @@ interface NativeSyncApi {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.getTrashedAssets())
|
||||
} 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.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) {
|
||||
|
||||
@@ -29,4 +29,9 @@ class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), Na
|
||||
private fun getMediaChanges(): SyncDelta {
|
||||
throw IllegalStateException("Method not supported on this Android version.")
|
||||
}
|
||||
|
||||
override fun getTrashedAssets(): Map<String, List<PlatformAsset>> {
|
||||
//Method not supported on this Android version.
|
||||
return emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,4 +99,29 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
|
||||
// Unmounted volumes are handled in dart when the album is removed
|
||||
return SyncDelta(hasChanges, changed, deleted, assetAlbums)
|
||||
}
|
||||
|
||||
override fun getTrashedAssets(): Map<String, List<PlatformAsset>> {
|
||||
|
||||
val result = LinkedHashMap<String, MutableList<PlatformAsset>>()
|
||||
val volumes = MediaStore.getExternalVolumeNames(ctx)
|
||||
|
||||
for (volume in volumes) {
|
||||
|
||||
val queryArgs = Bundle().apply {
|
||||
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, MEDIA_SELECTION)
|
||||
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, MEDIA_SELECTION_ARGS)
|
||||
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
|
||||
}
|
||||
|
||||
getCursor(volume, queryArgs).use { cursor ->
|
||||
getAssets(cursor).forEach { res ->
|
||||
if (res is AssetResult.ValidAsset) {
|
||||
result.getOrPut(res.albumId) { mutableListOf() }.add(res.asset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.mapValues { it.value.toList() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.ext.SdkExtensions
|
||||
@@ -16,6 +17,8 @@ import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.ImageHeaderParser
|
||||
import com.bumptech.glide.load.ImageHeaderParserUtils
|
||||
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -38,11 +41,12 @@ sealed class AssetResult {
|
||||
private const val TAG = "NativeSyncApiImplBase"
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAware {
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
private var hashTask: Job? = null
|
||||
private var syncJob: Job? = null
|
||||
private val mediaTrashDelegate = MediaTrashDelegate(ctx)
|
||||
|
||||
companion object {
|
||||
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
|
||||
@@ -373,11 +377,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
)?.use { cursor -> cursor.count.toLong() } ?: 0L
|
||||
|
||||
|
||||
fun getAssetsForAlbum(
|
||||
albumId: String,
|
||||
updatedTimeCond: Long?,
|
||||
callback: (Result<List<PlatformAsset>>) -> Unit
|
||||
) {
|
||||
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result<List<PlatformAsset>>) -> Unit) {
|
||||
runSync(callback) { getAssetsForAlbum(albumId, updatedTimeCond) }
|
||||
}
|
||||
|
||||
@@ -477,16 +477,33 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
try {
|
||||
completeWhenActive(callback, Result.success(work()))
|
||||
} catch (e: CancellationException) {
|
||||
completeWhenActive(
|
||||
callback,
|
||||
Result.failure(FlutterError(SYNC_CANCELLED_CODE, "Sync cancelled", null))
|
||||
)
|
||||
completeWhenActive(callback, Result.failure(FlutterError(SYNC_CANCELLED_CODE, "Sync cancelled", null)))
|
||||
} catch (e: Exception) {
|
||||
completeWhenActive(callback, Result.failure(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
|
||||
3550
mobile/drift_schemas/main/drift_schema_v32.json
generated
3550
mobile/drift_schemas/main/drift_schema_v32.json
generated
File diff suppressed because it is too large
Load Diff
@@ -166,11 +166,6 @@
|
||||
path = Sync;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B2D5F7B13005FCA00080CE3E /* AssetMedia */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = AssetMedia;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
@@ -292,7 +287,6 @@
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B2D5F7B13005FCA00080CE3E /* AssetMedia */,
|
||||
FE1BB4562F8319560087DBF9 /* Utility */,
|
||||
FEE084F22EC172080045228E /* Schemas */,
|
||||
B231F52D2E93A44A00BC45D1 /* Core */,
|
||||
@@ -394,7 +388,6 @@
|
||||
fileSystemSynchronizedGroups = (
|
||||
B231F52D2E93A44A00BC45D1 /* Core */,
|
||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||
B2D5F7B13005FCA00080CE3E /* AssetMedia */,
|
||||
FEE084F22EC172080045228E /* Schemas */,
|
||||
);
|
||||
name = Runner;
|
||||
|
||||
@@ -26,7 +26,6 @@ import native_video_player
|
||||
|
||||
public static func registerPlugins(with registry: FlutterPluginRegistry, messenger: FlutterBinaryMessenger) {
|
||||
NativeSyncApiImpl.register(with: registry.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
||||
AssetMediaApiImpl.register(with: registry.registrar(forPlugin: AssetMediaApiImpl.name)!)
|
||||
PermissionApiSetup.setUp(binaryMessenger: messenger, api: PermissionApiImpl())
|
||||
LocalImageApiSetup.setUp(binaryMessenger: messenger, api: LocalImageApiImpl())
|
||||
RemoteImageApiSetup.setUp(binaryMessenger: messenger, api: RemoteImageApiImpl())
|
||||
@@ -37,6 +36,5 @@ import native_video_player
|
||||
|
||||
public static func cancelPlugins(with engine: FlutterEngine) {
|
||||
(engine.valuePublished(byPlugin: NativeSyncApiImpl.name) as? NativeSyncApiImpl)?.detachFromEngine()
|
||||
(engine.valuePublished(byPlugin: AssetMediaApiImpl.name) as? AssetMediaApiImpl)?.detachFromEngine()
|
||||
}
|
||||
}
|
||||
|
||||
313
mobile/ios/Runner/AssetMedia/AssetMedia.g.swift
generated
313
mobile/ios/Runner/AssetMedia/AssetMedia.g.swift
generated
@@ -1,313 +0,0 @@
|
||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
|
||||
import Foundation
|
||||
|
||||
#if os(iOS)
|
||||
import Flutter
|
||||
#elseif os(macOS)
|
||||
import FlutterMacOS
|
||||
#else
|
||||
#error("Unsupported platform.")
|
||||
#endif
|
||||
|
||||
private func wrapResult(_ result: Any?) -> [Any?] {
|
||||
return [result]
|
||||
}
|
||||
|
||||
private func wrapError(_ error: Any) -> [Any?] {
|
||||
if let pigeonError = error as? PigeonError {
|
||||
return [
|
||||
pigeonError.code,
|
||||
pigeonError.message,
|
||||
pigeonError.details,
|
||||
]
|
||||
}
|
||||
if let flutterError = error as? FlutterError {
|
||||
return [
|
||||
flutterError.code,
|
||||
flutterError.message,
|
||||
flutterError.details,
|
||||
]
|
||||
}
|
||||
return [
|
||||
"\(error)",
|
||||
"\(Swift.type(of: error))",
|
||||
"Stacktrace: \(Thread.callStackSymbols)",
|
||||
]
|
||||
}
|
||||
|
||||
private func isNullish(_ value: Any?) -> Bool {
|
||||
return value is NSNull || value == nil
|
||||
}
|
||||
|
||||
private func nilOrValue<T>(_ value: Any?) -> T? {
|
||||
if value is NSNull { return nil }
|
||||
return value as! T?
|
||||
}
|
||||
|
||||
private func doubleEqualsAssetMedia(_ lhs: Double, _ rhs: Double) -> Bool {
|
||||
return (lhs.isNaN && rhs.isNaN) || lhs == rhs
|
||||
}
|
||||
|
||||
private func doubleHashAssetMedia(_ value: Double, _ hasher: inout Hasher) {
|
||||
if value.isNaN {
|
||||
hasher.combine(0x7FF8000000000000)
|
||||
} else {
|
||||
// Normalize -0.0 to 0.0
|
||||
hasher.combine(value == 0 ? 0 : value)
|
||||
}
|
||||
}
|
||||
|
||||
func deepEqualsAssetMedia(_ lhs: Any?, _ rhs: Any?) -> Bool {
|
||||
let cleanLhs = nilOrValue(lhs) as Any?
|
||||
let cleanRhs = nilOrValue(rhs) as Any?
|
||||
switch (cleanLhs, cleanRhs) {
|
||||
case (nil, nil):
|
||||
return true
|
||||
|
||||
case (nil, _), (_, nil):
|
||||
return false
|
||||
|
||||
case (let lhs as AnyObject, let rhs as AnyObject) where lhs === rhs:
|
||||
return true
|
||||
|
||||
case is (Void, Void):
|
||||
return true
|
||||
|
||||
case (let lhsArray, let rhsArray) as ([Any?], [Any?]):
|
||||
guard lhsArray.count == rhsArray.count else { return false }
|
||||
for (index, element) in lhsArray.enumerated() {
|
||||
if !deepEqualsAssetMedia(element, rhsArray[index]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
case (let lhsArray, let rhsArray) as ([Double], [Double]):
|
||||
guard lhsArray.count == rhsArray.count else { return false }
|
||||
for (index, element) in lhsArray.enumerated() {
|
||||
if !doubleEqualsAssetMedia(element, rhsArray[index]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
case (let lhsDictionary, let rhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]):
|
||||
guard lhsDictionary.count == rhsDictionary.count else { return false }
|
||||
for (lhsKey, lhsValue) in lhsDictionary {
|
||||
var found = false
|
||||
for (rhsKey, rhsValue) in rhsDictionary {
|
||||
if deepEqualsAssetMedia(lhsKey, rhsKey) {
|
||||
if deepEqualsAssetMedia(lhsValue, rhsValue) {
|
||||
found = true
|
||||
break
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found { return false }
|
||||
}
|
||||
return true
|
||||
|
||||
case (let lhs as Double, let rhs as Double):
|
||||
return doubleEqualsAssetMedia(lhs, rhs)
|
||||
|
||||
case (let lhsHashable, let rhsHashable) as (AnyHashable, AnyHashable):
|
||||
return lhsHashable == rhsHashable
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func deepHashAssetMedia(value: Any?, hasher: inout Hasher) {
|
||||
let cleanValue = nilOrValue(value) as Any?
|
||||
if let cleanValue = cleanValue {
|
||||
if let doubleValue = cleanValue as? Double {
|
||||
doubleHashAssetMedia(doubleValue, &hasher)
|
||||
} else if let valueList = cleanValue as? [Any?] {
|
||||
for item in valueList {
|
||||
deepHashAssetMedia(value: item, hasher: &hasher)
|
||||
}
|
||||
} else if let valueList = cleanValue as? [Double] {
|
||||
for item in valueList {
|
||||
doubleHashAssetMedia(item, &hasher)
|
||||
}
|
||||
} else if let valueDict = cleanValue as? [AnyHashable: Any?] {
|
||||
var result = 0
|
||||
for (key, value) in valueDict {
|
||||
var entryKeyHasher = Hasher()
|
||||
deepHashAssetMedia(value: key, hasher: &entryKeyHasher)
|
||||
var entryValueHasher = Hasher()
|
||||
deepHashAssetMedia(value: value, hasher: &entryValueHasher)
|
||||
result = result &+ ((entryKeyHasher.finalize() &* 31) ^ entryValueHasher.finalize())
|
||||
}
|
||||
hasher.combine(result)
|
||||
} else if let hashableValue = cleanValue as? AnyHashable {
|
||||
hasher.combine(hashableValue)
|
||||
} else {
|
||||
hasher.combine(String(describing: cleanValue))
|
||||
}
|
||||
} else {
|
||||
hasher.combine(0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum AssetMediaActionStatus: Int {
|
||||
case done = 0
|
||||
case alreadyInState = 1
|
||||
case notFound = 2
|
||||
case failed = 3
|
||||
}
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct AssetMediaActionResult: Hashable {
|
||||
var id: String
|
||||
var status: AssetMediaActionStatus
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
static func fromList(_ pigeonVar_list: [Any?]) -> AssetMediaActionResult? {
|
||||
let id = pigeonVar_list[0] as! String
|
||||
let status = pigeonVar_list[1] as! AssetMediaActionStatus
|
||||
|
||||
return AssetMediaActionResult(
|
||||
id: id,
|
||||
status: status
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
return [
|
||||
id,
|
||||
status,
|
||||
]
|
||||
}
|
||||
static func == (lhs: AssetMediaActionResult, rhs: AssetMediaActionResult) -> Bool {
|
||||
if Swift.type(of: lhs) != Swift.type(of: rhs) {
|
||||
return false
|
||||
}
|
||||
return deepEqualsAssetMedia(lhs.id, rhs.id) && deepEqualsAssetMedia(lhs.status, rhs.status)
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine("AssetMediaActionResult")
|
||||
deepHashAssetMedia(value: id, hasher: &hasher)
|
||||
deepHashAssetMedia(value: status, hasher: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
private class AssetMediaPigeonCodecReader: FlutterStandardReader {
|
||||
override func readValue(ofType type: UInt8) -> Any? {
|
||||
switch type {
|
||||
case 129:
|
||||
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
|
||||
if let enumResultAsInt = enumResultAsInt {
|
||||
return AssetMediaActionStatus(rawValue: enumResultAsInt)
|
||||
}
|
||||
return nil
|
||||
case 130:
|
||||
return AssetMediaActionResult.fromList(self.readValue() as! [Any?])
|
||||
default:
|
||||
return super.readValue(ofType: type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class AssetMediaPigeonCodecWriter: FlutterStandardWriter {
|
||||
override func writeValue(_ value: Any) {
|
||||
if let value = value as? AssetMediaActionStatus {
|
||||
super.writeByte(129)
|
||||
super.writeValue(value.rawValue)
|
||||
} else if let value = value as? AssetMediaActionResult {
|
||||
super.writeByte(130)
|
||||
super.writeValue(value.toList())
|
||||
} else {
|
||||
super.writeValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class AssetMediaPigeonCodecReaderWriter: FlutterStandardReaderWriter {
|
||||
override func reader(with data: Data) -> FlutterStandardReader {
|
||||
return AssetMediaPigeonCodecReader(data: data)
|
||||
}
|
||||
|
||||
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
|
||||
return AssetMediaPigeonCodecWriter(data: data)
|
||||
}
|
||||
}
|
||||
|
||||
class AssetMediaPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
|
||||
static let shared = AssetMediaPigeonCodec(readerWriter: AssetMediaPigeonCodecReaderWriter())
|
||||
}
|
||||
|
||||
|
||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||
protocol AssetMediaApi {
|
||||
func trash(ids: [String], completion: @escaping (Result<[AssetMediaActionResult], Error>) -> Void)
|
||||
func restore(ids: [String], completion: @escaping (Result<[AssetMediaActionResult], Error>) -> Void)
|
||||
func trashedAmong(ids: [String], completion: @escaping (Result<[String], Error>) -> Void)
|
||||
}
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
class AssetMediaApiSetup {
|
||||
static var codec: FlutterStandardMessageCodec { AssetMediaPigeonCodec.shared }
|
||||
/// Sets up an instance of `AssetMediaApi` to handle messages through the `binaryMessenger`.
|
||||
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: AssetMediaApi?, messageChannelSuffix: String = "") {
|
||||
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
|
||||
let trashChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.AssetMediaApi.trash\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
trashChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let idsArg = args[0] as! [String]
|
||||
api.trash(ids: idsArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
trashChannel.setMessageHandler(nil)
|
||||
}
|
||||
let restoreChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.AssetMediaApi.restore\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
restoreChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let idsArg = args[0] as! [String]
|
||||
api.restore(ids: idsArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
restoreChannel.setMessageHandler(nil)
|
||||
}
|
||||
let trashedAmongChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.AssetMediaApi.trashedAmong\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
trashedAmongChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let idsArg = args[0] as! [String]
|
||||
api.trashedAmong(ids: idsArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
trashedAmongChannel.setMessageHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
class AssetMediaApiImpl: ImmichPlugin, AssetMediaApi, FlutterPlugin {
|
||||
static let name = "AssetMediaApi"
|
||||
|
||||
static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let instance = AssetMediaApiImpl()
|
||||
AssetMediaApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance)
|
||||
registrar.publish(instance)
|
||||
}
|
||||
|
||||
func detachFromEngine(for registrar: FlutterPluginRegistrar) {
|
||||
AssetMediaApiSetup.setUp(binaryMessenger: registrar.messenger(), api: nil)
|
||||
super.detachFromEngine()
|
||||
}
|
||||
|
||||
func trash(ids: [String], completion: @escaping (Result<[AssetMediaActionResult], Error>) -> Void) {
|
||||
completeWhenActive(for: completion, with: .success(ids.map { AssetMediaActionResult(id: $0, status: .failed) }))
|
||||
}
|
||||
|
||||
func restore(ids: [String], completion: @escaping (Result<[AssetMediaActionResult], Error>) -> Void) {
|
||||
completeWhenActive(for: completion, with: .success(ids.map { AssetMediaActionResult(id: $0, status: .failed) }))
|
||||
}
|
||||
|
||||
func trashedAmong(ids: [String], completion: @escaping (Result<[String], Error>) -> Void) {
|
||||
completeWhenActive(for: completion, with: .success([]))
|
||||
}
|
||||
}
|
||||
35
mobile/ios/Runner/Sync/Messages.g.swift
generated
35
mobile/ios/Runner/Sync/Messages.g.swift
generated
@@ -537,6 +537,8 @@ protocol NativeSyncApi {
|
||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
||||
func cancelHashing() throws
|
||||
func cancelSync() throws
|
||||
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
||||
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
|
||||
}
|
||||
|
||||
@@ -721,6 +723,39 @@ class NativeSyncApiSetup {
|
||||
} else {
|
||||
cancelSyncChannel.setMessageHandler(nil)
|
||||
}
|
||||
let getTrashedAssetsChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
if let api = api {
|
||||
getTrashedAssetsChannel.setMessageHandler { _, reply in
|
||||
do {
|
||||
let result = try api.getTrashedAssets()
|
||||
reply(wrapResult(result))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getTrashedAssetsChannel.setMessageHandler(nil)
|
||||
}
|
||||
let restoreFromTrashByIdChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
restoreFromTrashByIdChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let mediaIdArg = args[0] as! String
|
||||
let typeArg = args[1] as! Int64
|
||||
api.restoreFromTrashById(mediaId: mediaIdArg, type: typeArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
restoreFromTrashByIdChannel.setMessageHandler(nil)
|
||||
}
|
||||
let getCloudIdForAssetIdsChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
|
||||
@@ -436,6 +436,14 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
})
|
||||
}
|
||||
|
||||
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
|
||||
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
|
||||
}
|
||||
|
||||
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||
completion(.success(false))
|
||||
}
|
||||
|
||||
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
|
||||
// Ensure to actually getting all assets for the Recents album
|
||||
if (album.assetCollectionSubtype == .smartAlbumUserLibrary) {
|
||||
|
||||
@@ -203,7 +203,10 @@ class ImmichAPI {
|
||||
|
||||
func fetchMemory(for date: Date) async throws -> [MemoryResult] {
|
||||
// get URL
|
||||
let memoryParams = [URLQueryItem(name: "for", value: date.ISO8601Format())]
|
||||
let localDay = date.formatted(
|
||||
Date.ISO8601FormatStyle(timeZone: .current).year().month().day().dateSeparator(.dash)
|
||||
)
|
||||
let memoryParams = [URLQueryItem(name: "for", value: localDay)]
|
||||
guard
|
||||
let searchURL = buildRequestURL(
|
||||
serverConfig: serverConfig,
|
||||
|
||||
@@ -26,17 +26,3 @@ enum SlideshowLook { contain, cover, blurredBackground }
|
||||
enum SlideshowDirection { forward, backward, shuffle }
|
||||
|
||||
enum PartnerDirection { sharedBy, sharedWith }
|
||||
|
||||
enum TrashSyncStatus {
|
||||
pending,
|
||||
trashed,
|
||||
/* The asset was restored outside of Immich. The implications of this are:
|
||||
* - Assets matching the same checksum won't be re-trashed
|
||||
* - Backups will ignore the asset from re-uploading
|
||||
*/
|
||||
dismissed,
|
||||
/* The asset was restored back out of the OS trash by us. This allows us
|
||||
* to copy the checksum back into the local asset table
|
||||
*/
|
||||
restored,
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ const defaultConfig = AppConfig();
|
||||
|
||||
class AppConfig {
|
||||
final LogLevel logLevel;
|
||||
final bool trashSyncEnabled;
|
||||
final ThemeConfig theme;
|
||||
final CleanupConfig cleanup;
|
||||
final MapConfig map;
|
||||
@@ -39,7 +38,6 @@ class AppConfig {
|
||||
|
||||
const AppConfig({
|
||||
this.logLevel = .info,
|
||||
this.trashSyncEnabled = false,
|
||||
this.theme = const .new(),
|
||||
this.cleanup = const .new(),
|
||||
this.map = const .new(),
|
||||
@@ -56,7 +54,6 @@ class AppConfig {
|
||||
|
||||
AppConfig copyWith({
|
||||
LogLevel? logLevel,
|
||||
bool? trashSyncEnabled,
|
||||
ThemeConfig? theme,
|
||||
CleanupConfig? cleanup,
|
||||
MapConfig? map,
|
||||
@@ -71,7 +68,6 @@ class AppConfig {
|
||||
FeatureMessageConfig? featureMessage,
|
||||
}) => .new(
|
||||
logLevel: logLevel ?? this.logLevel,
|
||||
trashSyncEnabled: trashSyncEnabled ?? this.trashSyncEnabled,
|
||||
theme: theme ?? this.theme,
|
||||
cleanup: cleanup ?? this.cleanup,
|
||||
map: map ?? this.map,
|
||||
@@ -91,7 +87,6 @@ class AppConfig {
|
||||
identical(this, other) ||
|
||||
(other is AppConfig &&
|
||||
other.logLevel == logLevel &&
|
||||
other.trashSyncEnabled == trashSyncEnabled &&
|
||||
other.theme == theme &&
|
||||
other.cleanup == cleanup &&
|
||||
other.map == map &&
|
||||
@@ -108,7 +103,6 @@ class AppConfig {
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
logLevel,
|
||||
trashSyncEnabled,
|
||||
theme,
|
||||
cleanup,
|
||||
map,
|
||||
@@ -125,12 +119,11 @@ class AppConfig {
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AppConfig(logLevel: $logLevel, trashSyncEnabled: $trashSyncEnabled, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share, featureMessage: $featureMessage)';
|
||||
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share, featureMessage: $featureMessage)';
|
||||
|
||||
T read<T>(SettingsKey<T> key) =>
|
||||
(switch (key) {
|
||||
.logLevel => logLevel,
|
||||
.trashSyncEnabled => trashSyncEnabled,
|
||||
.themePrimaryColor => theme.primaryColor,
|
||||
.themeMode => theme.mode,
|
||||
.themeDynamic => theme.dynamicTheme,
|
||||
@@ -183,7 +176,6 @@ class AppConfig {
|
||||
AppConfig write<T, U extends T>(SettingsKey<T> key, U value) {
|
||||
return switch (key) {
|
||||
.logLevel => copyWith(logLevel: value as LogLevel),
|
||||
.trashSyncEnabled => copyWith(trashSyncEnabled: value as bool),
|
||||
.themePrimaryColor => copyWith(theme: theme.copyWith(primaryColor: value as ImmichColorPreset)),
|
||||
.themeMode => copyWith(theme: theme.copyWith(mode: value as ThemeMode)),
|
||||
.themeDynamic => copyWith(theme: theme.copyWith(dynamicTheme: value as bool)),
|
||||
|
||||
@@ -36,9 +36,6 @@ enum SettingsKey<T> {
|
||||
albumIsReverse<bool>(),
|
||||
albumIsGrid<bool>(),
|
||||
|
||||
// Sync
|
||||
trashSyncEnabled<bool>(),
|
||||
|
||||
// Backup
|
||||
backupEnabled<bool>(),
|
||||
backupUseCellularForVideos<bool>(),
|
||||
|
||||
@@ -12,13 +12,13 @@ enum StoreKey<T> {
|
||||
advancedTroubleshooting<bool>._(114),
|
||||
enableHapticFeedback<bool>._(126),
|
||||
|
||||
manageLocalMediaAndroid<bool>._(137),
|
||||
// Read-only Mode settings
|
||||
readonlyModeEnabled<bool>._(138),
|
||||
|
||||
syncMigrationStatus<String>._(1013),
|
||||
|
||||
// Legacy keys that have been migrated to the new metadata store
|
||||
legacyManageLocalMediaAndroid<bool>._(137),
|
||||
legacyBackupRequireCharging<bool>._(7),
|
||||
legacyBackupTriggerDelay<int>._(8),
|
||||
legacySyncAlbums<bool>._(131),
|
||||
|
||||
@@ -4,8 +4,10 @@ import 'package:flutter/services.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/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/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
@@ -15,6 +17,7 @@ class HashService {
|
||||
final int _batchSize;
|
||||
final DriftLocalAlbumRepository _localAlbumRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final Completer<void>? _cancellation;
|
||||
final _log = Logger('HashService');
|
||||
@@ -22,6 +25,7 @@ class HashService {
|
||||
HashService({
|
||||
required this._localAlbumRepository,
|
||||
required this._localAssetRepository,
|
||||
required this._trashedLocalAssetRepository,
|
||||
required this._nativeSyncApi,
|
||||
this._cancellation,
|
||||
int? batchSize,
|
||||
@@ -54,6 +58,14 @@ class HashService {
|
||||
await _hashAssets(album, assetsToHash);
|
||||
}
|
||||
}
|
||||
if (CurrentPlatform.isAndroid && localAlbums.isNotEmpty) {
|
||||
final backupAlbumIds = localAlbums.map((e) => e.id);
|
||||
final trashedToHash = await _trashedLocalAssetRepository.getAssetsToHash(backupAlbumIds);
|
||||
if (trashedToHash.isNotEmpty) {
|
||||
final pseudoAlbum = LocalAlbum(id: '-pseudoAlbum', name: 'Trash', updatedAt: DateTime.now());
|
||||
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true);
|
||||
}
|
||||
}
|
||||
} on PlatformException catch (e) {
|
||||
if (e.code == _kHashCancelledCode) {
|
||||
_log.warning("Hashing cancelled by platform");
|
||||
@@ -70,7 +82,7 @@ class HashService {
|
||||
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
|
||||
/// with hash for those that were successfully hashed. Hashes are looked up in a table
|
||||
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
||||
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash) async {
|
||||
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash, {bool isTrashed = false}) async {
|
||||
final toHash = <String, LocalAsset>{};
|
||||
|
||||
for (final asset in assetsToHash) {
|
||||
@@ -81,16 +93,16 @@ class HashService {
|
||||
|
||||
toHash[asset.id] = asset;
|
||||
if (toHash.length == _batchSize) {
|
||||
await _processBatch(album, toHash);
|
||||
await _processBatch(album, toHash, isTrashed);
|
||||
toHash.clear();
|
||||
}
|
||||
}
|
||||
|
||||
await _processBatch(album, toHash);
|
||||
await _processBatch(album, toHash, isTrashed);
|
||||
}
|
||||
|
||||
/// Processes a batch of assets.
|
||||
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash) async {
|
||||
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash, bool isTrashed) async {
|
||||
if (toHash.isEmpty) {
|
||||
return;
|
||||
}
|
||||
@@ -125,6 +137,10 @@ class HashService {
|
||||
}
|
||||
|
||||
_log.fine("Hashed ${hashed.length}/${toHash.length} assets");
|
||||
await _localAssetRepository.updateHashes(hashed);
|
||||
if (isTrashed) {
|
||||
await _trashedLocalAssetRepository.updateHashes(hashed);
|
||||
} else {
|
||||
await _localAssetRepository.updateHashes(hashed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,15 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.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/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -20,16 +24,20 @@ class LocalSyncService {
|
||||
final DriftLocalAlbumRepository _localAlbumRepository;
|
||||
// ignore: unused_field
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftTrashSyncRepository _trashSyncRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final IPermissionRepository _permissionRepository;
|
||||
final Completer<void>? _cancellation;
|
||||
final Logger _log = Logger("DeviceSyncService");
|
||||
|
||||
LocalSyncService({
|
||||
required this._localAlbumRepository,
|
||||
required this._localAssetRepository,
|
||||
required this._trashSyncRepository,
|
||||
required this._nativeSyncApi,
|
||||
required this._trashedLocalAssetRepository,
|
||||
required this._assetMediaRepository,
|
||||
required this._permissionRepository,
|
||||
this._cancellation,
|
||||
}) {
|
||||
_cancellation?.future.then((_) => _nativeSyncApi.cancelSync().onError(_log.warning));
|
||||
@@ -40,6 +48,15 @@ class LocalSyncService {
|
||||
Future<void> sync({bool full = false}) async {
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
final hasPermission = await _permissionRepository.hasManageMediaPermission();
|
||||
if (hasPermission) {
|
||||
await _syncTrashedAssets();
|
||||
} else {
|
||||
_log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
}
|
||||
}
|
||||
|
||||
if (CurrentPlatform.isIOS) {
|
||||
// final assets = await _localAssetRepository.getEmptyCloudIdAssets();
|
||||
// await _mapIosCloudIds(assets);
|
||||
@@ -102,7 +119,6 @@ class LocalSyncService {
|
||||
await _mapIosCloudIds(newAssets);
|
||||
}
|
||||
await _nativeSyncApi.checkpointSync();
|
||||
await _trashSyncRepository.restoreChecksums();
|
||||
} on PlatformException catch (e, s) {
|
||||
if (e.code == _kSyncCancelledCode) {
|
||||
_log.warning("Local sync cancelled");
|
||||
@@ -134,7 +150,6 @@ class LocalSyncService {
|
||||
);
|
||||
|
||||
await _nativeSyncApi.checkpointSync();
|
||||
await _trashSyncRepository.restoreChecksums();
|
||||
stopwatch.stop();
|
||||
_log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
|
||||
} on PlatformException catch (e, s) {
|
||||
@@ -368,6 +383,48 @@ class LocalSyncService {
|
||||
bool _albumsEqual(LocalAlbum a, LocalAlbum b) {
|
||||
return a.name == b.name && a.assetCount == b.assetCount && a.updatedAt.isAtSameMomentAs(b.updatedAt);
|
||||
}
|
||||
|
||||
Future<void> _syncTrashedAssets() async {
|
||||
final trashedAssetMap = await _nativeSyncApi.getTrashedAssets();
|
||||
await processTrashedAssets(trashedAssetMap);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> processTrashedAssets(Map<String, List<PlatformAsset>> trashedAssetMap) async {
|
||||
if (trashedAssetMap.isEmpty) {
|
||||
_log.info("syncTrashedAssets, No trashed assets found");
|
||||
}
|
||||
final trashedAssets = trashedAssetMap.cast<String, List<Object?>>().entries.expand(
|
||||
(entry) => entry.value.cast<PlatformAsset>().toTrashedAssets(entry.key),
|
||||
);
|
||||
|
||||
_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 _assetMediaRepository.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 localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
|
||||
_log.info("Moving to trash ${localIds.join(", ")} assets");
|
||||
final movedIds = await _assetMediaRepository.deleteAll(localIds);
|
||||
if (movedIds.isNotEmpty) {
|
||||
final movedAssetsByAlbum = localAssetsToTrash.map(
|
||||
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
|
||||
)..removeWhere((_, assets) => assets.isEmpty);
|
||||
|
||||
await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
|
||||
}
|
||||
} else {
|
||||
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension on Iterable<PlatformAlbum> {
|
||||
@@ -388,6 +445,10 @@ extension on Iterable<PlatformAsset> {
|
||||
List<LocalAsset> toLocalAssets() {
|
||||
return map((e) => e.toLocalAsset()).toList();
|
||||
}
|
||||
|
||||
Iterable<TrashedAsset> toTrashedAssets(String albumId) {
|
||||
return map((e) => (albumId: albumId, asset: e.toLocalAsset()));
|
||||
}
|
||||
}
|
||||
|
||||
extension PlatformToLocalAsset on PlatformAsset {
|
||||
|
||||
@@ -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/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/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -28,7 +33,10 @@ class SyncStreamService {
|
||||
|
||||
final SyncApiRepository _syncApiRepository;
|
||||
final SyncStreamRepository _syncStreamRepository;
|
||||
final DriftTrashSyncRepository _trashSyncRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final IPermissionRepository _permissionRepository;
|
||||
final SyncMigrationRepository _syncMigrationRepository;
|
||||
final ApiService _api;
|
||||
final Completer<void>? _cancellation;
|
||||
@@ -36,7 +44,10 @@ class SyncStreamService {
|
||||
SyncStreamService({
|
||||
required this._syncApiRepository,
|
||||
required this._syncStreamRepository,
|
||||
required this._trashSyncRepository,
|
||||
required this._localAssetRepository,
|
||||
required this._trashedLocalAssetRepository,
|
||||
required this._assetMediaRepository,
|
||||
required this._permissionRepository,
|
||||
required this._syncMigrationRepository,
|
||||
required this._api,
|
||||
this._cancellation,
|
||||
@@ -191,13 +202,24 @@ class SyncStreamService {
|
||||
case SyncEntityType.partnerDeleteV1:
|
||||
return _syncStreamRepository.deletePartnerV1(data.cast());
|
||||
case SyncEntityType.assetV1:
|
||||
return _syncStreamRepository.updateAssetsV1(data.cast<SyncAssetV1>());
|
||||
final remoteSyncAssets = data.cast<SyncAssetV1>();
|
||||
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
|
||||
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:
|
||||
return _syncStreamRepository.updateAssetsV2(data.cast<SyncAssetV2>());
|
||||
final remoteSyncAssets = data.cast<SyncAssetV2>();
|
||||
await _syncStreamRepository.updateAssetsV2(remoteSyncAssets);
|
||||
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>();
|
||||
// Has to be before the delete call so we can record the checksums of the assets as well
|
||||
await _trashSyncRepository.recordHardDeletedChecksums(remoteSyncAssets.map((e) => e.assetId));
|
||||
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());
|
||||
@@ -475,4 +497,59 @@ class SyncStreamService {
|
||||
_logger.severe("Error processing AssetEditReadyV2 websocket event", error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
|
||||
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
|
||||
_logger.info("Moving to trash ${localIds.join(", ")} assets");
|
||||
final movedIds = await _assetMediaRepository.deleteAll(localIds);
|
||||
if (movedIds.isNotEmpty) {
|
||||
final movedAssetsByAlbum = localAssetsToTrash.map(
|
||||
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
|
||||
)..removeWhere((_, assets) => assets.isEmpty);
|
||||
|
||||
await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyRemoteRestoreToLocal() async {
|
||||
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
||||
if (assetsToRestore.isNotEmpty) {
|
||||
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
|
||||
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
||||
} else {
|
||||
_logger.info("No remote assets found for restoration");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncAssetTrashStatus(List<String> remoteIds) async {
|
||||
if (!(await _permissionRepository.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 _permissionRepository.hasManageMediaPermission())) {
|
||||
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
await _handleRemoteDeleted(remoteIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
|
||||
import 'package:immich_mobile/platform/asset_media_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class TrashSyncService {
|
||||
final DriftTrashSyncRepository _repo;
|
||||
final AssetMediaApi _assetMediaApi;
|
||||
final PermissionRepository _permission;
|
||||
final SettingsRepository _settings;
|
||||
final Logger _log = Logger('TrashSyncService');
|
||||
|
||||
TrashSyncService({
|
||||
required this._repo,
|
||||
required this._assetMediaApi,
|
||||
required this._permission,
|
||||
required this._settings,
|
||||
});
|
||||
|
||||
Future<void> reconcile() async {
|
||||
try {
|
||||
await _prune();
|
||||
|
||||
if (!_settings.appConfig.trashSyncEnabled || !await _canApplyToOsTrash()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _record();
|
||||
await _act();
|
||||
} catch (error, stack) {
|
||||
_log.severe("Trash reconcile failed", error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _prune() async {
|
||||
await _repo.pruneStaleMarkers();
|
||||
await _repo.pruneDismissedMarkers();
|
||||
await _repo.prunePendingMarkers();
|
||||
}
|
||||
|
||||
Future<void> _record() async {
|
||||
await _repo.recordSoftDeleteAssets();
|
||||
await _repo.recordHardDeletedAssets();
|
||||
}
|
||||
|
||||
Future<void> _act() async {
|
||||
await _trashAssets();
|
||||
await _restoreAssets();
|
||||
await _reconcileWithOSTrash();
|
||||
}
|
||||
|
||||
Future<void> _trashAssets() async {
|
||||
final pending = await _repo.getPendingAssetIds();
|
||||
if (pending.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final results = await _assetMediaApi.trash(pending);
|
||||
final movedIds = results.whereStatusIn(const {.done, .alreadyInState});
|
||||
final removedIds = results.whereStatusIn(const {.notFound});
|
||||
|
||||
if (movedIds.isNotEmpty) {
|
||||
await _repo.markTrashed(movedIds);
|
||||
}
|
||||
|
||||
if (removedIds.isNotEmpty) {
|
||||
await _repo.deleteMarkers(removedIds);
|
||||
}
|
||||
|
||||
_log.fine("Trashed ${movedIds.length}, dropped ${removedIds.length} out of ${pending.length} pending");
|
||||
}
|
||||
|
||||
Future<void> _restoreAssets() async {
|
||||
final restorable = await _repo.getRestorableAssetIds();
|
||||
if (restorable.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final results = await _assetMediaApi.restore(restorable);
|
||||
final restoredIds = results.whereStatusIn(const {.done, .alreadyInState});
|
||||
final goneIds = results.whereStatusIn(const {.notFound});
|
||||
|
||||
if (restoredIds.isNotEmpty) {
|
||||
await _repo.markRestored(restoredIds);
|
||||
}
|
||||
|
||||
if (goneIds.isNotEmpty) {
|
||||
await _repo.deleteMarkers(goneIds);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reconcileWithOSTrash() async {
|
||||
final trashed = await _repo.getTrashedAssetIds();
|
||||
if (trashed.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final stillTrashed = (await _assetMediaApi.trashedAmong(trashed)).toSet();
|
||||
final gone = trashed.where((id) => !stillTrashed.contains(id)).toList();
|
||||
if (gone.isNotEmpty) {
|
||||
await _repo.reconcileTrashed(gone);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _canApplyToOsTrash() async {
|
||||
if (!CurrentPlatform.isAndroid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final hasPermission = await _permission.hasManageMediaPermission();
|
||||
if (!hasPermission) {
|
||||
_log.fine("OS trash sync skipped: MANAGE_MEDIA permission not granted");
|
||||
}
|
||||
|
||||
return hasPermission;
|
||||
}
|
||||
}
|
||||
|
||||
extension on Iterable<AssetMediaActionResult> {
|
||||
Set<String> whereStatusIn(Set<AssetMediaActionStatus> statuses) =>
|
||||
where((r) => statuses.contains(r.status)).map((r) => r.id).toSet();
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import 'dart:async';
|
||||
import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart' as m;
|
||||
import 'package:immich_mobile/domain/utils/sync_linked_album.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
|
||||
import 'package:immich_mobile/utils/isolate.dart';
|
||||
import 'package:worker_manager/worker_manager.dart';
|
||||
|
||||
@@ -34,7 +33,6 @@ class BackgroundSyncManager {
|
||||
Cancelable<void>? _deviceAlbumSyncTask;
|
||||
Cancelable<void>? _linkedAlbumSyncTask;
|
||||
Cancelable<void>? _hashTask;
|
||||
Cancelable<void>? _trashSyncTask;
|
||||
|
||||
BackgroundSyncManager({
|
||||
this.onRemoteSyncStart,
|
||||
@@ -59,7 +57,6 @@ class BackgroundSyncManager {
|
||||
_linkedAlbumSyncTask,
|
||||
_deviceAlbumSyncTask,
|
||||
_hashTask,
|
||||
_trashSyncTask,
|
||||
];
|
||||
final futures = [
|
||||
for (final task in tasks)
|
||||
@@ -74,7 +71,6 @@ class BackgroundSyncManager {
|
||||
_linkedAlbumSyncTask = null;
|
||||
_deviceAlbumSyncTask = null;
|
||||
_hashTask = null;
|
||||
_trashSyncTask = null;
|
||||
|
||||
try {
|
||||
await Future.wait(futures);
|
||||
@@ -164,21 +160,6 @@ class BackgroundSyncManager {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> syncTrash() {
|
||||
if (_trashSyncTask != null) {
|
||||
return _trashSyncTask!.future;
|
||||
}
|
||||
|
||||
_trashSyncTask = runInIsolateGentle(
|
||||
computation: (ref) => ref.read(trashSyncServiceProvider).reconcile(),
|
||||
debugLabel: 'trash-sync',
|
||||
);
|
||||
|
||||
return _trashSyncTask!.whenComplete(() {
|
||||
_trashSyncTask = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> syncWebsocketBatchV1(List<dynamic> batchData) {
|
||||
if (_syncWebsocketTask != null) {
|
||||
return _syncWebsocketTask!.future;
|
||||
|
||||
@@ -16,11 +16,6 @@ ON remote_asset_entity (owner_id, library_id, checksum)
|
||||
WHERE (library_id IS NOT NULL);
|
||||
''')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)')
|
||||
@TableIndex.sql('''
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_asset_soft_deleted_checksum
|
||||
ON remote_asset_entity (checksum)
|
||||
WHERE deleted_at IS NOT NULL
|
||||
''')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)')
|
||||
@TableIndex.sql('''
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created
|
||||
|
||||
@@ -1762,10 +1762,6 @@ i0.Index get idxRemoteAssetChecksum => i0.Index(
|
||||
'idx_remote_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||
);
|
||||
i0.Index get idxRemoteAssetSoftDeletedChecksum => i0.Index(
|
||||
'idx_remote_asset_soft_deleted_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_soft_deleted_checksum ON remote_asset_entity (checksum) WHERE deleted_at IS NOT NULL',
|
||||
);
|
||||
i0.Index get idxRemoteAssetStackId => i0.Index(
|
||||
'idx_remote_asset_stack_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
|
||||
class ServerDeletedChecksumEntity extends Table with DriftDefaultsMixin {
|
||||
const ServerDeletedChecksumEntity();
|
||||
|
||||
TextColumn get checksum => text()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {checksum};
|
||||
|
||||
@override
|
||||
String get tableName => "server_deleted_checksum";
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
// dart format width=80
|
||||
// ignore_for_file: type=lint
|
||||
import 'package:drift/drift.dart' as i0;
|
||||
import 'package:immich_mobile/infrastructure/entities/server_deleted_checksum.entity.drift.dart'
|
||||
as i1;
|
||||
import 'package:immich_mobile/infrastructure/entities/server_deleted_checksum.entity.dart'
|
||||
as i2;
|
||||
|
||||
typedef $$ServerDeletedChecksumEntityTableCreateCompanionBuilder =
|
||||
i1.ServerDeletedChecksumEntityCompanion Function({
|
||||
required String checksum,
|
||||
});
|
||||
typedef $$ServerDeletedChecksumEntityTableUpdateCompanionBuilder =
|
||||
i1.ServerDeletedChecksumEntityCompanion Function({
|
||||
i0.Value<String> checksum,
|
||||
});
|
||||
|
||||
class $$ServerDeletedChecksumEntityTableFilterComposer
|
||||
extends
|
||||
i0.Composer<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$ServerDeletedChecksumEntityTable
|
||||
> {
|
||||
$$ServerDeletedChecksumEntityTableFilterComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.ColumnFilters<String> get checksum => $composableBuilder(
|
||||
column: $table.checksum,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$ServerDeletedChecksumEntityTableOrderingComposer
|
||||
extends
|
||||
i0.Composer<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$ServerDeletedChecksumEntityTable
|
||||
> {
|
||||
$$ServerDeletedChecksumEntityTableOrderingComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.ColumnOrderings<String> get checksum => $composableBuilder(
|
||||
column: $table.checksum,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$ServerDeletedChecksumEntityTableAnnotationComposer
|
||||
extends
|
||||
i0.Composer<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$ServerDeletedChecksumEntityTable
|
||||
> {
|
||||
$$ServerDeletedChecksumEntityTableAnnotationComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.GeneratedColumn<String> get checksum =>
|
||||
$composableBuilder(column: $table.checksum, builder: (column) => column);
|
||||
}
|
||||
|
||||
class $$ServerDeletedChecksumEntityTableTableManager
|
||||
extends
|
||||
i0.RootTableManager<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$ServerDeletedChecksumEntityTable,
|
||||
i1.ServerDeletedChecksumEntityData,
|
||||
i1.$$ServerDeletedChecksumEntityTableFilterComposer,
|
||||
i1.$$ServerDeletedChecksumEntityTableOrderingComposer,
|
||||
i1.$$ServerDeletedChecksumEntityTableAnnotationComposer,
|
||||
$$ServerDeletedChecksumEntityTableCreateCompanionBuilder,
|
||||
$$ServerDeletedChecksumEntityTableUpdateCompanionBuilder,
|
||||
(
|
||||
i1.ServerDeletedChecksumEntityData,
|
||||
i0.BaseReferences<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$ServerDeletedChecksumEntityTable,
|
||||
i1.ServerDeletedChecksumEntityData
|
||||
>,
|
||||
),
|
||||
i1.ServerDeletedChecksumEntityData,
|
||||
i0.PrefetchHooks Function()
|
||||
> {
|
||||
$$ServerDeletedChecksumEntityTableTableManager(
|
||||
i0.GeneratedDatabase db,
|
||||
i1.$ServerDeletedChecksumEntityTable table,
|
||||
) : super(
|
||||
i0.TableManagerState(
|
||||
db: db,
|
||||
table: table,
|
||||
createFilteringComposer: () =>
|
||||
i1.$$ServerDeletedChecksumEntityTableFilterComposer(
|
||||
$db: db,
|
||||
$table: table,
|
||||
),
|
||||
createOrderingComposer: () =>
|
||||
i1.$$ServerDeletedChecksumEntityTableOrderingComposer(
|
||||
$db: db,
|
||||
$table: table,
|
||||
),
|
||||
createComputedFieldComposer: () =>
|
||||
i1.$$ServerDeletedChecksumEntityTableAnnotationComposer(
|
||||
$db: db,
|
||||
$table: table,
|
||||
),
|
||||
updateCompanionCallback:
|
||||
({i0.Value<String> checksum = const i0.Value.absent()}) =>
|
||||
i1.ServerDeletedChecksumEntityCompanion(checksum: checksum),
|
||||
createCompanionCallback: ({required String checksum}) =>
|
||||
i1.ServerDeletedChecksumEntityCompanion.insert(
|
||||
checksum: checksum,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||
.toList(),
|
||||
prefetchHooksCallback: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
typedef $$ServerDeletedChecksumEntityTableProcessedTableManager =
|
||||
i0.ProcessedTableManager<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$ServerDeletedChecksumEntityTable,
|
||||
i1.ServerDeletedChecksumEntityData,
|
||||
i1.$$ServerDeletedChecksumEntityTableFilterComposer,
|
||||
i1.$$ServerDeletedChecksumEntityTableOrderingComposer,
|
||||
i1.$$ServerDeletedChecksumEntityTableAnnotationComposer,
|
||||
$$ServerDeletedChecksumEntityTableCreateCompanionBuilder,
|
||||
$$ServerDeletedChecksumEntityTableUpdateCompanionBuilder,
|
||||
(
|
||||
i1.ServerDeletedChecksumEntityData,
|
||||
i0.BaseReferences<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$ServerDeletedChecksumEntityTable,
|
||||
i1.ServerDeletedChecksumEntityData
|
||||
>,
|
||||
),
|
||||
i1.ServerDeletedChecksumEntityData,
|
||||
i0.PrefetchHooks Function()
|
||||
>;
|
||||
|
||||
class $ServerDeletedChecksumEntityTable extends i2.ServerDeletedChecksumEntity
|
||||
with
|
||||
i0.TableInfo<
|
||||
$ServerDeletedChecksumEntityTable,
|
||||
i1.ServerDeletedChecksumEntityData
|
||||
> {
|
||||
@override
|
||||
final i0.GeneratedDatabase attachedDatabase;
|
||||
final String? _alias;
|
||||
$ServerDeletedChecksumEntityTable(this.attachedDatabase, [this._alias]);
|
||||
static const i0.VerificationMeta _checksumMeta = const i0.VerificationMeta(
|
||||
'checksum',
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> checksum = i0.GeneratedColumn<String>(
|
||||
'checksum',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
@override
|
||||
List<i0.GeneratedColumn> get $columns => [checksum];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
String get actualTableName => $name;
|
||||
static const String $name = 'server_deleted_checksum';
|
||||
@override
|
||||
i0.VerificationContext validateIntegrity(
|
||||
i0.Insertable<i1.ServerDeletedChecksumEntityData> instance, {
|
||||
bool isInserting = false,
|
||||
}) {
|
||||
final context = i0.VerificationContext();
|
||||
final data = instance.toColumns(true);
|
||||
if (data.containsKey('checksum')) {
|
||||
context.handle(
|
||||
_checksumMeta,
|
||||
checksum.isAcceptableOrUnknown(data['checksum']!, _checksumMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_checksumMeta);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@override
|
||||
Set<i0.GeneratedColumn> get $primaryKey => {checksum};
|
||||
@override
|
||||
i1.ServerDeletedChecksumEntityData map(
|
||||
Map<String, dynamic> data, {
|
||||
String? tablePrefix,
|
||||
}) {
|
||||
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||
return i1.ServerDeletedChecksumEntityData(
|
||||
checksum: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}checksum'],
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
$ServerDeletedChecksumEntityTable createAlias(String alias) {
|
||||
return $ServerDeletedChecksumEntityTable(attachedDatabase, alias);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get withoutRowId => true;
|
||||
@override
|
||||
bool get isStrict => true;
|
||||
}
|
||||
|
||||
class ServerDeletedChecksumEntityData extends i0.DataClass
|
||||
implements i0.Insertable<i1.ServerDeletedChecksumEntityData> {
|
||||
final String checksum;
|
||||
const ServerDeletedChecksumEntityData({required this.checksum});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, i0.Expression>{};
|
||||
map['checksum'] = i0.Variable<String>(checksum);
|
||||
return map;
|
||||
}
|
||||
|
||||
factory ServerDeletedChecksumEntityData.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
i0.ValueSerializer? serializer,
|
||||
}) {
|
||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
||||
return ServerDeletedChecksumEntityData(
|
||||
checksum: serializer.fromJson<String>(json['checksum']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
|
||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
||||
return <String, dynamic>{'checksum': serializer.toJson<String>(checksum)};
|
||||
}
|
||||
|
||||
i1.ServerDeletedChecksumEntityData copyWith({String? checksum}) =>
|
||||
i1.ServerDeletedChecksumEntityData(checksum: checksum ?? this.checksum);
|
||||
ServerDeletedChecksumEntityData copyWithCompanion(
|
||||
i1.ServerDeletedChecksumEntityCompanion data,
|
||||
) {
|
||||
return ServerDeletedChecksumEntityData(
|
||||
checksum: data.checksum.present ? data.checksum.value : this.checksum,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('ServerDeletedChecksumEntityData(')
|
||||
..write('checksum: $checksum')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => checksum.hashCode;
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is i1.ServerDeletedChecksumEntityData &&
|
||||
other.checksum == this.checksum);
|
||||
}
|
||||
|
||||
class ServerDeletedChecksumEntityCompanion
|
||||
extends i0.UpdateCompanion<i1.ServerDeletedChecksumEntityData> {
|
||||
final i0.Value<String> checksum;
|
||||
const ServerDeletedChecksumEntityCompanion({
|
||||
this.checksum = const i0.Value.absent(),
|
||||
});
|
||||
ServerDeletedChecksumEntityCompanion.insert({required String checksum})
|
||||
: checksum = i0.Value(checksum);
|
||||
static i0.Insertable<i1.ServerDeletedChecksumEntityData> custom({
|
||||
i0.Expression<String>? checksum,
|
||||
}) {
|
||||
return i0.RawValuesInsertable({if (checksum != null) 'checksum': checksum});
|
||||
}
|
||||
|
||||
i1.ServerDeletedChecksumEntityCompanion copyWith({
|
||||
i0.Value<String>? checksum,
|
||||
}) {
|
||||
return i1.ServerDeletedChecksumEntityCompanion(
|
||||
checksum: checksum ?? this.checksum,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, i0.Expression>{};
|
||||
if (checksum.present) {
|
||||
map['checksum'] = i0.Variable<String>(checksum.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('ServerDeletedChecksumEntityCompanion(')
|
||||
..write('checksum: $checksum')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trash_sync_checksum ON trash_sync (checksum)')
|
||||
class TrashSyncEntity extends Table with DriftDefaultsMixin {
|
||||
const TrashSyncEntity();
|
||||
|
||||
TextColumn get assetId => text()();
|
||||
|
||||
TextColumn get checksum => text()();
|
||||
|
||||
IntColumn get status => intEnum<TrashSyncStatus>().withDefault(Constant(TrashSyncStatus.pending.index))();
|
||||
|
||||
DateTimeColumn get assetUpdatedAt => dateTime().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {assetId};
|
||||
|
||||
@override
|
||||
String get tableName => "trash_sync";
|
||||
}
|
||||
@@ -1,524 +0,0 @@
|
||||
// dart format width=80
|
||||
// ignore_for_file: type=lint
|
||||
import 'package:drift/drift.dart' as i0;
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart'
|
||||
as i1;
|
||||
import 'package:immich_mobile/constants/enums.dart' as i2;
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart'
|
||||
as i3;
|
||||
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i4;
|
||||
|
||||
typedef $$TrashSyncEntityTableCreateCompanionBuilder =
|
||||
i1.TrashSyncEntityCompanion Function({
|
||||
required String assetId,
|
||||
required String checksum,
|
||||
i0.Value<i2.TrashSyncStatus> status,
|
||||
i0.Value<DateTime?> assetUpdatedAt,
|
||||
});
|
||||
typedef $$TrashSyncEntityTableUpdateCompanionBuilder =
|
||||
i1.TrashSyncEntityCompanion Function({
|
||||
i0.Value<String> assetId,
|
||||
i0.Value<String> checksum,
|
||||
i0.Value<i2.TrashSyncStatus> status,
|
||||
i0.Value<DateTime?> assetUpdatedAt,
|
||||
});
|
||||
|
||||
class $$TrashSyncEntityTableFilterComposer
|
||||
extends i0.Composer<i0.GeneratedDatabase, i1.$TrashSyncEntityTable> {
|
||||
$$TrashSyncEntityTableFilterComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.ColumnFilters<String> get assetId => $composableBuilder(
|
||||
column: $table.assetId,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<String> get checksum => $composableBuilder(
|
||||
column: $table.checksum,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnWithTypeConverterFilters<i2.TrashSyncStatus, i2.TrashSyncStatus, int>
|
||||
get status => $composableBuilder(
|
||||
column: $table.status,
|
||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<DateTime> get assetUpdatedAt => $composableBuilder(
|
||||
column: $table.assetUpdatedAt,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$TrashSyncEntityTableOrderingComposer
|
||||
extends i0.Composer<i0.GeneratedDatabase, i1.$TrashSyncEntityTable> {
|
||||
$$TrashSyncEntityTableOrderingComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.ColumnOrderings<String> get assetId => $composableBuilder(
|
||||
column: $table.assetId,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<String> get checksum => $composableBuilder(
|
||||
column: $table.checksum,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<int> get status => $composableBuilder(
|
||||
column: $table.status,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<DateTime> get assetUpdatedAt => $composableBuilder(
|
||||
column: $table.assetUpdatedAt,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$TrashSyncEntityTableAnnotationComposer
|
||||
extends i0.Composer<i0.GeneratedDatabase, i1.$TrashSyncEntityTable> {
|
||||
$$TrashSyncEntityTableAnnotationComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.GeneratedColumn<String> get assetId =>
|
||||
$composableBuilder(column: $table.assetId, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumn<String> get checksum =>
|
||||
$composableBuilder(column: $table.checksum, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumnWithTypeConverter<i2.TrashSyncStatus, int> get status =>
|
||||
$composableBuilder(column: $table.status, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumn<DateTime> get assetUpdatedAt => $composableBuilder(
|
||||
column: $table.assetUpdatedAt,
|
||||
builder: (column) => column,
|
||||
);
|
||||
}
|
||||
|
||||
class $$TrashSyncEntityTableTableManager
|
||||
extends
|
||||
i0.RootTableManager<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$TrashSyncEntityTable,
|
||||
i1.TrashSyncEntityData,
|
||||
i1.$$TrashSyncEntityTableFilterComposer,
|
||||
i1.$$TrashSyncEntityTableOrderingComposer,
|
||||
i1.$$TrashSyncEntityTableAnnotationComposer,
|
||||
$$TrashSyncEntityTableCreateCompanionBuilder,
|
||||
$$TrashSyncEntityTableUpdateCompanionBuilder,
|
||||
(
|
||||
i1.TrashSyncEntityData,
|
||||
i0.BaseReferences<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$TrashSyncEntityTable,
|
||||
i1.TrashSyncEntityData
|
||||
>,
|
||||
),
|
||||
i1.TrashSyncEntityData,
|
||||
i0.PrefetchHooks Function()
|
||||
> {
|
||||
$$TrashSyncEntityTableTableManager(
|
||||
i0.GeneratedDatabase db,
|
||||
i1.$TrashSyncEntityTable table,
|
||||
) : super(
|
||||
i0.TableManagerState(
|
||||
db: db,
|
||||
table: table,
|
||||
createFilteringComposer: () =>
|
||||
i1.$$TrashSyncEntityTableFilterComposer($db: db, $table: table),
|
||||
createOrderingComposer: () =>
|
||||
i1.$$TrashSyncEntityTableOrderingComposer($db: db, $table: table),
|
||||
createComputedFieldComposer: () => i1
|
||||
.$$TrashSyncEntityTableAnnotationComposer($db: db, $table: table),
|
||||
updateCompanionCallback:
|
||||
({
|
||||
i0.Value<String> assetId = const i0.Value.absent(),
|
||||
i0.Value<String> checksum = const i0.Value.absent(),
|
||||
i0.Value<i2.TrashSyncStatus> status = const i0.Value.absent(),
|
||||
i0.Value<DateTime?> assetUpdatedAt = const i0.Value.absent(),
|
||||
}) => i1.TrashSyncEntityCompanion(
|
||||
assetId: assetId,
|
||||
checksum: checksum,
|
||||
status: status,
|
||||
assetUpdatedAt: assetUpdatedAt,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
required String assetId,
|
||||
required String checksum,
|
||||
i0.Value<i2.TrashSyncStatus> status = const i0.Value.absent(),
|
||||
i0.Value<DateTime?> assetUpdatedAt = const i0.Value.absent(),
|
||||
}) => i1.TrashSyncEntityCompanion.insert(
|
||||
assetId: assetId,
|
||||
checksum: checksum,
|
||||
status: status,
|
||||
assetUpdatedAt: assetUpdatedAt,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||
.toList(),
|
||||
prefetchHooksCallback: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
typedef $$TrashSyncEntityTableProcessedTableManager =
|
||||
i0.ProcessedTableManager<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$TrashSyncEntityTable,
|
||||
i1.TrashSyncEntityData,
|
||||
i1.$$TrashSyncEntityTableFilterComposer,
|
||||
i1.$$TrashSyncEntityTableOrderingComposer,
|
||||
i1.$$TrashSyncEntityTableAnnotationComposer,
|
||||
$$TrashSyncEntityTableCreateCompanionBuilder,
|
||||
$$TrashSyncEntityTableUpdateCompanionBuilder,
|
||||
(
|
||||
i1.TrashSyncEntityData,
|
||||
i0.BaseReferences<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$TrashSyncEntityTable,
|
||||
i1.TrashSyncEntityData
|
||||
>,
|
||||
),
|
||||
i1.TrashSyncEntityData,
|
||||
i0.PrefetchHooks Function()
|
||||
>;
|
||||
i0.Index get idxTrashSyncChecksum => i0.Index(
|
||||
'idx_trash_sync_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trash_sync_checksum ON trash_sync (checksum)',
|
||||
);
|
||||
|
||||
class $TrashSyncEntityTable extends i3.TrashSyncEntity
|
||||
with i0.TableInfo<$TrashSyncEntityTable, i1.TrashSyncEntityData> {
|
||||
@override
|
||||
final i0.GeneratedDatabase attachedDatabase;
|
||||
final String? _alias;
|
||||
$TrashSyncEntityTable(this.attachedDatabase, [this._alias]);
|
||||
static const i0.VerificationMeta _assetIdMeta = const i0.VerificationMeta(
|
||||
'assetId',
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> assetId = i0.GeneratedColumn<String>(
|
||||
'asset_id',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const i0.VerificationMeta _checksumMeta = const i0.VerificationMeta(
|
||||
'checksum',
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> checksum = i0.GeneratedColumn<String>(
|
||||
'checksum',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumnWithTypeConverter<i2.TrashSyncStatus, int>
|
||||
status =
|
||||
i0.GeneratedColumn<int>(
|
||||
'status',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.int,
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: i4.Constant(i2.TrashSyncStatus.pending.index),
|
||||
).withConverter<i2.TrashSyncStatus>(
|
||||
i1.$TrashSyncEntityTable.$converterstatus,
|
||||
);
|
||||
static const i0.VerificationMeta _assetUpdatedAtMeta =
|
||||
const i0.VerificationMeta('assetUpdatedAt');
|
||||
@override
|
||||
late final i0.GeneratedColumn<DateTime> assetUpdatedAt =
|
||||
i0.GeneratedColumn<DateTime>(
|
||||
'asset_updated_at',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i0.DriftSqlType.dateTime,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
@override
|
||||
List<i0.GeneratedColumn> get $columns => [
|
||||
assetId,
|
||||
checksum,
|
||||
status,
|
||||
assetUpdatedAt,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
String get actualTableName => $name;
|
||||
static const String $name = 'trash_sync';
|
||||
@override
|
||||
i0.VerificationContext validateIntegrity(
|
||||
i0.Insertable<i1.TrashSyncEntityData> instance, {
|
||||
bool isInserting = false,
|
||||
}) {
|
||||
final context = i0.VerificationContext();
|
||||
final data = instance.toColumns(true);
|
||||
if (data.containsKey('asset_id')) {
|
||||
context.handle(
|
||||
_assetIdMeta,
|
||||
assetId.isAcceptableOrUnknown(data['asset_id']!, _assetIdMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_assetIdMeta);
|
||||
}
|
||||
if (data.containsKey('checksum')) {
|
||||
context.handle(
|
||||
_checksumMeta,
|
||||
checksum.isAcceptableOrUnknown(data['checksum']!, _checksumMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_checksumMeta);
|
||||
}
|
||||
if (data.containsKey('asset_updated_at')) {
|
||||
context.handle(
|
||||
_assetUpdatedAtMeta,
|
||||
assetUpdatedAt.isAcceptableOrUnknown(
|
||||
data['asset_updated_at']!,
|
||||
_assetUpdatedAtMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@override
|
||||
Set<i0.GeneratedColumn> get $primaryKey => {assetId};
|
||||
@override
|
||||
i1.TrashSyncEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||
return i1.TrashSyncEntityData(
|
||||
assetId: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}asset_id'],
|
||||
)!,
|
||||
checksum: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}checksum'],
|
||||
)!,
|
||||
status: i1.$TrashSyncEntityTable.$converterstatus.fromSql(
|
||||
attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.int,
|
||||
data['${effectivePrefix}status'],
|
||||
)!,
|
||||
),
|
||||
assetUpdatedAt: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.dateTime,
|
||||
data['${effectivePrefix}asset_updated_at'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
$TrashSyncEntityTable createAlias(String alias) {
|
||||
return $TrashSyncEntityTable(attachedDatabase, alias);
|
||||
}
|
||||
|
||||
static i0.JsonTypeConverter2<i2.TrashSyncStatus, int, int> $converterstatus =
|
||||
const i0.EnumIndexConverter<i2.TrashSyncStatus>(
|
||||
i2.TrashSyncStatus.values,
|
||||
);
|
||||
@override
|
||||
bool get withoutRowId => true;
|
||||
@override
|
||||
bool get isStrict => true;
|
||||
}
|
||||
|
||||
class TrashSyncEntityData extends i0.DataClass
|
||||
implements i0.Insertable<i1.TrashSyncEntityData> {
|
||||
final String assetId;
|
||||
final String checksum;
|
||||
final i2.TrashSyncStatus status;
|
||||
final DateTime? assetUpdatedAt;
|
||||
const TrashSyncEntityData({
|
||||
required this.assetId,
|
||||
required this.checksum,
|
||||
required this.status,
|
||||
this.assetUpdatedAt,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, i0.Expression>{};
|
||||
map['asset_id'] = i0.Variable<String>(assetId);
|
||||
map['checksum'] = i0.Variable<String>(checksum);
|
||||
{
|
||||
map['status'] = i0.Variable<int>(
|
||||
i1.$TrashSyncEntityTable.$converterstatus.toSql(status),
|
||||
);
|
||||
}
|
||||
if (!nullToAbsent || assetUpdatedAt != null) {
|
||||
map['asset_updated_at'] = i0.Variable<DateTime>(assetUpdatedAt);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
factory TrashSyncEntityData.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
i0.ValueSerializer? serializer,
|
||||
}) {
|
||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
||||
return TrashSyncEntityData(
|
||||
assetId: serializer.fromJson<String>(json['assetId']),
|
||||
checksum: serializer.fromJson<String>(json['checksum']),
|
||||
status: i1.$TrashSyncEntityTable.$converterstatus.fromJson(
|
||||
serializer.fromJson<int>(json['status']),
|
||||
),
|
||||
assetUpdatedAt: serializer.fromJson<DateTime?>(json['assetUpdatedAt']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
|
||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
||||
return <String, dynamic>{
|
||||
'assetId': serializer.toJson<String>(assetId),
|
||||
'checksum': serializer.toJson<String>(checksum),
|
||||
'status': serializer.toJson<int>(
|
||||
i1.$TrashSyncEntityTable.$converterstatus.toJson(status),
|
||||
),
|
||||
'assetUpdatedAt': serializer.toJson<DateTime?>(assetUpdatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
i1.TrashSyncEntityData copyWith({
|
||||
String? assetId,
|
||||
String? checksum,
|
||||
i2.TrashSyncStatus? status,
|
||||
i0.Value<DateTime?> assetUpdatedAt = const i0.Value.absent(),
|
||||
}) => i1.TrashSyncEntityData(
|
||||
assetId: assetId ?? this.assetId,
|
||||
checksum: checksum ?? this.checksum,
|
||||
status: status ?? this.status,
|
||||
assetUpdatedAt: assetUpdatedAt.present
|
||||
? assetUpdatedAt.value
|
||||
: this.assetUpdatedAt,
|
||||
);
|
||||
TrashSyncEntityData copyWithCompanion(i1.TrashSyncEntityCompanion data) {
|
||||
return TrashSyncEntityData(
|
||||
assetId: data.assetId.present ? data.assetId.value : this.assetId,
|
||||
checksum: data.checksum.present ? data.checksum.value : this.checksum,
|
||||
status: data.status.present ? data.status.value : this.status,
|
||||
assetUpdatedAt: data.assetUpdatedAt.present
|
||||
? data.assetUpdatedAt.value
|
||||
: this.assetUpdatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('TrashSyncEntityData(')
|
||||
..write('assetId: $assetId, ')
|
||||
..write('checksum: $checksum, ')
|
||||
..write('status: $status, ')
|
||||
..write('assetUpdatedAt: $assetUpdatedAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(assetId, checksum, status, assetUpdatedAt);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is i1.TrashSyncEntityData &&
|
||||
other.assetId == this.assetId &&
|
||||
other.checksum == this.checksum &&
|
||||
other.status == this.status &&
|
||||
other.assetUpdatedAt == this.assetUpdatedAt);
|
||||
}
|
||||
|
||||
class TrashSyncEntityCompanion
|
||||
extends i0.UpdateCompanion<i1.TrashSyncEntityData> {
|
||||
final i0.Value<String> assetId;
|
||||
final i0.Value<String> checksum;
|
||||
final i0.Value<i2.TrashSyncStatus> status;
|
||||
final i0.Value<DateTime?> assetUpdatedAt;
|
||||
const TrashSyncEntityCompanion({
|
||||
this.assetId = const i0.Value.absent(),
|
||||
this.checksum = const i0.Value.absent(),
|
||||
this.status = const i0.Value.absent(),
|
||||
this.assetUpdatedAt = const i0.Value.absent(),
|
||||
});
|
||||
TrashSyncEntityCompanion.insert({
|
||||
required String assetId,
|
||||
required String checksum,
|
||||
this.status = const i0.Value.absent(),
|
||||
this.assetUpdatedAt = const i0.Value.absent(),
|
||||
}) : assetId = i0.Value(assetId),
|
||||
checksum = i0.Value(checksum);
|
||||
static i0.Insertable<i1.TrashSyncEntityData> custom({
|
||||
i0.Expression<String>? assetId,
|
||||
i0.Expression<String>? checksum,
|
||||
i0.Expression<int>? status,
|
||||
i0.Expression<DateTime>? assetUpdatedAt,
|
||||
}) {
|
||||
return i0.RawValuesInsertable({
|
||||
if (assetId != null) 'asset_id': assetId,
|
||||
if (checksum != null) 'checksum': checksum,
|
||||
if (status != null) 'status': status,
|
||||
if (assetUpdatedAt != null) 'asset_updated_at': assetUpdatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
i1.TrashSyncEntityCompanion copyWith({
|
||||
i0.Value<String>? assetId,
|
||||
i0.Value<String>? checksum,
|
||||
i0.Value<i2.TrashSyncStatus>? status,
|
||||
i0.Value<DateTime?>? assetUpdatedAt,
|
||||
}) {
|
||||
return i1.TrashSyncEntityCompanion(
|
||||
assetId: assetId ?? this.assetId,
|
||||
checksum: checksum ?? this.checksum,
|
||||
status: status ?? this.status,
|
||||
assetUpdatedAt: assetUpdatedAt ?? this.assetUpdatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, i0.Expression>{};
|
||||
if (assetId.present) {
|
||||
map['asset_id'] = i0.Variable<String>(assetId.value);
|
||||
}
|
||||
if (checksum.present) {
|
||||
map['checksum'] = i0.Variable<String>(checksum.value);
|
||||
}
|
||||
if (status.present) {
|
||||
map['status'] = i0.Variable<int>(
|
||||
i1.$TrashSyncEntityTable.$converterstatus.toSql(status.value),
|
||||
);
|
||||
}
|
||||
if (assetUpdatedAt.present) {
|
||||
map['asset_updated_at'] = i0.Variable<DateTime>(assetUpdatedAt.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('TrashSyncEntityCompanion(')
|
||||
..write('assetId: $assetId, ')
|
||||
..write('checksum: $checksum, ')
|
||||
..write('status: $status, ')
|
||||
..write('assetUpdatedAt: $assetUpdatedAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.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!
|
||||
localSync,
|
||||
remoteSync,
|
||||
localUser,
|
||||
}
|
||||
|
||||
@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 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};
|
||||
}
|
||||
|
||||
extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityData {
|
||||
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,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
1241
mobile/lib/infrastructure/entities/trashed_local_asset.entity.drift.dart
generated
Normal file
1241
mobile/lib/infrastructure/entities/trashed_local_asset.entity.drift.dart
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -58,12 +58,6 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
INNER JOIN main.local_album_entity la on laa.album_id = la.id
|
||||
WHERE laa.asset_id = lae.id
|
||||
AND la.backup_selection = ?3
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM trash_sync ts WHERE ts.asset_id = lae.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM server_deleted_checksum sd WHERE sd.checksum = lae.checksum
|
||||
);
|
||||
''';
|
||||
|
||||
@@ -75,14 +69,7 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
Variable.withInt(BackupSelection.selected.index),
|
||||
Variable.withInt(BackupSelection.excluded.index),
|
||||
],
|
||||
readsFrom: {
|
||||
_db.localAlbumAssetEntity,
|
||||
_db.localAlbumEntity,
|
||||
_db.localAssetEntity,
|
||||
_db.remoteAssetEntity,
|
||||
_db.trashSyncEntity,
|
||||
_db.serverDeletedChecksumEntity,
|
||||
},
|
||||
readsFrom: {_db.localAlbumAssetEntity, _db.localAlbumEntity, _db.localAssetEntity, _db.remoteAssetEntity},
|
||||
)
|
||||
.getSingle();
|
||||
|
||||
@@ -117,16 +104,6 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
_db.remoteAssetEntity.checksum.equalsExp(lae.checksum) & _db.remoteAssetEntity.ownerId.equals(userId),
|
||||
),
|
||||
) &
|
||||
notExistsQuery(
|
||||
_db.trashSyncEntity.selectOnly()
|
||||
..addColumns([_db.trashSyncEntity.assetId])
|
||||
..where(_db.trashSyncEntity.assetId.equalsExp(lae.id)),
|
||||
) &
|
||||
notExistsQuery(
|
||||
_db.serverDeletedChecksumEntity.selectOnly()
|
||||
..addColumns([_db.serverDeletedChecksumEntity.checksum])
|
||||
..where(_db.serverDeletedChecksumEntity.checksum.equalsExp(lae.checksum)),
|
||||
) &
|
||||
lae.id.isNotInQuery(_getExcludedSubquery()),
|
||||
)
|
||||
..orderBy([(localAsset) => OrderingTerm.desc(localAsset.createdAt)]);
|
||||
|
||||
@@ -25,11 +25,11 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.d
|
||||
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/remote_asset_cloud_id.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/server_deleted_checksum.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/settings.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';
|
||||
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart';
|
||||
@@ -63,8 +63,7 @@ import 'package:sqlite_async/sqlite_async.dart';
|
||||
PersonEntity,
|
||||
AssetFaceEntity,
|
||||
StoreEntity,
|
||||
TrashSyncEntity,
|
||||
ServerDeletedChecksumEntity,
|
||||
TrashedLocalAssetEntity,
|
||||
AssetEditEntity,
|
||||
SettingsEntity,
|
||||
AssetOcrEntity,
|
||||
@@ -121,7 +120,7 @@ class Drift extends $Drift {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 32;
|
||||
int get schemaVersion => 31;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -222,7 +221,7 @@ class Drift extends $Drift {
|
||||
await m.alterTable(
|
||||
TableMigration(
|
||||
v15.trashedLocalAssetEntity,
|
||||
columnTransformer: {v15.trashedLocalAssetEntity.source: const Constant(0)}, // TrashOrigin.localSync
|
||||
columnTransformer: {v15.trashedLocalAssetEntity.source: Constant(TrashOrigin.localSync.index)},
|
||||
newColumns: [v15.trashedLocalAssetEntity.source],
|
||||
),
|
||||
);
|
||||
@@ -281,7 +280,11 @@ class Drift extends $Drift {
|
||||
durationMs: v23.remoteAssetEntity.durationMs * const Constant(1000),
|
||||
),
|
||||
);
|
||||
await customStatement('UPDATE trashed_local_asset_entity SET duration_ms = duration_ms * 1000');
|
||||
await trashedLocalAssetEntity.update().write(
|
||||
TrashedLocalAssetEntityCompanion.custom(
|
||||
durationMs: v23.trashedLocalAssetEntity.durationMs * const Constant(1000),
|
||||
),
|
||||
);
|
||||
},
|
||||
from23To24: (m, v24) async {
|
||||
await customStatement('DROP INDEX IF EXISTS idx_remote_album_owner_id');
|
||||
@@ -315,13 +318,6 @@ class Drift extends $Drift {
|
||||
from30To31: (m, v31) async {
|
||||
await m.createIndex(v31.idxRemoteAssetUploaded);
|
||||
},
|
||||
from31To32: (m, v32) async {
|
||||
await m.createTable(v32.trashSync);
|
||||
await m.create(v32.idxTrashSyncChecksum);
|
||||
await m.createTable(v32.serverDeletedChecksum);
|
||||
await m.create(v32.idxRemoteAssetSoftDeletedChecksum);
|
||||
await m.deleteTable('trashed_local_asset_entity');
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -353,8 +349,6 @@ class DriftDatabaseRepository {
|
||||
const DriftDatabaseRepository(this._db);
|
||||
|
||||
Future<T> transaction<T>(Future<T> Function() callback) => _db.transaction(callback);
|
||||
|
||||
BaseSelectStatement currentUserIdQuery() => _db.selectOnly(_db.authUserEntity)..addColumns([_db.authUserEntity.id]);
|
||||
}
|
||||
|
||||
// ignore: invalid_use_of_internal_member
|
||||
|
||||
@@ -39,19 +39,17 @@ import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.da
|
||||
as i18;
|
||||
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
|
||||
as i19;
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart'
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'
|
||||
as i20;
|
||||
import 'package:immich_mobile/infrastructure/entities/server_deleted_checksum.entity.drift.dart'
|
||||
as i21;
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
|
||||
as i22;
|
||||
as i21;
|
||||
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart'
|
||||
as i23;
|
||||
as i22;
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart'
|
||||
as i24;
|
||||
as i23;
|
||||
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
||||
as i25;
|
||||
import 'package:drift/internal/modular.dart' as i26;
|
||||
as i24;
|
||||
import 'package:drift/internal/modular.dart' as i25;
|
||||
|
||||
abstract class $Drift extends i0.GeneratedDatabase {
|
||||
$Drift(i0.QueryExecutor e) : super(e);
|
||||
@@ -91,21 +89,19 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
late final i18.$AssetFaceEntityTable assetFaceEntity = i18
|
||||
.$AssetFaceEntityTable(this);
|
||||
late final i19.$StoreEntityTable storeEntity = i19.$StoreEntityTable(this);
|
||||
late final i20.$TrashSyncEntityTable trashSyncEntity = i20
|
||||
.$TrashSyncEntityTable(this);
|
||||
late final i21.$ServerDeletedChecksumEntityTable serverDeletedChecksumEntity =
|
||||
i21.$ServerDeletedChecksumEntityTable(this);
|
||||
late final i22.$AssetEditEntityTable assetEditEntity = i22
|
||||
late final i20.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i20
|
||||
.$TrashedLocalAssetEntityTable(this);
|
||||
late final i21.$AssetEditEntityTable assetEditEntity = i21
|
||||
.$AssetEditEntityTable(this);
|
||||
late final i23.$SettingsEntityTable settingsEntity = i23.$SettingsEntityTable(
|
||||
late final i22.$SettingsEntityTable settingsEntity = i22.$SettingsEntityTable(
|
||||
this,
|
||||
);
|
||||
late final i24.$AssetOcrEntityTable assetOcrEntity = i24.$AssetOcrEntityTable(
|
||||
late final i23.$AssetOcrEntityTable assetOcrEntity = i23.$AssetOcrEntityTable(
|
||||
this,
|
||||
);
|
||||
i25.MergedAssetDrift get mergedAssetDrift => i26.ReadDatabaseContainer(
|
||||
i24.MergedAssetDrift get mergedAssetDrift => i25.ReadDatabaseContainer(
|
||||
this,
|
||||
).accessor<i25.MergedAssetDrift>(i25.MergedAssetDrift.new);
|
||||
).accessor<i24.MergedAssetDrift>(i24.MergedAssetDrift.new);
|
||||
@override
|
||||
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
|
||||
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
|
||||
@@ -126,7 +122,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
i2.uQRemoteAssetsOwnerChecksum,
|
||||
i2.uQRemoteAssetsOwnerLibraryChecksum,
|
||||
i2.idxRemoteAssetChecksum,
|
||||
i2.idxRemoteAssetSoftDeletedChecksum,
|
||||
i2.idxRemoteAssetStackId,
|
||||
i2.idxRemoteAssetOwnerVisibilityDeletedCreated,
|
||||
i2.idxRemoteAssetUploaded,
|
||||
@@ -142,8 +137,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashSyncEntity,
|
||||
serverDeletedChecksumEntity,
|
||||
trashedLocalAssetEntity,
|
||||
assetEditEntity,
|
||||
settingsEntity,
|
||||
assetOcrEntity,
|
||||
@@ -156,9 +150,10 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
i18.idxAssetFacePersonId,
|
||||
i18.idxAssetFaceAssetId,
|
||||
i18.idxAssetFaceVisiblePerson,
|
||||
i20.idxTrashSyncChecksum,
|
||||
i22.idxAssetEditAssetId,
|
||||
i24.idxAssetOcrAssetId,
|
||||
i20.idxTrashedLocalAssetChecksum,
|
||||
i20.idxTrashedLocalAssetAlbum,
|
||||
i21.idxAssetEditAssetId,
|
||||
i23.idxAssetOcrAssetId,
|
||||
];
|
||||
@override
|
||||
i0.StreamQueryUpdateRules
|
||||
@@ -409,18 +404,15 @@ class $DriftManager {
|
||||
i18.$$AssetFaceEntityTableTableManager(_db, _db.assetFaceEntity);
|
||||
i19.$$StoreEntityTableTableManager get storeEntity =>
|
||||
i19.$$StoreEntityTableTableManager(_db, _db.storeEntity);
|
||||
i20.$$TrashSyncEntityTableTableManager get trashSyncEntity =>
|
||||
i20.$$TrashSyncEntityTableTableManager(_db, _db.trashSyncEntity);
|
||||
i21.$$ServerDeletedChecksumEntityTableTableManager
|
||||
get serverDeletedChecksumEntity =>
|
||||
i21.$$ServerDeletedChecksumEntityTableTableManager(
|
||||
i20.$$TrashedLocalAssetEntityTableTableManager get trashedLocalAssetEntity =>
|
||||
i20.$$TrashedLocalAssetEntityTableTableManager(
|
||||
_db,
|
||||
_db.serverDeletedChecksumEntity,
|
||||
_db.trashedLocalAssetEntity,
|
||||
);
|
||||
i22.$$AssetEditEntityTableTableManager get assetEditEntity =>
|
||||
i22.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
|
||||
i23.$$SettingsEntityTableTableManager get settingsEntity =>
|
||||
i23.$$SettingsEntityTableTableManager(_db, _db.settingsEntity);
|
||||
i24.$$AssetOcrEntityTableTableManager get assetOcrEntity =>
|
||||
i24.$$AssetOcrEntityTableTableManager(_db, _db.assetOcrEntity);
|
||||
i21.$$AssetEditEntityTableTableManager get assetEditEntity =>
|
||||
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
|
||||
i22.$$SettingsEntityTableTableManager get settingsEntity =>
|
||||
i22.$$SettingsEntityTableTableManager(_db, _db.settingsEntity);
|
||||
i23.$$AssetOcrEntityTableTableManager get assetOcrEntity =>
|
||||
i23.$$AssetOcrEntityTableTableManager(_db, _db.assetOcrEntity);
|
||||
}
|
||||
|
||||
@@ -16506,632 +16506,6 @@ final class Schema31 extends i0.VersionedSchema {
|
||||
);
|
||||
}
|
||||
|
||||
final class Schema32 extends i0.VersionedSchema {
|
||||
Schema32({required super.database}) : super(version: 32);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
userEntity,
|
||||
remoteAssetEntity,
|
||||
stackEntity,
|
||||
localAssetEntity,
|
||||
remoteAlbumEntity,
|
||||
localAlbumEntity,
|
||||
localAlbumAssetEntity,
|
||||
idxLocalAlbumAssetAlbumAsset,
|
||||
idxLocalAssetChecksum,
|
||||
idxLocalAssetCloudId,
|
||||
idxLocalAssetCreatedAt,
|
||||
idxStackPrimaryAssetId,
|
||||
uQRemoteAssetsOwnerChecksum,
|
||||
uQRemoteAssetsOwnerLibraryChecksum,
|
||||
idxRemoteAssetChecksum,
|
||||
idxRemoteAssetSoftDeletedChecksum,
|
||||
idxRemoteAssetStackId,
|
||||
idxRemoteAssetOwnerVisibilityDeletedCreated,
|
||||
idxRemoteAssetUploaded,
|
||||
authUserEntity,
|
||||
userMetadataEntity,
|
||||
partnerEntity,
|
||||
remoteExifEntity,
|
||||
remoteAlbumAssetEntity,
|
||||
remoteAlbumUserEntity,
|
||||
remoteAssetCloudIdEntity,
|
||||
memoryEntity,
|
||||
memoryAssetEntity,
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashSync,
|
||||
serverDeletedChecksum,
|
||||
assetEditEntity,
|
||||
settings,
|
||||
assetOcrEntity,
|
||||
idxPartnerSharedWithId,
|
||||
idxLatLng,
|
||||
idxRemoteExifCity,
|
||||
idxRemoteAlbumAssetAlbumAsset,
|
||||
idxRemoteAssetCloudId,
|
||||
idxPersonOwnerId,
|
||||
idxAssetFacePersonId,
|
||||
idxAssetFaceAssetId,
|
||||
idxAssetFaceVisiblePerson,
|
||||
idxTrashSyncChecksum,
|
||||
idxAssetEditAssetId,
|
||||
idxAssetOcrAssetId,
|
||||
];
|
||||
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 idxLocalAssetCreatedAt = i1.Index(
|
||||
'idx_local_asset_created_at',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
|
||||
);
|
||||
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 idxRemoteAssetSoftDeletedChecksum = i1.Index(
|
||||
'idx_remote_asset_soft_deleted_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_soft_deleted_checksum ON remote_asset_entity (checksum) WHERE deleted_at IS NOT NULL',
|
||||
);
|
||||
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)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetUploaded = i1.Index(
|
||||
'idx_remote_asset_uploaded',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_uploaded ON remote_asset_entity (uploaded_at)',
|
||||
);
|
||||
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 Shape52 trashSync = Shape52(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'trash_sync',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [_column_225, _column_119, _column_226, _column_227],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape53 serverDeletedChecksum = Shape53(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'server_deleted_checksum',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(checksum)'],
|
||||
columns: [_column_119],
|
||||
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 settings = Shape49(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'settings',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY("key")'],
|
||||
columns: [_column_210, _column_224, _column_115],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape51 assetOcrEntity = Shape51(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_ocr_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_213,
|
||||
_column_214,
|
||||
_column_215,
|
||||
_column_216,
|
||||
_column_217,
|
||||
_column_218,
|
||||
_column_219,
|
||||
_column_220,
|
||||
_column_221,
|
||||
_column_222,
|
||||
_column_223,
|
||||
_column_201,
|
||||
],
|
||||
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 idxTrashSyncChecksum = i1.Index(
|
||||
'idx_trash_sync_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trash_sync_checksum ON trash_sync (checksum)',
|
||||
);
|
||||
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 idxAssetOcrAssetId = i1.Index(
|
||||
'idx_asset_ocr_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)',
|
||||
);
|
||||
}
|
||||
|
||||
class Shape52 extends i0.VersionedTable {
|
||||
Shape52({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get assetId =>
|
||||
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get checksum =>
|
||||
columnsByName['checksum']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get status =>
|
||||
columnsByName['status']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get assetUpdatedAt =>
|
||||
columnsByName['asset_updated_at']! as i1.GeneratedColumn<String>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<String> _column_225(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'asset_id',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<int> _column_226(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>(
|
||||
'status',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.int,
|
||||
$customConstraints: 'NOT NULL DEFAULT 0',
|
||||
defaultValue: const i1.CustomExpression('0'),
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_227(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'asset_updated_at',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NULL',
|
||||
);
|
||||
|
||||
class Shape53 extends i0.VersionedTable {
|
||||
Shape53({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get checksum =>
|
||||
columnsByName['checksum']! as i1.GeneratedColumn<String>;
|
||||
}
|
||||
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
@@ -17163,7 +16537,6 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
|
||||
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
|
||||
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
|
||||
required Future<void> Function(i1.Migrator m, Schema32 schema) from31To32,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -17317,11 +16690,6 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from30To31(migrator, schema);
|
||||
return 31;
|
||||
case 31:
|
||||
final schema = Schema32(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from31To32(migrator, schema);
|
||||
return 32;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -17359,7 +16727,6 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
|
||||
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
|
||||
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
|
||||
required Future<void> Function(i1.Migrator m, Schema32 schema) from31To32,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
@@ -17392,6 +16759,5 @@ i1.OnUpgrade stepByStep({
|
||||
from28To29: from28To29,
|
||||
from29To30: from29To30,
|
||||
from30To31: from30To31,
|
||||
from31To32: from31To32,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/settings_key.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
|
||||
@@ -30,12 +29,6 @@ class SettingsRepository extends CachedKeyValueRepository<SettingsKey, AppConfig
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static Future<void> reset() async {
|
||||
await _instance?.clear(SettingsKey.values);
|
||||
_instance = null;
|
||||
}
|
||||
|
||||
@override
|
||||
List<SettingsKey> get keys => SettingsKey.values;
|
||||
|
||||
|
||||
@@ -71,8 +71,6 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
await _db.remoteAssetCloudIdEntity.deleteAll();
|
||||
await _db.assetEditEntity.deleteAll();
|
||||
await _db.assetOcrEntity.deleteAll();
|
||||
await _db.trashSyncEntity.deleteAll();
|
||||
await _db.serverDeletedChecksumEntity.deleteAll();
|
||||
});
|
||||
} finally {
|
||||
// re-enable FK even if the transaction throws, otherwise the connection
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/server_deleted_checksum.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class DriftTrashSyncRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
|
||||
const DriftTrashSyncRepository(this._db) : super(_db);
|
||||
|
||||
// Prunes asset markers for assets that are now live on the server
|
||||
Future<void> pruneStaleMarkers() async {
|
||||
final liveChecksums = _db.selectOnly(_db.remoteAssetEntity)
|
||||
..addColumns([_db.remoteAssetEntity.checksum])
|
||||
..where(_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAssetEntity.ownerId.isInQuery(currentUserIdQuery()));
|
||||
|
||||
await _db.transaction(() async {
|
||||
await (_db.delete(_db.serverDeletedChecksumEntity)..where((t) => t.checksum.isInQuery(liveChecksums))).go();
|
||||
await (_db.delete(
|
||||
_db.trashSyncEntity,
|
||||
)..where((t) => t.checksum.isInQuery(liveChecksums) & t.status.equalsValue(.pending))).go();
|
||||
});
|
||||
}
|
||||
|
||||
// Prunes dismissed assets marker for assets that are not on the server anymore
|
||||
Future<void> pruneDismissedMarkers() async {
|
||||
final softDeletedChecksums = _db.selectOnly(_db.remoteAssetEntity)
|
||||
..addColumns([_db.remoteAssetEntity.checksum])
|
||||
..where(
|
||||
_db.remoteAssetEntity.deletedAt.isNotNull() & _db.remoteAssetEntity.ownerId.isInQuery(currentUserIdQuery()),
|
||||
);
|
||||
|
||||
final serverDeletedChecksums = _db.selectOnly(_db.serverDeletedChecksumEntity)
|
||||
..addColumns([_db.serverDeletedChecksumEntity.checksum]);
|
||||
|
||||
await (_db.delete(_db.trashSyncEntity)..where(
|
||||
(t) =>
|
||||
t.status.equalsValue(.dismissed) &
|
||||
t.checksum.isNotInQuery(softDeletedChecksums) &
|
||||
t.checksum.isNotInQuery(serverDeletedChecksums),
|
||||
))
|
||||
.go();
|
||||
}
|
||||
|
||||
// Prunes pending markers for assets that has been modified locally
|
||||
Future<void> prunePendingMarkers() async {
|
||||
final staleIds = _db.selectOnly(_db.trashSyncEntity)
|
||||
..addColumns([_db.trashSyncEntity.assetId])
|
||||
..join([
|
||||
innerJoin(
|
||||
_db.localAssetEntity,
|
||||
_db.localAssetEntity.id.equalsExp(_db.trashSyncEntity.assetId),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(
|
||||
_db.trashSyncEntity.status.equalsValue(.pending) &
|
||||
_db.localAssetEntity.checksum.isNotNull() &
|
||||
_db.localAssetEntity.checksum.equalsExp(_db.trashSyncEntity.checksum).not(),
|
||||
);
|
||||
await (_db.delete(_db.trashSyncEntity)..where((t) => t.assetId.isInQuery(staleIds))).go();
|
||||
}
|
||||
|
||||
Future<void> recordHardDeletedChecksums(Iterable<String> remoteIds) async {
|
||||
for (final slice in remoteIds.toSet().slices(kDriftMaxChunk)) {
|
||||
final checksums =
|
||||
await (_db.selectOnly(_db.remoteAssetEntity, distinct: true)
|
||||
..addColumns([_db.remoteAssetEntity.checksum])
|
||||
..where(
|
||||
_db.remoteAssetEntity.id.isIn(slice) & _db.remoteAssetEntity.ownerId.isInQuery(currentUserIdQuery()),
|
||||
))
|
||||
.map((r) => r.read(_db.remoteAssetEntity.checksum)!)
|
||||
.get();
|
||||
if (checksums.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await _db.batch((batch) {
|
||||
for (final checksum in checksums) {
|
||||
batch.insert(
|
||||
_db.serverDeletedChecksumEntity,
|
||||
ServerDeletedChecksumEntityCompanion.insert(checksum: checksum),
|
||||
onConflict: DoNothing(),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> recordSoftDeleteAssets() => _recordAssets(
|
||||
innerJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum) &
|
||||
_db.remoteAssetEntity.deletedAt.isNotNull() &
|
||||
_db.remoteAssetEntity.ownerId.isInQuery(currentUserIdQuery()),
|
||||
useColumns: false,
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> recordHardDeletedAssets() => _recordAssets(
|
||||
innerJoin(
|
||||
_db.serverDeletedChecksumEntity,
|
||||
_db.serverDeletedChecksumEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
|
||||
useColumns: false,
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _recordAssets(Join contentJoin) async {
|
||||
final excludedAssetIds = _db.selectOnly(_db.localAlbumAssetEntity)
|
||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||
..join([
|
||||
innerJoin(
|
||||
_db.localAlbumEntity,
|
||||
_db.localAlbumEntity.id.equalsExp(_db.localAlbumAssetEntity.albumId),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(_db.localAlbumEntity.backupSelection.equalsValue(.excluded));
|
||||
|
||||
final selectedAssetsQuery = _db.selectOnly(_db.localAlbumAssetEntity)
|
||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||
..where(
|
||||
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id) &
|
||||
_db.localAlbumAssetEntity.albumId.isInQuery(
|
||||
_db.selectOnly(_db.localAlbumEntity)
|
||||
..addColumns([_db.localAlbumEntity.id])
|
||||
..where(_db.localAlbumEntity.backupSelection.equalsValue(.selected)),
|
||||
),
|
||||
);
|
||||
|
||||
final dismissedAssetsQuery = _db.selectOnly(_db.trashSyncEntity)
|
||||
..addColumns([_db.trashSyncEntity.assetId])
|
||||
..where(
|
||||
_db.trashSyncEntity.checksum.equalsExp(_db.localAssetEntity.checksum) &
|
||||
_db.trashSyncEntity.status.equalsValue(.dismissed),
|
||||
);
|
||||
|
||||
final source = _db.selectOnly(_db.localAssetEntity)
|
||||
..addColumns([_db.localAssetEntity.id, _db.localAssetEntity.checksum, _db.localAssetEntity.updatedAt])
|
||||
..join([contentJoin])
|
||||
..where(
|
||||
_db.localAssetEntity.checksum.isNotNull() &
|
||||
existsQuery(selectedAssetsQuery) &
|
||||
_db.localAssetEntity.id.isNotInQuery(excludedAssetIds) &
|
||||
notExistsQuery(dismissedAssetsQuery),
|
||||
);
|
||||
|
||||
await _db
|
||||
.into(_db.trashSyncEntity)
|
||||
.insertFromSelect(
|
||||
source,
|
||||
columns: {
|
||||
_db.trashSyncEntity.assetId: _db.localAssetEntity.id,
|
||||
_db.trashSyncEntity.checksum: _db.localAssetEntity.checksum,
|
||||
_db.trashSyncEntity.assetUpdatedAt: _db.localAssetEntity.updatedAt,
|
||||
},
|
||||
mode: .insertOrIgnore,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> markTrashed(Iterable<String> assetIds) async {
|
||||
final set = assetIds.toSet();
|
||||
if (set.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await _db.transaction(() async {
|
||||
for (final slice in set.slices(kDriftMaxChunk)) {
|
||||
await (_db.update(
|
||||
_db.trashSyncEntity,
|
||||
)..where((t) => t.assetId.isIn(slice))).write(const TrashSyncEntityCompanion(status: .new(.trashed)));
|
||||
await (_db.delete(_db.localAssetEntity)..where((t) => t.id.isIn(slice))).go();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> markRestored(Iterable<String> assetIds) async {
|
||||
final set = assetIds.toSet();
|
||||
if (set.isEmpty) {
|
||||
return;
|
||||
}
|
||||
for (final slice in set.slices(kDriftMaxChunk)) {
|
||||
await (_db.update(
|
||||
_db.trashSyncEntity,
|
||||
)..where((t) => t.assetId.isIn(slice))).write(const TrashSyncEntityCompanion(status: .new(.restored)));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> restoreChecksums() async {
|
||||
final restored = await (_db.select(_db.trashSyncEntity)..where((t) => t.status.equalsValue(.restored))).get();
|
||||
if (restored.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _db.batch((batch) {
|
||||
for (final row in restored) {
|
||||
final assetUpdatedAt = row.assetUpdatedAt;
|
||||
if (assetUpdatedAt == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
batch.update(
|
||||
_db.localAssetEntity,
|
||||
LocalAssetEntityCompanion(checksum: .new(row.checksum)),
|
||||
where: (t) => t.id.equals(row.assetId) & t.checksum.isNull() & t.updatedAt.equals(assetUpdatedAt),
|
||||
);
|
||||
}
|
||||
|
||||
batch.deleteWhere(_db.trashSyncEntity, (t) => t.status.equalsValue(.restored));
|
||||
});
|
||||
}
|
||||
|
||||
// Mark assets that were previously marked as trashed but are now live on the device as dismissed
|
||||
Future<void> reconcileTrashed(Iterable<String> assetIds) async {
|
||||
final set = assetIds.toSet();
|
||||
if (set.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
JoinedSelectStatement localAssetQuery($TrashSyncEntityTable trash) => _db.selectOnly(_db.localAssetEntity)
|
||||
..addColumns([_db.localAssetEntity.id])
|
||||
..where(_db.localAssetEntity.id.equalsExp(trash.assetId));
|
||||
|
||||
await _db.transaction(() async {
|
||||
for (final slice in set.slices(kDriftMaxChunk)) {
|
||||
await (_db.update(_db.trashSyncEntity)..where((t) => t.assetId.isIn(slice) & existsQuery(localAssetQuery(t))))
|
||||
.write(const TrashSyncEntityCompanion(status: .new(.dismissed)));
|
||||
|
||||
await (_db.delete(
|
||||
_db.trashSyncEntity,
|
||||
)..where((t) => t.assetId.isIn(slice) & notExistsQuery(localAssetQuery(t)))).go();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deleteMarkers(Iterable<String> assetIds) async {
|
||||
final set = assetIds.toSet();
|
||||
if (set.isEmpty) {
|
||||
return;
|
||||
}
|
||||
for (final slice in set.slices(kDriftMaxChunk)) {
|
||||
await (_db.delete(_db.trashSyncEntity)..where((t) => t.assetId.isIn(slice))).go();
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String>> getPendingAssetIds() =>
|
||||
_trashSyncAssetIdsWhere(_db.trashSyncEntity.status.equalsValue(.pending));
|
||||
|
||||
Future<List<String>> getTrashedAssetIds() =>
|
||||
_trashSyncAssetIdsWhere(_db.trashSyncEntity.status.equalsValue(.trashed));
|
||||
|
||||
Future<List<String>> _trashSyncAssetIdsWhere(Expression<bool> filter) {
|
||||
return (_db.selectOnly(_db.trashSyncEntity)
|
||||
..addColumns([_db.trashSyncEntity.assetId])
|
||||
..where(filter))
|
||||
.map((row) => row.read(_db.trashSyncEntity.assetId)!)
|
||||
.get();
|
||||
}
|
||||
|
||||
Future<List<String>> getRestorableAssetIds() {
|
||||
return (_db.selectOnly(_db.trashSyncEntity, distinct: true)
|
||||
..addColumns([_db.trashSyncEntity.assetId])
|
||||
..join([
|
||||
innerJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.remoteAssetEntity.checksum.equalsExp(_db.trashSyncEntity.checksum),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(
|
||||
_db.trashSyncEntity.status.equalsValue(.trashed) &
|
||||
_db.remoteAssetEntity.deletedAt.isNull() &
|
||||
_db.remoteAssetEntity.ownerId.isInQuery(currentUserIdQuery()),
|
||||
))
|
||||
.map((row) => row.read(_db.trashSyncEntity.assetId)!)
|
||||
.get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
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/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';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
typedef TrashedAsset = ({String albumId, LocalAsset asset});
|
||||
|
||||
class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
|
||||
const DriftTrashedLocalAssetRepository(this._db) : super(_db);
|
||||
|
||||
Future<void> updateHashes(Map<String, String> hashes) {
|
||||
if (hashes.isEmpty) {
|
||||
return Future.value();
|
||||
}
|
||||
return _db.batch((batch) async {
|
||||
for (final entry in hashes.entries) {
|
||||
batch.update(
|
||||
_db.trashedLocalAssetEntity,
|
||||
TrashedLocalAssetEntityCompanion(checksum: Value(entry.value)),
|
||||
where: (e) => e.id.equals(entry.key),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<LocalAsset>> getAssetsToHash(Iterable<String> albumIds) {
|
||||
final query = _db.trashedLocalAssetEntity.select()..where((r) => r.albumId.isIn(albumIds) & r.checksum.isNull());
|
||||
return query.map((row) => row.toLocalAsset()).get();
|
||||
}
|
||||
|
||||
Future<Iterable<LocalAsset>> getToRestore() async {
|
||||
final selectedAlbumIds = (_db.selectOnly(_db.localAlbumEntity)
|
||||
..addColumns([_db.localAlbumEntity.id])
|
||||
..where(_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected)));
|
||||
|
||||
final rows =
|
||||
await (_db.select(_db.trashedLocalAssetEntity).join([
|
||||
innerJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.remoteAssetEntity.checksum.equalsExp(_db.trashedLocalAssetEntity.checksum),
|
||||
),
|
||||
])..where(
|
||||
_db.trashedLocalAssetEntity.source.equalsValue(TrashOrigin.remoteSync) &
|
||||
_db.trashedLocalAssetEntity.albumId.isInQuery(selectedAlbumIds) &
|
||||
_db.remoteAssetEntity.deletedAt.isNull(),
|
||||
))
|
||||
.get();
|
||||
|
||||
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();
|
||||
return;
|
||||
}
|
||||
final assetIds = trashedAssets.map((e) => e.asset.id).toSet();
|
||||
Map<String, String> localChecksumById = await _getCachedChecksums(assetIds);
|
||||
|
||||
return _db.transaction(() async {
|
||||
await _db.batch((batch) {
|
||||
for (final item in trashedAssets) {
|
||||
final effectiveChecksum = localChecksumById[item.asset.id] ?? item.asset.checksum;
|
||||
final companion = TrashedLocalAssetEntityCompanion.insert(
|
||||
id: item.asset.id,
|
||||
albumId: item.albumId,
|
||||
checksum: Value(effectiveChecksum),
|
||||
name: item.asset.name,
|
||||
type: item.asset.type,
|
||||
createdAt: Value(item.asset.createdAt),
|
||||
updatedAt: Value(item.asset.updatedAt),
|
||||
width: Value(item.asset.width),
|
||||
height: Value(item.asset.height),
|
||||
durationMs: Value(item.asset.durationMs),
|
||||
isFavorite: Value(item.asset.isFavorite),
|
||||
orientation: Value(item.asset.orientation),
|
||||
playbackStyle: Value(item.asset.playbackStyle),
|
||||
source: TrashOrigin.localSync,
|
||||
);
|
||||
|
||||
batch.insert<$TrashedLocalAssetEntityTable, TrashedLocalAssetEntityData>(
|
||||
_db.trashedLocalAssetEntity,
|
||||
companion,
|
||||
onConflict: DoUpdate((_) => companion, where: (old) => old.updatedAt.isNotValue(item.asset.updatedAt)),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (assetIds.length <= kDriftMaxChunk) {
|
||||
await (_db.delete(_db.trashedLocalAssetEntity)..where((row) => row.id.isNotIn(assetIds))).go();
|
||||
} else {
|
||||
final existingIds = await (_db.selectOnly(
|
||||
_db.trashedLocalAssetEntity,
|
||||
)..addColumns([_db.trashedLocalAssetEntity.id])).map((r) => r.read(_db.trashedLocalAssetEntity.id)!).get();
|
||||
final idToDelete = existingIds.where((id) => !assetIds.contains(id));
|
||||
for (final slice in idToDelete.slices(kDriftMaxChunk)) {
|
||||
await (_db.delete(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice))).go();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Stream<int> watchCount() {
|
||||
return (_db.selectOnly(_db.trashedLocalAssetEntity)..addColumns([_db.trashedLocalAssetEntity.id.count()]))
|
||||
.watchSingle()
|
||||
.map((row) => row.read<int>(_db.trashedLocalAssetEntity.id.count()) ?? 0);
|
||||
}
|
||||
|
||||
Stream<int> watchHashedCount() {
|
||||
return (_db.selectOnly(_db.trashedLocalAssetEntity)
|
||||
..addColumns([_db.trashedLocalAssetEntity.id.count()])
|
||||
..where(_db.trashedLocalAssetEntity.checksum.isNotNull()))
|
||||
.watchSingle()
|
||||
.map((row) => row.read<int>(_db.trashedLocalAssetEntity.id.count()) ?? 0);
|
||||
}
|
||||
|
||||
Future<void> trashLocalAsset(Map<String, List<LocalAsset>> assetsByAlbums) async {
|
||||
if (assetsByAlbums.isEmpty) {
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
final companions = <TrashedLocalAssetEntityCompanion>[];
|
||||
final idToDelete = <String>{};
|
||||
|
||||
for (final entry in assetsByAlbums.entries) {
|
||||
for (final asset in entry.value) {
|
||||
idToDelete.add(asset.id);
|
||||
companions.add(
|
||||
TrashedLocalAssetEntityCompanion(
|
||||
id: Value(asset.id),
|
||||
name: Value(asset.name),
|
||||
albumId: Value(entry.key),
|
||||
checksum: Value(asset.checksum),
|
||||
type: Value(asset.type),
|
||||
width: Value(asset.width),
|
||||
height: Value(asset.height),
|
||||
durationMs: Value(asset.durationMs),
|
||||
isFavorite: Value(asset.isFavorite),
|
||||
orientation: Value(asset.orientation),
|
||||
playbackStyle: Value(asset.playbackStyle),
|
||||
createdAt: Value(asset.createdAt),
|
||||
updatedAt: Value(asset.updatedAt),
|
||||
source: const Value(TrashOrigin.remoteSync),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await _db.transaction(() async {
|
||||
for (final companion in companions) {
|
||||
await _db.into(_db.trashedLocalAssetEntity).insertOnConflictUpdate(companion);
|
||||
}
|
||||
|
||||
for (final slice in idToDelete.slices(kDriftMaxChunk)) {
|
||||
await (_db.delete(_db.localAssetEntity)..where((t) => t.id.isIn(slice))).go();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> applyRestoredAssets(List<String> idList) async {
|
||||
if (idList.isEmpty) {
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
final trashedAssets = <TrashedLocalAssetEntityData>[];
|
||||
|
||||
for (final slice in idList.slices(kDriftMaxChunk)) {
|
||||
final q = _db.select(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice));
|
||||
trashedAssets.addAll(await q.get());
|
||||
}
|
||||
|
||||
if (trashedAssets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final companions = trashedAssets.map((e) {
|
||||
return LocalAssetEntityCompanion.insert(
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
type: e.type,
|
||||
createdAt: Value(e.createdAt),
|
||||
updatedAt: Value(e.updatedAt),
|
||||
width: Value(e.width),
|
||||
height: Value(e.height),
|
||||
durationMs: Value(e.durationMs),
|
||||
checksum: Value(e.checksum),
|
||||
isFavorite: Value(e.isFavorite),
|
||||
orientation: Value(e.orientation),
|
||||
playbackStyle: Value(e.playbackStyle),
|
||||
);
|
||||
});
|
||||
|
||||
await _db.transaction(() async {
|
||||
for (final companion in companions) {
|
||||
await _db.into(_db.localAssetEntity).insertOnConflictUpdate(companion);
|
||||
}
|
||||
for (final slice in idList.slices(kDriftMaxChunk)) {
|
||||
await (_db.delete(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice))).go();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> applyTrashedAssets(List<String> idList) async {
|
||||
if (idList.isEmpty) {
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
final trashedAssets = <({LocalAssetEntityData asset, String albumId})>[];
|
||||
|
||||
for (final slice in idList.slices(kDriftMaxChunk)) {
|
||||
final rows = await (_db.select(_db.localAlbumAssetEntity).join([
|
||||
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
|
||||
])..where(_db.localAlbumAssetEntity.assetId.isIn(slice))).get();
|
||||
|
||||
final assetsWithAlbum = rows.map(
|
||||
(row) =>
|
||||
(albumId: row.readTable(_db.localAlbumAssetEntity).albumId, asset: row.readTable(_db.localAssetEntity)),
|
||||
);
|
||||
|
||||
trashedAssets.addAll(assetsWithAlbum);
|
||||
}
|
||||
|
||||
if (trashedAssets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final companions = trashedAssets.map((e) {
|
||||
return TrashedLocalAssetEntityCompanion.insert(
|
||||
id: e.asset.id,
|
||||
name: e.asset.name,
|
||||
type: e.asset.type,
|
||||
createdAt: Value(e.asset.createdAt),
|
||||
updatedAt: Value(e.asset.updatedAt),
|
||||
width: Value(e.asset.width),
|
||||
height: Value(e.asset.height),
|
||||
durationMs: Value(e.asset.durationMs),
|
||||
checksum: Value(e.asset.checksum),
|
||||
isFavorite: Value(e.asset.isFavorite),
|
||||
orientation: Value(e.asset.orientation),
|
||||
playbackStyle: Value(e.asset.playbackStyle),
|
||||
source: TrashOrigin.localUser,
|
||||
albumId: e.albumId,
|
||||
);
|
||||
});
|
||||
|
||||
await _db.transaction(() async {
|
||||
for (final companion in companions) {
|
||||
await _db.into(_db.trashedLocalAssetEntity).insertOnConflictUpdate(companion);
|
||||
}
|
||||
for (final slice in idList.slices(kDriftMaxChunk)) {
|
||||
await (_db.delete(_db.localAssetEntity)..where((t) => t.id.isIn(slice))).go();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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>{};
|
||||
|
||||
for (final slice in assetIds.slices(kDriftMaxChunk)) {
|
||||
final rows =
|
||||
await (_db.selectOnly(_db.localAssetEntity)
|
||||
..where(_db.localAssetEntity.id.isIn(slice) & _db.localAssetEntity.checksum.isNotNull())
|
||||
..addColumns([_db.localAssetEntity.id, _db.localAssetEntity.checksum]))
|
||||
.get();
|
||||
|
||||
for (final r in rows) {
|
||||
localChecksumById[r.read(_db.localAssetEntity.id)!] = r.read(_db.localAssetEntity.checksum)!;
|
||||
}
|
||||
}
|
||||
|
||||
return localChecksumById;
|
||||
}
|
||||
}
|
||||
@@ -333,7 +333,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
// TODO: Bring back when the soft freeze issue is addressed
|
||||
// backgroundManager.syncCloudIds(),
|
||||
]);
|
||||
await backgroundManager.syncTrash();
|
||||
} else {
|
||||
await backgroundManager.hashAssets();
|
||||
}
|
||||
|
||||
228
mobile/lib/platform/asset_media_api.g.dart
generated
228
mobile/lib/platform/asset_media_api.g.dart
generated
@@ -1,228 +0,0 @@
|
||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
// ignore_for_file: unused_import, unused_shown_name
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data' show Float64List, Int32List, Int64List;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||
|
||||
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
|
||||
if (replyList == null) {
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
} else if (replyList.length > 1) {
|
||||
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
|
||||
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
}
|
||||
return replyList.firstOrNull;
|
||||
}
|
||||
|
||||
bool _deepEquals(Object? a, Object? b) {
|
||||
if (identical(a, b)) {
|
||||
return true;
|
||||
}
|
||||
if (a is double && b is double) {
|
||||
if (a.isNaN && b.isNaN) {
|
||||
return true;
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
if (a is List && b is List) {
|
||||
return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||
}
|
||||
if (a is Map && b is Map) {
|
||||
if (a.length != b.length) {
|
||||
return false;
|
||||
}
|
||||
for (final MapEntry<Object?, Object?> entryA in a.entries) {
|
||||
bool found = false;
|
||||
for (final MapEntry<Object?, Object?> entryB in b.entries) {
|
||||
if (_deepEquals(entryA.key, entryB.key)) {
|
||||
if (_deepEquals(entryA.value, entryB.value)) {
|
||||
found = true;
|
||||
break;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
|
||||
int _deepHash(Object? value) {
|
||||
if (value is List) {
|
||||
return Object.hashAll(value.map(_deepHash));
|
||||
}
|
||||
if (value is Map) {
|
||||
int result = 0;
|
||||
for (final MapEntry<Object?, Object?> entry in value.entries) {
|
||||
result += (_deepHash(entry.key) * 31) ^ _deepHash(entry.value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (value is double && value.isNaN) {
|
||||
// Normalize NaN to a consistent hash.
|
||||
return 0x7FF8000000000000.hashCode;
|
||||
}
|
||||
if (value is double && value == 0.0) {
|
||||
// Normalize -0.0 to 0.0 so they have the same hash code.
|
||||
return 0.0.hashCode;
|
||||
}
|
||||
return value.hashCode;
|
||||
}
|
||||
|
||||
enum AssetMediaActionStatus { done, alreadyInState, notFound, failed }
|
||||
|
||||
class AssetMediaActionResult {
|
||||
AssetMediaActionResult({required this.id, required this.status});
|
||||
|
||||
String id;
|
||||
|
||||
AssetMediaActionStatus status;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[id, status];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static AssetMediaActionResult decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return AssetMediaActionResult(id: result[0]! as String, status: result[1]! as AssetMediaActionStatus);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! AssetMediaActionResult || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(id, other.id) && _deepEquals(status, other.status);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
void writeValue(WriteBuffer buffer, Object? value) {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else if (value is AssetMediaActionStatus) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is AssetMediaActionResult) {
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
case 129:
|
||||
final value = readValue(buffer) as int?;
|
||||
return value == null ? null : AssetMediaActionStatus.values[value];
|
||||
case 130:
|
||||
return AssetMediaActionResult.decode(readValue(buffer)!);
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AssetMediaApi {
|
||||
/// Constructor for [AssetMediaApi]. The [binaryMessenger] named argument is
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
AssetMediaApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<List<AssetMediaActionResult>> trash(List<String> ids) async {
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.AssetMediaApi.trash$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[ids]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<AssetMediaActionResult>();
|
||||
}
|
||||
|
||||
Future<List<AssetMediaActionResult>> restore(List<String> ids) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.AssetMediaApi.restore$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[ids]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<AssetMediaActionResult>();
|
||||
}
|
||||
|
||||
Future<List<String>> trashedAmong(List<String> ids) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.AssetMediaApi.trashedAmong$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[ids]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<String>();
|
||||
}
|
||||
}
|
||||
38
mobile/lib/platform/native_sync_api.g.dart
generated
38
mobile/lib/platform/native_sync_api.g.dart
generated
@@ -649,6 +649,44 @@ class NativeSyncApi {
|
||||
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
|
||||
}
|
||||
|
||||
Future<Map<String, List<PlatformAsset>>> getTrashedAssets() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$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 Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
@@ -126,7 +126,6 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
// TODO: Bring back when the soft freeze issue is addressed
|
||||
// _safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
|
||||
]);
|
||||
await _safeRun(backgroundManager.syncTrash(), "syncTrash");
|
||||
} else {
|
||||
await _safeRun(backgroundManager.hashAssets(), "hashAssets");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.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/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
@@ -14,6 +15,10 @@ final remoteAssetRepositoryProvider = Provider<RemoteAssetRepository>(
|
||||
(ref) => RemoteAssetRepository(ref.watch(driftProvider)),
|
||||
);
|
||||
|
||||
final trashedLocalAssetRepository = Provider<DriftTrashedLocalAssetRepository>(
|
||||
(ref) => DriftTrashedLocalAssetRepository(ref.watch(driftProvider)),
|
||||
);
|
||||
|
||||
final assetServiceProvider = Provider(
|
||||
(ref) => AssetService(
|
||||
remoteRepository: ref.watch(remoteAssetRepositoryProvider),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/background_worker.service.dart';
|
||||
import 'package:immich_mobile/platform/asset_media_api.g.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
@@ -18,8 +17,6 @@ final backgroundWorkerLockServiceProvider = Provider<BackgroundWorkerLockService
|
||||
|
||||
final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
|
||||
|
||||
final assetMediaApiProvider = Provider<AssetMediaApi>((_) => AssetMediaApi());
|
||||
|
||||
final permissionApiProvider = Provider<PermissionApi>((_) => PermissionApi());
|
||||
|
||||
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
|
||||
|
||||
@@ -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/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.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),
|
||||
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
|
||||
permissionRepository: ref.watch(permissionRepositoryProvider),
|
||||
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
|
||||
api: ref.watch(apiServiceProvider),
|
||||
cancellation: ref.watch(cancellationProvider),
|
||||
@@ -34,7 +38,9 @@ final localSyncServiceProvider = Provider(
|
||||
(ref) => LocalSyncService(
|
||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
trashSyncRepository: ref.watch(trashSyncRepositoryProvider),
|
||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
|
||||
permissionRepository: ref.watch(permissionRepositoryProvider),
|
||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||
cancellation: ref.watch(cancellationProvider),
|
||||
),
|
||||
@@ -45,6 +51,7 @@ final hashServiceProvider = Provider(
|
||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||
cancellation: ref.watch(cancellationProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import 'package:async/async.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/trash_sync.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.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/settings.provider.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
|
||||
final trashSyncRepositoryProvider = Provider<DriftTrashSyncRepository>(
|
||||
(ref) => DriftTrashSyncRepository(ref.watch(driftProvider)),
|
||||
);
|
||||
typedef TrashedAssetsCount = ({int total, int hashed});
|
||||
|
||||
final trashSyncServiceProvider = Provider<TrashSyncService>(
|
||||
(ref) => TrashSyncService(
|
||||
repo: ref.watch(trashSyncRepositoryProvider),
|
||||
assetMediaApi: ref.watch(assetMediaApiProvider),
|
||||
permission: ref.watch(permissionRepositoryProvider),
|
||||
settings: ref.watch(settingsProvider),
|
||||
),
|
||||
);
|
||||
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]));
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ 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/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
@@ -22,13 +24,16 @@ import 'package:share_plus/share_plus.dart';
|
||||
|
||||
typedef _ShareFile = ({File file, bool cleanup, String displayName});
|
||||
|
||||
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(storageRepositoryProvider)));
|
||||
final assetMediaRepositoryProvider = Provider(
|
||||
(ref) => AssetMediaRepository(ref.watch(nativeSyncApiProvider), ref.watch(storageRepositoryProvider)),
|
||||
);
|
||||
|
||||
class AssetMediaRepository {
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final StorageRepository _storageRepository;
|
||||
static final Logger _log = Logger("AssetMediaRepository");
|
||||
|
||||
const AssetMediaRepository(this._storageRepository);
|
||||
const AssetMediaRepository(this._nativeSyncApi, this._storageRepository);
|
||||
|
||||
Future<bool> _androidSupportsTrash() async {
|
||||
if (Platform.isAndroid) {
|
||||
@@ -53,6 +58,27 @@ class AssetMediaRepository {
|
||||
return PhotoManager.editor.deleteWithIds(ids);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -6,10 +6,14 @@ 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/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/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/repositories/asset_api.repository.dart';
|
||||
@@ -30,6 +34,7 @@ final actionServiceProvider = Provider<ActionService>(
|
||||
ref.watch(localAssetRepository),
|
||||
ref.watch(driftAlbumApiRepositoryProvider),
|
||||
ref.watch(remoteAlbumRepository),
|
||||
ref.watch(trashedLocalAssetRepository),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
ref.watch(downloadRepositoryProvider),
|
||||
ref.watch(tagServiceProvider),
|
||||
@@ -42,6 +47,7 @@ class ActionService {
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftAlbumApiRepository _albumApiRepository;
|
||||
final DriftRemoteAlbumRepository _remoteAlbumRepository;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final DownloadRepository _downloadRepository;
|
||||
final TagService _tagService;
|
||||
@@ -52,6 +58,7 @@ class ActionService {
|
||||
this._localAssetRepository,
|
||||
this._albumApiRepository,
|
||||
this._remoteAlbumRepository,
|
||||
this._trashedLocalAssetRepository,
|
||||
this._assetMediaRepository,
|
||||
this._downloadRepository,
|
||||
this._tagService,
|
||||
@@ -311,7 +318,11 @@ class ActionService {
|
||||
if (deletedIds.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
await _localAssetRepository.delete(deletedIds);
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds);
|
||||
} else {
|
||||
await _localAssetRepository.delete(deletedIds);
|
||||
}
|
||||
return deletedIds.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
|
||||
enum AppSettingsEnum<T> {
|
||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
||||
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
|
||||
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
|
||||
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import 'package:immich_mobile/infrastructure/repositories/settings.repository.da
|
||||
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
|
||||
const int targetVersion = 27;
|
||||
const int targetVersion = 26;
|
||||
|
||||
Future<void> migrateDatabaseIfNeeded(Drift drift) async {
|
||||
final int? storedVersion = Store.tryGet(StoreKey.version);
|
||||
@@ -34,10 +34,6 @@ Future<void> migrateDatabaseIfNeeded(Drift drift) async {
|
||||
await _migrateTo26(drift);
|
||||
}
|
||||
|
||||
if (version < 27) {
|
||||
await _migrateTo27(drift);
|
||||
}
|
||||
|
||||
if (storedVersion == null) {
|
||||
await FeatureMessageService(SettingsRepository.instance).markSeen();
|
||||
}
|
||||
@@ -149,12 +145,6 @@ Future<void> _migrateTo26(Drift drift) async {
|
||||
await migrator.complete();
|
||||
}
|
||||
|
||||
Future<void> _migrateTo27(Drift drift) async {
|
||||
final migrator = _StoreMigrator(drift);
|
||||
await migrator.migrateBool(.legacyManageLocalMediaAndroid, .trashSyncEnabled);
|
||||
await migrator.complete();
|
||||
}
|
||||
|
||||
Future<void> _migrateAlbumSortMode(_StoreMigrator migrator) async {
|
||||
final raw = await migrator.readLegacyStoreInt(StoreKey.legacySelectedAlbumSortOrder.id);
|
||||
final mode = AlbumSortMode.values.firstWhereOrNull((e) => raw != null && e.storeIndex == raw);
|
||||
|
||||
@@ -11,16 +11,15 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/feature_message.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/oauth.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
||||
@@ -180,71 +179,64 @@ class LoginForm extends HookConsumerWidget {
|
||||
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
|
||||
|
||||
await backgroundManager.syncLocal(full: true);
|
||||
final syncSuccess = await backgroundManager.syncRemote();
|
||||
await backgroundManager.syncRemote();
|
||||
await viewIntentHandler.flushDeferredViewIntent();
|
||||
await backgroundManager.hashAssets();
|
||||
if (syncSuccess) {
|
||||
await backgroundManager.syncTrash();
|
||||
}
|
||||
|
||||
if (SettingsRepository.instance.appConfig.backup.syncAlbums) {
|
||||
await backgroundManager.syncLinkedAlbum();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> promptManageMediaIfNeeded() async {
|
||||
if (!CurrentPlatform.isAndroid || !ref.read(appConfigProvider).trashSyncEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
final permission = ref.read(permissionRepositoryProvider);
|
||||
if (await permission.hasManageMediaPermission() || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(10))),
|
||||
elevation: 5,
|
||||
title: Text(
|
||||
context.t.manage_media_access_title,
|
||||
style: .new(fontSize: 16, fontWeight: .bold, color: context.primaryColor),
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: ListBody(
|
||||
children: [
|
||||
Text(context.t.manage_media_access_subtitle, style: const .new(fontSize: 14)),
|
||||
const SizedBox(height: 4),
|
||||
Text(context.t.manage_media_access_rationale, style: const .new(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(
|
||||
context.t.cancel,
|
||||
style: .new(fontWeight: .w600, color: context.primaryColor),
|
||||
getManageMediaPermission() async {
|
||||
final hasPermission = await ref.read(permissionRepositoryProvider).hasManageMediaPermission();
|
||||
if (!hasPermission) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
|
||||
elevation: 5,
|
||||
title: Text(
|
||||
'manage_media_access_title',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: context.primaryColor),
|
||||
).tr(),
|
||||
content: SingleChildScrollView(
|
||||
child: ListBody(
|
||||
children: [
|
||||
const Text('manage_media_access_subtitle', style: TextStyle(fontSize: 14)).tr(),
|
||||
const SizedBox(height: 4),
|
||||
const Text('manage_media_access_rationale', style: TextStyle(fontSize: 12)).tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
unawaited(permission.requestManageMediaPermission());
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
context.t.manage_media_access_settings,
|
||||
style: .new(fontWeight: .w600, color: context.primaryColor),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(
|
||||
'cancel'.tr(),
|
||||
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
unawaited(ref.read(permissionRepositoryProvider).requestManageMediaPermission());
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
'manage_media_access_settings'.tr(),
|
||||
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool isSyncRemoteDeletionsMode() => Platform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false);
|
||||
|
||||
login() async {
|
||||
TextInput.finishAutofillContext();
|
||||
|
||||
@@ -258,7 +250,9 @@ class LoginForm extends HookConsumerWidget {
|
||||
unawaited(context.pushRoute(const ChangePasswordRoute()));
|
||||
} else {
|
||||
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
|
||||
await promptManageMediaIfNeeded();
|
||||
if (isSyncRemoteDeletionsMode()) {
|
||||
await getManageMediaPermission();
|
||||
}
|
||||
unawaited(handleSyncFlow());
|
||||
ref.read(websocketProvider.notifier).connect();
|
||||
unawaited(ref.read(featureMessageServiceProvider).markSeen());
|
||||
@@ -345,7 +339,9 @@ class LoginForm extends HookConsumerWidget {
|
||||
|
||||
if (isSuccess) {
|
||||
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
|
||||
await promptManageMediaIfNeeded();
|
||||
if (isSyncRemoteDeletionsMode()) {
|
||||
await getManageMediaPermission();
|
||||
}
|
||||
unawaited(handleSyncFlow());
|
||||
unawaited(ref.read(featureMessageServiceProvider).markSeen());
|
||||
unawaited(context.router.replaceAll([const TabShellRoute()]));
|
||||
|
||||
@@ -7,9 +7,9 @@ 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/infrastructure/settings.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/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
@@ -28,7 +28,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
|
||||
final trashSyncEnabled = useState(ref.read(appConfigProvider).trashSyncEnabled);
|
||||
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
|
||||
final isManageMediaSupported = useState(false);
|
||||
final manageMediaAndroidPermission = useState(false);
|
||||
final levelId = useState<int>(ref.read(appConfigProvider).logLevel.index);
|
||||
@@ -75,16 +75,14 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
children: [
|
||||
SettingsSwitchListTile(
|
||||
enabled: true,
|
||||
valueNotifier: trashSyncEnabled,
|
||||
valueNotifier: manageLocalMediaAndroid,
|
||||
title: "advanced_settings_sync_remote_deletions_title".tr(),
|
||||
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
|
||||
onChanged: (value) async {
|
||||
trashSyncEnabled.value = value;
|
||||
await ref.read(settingsProvider).write(.trashSyncEnabled, value);
|
||||
if (value) {
|
||||
manageMediaAndroidPermission.value = await ref
|
||||
.read(permissionRepositoryProvider)
|
||||
.requestManageMediaPermission();
|
||||
final result = await ref.read(permissionRepositoryProvider).requestManageMediaPermission();
|
||||
manageLocalMediaAndroid.value = result;
|
||||
manageMediaAndroidPermission.value = result;
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -92,14 +90,12 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
title: "manage_media_access_title".tr(),
|
||||
statusText: manageMediaAndroidPermission.value ? "allowed".tr() : "not_allowed".tr(),
|
||||
subtitle: "manage_media_access_rationale".tr(),
|
||||
statusColor: trashSyncEnabled.value && !manageMediaAndroidPermission.value
|
||||
statusColor: manageLocalMediaAndroid.value && !manageMediaAndroidPermission.value
|
||||
? const Color.fromARGB(255, 243, 188, 106)
|
||||
: null,
|
||||
onActionTap: () async {
|
||||
await ref.read(permissionRepositoryProvider).manageMediaPermission();
|
||||
manageMediaAndroidPermission.value = await ref
|
||||
.read(permissionRepositoryProvider)
|
||||
.hasManageMediaPermission();
|
||||
final result = await ref.read(permissionRepositoryProvider).manageMediaPermission();
|
||||
manageMediaAndroidPermission.value = result;
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
@@ -10,6 +9,7 @@ import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
@@ -255,11 +255,11 @@ class _BackupDelaySlider extends ConsumerWidget {
|
||||
_ => 600,
|
||||
};
|
||||
|
||||
static String formatBackupDelaySliderValue(int v) => switch (v) {
|
||||
0 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '5'}),
|
||||
1 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '30'}),
|
||||
2 => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '2'}),
|
||||
_ => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '10'}),
|
||||
static String formatBackupDelaySliderValue(BuildContext context, int v) => switch (v) {
|
||||
0 => context.t.setting_notifications_notify_seconds(count: 5),
|
||||
1 => context.t.setting_notifications_notify_seconds(count: 30),
|
||||
2 => context.t.setting_notifications_notify_minutes(count: 2),
|
||||
_ => context.t.setting_notifications_notify_minutes(count: 10),
|
||||
};
|
||||
|
||||
@override
|
||||
@@ -272,8 +272,8 @@ class _BackupDelaySlider extends ConsumerWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 24.0, top: 8.0),
|
||||
child: Text(
|
||||
'backup_controller_page_background_delay'.tr(
|
||||
namedArgs: {'duration': formatBackupDelaySliderValue(currentValue)},
|
||||
context.t.backup_controller_page_background_delay(
|
||||
duration: formatBackupDelaySliderValue(context, currentValue),
|
||||
),
|
||||
style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
@@ -291,7 +291,7 @@ class _BackupDelaySlider extends ConsumerWidget {
|
||||
max: 3.0,
|
||||
min: 0.0,
|
||||
divisions: 3,
|
||||
label: formatBackupDelaySliderValue(currentValue),
|
||||
label: formatBackupDelaySliderValue(context, currentValue),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -7,14 +7,17 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.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/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
|
||||
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
|
||||
import 'package:immich_mobile/widgets/settings/setting_list_tile.dart';
|
||||
@@ -216,6 +219,7 @@ class _SyncStatsCounts extends ConsumerWidget {
|
||||
final localAlbumService = ref.watch(localAlbumServiceProvider);
|
||||
final remoteAlbumService = ref.watch(remoteAlbumServiceProvider);
|
||||
final memoryService = ref.watch(driftMemoryServiceProvider);
|
||||
final appSettingsService = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
Future<List<dynamic>> loadCounts() async {
|
||||
final assetCounts = assetService.getAssetCounts();
|
||||
@@ -349,6 +353,47 @@ class _SyncStatsCounts extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
// To be removed once the experimental feature is stable
|
||||
if (CurrentPlatform.isAndroid &&
|
||||
appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid)) ...[
|
||||
SettingGroupTitle(title: "trash".t(context: context)),
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final counts = ref.watch(trashedAssetsCountProvider);
|
||||
return counts.when(
|
||||
data: (c) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: IntrinsicHeight(
|
||||
child: Flex(
|
||||
direction: Axis.horizontal,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch, // Added
|
||||
spacing: 8.0,
|
||||
children: [
|
||||
Expanded(
|
||||
child: EntityCountTile(
|
||||
label: "local".t(context: context),
|
||||
count: c.total,
|
||||
icon: Icons.delete_outline,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: EntityCountTile(
|
||||
label: "hashed_assets".t(context: context),
|
||||
count: c.hashed,
|
||||
icon: Icons.tag,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (e, st) => Text('Error: $e'),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import 'package:pigeon/pigeon.dart';
|
||||
|
||||
@ConfigurePigeon(
|
||||
PigeonOptions(
|
||||
dartOut: 'lib/platform/asset_media_api.g.dart',
|
||||
swiftOut: 'ios/Runner/AssetMedia/AssetMedia.g.swift',
|
||||
swiftOptions: SwiftOptions(includeErrorClass: false),
|
||||
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/media/AssetMedia.g.kt',
|
||||
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.media'),
|
||||
dartOptions: DartOptions(),
|
||||
dartPackageName: 'immich_mobile',
|
||||
),
|
||||
)
|
||||
enum AssetMediaActionStatus { done, alreadyInState, notFound, failed }
|
||||
|
||||
class AssetMediaActionResult {
|
||||
final String id;
|
||||
final AssetMediaActionStatus status;
|
||||
|
||||
const AssetMediaActionResult({required this.id, required this.status});
|
||||
}
|
||||
|
||||
@HostApi()
|
||||
abstract class AssetMediaApi {
|
||||
@async
|
||||
List<AssetMediaActionResult> trash(List<String> ids);
|
||||
|
||||
@async
|
||||
List<AssetMediaActionResult> restore(List<String> ids);
|
||||
|
||||
@async
|
||||
List<String> trashedAmong(List<String> ids);
|
||||
}
|
||||
@@ -135,6 +135,12 @@ abstract class NativeSyncApi {
|
||||
|
||||
void cancelSync();
|
||||
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
Map<String, List<PlatformAsset>> getTrashedAssets();
|
||||
|
||||
@async
|
||||
bool restoreFromTrashById(String mediaId, int type);
|
||||
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/domain/services/partner.service.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/domain/services/trash_sync.service.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||
import 'package:immich_mobile/platform/asset_media_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
@@ -15,10 +13,6 @@ class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
|
||||
|
||||
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
|
||||
|
||||
class MockAssetMediaApi extends Mock implements AssetMediaApi {}
|
||||
|
||||
class MockTrashSyncService extends Mock implements TrashSyncService {}
|
||||
|
||||
class MockAppSettingsService extends Mock implements AppSettingsService {}
|
||||
|
||||
class MockPartnerService extends Mock implements PartnerService {}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.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';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
@@ -10,17 +11,23 @@ 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/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/asset_media.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../domain/service.mock.dart';
|
||||
import '../../fixtures/asset.stub.dart';
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
import '../../repository.mocks.dart';
|
||||
|
||||
void main() {
|
||||
late LocalSyncService sut;
|
||||
late DriftLocalAlbumRepository mockLocalAlbumRepository;
|
||||
late DriftLocalAssetRepository mockLocalAssetRepository;
|
||||
late MockTrashSyncRepository mockTrashSyncRepository;
|
||||
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
|
||||
late AssetMediaRepository mockAssetMediaRepository;
|
||||
late MockPermissionRepository mockPermissionRepository;
|
||||
late MockNativeSyncApi mockNativeSyncApi;
|
||||
late Drift db;
|
||||
|
||||
@@ -41,29 +48,175 @@ void main() {
|
||||
setUp(() async {
|
||||
mockLocalAlbumRepository = MockLocalAlbumRepository();
|
||||
mockLocalAssetRepository = MockLocalAssetRepository();
|
||||
mockTrashSyncRepository = MockTrashSyncRepository();
|
||||
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
|
||||
mockAssetMediaRepository = MockAssetMediaRepository();
|
||||
mockPermissionRepository = MockPermissionRepository();
|
||||
mockNativeSyncApi = MockNativeSyncApi();
|
||||
|
||||
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
|
||||
when(() => mockNativeSyncApi.getMediaChanges()).thenAnswer(
|
||||
(_) async => SyncDelta(hasChanges: false, updates: const [], deletes: const [], assetAlbums: const {}),
|
||||
);
|
||||
when(() => mockTrashSyncRepository.restoreChecksums()).thenAnswer((_) async {});
|
||||
when(() => mockNativeSyncApi.getTrashedAssets()).thenAnswer((_) async => {});
|
||||
when(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).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(() => mockAssetMediaRepository.deleteAll(any())).thenAnswer((invocation) async {
|
||||
final ids = invocation.positionalArguments.first as List<String>;
|
||||
return ids;
|
||||
});
|
||||
|
||||
sut = LocalSyncService(
|
||||
localAlbumRepository: mockLocalAlbumRepository,
|
||||
localAssetRepository: mockLocalAssetRepository,
|
||||
trashSyncRepository: mockTrashSyncRepository,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
|
||||
assetMediaRepository: mockAssetMediaRepository,
|
||||
permissionRepository: mockPermissionRepository,
|
||||
nativeSyncApi: mockNativeSyncApi,
|
||||
);
|
||||
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||
});
|
||||
|
||||
group('LocalSyncService - no changes', () {
|
||||
test('does nothing when there are no media changes', () async {
|
||||
group('LocalSyncService - syncTrashedAssets gating', () {
|
||||
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
|
||||
await sut.sync();
|
||||
|
||||
verify(() => mockNativeSyncApi.getMediaChanges()).called(1);
|
||||
verifyNever(() => mockLocalAlbumRepository.updateAll(any()));
|
||||
verify(() => mockNativeSyncApi.getTrashedAssets()).called(1);
|
||||
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1);
|
||||
});
|
||||
|
||||
test('skips syncTrashedAssets when store flag disabled', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
|
||||
await sut.sync();
|
||||
|
||||
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
|
||||
});
|
||||
|
||||
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
when(() => mockPermissionRepository.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(() => mockPermissionRepository.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',
|
||||
type: AssetType.image.index,
|
||||
durationMs: 0,
|
||||
orientation: 0,
|
||||
isFavorite: false,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||
);
|
||||
|
||||
final assetsToRestore = [LocalAssetStub.image1];
|
||||
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => assetsToRestore);
|
||||
final restoredIds = ['image1'];
|
||||
when(() => mockAssetMediaRepository.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],
|
||||
},
|
||||
);
|
||||
|
||||
await sut.processTrashedAssets({
|
||||
'album-a': [platformAsset],
|
||||
});
|
||||
|
||||
final trashedSnapshot =
|
||||
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single
|
||||
as Iterable<TrashedAsset>;
|
||||
expect(trashedSnapshot.length, 1);
|
||||
final trashedEntry = trashedSnapshot.single;
|
||||
expect(trashedEntry.albumId, 'album-a');
|
||||
expect(trashedEntry.asset.id, platformAsset.id);
|
||||
expect(trashedEntry.asset.name, platformAsset.name);
|
||||
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
|
||||
|
||||
verify(() => mockAssetMediaRepository.restoreAssetsFromTrash(any())).called(1);
|
||||
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
|
||||
|
||||
final moveArgs = verify(() => mockAssetMediaRepository.deleteAll(captureAny())).captured.single as List<String>;
|
||||
expect(moveArgs, ['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('records only local assets that were moved to device trash', () async {
|
||||
final movedAsset = LocalAssetStub.image1.copyWith(id: 'moved-local', checksum: 'checksum-moved');
|
||||
final skippedAsset = LocalAssetStub.image2.copyWith(id: 'skipped-local', checksum: 'checksum-skipped');
|
||||
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer(
|
||||
(_) async => {
|
||||
'album-a': [movedAsset],
|
||||
'album-b': [skippedAsset],
|
||||
},
|
||||
);
|
||||
when(() => mockAssetMediaRepository.deleteAll(any())).thenAnswer((_) async => ['moved-local']);
|
||||
|
||||
await sut.processTrashedAssets({});
|
||||
|
||||
final trashArgs =
|
||||
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
|
||||
as Map<String, List<LocalAsset>>;
|
||||
expect(trashArgs.keys, ['album-a']);
|
||||
expect(trashArgs['album-a'], [movedAsset]);
|
||||
});
|
||||
|
||||
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);
|
||||
verifyNever(() => mockAssetMediaRepository.restoreAssetsFromTrash(any()));
|
||||
verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any()));
|
||||
});
|
||||
|
||||
test('does not move local assets when repository finds nothing to trash', () async {
|
||||
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
|
||||
|
||||
await sut.processTrashedAssets({});
|
||||
|
||||
verifyNever(() => mockAssetMediaRepository.deleteAll(any()));
|
||||
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,15 +4,19 @@ 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/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/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
@@ -21,6 +25,7 @@ import '../../api.mocks.dart';
|
||||
import '../../fixtures/asset.stub.dart';
|
||||
import '../../fixtures/sync_stream.stub.dart';
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
import '../../repository.mocks.dart';
|
||||
import '../../service.mocks.dart';
|
||||
|
||||
class _AbortCallbackWrapper {
|
||||
@@ -31,11 +36,15 @@ class _AbortCallbackWrapper {
|
||||
|
||||
class _MockAbortCallbackWrapper extends Mock implements _AbortCallbackWrapper {}
|
||||
|
||||
|
||||
void main() {
|
||||
late SyncStreamService sut;
|
||||
late SyncStreamRepository mockSyncStreamRepo;
|
||||
late SyncApiRepository mockSyncApiRepo;
|
||||
late MockTrashSyncRepository mockTrashSyncRepo;
|
||||
late DriftLocalAssetRepository mockLocalAssetRepo;
|
||||
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
|
||||
late AssetMediaRepository mockAssetMediaRepo;
|
||||
late MockPermissionRepository mockPermissionRepo;
|
||||
late MockApiService mockApi;
|
||||
late MockServerApi mockServerApi;
|
||||
late MockSyncMigrationRepository mockSyncMigrationRepo;
|
||||
@@ -43,6 +52,7 @@ void main() {
|
||||
late _MockAbortCallbackWrapper mockAbortCallbackWrapper;
|
||||
late _MockAbortCallbackWrapper mockResetCallbackWrapper;
|
||||
late Drift db;
|
||||
late bool hasManageMediaPermission;
|
||||
|
||||
setUpAll(() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -65,7 +75,10 @@ void main() {
|
||||
setUp(() async {
|
||||
mockSyncStreamRepo = MockSyncStreamRepository();
|
||||
mockSyncApiRepo = MockSyncApiRepository();
|
||||
mockTrashSyncRepo = MockTrashSyncRepository();
|
||||
mockLocalAssetRepo = MockLocalAssetRepository();
|
||||
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
|
||||
mockAssetMediaRepo = MockAssetMediaRepository();
|
||||
mockPermissionRepo = MockPermissionRepository();
|
||||
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
|
||||
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
|
||||
mockApi = MockApiService();
|
||||
@@ -140,12 +153,26 @@ void main() {
|
||||
sut = SyncStreamService(
|
||||
syncApiRepository: mockSyncApiRepo,
|
||||
syncStreamRepository: mockSyncStreamRepo,
|
||||
trashSyncRepository: mockTrashSyncRepo,
|
||||
localAssetRepository: mockLocalAssetRepo,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||
assetMediaRepository: mockAssetMediaRepo,
|
||||
permissionRepository: mockPermissionRepo,
|
||||
api: mockApi,
|
||||
syncMigrationRepository: mockSyncMigrationRepo,
|
||||
);
|
||||
|
||||
when(() => mockTrashSyncRepo.recordHardDeletedChecksums(any())).thenAnswer((_) => Future.value());
|
||||
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((_) async => {});
|
||||
when(() => mockTrashedLocalAssetRepo.trashLocalAsset(any())).thenAnswer((_) async {});
|
||||
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []);
|
||||
when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {});
|
||||
hasManageMediaPermission = false;
|
||||
when(() => mockPermissionRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
|
||||
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
|
||||
final ids = invocation.positionalArguments.first as List<String>;
|
||||
return ids;
|
||||
});
|
||||
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
});
|
||||
|
||||
Future<void> simulateEvents(List<SyncEvent> events) async {
|
||||
@@ -178,13 +205,6 @@ void main() {
|
||||
verifyNever(() => mockAbortCallbackWrapper());
|
||||
});
|
||||
|
||||
test("assetDeleteV1 records the server deleted checksums", () async {
|
||||
await simulateEvents([SyncStreamStub.assetDeleteV1]);
|
||||
|
||||
verify(() => mockTrashSyncRepo.recordHardDeletedChecksums(any())).called(1);
|
||||
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
|
||||
});
|
||||
|
||||
test("processes final batch correctly", () async {
|
||||
final events = [SyncStreamStub.userDeleteV1, SyncStreamStub.userV1Admin];
|
||||
|
||||
@@ -216,7 +236,10 @@ void main() {
|
||||
sut = SyncStreamService(
|
||||
syncApiRepository: mockSyncApiRepo,
|
||||
syncStreamRepository: mockSyncStreamRepo,
|
||||
trashSyncRepository: mockTrashSyncRepo,
|
||||
localAssetRepository: mockLocalAssetRepo,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||
assetMediaRepository: mockAssetMediaRepo,
|
||||
permissionRepository: mockPermissionRepo,
|
||||
cancellation: cancellation,
|
||||
api: mockApi,
|
||||
syncMigrationRepository: mockSyncMigrationRepo,
|
||||
@@ -253,7 +276,10 @@ void main() {
|
||||
sut = SyncStreamService(
|
||||
syncApiRepository: mockSyncApiRepo,
|
||||
syncStreamRepository: mockSyncStreamRepo,
|
||||
trashSyncRepository: mockTrashSyncRepo,
|
||||
localAssetRepository: mockLocalAssetRepo,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||
assetMediaRepository: mockAssetMediaRepo,
|
||||
permissionRepository: mockPermissionRepo,
|
||||
cancellation: cancellation,
|
||||
api: mockApi,
|
||||
syncMigrationRepository: mockSyncMigrationRepo,
|
||||
@@ -366,6 +392,164 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group("SyncStreamService - remote trash & restore", () {
|
||||
setUp(() async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
hasManageMediaPermission = true;
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
hasManageMediaPermission = false;
|
||||
});
|
||||
|
||||
test("moves backed up local and merged assets to device trash when remote trash events are received", () async {
|
||||
final localAsset = LocalAssetStub.image1.copyWith(id: 'local-only', checksum: 'checksum-local', remoteId: null);
|
||||
final mergedAsset = LocalAssetStub.image2.copyWith(
|
||||
id: 'merged-local',
|
||||
checksum: 'checksum-merged',
|
||||
remoteId: 'remote-merged',
|
||||
);
|
||||
final assetsByAlbum = {
|
||||
'album-a': [localAsset],
|
||||
'album-b': [mergedAsset],
|
||||
};
|
||||
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
|
||||
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
|
||||
expect(requestedRemoteIds.toSet(), equals({'remote-1', 'remote-2', 'remote-3'}));
|
||||
return assetsByAlbum;
|
||||
});
|
||||
|
||||
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
|
||||
final ids = invocation.positionalArguments.first as List<String>;
|
||||
expect(ids, unorderedEquals(['local-only', 'merged-local']));
|
||||
return ids;
|
||||
});
|
||||
|
||||
final events = [
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-1',
|
||||
checksum: localAsset.checksum!,
|
||||
ack: 'asset-remote-local-1',
|
||||
trashedAt: DateTime(2025, 5, 1),
|
||||
),
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-2',
|
||||
checksum: mergedAsset.checksum!,
|
||||
ack: 'asset-remote-merged-2',
|
||||
trashedAt: DateTime(2025, 5, 2),
|
||||
),
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-3',
|
||||
checksum: 'checksum-remote-only',
|
||||
ack: 'asset-remote-only-3',
|
||||
trashedAt: DateTime(2025, 5, 3),
|
||||
),
|
||||
];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
final trashArgs =
|
||||
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(captureAny())).captured.single
|
||||
as Map<String, List<LocalAsset>>;
|
||||
expect(trashArgs.keys, unorderedEquals(['album-a', 'album-b']));
|
||||
expect(trashArgs['album-a'], [localAsset]);
|
||||
expect(trashArgs['album-b'], [mergedAsset]);
|
||||
verify(() => mockAssetMediaRepo.deleteAll(any())).called(1);
|
||||
verify(() => mockSyncApiRepo.ack(['asset-remote-only-3'])).called(1);
|
||||
});
|
||||
|
||||
test("records only assets that were moved to device trash", () async {
|
||||
final movedAsset = LocalAssetStub.image1.copyWith(id: 'moved-local', checksum: 'checksum-moved');
|
||||
final skippedAsset = LocalAssetStub.image2.copyWith(id: 'skipped-local', checksum: 'checksum-skipped');
|
||||
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer(
|
||||
(_) async => {
|
||||
'album-a': [movedAsset],
|
||||
'album-b': [skippedAsset],
|
||||
},
|
||||
);
|
||||
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((_) async => ['moved-local']);
|
||||
|
||||
final events = [
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-moved',
|
||||
checksum: movedAsset.checksum!,
|
||||
ack: 'asset-remote-moved',
|
||||
trashedAt: DateTime(2025, 5, 1),
|
||||
),
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-skipped',
|
||||
checksum: skippedAsset.checksum!,
|
||||
ack: 'asset-remote-skipped',
|
||||
trashedAt: DateTime(2025, 5, 2),
|
||||
),
|
||||
];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
final trashArgs =
|
||||
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(captureAny())).captured.single
|
||||
as Map<String, List<LocalAsset>>;
|
||||
expect(trashArgs.keys, ['album-a']);
|
||||
expect(trashArgs['album-a'], [movedAsset]);
|
||||
});
|
||||
|
||||
test("skips device trashing when no local assets match the remote trash payload", () async {
|
||||
final events = [
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-only',
|
||||
checksum: 'checksum-only',
|
||||
ack: 'asset-remote-only-9',
|
||||
trashedAt: DateTime(2025, 6, 1),
|
||||
),
|
||||
];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
||||
verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
|
||||
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
|
||||
});
|
||||
|
||||
test("requests local deletions lookup by remote ids for permanent remote delete events", () async {
|
||||
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
|
||||
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
|
||||
expect(requestedRemoteIds.toSet(), equals({'remote-asset'}));
|
||||
return {};
|
||||
});
|
||||
|
||||
final events = [SyncStreamStub.assetDeleteV1];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
||||
verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
|
||||
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
|
||||
});
|
||||
|
||||
test("restores trashed local assets once the matching remote assets leave the trash", () async {
|
||||
final trashedAssets = [
|
||||
LocalAssetStub.image1.copyWith(id: 'trashed-1', checksum: 'checksum-trash', remoteId: 'remote-1'),
|
||||
];
|
||||
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => trashedAssets);
|
||||
|
||||
final restoredIds = ['trashed-1'];
|
||||
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
||||
final Iterable<LocalAsset> requestedAssets = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
||||
expect(requestedAssets, orderedEquals(trashedAssets));
|
||||
return restoredIds;
|
||||
});
|
||||
|
||||
final events = [
|
||||
SyncStreamStub.assetModified(id: 'remote-1', checksum: 'checksum-trash', ack: 'asset-remote-1-11'),
|
||||
];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockTrashedLocalAssetRepo.applyRestoredAssets(restoredIds)).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncStreamService - Sync Migration', () {
|
||||
test('ensure that <2.5.0 migrations run', () async {
|
||||
await Store.put(StoreKey.syncMigrationStatus, "[]");
|
||||
|
||||
4
mobile/test/drift/main/generated/schema.dart
generated
4
mobile/test/drift/main/generated/schema.dart
generated
@@ -35,7 +35,6 @@ import 'schema_v28.dart' as v28;
|
||||
import 'schema_v29.dart' as v29;
|
||||
import 'schema_v30.dart' as v30;
|
||||
import 'schema_v31.dart' as v31;
|
||||
import 'schema_v32.dart' as v32;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
@@ -103,8 +102,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
return v30.DatabaseAtV30(db);
|
||||
case 31:
|
||||
return v31.DatabaseAtV31(db);
|
||||
case 32:
|
||||
return v32.DatabaseAtV32(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
@@ -142,6 +139,5 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
29,
|
||||
30,
|
||||
31,
|
||||
32,
|
||||
];
|
||||
}
|
||||
|
||||
9792
mobile/test/drift/main/generated/schema_v32.dart
generated
9792
mobile/test/drift/main/generated/schema_v32.dart
generated
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ import 'package:immich_mobile/infrastructure/repositories/store.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/infrastructure/repositories/user.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
@@ -36,7 +36,7 @@ class MockDriftLocalAssetRepository extends Mock implements DriftLocalAssetRepos
|
||||
|
||||
class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {}
|
||||
|
||||
class MockTrashSyncRepository extends Mock implements DriftTrashSyncRepository {}
|
||||
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
|
||||
|
||||
class MockStorageRepository extends Mock implements StorageRepository {}
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/server_deleted_checksum.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
@@ -116,32 +114,6 @@ void main() {
|
||||
expect(result.remainder, 1);
|
||||
});
|
||||
|
||||
test('excludes asset with a trash sync row from counts', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final asset = await ctx.newLocalAsset();
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
|
||||
await ctx.db
|
||||
.into(ctx.db.trashSyncEntity)
|
||||
.insert(TrashSyncEntityCompanion.insert(assetId: asset.id, checksum: 'trashed'));
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 0);
|
||||
expect(result.remainder, 0);
|
||||
});
|
||||
|
||||
test('excludes an asset whose content the server permanently deleted', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final asset = await ctx.newLocalAsset(checksum: 'deleted');
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
|
||||
await ctx.db
|
||||
.into(ctx.db.serverDeletedChecksumEntity)
|
||||
.insert(ServerDeletedChecksumEntityCompanion.insert(checksum: 'deleted'));
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 0);
|
||||
expect(result.remainder, 0);
|
||||
});
|
||||
|
||||
test('mixed assets produce correct combined counts', () async {
|
||||
final selectedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
|
||||
@@ -257,30 +229,6 @@ void main() {
|
||||
expect(result.map((a) => a.id).toList(), [asset2.id, asset3.id, asset1.id]);
|
||||
});
|
||||
|
||||
test('excludes asset with a trash sync row', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final asset = await ctx.newLocalAsset();
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
|
||||
await ctx.db
|
||||
.into(ctx.db.trashSyncEntity)
|
||||
.insert(TrashSyncEntityCompanion.insert(assetId: asset.id, checksum: 'trashed'));
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('excludes an asset whose content the server permanently deleted', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final asset = await ctx.newLocalAsset(checksum: 'deleted');
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
|
||||
await ctx.db
|
||||
.into(ctx.db.serverDeletedChecksumEntity)
|
||||
.insert(ServerDeletedChecksumEntityCompanion.insert(checksum: 'deleted'));
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('does not return duplicate when asset is in multiple selected albums', () async {
|
||||
final album1 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final album2 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
|
||||
@@ -1,505 +0,0 @@
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/server_deleted_checksum.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
|
||||
|
||||
import '../repository_context.dart';
|
||||
|
||||
void main() {
|
||||
late MediumRepositoryContext ctx;
|
||||
late DriftTrashSyncRepository sut;
|
||||
|
||||
setUp(() {
|
||||
ctx = MediumRepositoryContext();
|
||||
sut = DriftTrashSyncRepository(ctx.db);
|
||||
});
|
||||
|
||||
tearDown(() => ctx.dispose());
|
||||
|
||||
Future<({String localId, String checksum, String remoteId})> backedUpAsset({
|
||||
required String ownerId,
|
||||
DateTime? remoteDeletedAt,
|
||||
BackupSelection album = .selected,
|
||||
}) async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: ownerId, deletedAt: remoteDeletedAt);
|
||||
final local = await ctx.newLocalAsset(checksum: remote.checksum);
|
||||
final localAlbum = await ctx.newLocalAlbum(backupSelection: album);
|
||||
await ctx.newLocalAlbumAsset(albumId: localAlbum.id, assetId: local.id);
|
||||
return (localId: local.id, checksum: remote.checksum, remoteId: remote.id);
|
||||
}
|
||||
|
||||
Future<TrashSyncStatus?> trashStatusOf(String assetId) async {
|
||||
final rows = await ctx.db.select(ctx.db.trashSyncEntity).get();
|
||||
return rows.where((r) => r.assetId == assetId).map((r) => r.status).firstOrNull;
|
||||
}
|
||||
|
||||
Future<void> markAsset({
|
||||
required String assetId,
|
||||
required String checksum,
|
||||
required TrashSyncStatus status,
|
||||
DateTime? assetUpdatedAt,
|
||||
}) => ctx.db
|
||||
.into(ctx.db.trashSyncEntity)
|
||||
.insert(
|
||||
TrashSyncEntityCompanion.insert(
|
||||
assetId: assetId,
|
||||
checksum: checksum,
|
||||
status: Value(status),
|
||||
assetUpdatedAt: Value.absentIfNull(assetUpdatedAt),
|
||||
),
|
||||
);
|
||||
|
||||
Future<String?> checksumOf(String assetId) async {
|
||||
final rows = await ctx.db.select(ctx.db.localAssetEntity).get();
|
||||
return rows.where((r) => r.id == assetId).map((r) => r.checksum).firstOrNull;
|
||||
}
|
||||
|
||||
late String userId;
|
||||
setUp(() async {
|
||||
userId = (await ctx.newUser()).id;
|
||||
await ctx.newAuthUser(id: userId);
|
||||
});
|
||||
|
||||
group('recordSoftDeleteAssets', () {
|
||||
test('records marker when server asset is trashed', () async {
|
||||
final asset = await backedUpAsset(ownerId: userId, remoteDeletedAt: .new(2026, 1, 1));
|
||||
|
||||
await sut.recordSoftDeleteAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), [asset.localId]);
|
||||
});
|
||||
|
||||
test('#24124: asset on server is never trashed', () async {
|
||||
await backedUpAsset(ownerId: userId, remoteDeletedAt: null);
|
||||
|
||||
await sut.recordSoftDeleteAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), isEmpty);
|
||||
});
|
||||
|
||||
test('ignores assets that are not in a backup selected album', () async {
|
||||
await backedUpAsset(ownerId: userId, remoteDeletedAt: .new(2026, 1, 1), album: .none);
|
||||
|
||||
await sut.recordSoftDeleteAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), isEmpty);
|
||||
});
|
||||
|
||||
test('existing marker is not duplicated', () async {
|
||||
await backedUpAsset(ownerId: userId, remoteDeletedAt: .new(2026, 1, 1));
|
||||
|
||||
await sut.recordSoftDeleteAssets();
|
||||
await sut.recordSoftDeleteAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), hasLength(1));
|
||||
});
|
||||
|
||||
test('#24124: does not retrash a dismissed / restored asset', () async {
|
||||
final asset = await backedUpAsset(ownerId: userId, remoteDeletedAt: .new(2026, 1, 1));
|
||||
await markAsset(assetId: asset.localId, checksum: asset.checksum, status: .dismissed);
|
||||
|
||||
await sut.recordSoftDeleteAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), isEmpty);
|
||||
expect(await trashStatusOf(asset.localId), TrashSyncStatus.dismissed);
|
||||
});
|
||||
});
|
||||
|
||||
group('getRestorableAssetIds', () {
|
||||
test('should mark asset restorable when asset is back on server', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: null);
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .trashed);
|
||||
|
||||
expect(await sut.getRestorableAssetIds(), ['asset']);
|
||||
});
|
||||
|
||||
test('pending assets are not restored', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: null);
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .pending);
|
||||
|
||||
expect(await sut.getRestorableAssetIds(), isEmpty);
|
||||
});
|
||||
|
||||
test('do not mark asset restorable when server asset is still trashed', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: .new(2026, 1, 1));
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .trashed);
|
||||
|
||||
expect(await sut.getRestorableAssetIds(), isEmpty);
|
||||
});
|
||||
|
||||
test('do not mark asset restorable when server asset is permanently deleted', () async {
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum', status: .trashed);
|
||||
|
||||
expect(await sut.getRestorableAssetIds(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('duplicate local copies of the same asset', () {
|
||||
test('recordSoftDeleteAssets records one marker per local copy', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: .new(2026, 1, 1));
|
||||
final album = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
final local1 = await ctx.newLocalAsset(checksum: remote.checksum);
|
||||
final local2 = await ctx.newLocalAsset(checksum: remote.checksum);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local1.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local2.id);
|
||||
|
||||
await sut.recordSoftDeleteAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), unorderedEquals([local1.id, local2.id]));
|
||||
});
|
||||
|
||||
test('getRestorableAssetIds returns every trashed copy sharing the revived checksum', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: null);
|
||||
await markAsset(assetId: 'local1', checksum: remote.checksum, status: .trashed);
|
||||
await markAsset(assetId: 'local2', checksum: remote.checksum, status: .trashed);
|
||||
|
||||
expect(await sut.getRestorableAssetIds(), unorderedEquals(['local1', 'local2']));
|
||||
});
|
||||
});
|
||||
|
||||
group('pruneStaleMarkers - asset came back before trashing', () {
|
||||
test('removes marker when asset is back on server', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: null);
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .pending);
|
||||
|
||||
await sut.pruneStaleMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
|
||||
test('dismissed marker is kept even after asset is back on server to prevent re-trashing it', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: null);
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .dismissed);
|
||||
|
||||
await sut.pruneStaleMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.dismissed);
|
||||
});
|
||||
|
||||
test('keeps a pending marker for asset that is still deleted', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: .new(2026, 1, 1));
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .pending);
|
||||
|
||||
await sut.pruneStaleMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.pending);
|
||||
});
|
||||
|
||||
test('does not remove a trashed marker', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: null);
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .trashed);
|
||||
|
||||
await sut.pruneStaleMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.trashed);
|
||||
});
|
||||
});
|
||||
|
||||
group('excluded album handling', () {
|
||||
test('recordSoftDeleteAssets ignores an asset also in an excluded album', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: .new(2026, 1, 1));
|
||||
final local = await ctx.newLocalAsset(checksum: remote.checksum);
|
||||
final selected = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
final excluded = await ctx.newLocalAlbum(backupSelection: .excluded);
|
||||
await ctx.newLocalAlbumAsset(albumId: selected.id, assetId: local.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: excluded.id, assetId: local.id);
|
||||
|
||||
await sut.recordSoftDeleteAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('duplicate assets', () {
|
||||
test('recordSoftDeleteAssets skips duplicate asset when previous asset is dismissed', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: .new(2026, 1, 1));
|
||||
final album = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
final reimport = await ctx.newLocalAsset(checksum: remote.checksum);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: reimport.id);
|
||||
await markAsset(assetId: 'old-asset', checksum: remote.checksum, status: .dismissed);
|
||||
|
||||
await sut.recordSoftDeleteAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('prunePendingMarkers', () {
|
||||
test('removes pending marker when local asset is modified', () async {
|
||||
final local = await ctx.newLocalAsset(checksum: 'current-checksum');
|
||||
await markAsset(assetId: local.id, checksum: 'marked-checksum', status: .pending);
|
||||
|
||||
await sut.prunePendingMarkers();
|
||||
|
||||
expect(await trashStatusOf(local.id), isNull);
|
||||
});
|
||||
|
||||
test('keeps pending marker for local asset', () async {
|
||||
final local = await ctx.newLocalAsset(checksum: 'checksum');
|
||||
await markAsset(assetId: local.id, checksum: 'checksum', status: .pending);
|
||||
|
||||
await sut.prunePendingMarkers();
|
||||
|
||||
expect(await trashStatusOf(local.id), TrashSyncStatus.pending);
|
||||
});
|
||||
|
||||
test('keeps pending marker for missing local asset', () async {
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum', status: .pending);
|
||||
|
||||
await sut.prunePendingMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.pending);
|
||||
});
|
||||
});
|
||||
|
||||
group('server deleted checksum table', () {
|
||||
Future<void> insertDeletedChecksum(String checksum) => ctx.db
|
||||
.into(ctx.db.serverDeletedChecksumEntity)
|
||||
.insert(ServerDeletedChecksumEntityCompanion.insert(checksum: checksum));
|
||||
|
||||
Future<List<String>> deletedChecksums() async =>
|
||||
(await ctx.db.select(ctx.db.serverDeletedChecksumEntity).get()).map((r) => r.checksum).toList();
|
||||
|
||||
group('recordHardDeletedChecksums', () {
|
||||
test('marks owned permanently deleted assets', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId);
|
||||
|
||||
await sut.recordHardDeletedChecksums([remote.id]);
|
||||
|
||||
expect(await deletedChecksums(), [remote.checksum]);
|
||||
});
|
||||
|
||||
test('ignores a partner assets', () async {
|
||||
final partner = await ctx.newUser();
|
||||
final remote = await ctx.newRemoteAsset(ownerId: partner.id);
|
||||
|
||||
await sut.recordHardDeletedChecksums([remote.id]);
|
||||
|
||||
expect(await deletedChecksums(), isEmpty);
|
||||
});
|
||||
|
||||
test('only marks local assets from backup selected album', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
final local = await ctx.newLocalAsset(checksum: 'checksum');
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
await insertDeletedChecksum('checksum');
|
||||
|
||||
await sut.recordHardDeletedAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), [local.id]);
|
||||
});
|
||||
|
||||
test('ignores an excluded album asset', () async {
|
||||
final selected = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
final excluded = await ctx.newLocalAlbum(backupSelection: .excluded);
|
||||
final local = await ctx.newLocalAsset(checksum: 'checksum');
|
||||
await ctx.newLocalAlbumAsset(albumId: selected.id, assetId: local.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: excluded.id, assetId: local.id);
|
||||
await insertDeletedChecksum('checksum');
|
||||
|
||||
await sut.recordHardDeletedAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('pruneStaleMarkers', () {
|
||||
test('removes marker for asset back on the server', () async {
|
||||
await insertDeletedChecksum('checksum');
|
||||
await ctx.newRemoteAsset(ownerId: userId, deletedAt: null, checksum: 'checksum');
|
||||
|
||||
await sut.pruneStaleMarkers();
|
||||
|
||||
expect(await deletedChecksums(), isEmpty);
|
||||
});
|
||||
|
||||
test('keeps a checksum with no owned remote asset', () async {
|
||||
await insertDeletedChecksum('checksum');
|
||||
|
||||
await sut.pruneStaleMarkers();
|
||||
|
||||
expect(await deletedChecksums(), ['checksum']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('owner scoping', () {
|
||||
test('recordSoftDeleteAssets: ignores trashed assets from partner', () async {
|
||||
final partner = await ctx.newUser();
|
||||
final remote = await ctx.newRemoteAsset(ownerId: partner.id, deletedAt: .new(2026, 1, 1));
|
||||
final album = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
final local = await ctx.newLocalAsset(checksum: remote.checksum);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
await sut.recordSoftDeleteAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), isEmpty);
|
||||
});
|
||||
|
||||
test('getRestorableAssetIds: ignores restore when server remote asset is only partner owned', () async {
|
||||
final partner = await ctx.newUser();
|
||||
final remote = await ctx.newRemoteAsset(ownerId: partner.id, deletedAt: null);
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .trashed);
|
||||
|
||||
expect(await sut.getRestorableAssetIds(), isEmpty);
|
||||
});
|
||||
|
||||
test('pruneStaleMarkers: ignores remote asset that is only partner owned', () async {
|
||||
final partner = await ctx.newUser();
|
||||
final remote = await ctx.newRemoteAsset(ownerId: partner.id, deletedAt: null);
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .pending);
|
||||
|
||||
await sut.pruneStaleMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.pending);
|
||||
});
|
||||
});
|
||||
|
||||
group('reconcileTrashed', () {
|
||||
test('dismisses a trashed marker whose local asset is not trashed', () async {
|
||||
final local = await ctx.newLocalAsset();
|
||||
await markAsset(assetId: local.id, checksum: 'checksum', status: .trashed);
|
||||
|
||||
await sut.reconcileTrashed([local.id]);
|
||||
|
||||
expect(await trashStatusOf(local.id), TrashSyncStatus.dismissed);
|
||||
});
|
||||
|
||||
test('removes a trashed marker whose local asset was deleted', () async {
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum', status: .trashed);
|
||||
|
||||
await sut.reconcileTrashed(['asset']);
|
||||
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
|
||||
test('splits a mixed batch in one call: live id dismissed, purged id deleted', () async {
|
||||
final live = await ctx.newLocalAsset();
|
||||
await markAsset(assetId: live.id, checksum: 'checksum-1', status: .trashed);
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum-2', status: .trashed);
|
||||
|
||||
await sut.reconcileTrashed([live.id, 'asset']);
|
||||
|
||||
expect(await trashStatusOf(live.id), TrashSyncStatus.dismissed);
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('pruneDismissedMarkers', () {
|
||||
Future<void> insertDeletedChecksum(String checksum) => ctx.db
|
||||
.into(ctx.db.serverDeletedChecksumEntity)
|
||||
.insert(ServerDeletedChecksumEntityCompanion.insert(checksum: checksum));
|
||||
|
||||
test('removes a dismissed marker when its checksum is not server deleted', () async {
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum', status: .dismissed);
|
||||
|
||||
await sut.pruneDismissedMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
|
||||
test('keeps dismissed marker still trashed on the server', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: .new(2026, 1, 1));
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .dismissed);
|
||||
|
||||
await sut.pruneDismissedMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.dismissed);
|
||||
});
|
||||
|
||||
test('keeps dismissed marker for permanently deleted assets on server', () async {
|
||||
await insertDeletedChecksum('checksum');
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum', status: .dismissed);
|
||||
|
||||
await sut.pruneDismissedMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.dismissed);
|
||||
});
|
||||
|
||||
test('removes dismissed marker of asset matching only partner trashed asset', () async {
|
||||
final partner = await ctx.newUser();
|
||||
final remote = await ctx.newRemoteAsset(ownerId: partner.id, deletedAt: .new(2026, 1, 1));
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .dismissed);
|
||||
|
||||
await sut.pruneDismissedMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
|
||||
test('do not delete trashed marker when server checksum is empty', () async {
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum', status: .trashed);
|
||||
|
||||
await sut.pruneDismissedMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.trashed);
|
||||
});
|
||||
});
|
||||
|
||||
group('markTrashed', () {
|
||||
test('marks trashed and removes the local asset', () async {
|
||||
final local = await ctx.newLocalAsset();
|
||||
await markAsset(assetId: local.id, checksum: 'checksum', status: .pending);
|
||||
|
||||
await sut.markTrashed([local.id]);
|
||||
|
||||
expect(await trashStatusOf(local.id), TrashSyncStatus.trashed);
|
||||
final locals = await ctx.db.select(ctx.db.localAssetEntity).get();
|
||||
expect(locals.where((l) => l.id == local.id), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('markRestored', () {
|
||||
test('marks as restored, leaving the rows in place', () async {
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum', status: .trashed);
|
||||
|
||||
await sut.markRestored(['asset']);
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.restored);
|
||||
});
|
||||
});
|
||||
|
||||
group('restoreChecksums', () {
|
||||
test('copies the stored checksum to local asset whose modified time is unchanged', () async {
|
||||
final modifiedAt = DateTime(2026, 1, 1);
|
||||
await ctx.newLocalAsset(id: 'asset', checksumOption: const .none(), updatedAt: modifiedAt);
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum', status: .restored, assetUpdatedAt: modifiedAt);
|
||||
|
||||
await sut.restoreChecksums();
|
||||
|
||||
expect(await checksumOf('asset'), 'checksum');
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
|
||||
test('leaves the checksum null when the asset was modified after restore', () async {
|
||||
await ctx.newLocalAsset(id: 'asset', checksumOption: const .none(), updatedAt: .new(2026, 1, 1));
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum', status: .restored, assetUpdatedAt: DateTime(2024, 3, 1));
|
||||
|
||||
await sut.restoreChecksums();
|
||||
|
||||
expect(await checksumOf('asset'), isNull);
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
|
||||
test('does not overwrite a checksum the hash pass already computed', () async {
|
||||
final modifiedAt = DateTime(2026, 1, 1);
|
||||
await ctx.newLocalAsset(id: 'asset', checksum: 'checksum', updatedAt: modifiedAt);
|
||||
await markAsset(assetId: 'asset', checksum: 'old-checksum', status: .restored, assetUpdatedAt: modifiedAt);
|
||||
|
||||
await sut.restoreChecksums();
|
||||
|
||||
expect(await checksumOf('asset'), 'checksum');
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
|
||||
test('clears restored markers even when local asset does not exist', () async {
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum', status: .restored, assetUpdatedAt: .new(2026, 1, 1));
|
||||
|
||||
await sut.restoreChecksums();
|
||||
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
@@ -51,20 +50,6 @@ class MediumRepositoryContext {
|
||||
return .new(fallback);
|
||||
}
|
||||
|
||||
Future<AuthUserEntityData> newAuthUser({String? id, String? email}) async {
|
||||
id ??= TestUtils.uuid();
|
||||
return db
|
||||
.into(db.authUserEntity)
|
||||
.insertReturning(
|
||||
AuthUserEntityCompanion(
|
||||
id: .new(id),
|
||||
email: .new(email ?? '$id@demo.immich.app'),
|
||||
name: .new(email ?? 'user_$id'),
|
||||
avatarColor: .new(TestUtils.randElement(AvatarColor.values)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<UserEntityData> newUser({
|
||||
String? id,
|
||||
String? email,
|
||||
@@ -78,7 +63,7 @@ class MediumRepositoryContext {
|
||||
.insertReturning(
|
||||
UserEntityCompanion(
|
||||
id: .new(id),
|
||||
email: .new(email ?? '$id@demo.immich.app'),
|
||||
email: .new(email ?? '$id@test.com'),
|
||||
name: .new(email ?? 'user_$id'),
|
||||
avatarColor: .new(avatarColor ?? TestUtils.randElement(AvatarColor.values)),
|
||||
profileChangedAt: .new(TestUtils.date(profileChangedAt)),
|
||||
|
||||
@@ -1,46 +1,13 @@
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
||||
import 'package:immich_mobile/repositories/partner_api.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../api.mocks.dart';
|
||||
import '../domain/service.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
import '../utils.dart';
|
||||
import 'repository_context.dart';
|
||||
|
||||
class MediumServiceContext extends MediumRepositoryContext {
|
||||
late final UserRepository userRepository = UserRepository(db);
|
||||
late final PartnerRepository partnerRepository = PartnerRepository(db);
|
||||
late final DriftTrashSyncRepository trashSyncRepository = DriftTrashSyncRepository(db);
|
||||
late final SettingsRepository settings;
|
||||
|
||||
final partnerApi = MockPartnerApiRepository();
|
||||
final assetMediaApi = MockAssetMediaApi();
|
||||
final permissionRepository = MockPermissionRepository();
|
||||
|
||||
MediumServiceContext._() {
|
||||
_stubPartnerApi(partnerApi);
|
||||
_stubPermissionRepository(permissionRepository);
|
||||
_stubAssetMediaApi(assetMediaApi);
|
||||
}
|
||||
|
||||
static Future<MediumServiceContext> init() async {
|
||||
final context = MediumServiceContext._();
|
||||
context.settings = await SettingsRepository.ensureInitialized(context.db);
|
||||
return context;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await SettingsRepository.reset();
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
void _stubPartnerApi(MockPartnerApiRepository api) {
|
||||
final id = TestUtils.uuid();
|
||||
final partner = UserDto(id: id, email: '$id@example.com', name: 'name $id', profileChangedAt: TestUtils.now());
|
||||
@@ -52,13 +19,13 @@ void _stubPartnerApi(MockPartnerApiRepository api) {
|
||||
when(() => api.delete(any())).thenAnswer((_) async {});
|
||||
}
|
||||
|
||||
void _stubPermissionRepository(MockPermissionRepository permission) {
|
||||
when(() => permission.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
}
|
||||
class MediumServiceContext extends MediumRepositoryContext {
|
||||
late final UserRepository userRepository = UserRepository(db);
|
||||
late final PartnerRepository partnerRepository = PartnerRepository(db);
|
||||
|
||||
void _stubAssetMediaApi(MockAssetMediaApi assetMediaApi) {
|
||||
registerFallbackValue(const <String>[]);
|
||||
when(() => assetMediaApi.trash(any())).thenAnswer((_) async => []);
|
||||
when(() => assetMediaApi.restore(any())).thenAnswer((_) async => []);
|
||||
when(() => assetMediaApi.trashedAmong(any())).thenAnswer((_) async => []);
|
||||
final partnerApi = MockPartnerApiRepository();
|
||||
|
||||
MediumServiceContext() {
|
||||
_stubPartnerApi(partnerApi);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ void main() {
|
||||
late MediumServiceContext ctx;
|
||||
late PartnerService sut;
|
||||
|
||||
setUp(() async {
|
||||
ctx = await MediumServiceContext.init();
|
||||
setUp(() {
|
||||
ctx = MediumServiceContext();
|
||||
sut = PartnerService(ctx.userRepository, ctx.partnerRepository, ctx.partnerApi);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/services/trash_sync.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
|
||||
import 'package:immich_mobile/platform/asset_media_api.g.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../service_context.dart';
|
||||
|
||||
void main() {
|
||||
late MediumServiceContext ctx;
|
||||
late TrashSyncService sut;
|
||||
late String userId;
|
||||
|
||||
setUpAll(() => debugDefaultTargetPlatformOverride = .android);
|
||||
tearDownAll(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
setUp(() async {
|
||||
ctx = await MediumServiceContext.init();
|
||||
sut = TrashSyncService(
|
||||
repo: ctx.trashSyncRepository,
|
||||
assetMediaApi: ctx.assetMediaApi,
|
||||
permission: ctx.permissionRepository,
|
||||
settings: ctx.settings,
|
||||
);
|
||||
userId = (await ctx.newUser()).id;
|
||||
await ctx.newAuthUser(id: userId);
|
||||
await ctx.settings.write(.trashSyncEnabled, true);
|
||||
|
||||
when(() => ctx.assetMediaApi.trash(any())).thenAnswer(_mapStatus(.done));
|
||||
when(() => ctx.assetMediaApi.restore(any())).thenAnswer(_mapStatus(.done));
|
||||
when(() => ctx.assetMediaApi.trashedAmong(any())).thenAnswer((inv) async => _ids(inv));
|
||||
});
|
||||
|
||||
tearDown(() => ctx.dispose());
|
||||
|
||||
Future<({String localId, String remoteId, String checksum})> backedUpAsset({DateTime? remoteDeletedAt}) async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: remoteDeletedAt);
|
||||
final local = await ctx.newLocalAsset(checksum: remote.checksum);
|
||||
final album = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
return (localId: local.id, remoteId: remote.id, checksum: remote.checksum);
|
||||
}
|
||||
|
||||
Future<TrashSyncStatus?> trashStatusOf(String id) async {
|
||||
final rows = (await ctx.db.select(ctx.db.trashSyncEntity).get()).where((r) => r.assetId == id).toList();
|
||||
return rows.isEmpty ? null : rows.first.status;
|
||||
}
|
||||
|
||||
Future<bool> localAssetExists(String id) async =>
|
||||
(await ctx.db.select(ctx.db.localAssetEntity).get()).any((r) => r.id == id);
|
||||
|
||||
Future<void> markAsset({required String localId, required String checksum, TrashSyncStatus status = .pending}) async {
|
||||
await ctx.db
|
||||
.into(ctx.db.trashSyncEntity)
|
||||
.insert(TrashSyncEntityCompanion.insert(assetId: localId, checksum: checksum, status: .new(status)));
|
||||
}
|
||||
|
||||
test('trashed asset is recorded, trashed, and its local row pruned', () async {
|
||||
final asset = await backedUpAsset(remoteDeletedAt: .new(2026, 1, 1));
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf(asset.localId), TrashSyncStatus.trashed);
|
||||
expect(await localAssetExists(asset.localId), isFalse);
|
||||
});
|
||||
|
||||
test('trash sync disabled: trashed asset is left untouched', () async {
|
||||
await ctx.settings.write(.trashSyncEnabled, false);
|
||||
final asset = await backedUpAsset(remoteDeletedAt: .new(2026, 1, 1));
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf(asset.localId), isNull);
|
||||
expect(await localAssetExists(asset.localId), isTrue);
|
||||
verifyNever(() => ctx.assetMediaApi.trash(any()));
|
||||
});
|
||||
|
||||
test('trash pending asset that is removed from device is not trashed and marker is removed', () async {
|
||||
final asset = await backedUpAsset(remoteDeletedAt: .new(2026, 1, 1));
|
||||
when(() => ctx.assetMediaApi.trash(any())).thenAnswer(_mapStatus(.notFound));
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf(asset.localId), isNull);
|
||||
expect(await localAssetExists(asset.localId), isTrue);
|
||||
});
|
||||
|
||||
test('partner trash does not affect our local copy', () async {
|
||||
final partner = await ctx.newUser();
|
||||
final remote = await ctx.newRemoteAsset(ownerId: partner.id, deletedAt: .new(2026, 1, 1));
|
||||
final local = await ctx.newLocalAsset(checksum: remote.checksum);
|
||||
final album = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf(local.id), isNull);
|
||||
expect(await localAssetExists(local.id), isTrue);
|
||||
verifyNever(() => ctx.assetMediaApi.trash(any()));
|
||||
});
|
||||
|
||||
test('trash pending marker is removed when the asset is back', () async {
|
||||
final asset = await backedUpAsset(remoteDeletedAt: null);
|
||||
await markAsset(localId: asset.localId, checksum: asset.checksum);
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf(asset.localId), isNull);
|
||||
expect(await localAssetExists(asset.localId), isTrue);
|
||||
verifyNever(() => ctx.assetMediaApi.trash(any()));
|
||||
});
|
||||
|
||||
test('reupload after a failed trash cleans the marker', () async {
|
||||
final asset = await backedUpAsset(remoteDeletedAt: .new(2026, 1, 1));
|
||||
when(() => ctx.assetMediaApi.trash(any())).thenAnswer(_mapStatus(.failed));
|
||||
|
||||
await sut.reconcile();
|
||||
expect(await trashStatusOf(asset.localId), TrashSyncStatus.pending);
|
||||
expect(await localAssetExists(asset.localId), isTrue);
|
||||
|
||||
await (ctx.db.update(
|
||||
ctx.db.remoteAssetEntity,
|
||||
)..where((t) => t.id.equals(asset.remoteId))).write(const RemoteAssetEntityCompanion(deletedAt: .new(null)));
|
||||
when(() => ctx.assetMediaApi.trash(any())).thenAnswer(_mapStatus(.done));
|
||||
|
||||
await sut.reconcile();
|
||||
expect(await trashStatusOf(asset.localId), isNull);
|
||||
expect(await localAssetExists(asset.localId), isTrue);
|
||||
});
|
||||
|
||||
test('server delete is also sent to the OS trash', () async {
|
||||
final asset = await backedUpAsset(remoteDeletedAt: null);
|
||||
await ctx.trashSyncRepository.recordHardDeletedChecksums([asset.remoteId]);
|
||||
await (ctx.db.delete(ctx.db.remoteAssetEntity)..where((t) => t.id.equals(asset.remoteId))).go();
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf(asset.localId), TrashSyncStatus.trashed);
|
||||
expect(await localAssetExists(asset.localId), isFalse);
|
||||
});
|
||||
|
||||
test('trashed asset permanently deleted from the server stays trashed on device', () async {
|
||||
await markAsset(localId: 'asset', checksum: 'checksum', status: .trashed);
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.trashed);
|
||||
verifyNever(() => ctx.assetMediaApi.restore(any()));
|
||||
});
|
||||
|
||||
test('permanently deleted asset, that was externally trashed and restored is marked as dismissed', () async {
|
||||
final asset = await backedUpAsset(remoteDeletedAt: null);
|
||||
await ctx.trashSyncRepository.recordHardDeletedChecksums([asset.remoteId]);
|
||||
await (ctx.db.delete(ctx.db.remoteAssetEntity)..where((t) => t.id.equals(asset.remoteId))).go();
|
||||
when(() => ctx.assetMediaApi.trash(any())).thenAnswer(_mapStatus(.alreadyInState));
|
||||
|
||||
await sut.reconcile();
|
||||
expect(await trashStatusOf(asset.localId), TrashSyncStatus.trashed);
|
||||
expect(await localAssetExists(asset.localId), isFalse);
|
||||
|
||||
await ctx.newLocalAsset(id: asset.localId, checksum: asset.checksum);
|
||||
when(() => ctx.assetMediaApi.trashedAmong(any())).thenAnswer((_) async => const <String>[]);
|
||||
|
||||
await sut.reconcile();
|
||||
expect(await trashStatusOf(asset.localId), TrashSyncStatus.dismissed);
|
||||
expect(await localAssetExists(asset.localId), isTrue);
|
||||
});
|
||||
|
||||
test('dismissed guard prevents assets from being re-trashed again', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: .new(2026, 1, 1));
|
||||
final album = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
await markAsset(localId: 'asset', checksum: remote.checksum, status: .dismissed);
|
||||
|
||||
await sut.reconcile();
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.dismissed);
|
||||
|
||||
final local = await ctx.newLocalAsset(id: 'asset', checksum: remote.checksum);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.dismissed);
|
||||
expect(await localAssetExists('asset'), isTrue);
|
||||
verifyNever(() => ctx.assetMediaApi.trash(any()));
|
||||
});
|
||||
|
||||
test('dismissed guard is pruned once its asset is back on the server', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: null);
|
||||
await markAsset(localId: 'asset', checksum: remote.checksum, status: .dismissed);
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
|
||||
test('restored assets are restored locally and marked for backfill', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: null);
|
||||
await markAsset(localId: 'asset', checksum: remote.checksum, status: .trashed);
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
verify(() => ctx.assetMediaApi.restore(['asset'])).called(1);
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.restored);
|
||||
});
|
||||
|
||||
test('restorable asset with not found status has its marker dropped', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: null);
|
||||
await markAsset(localId: 'asset', checksum: remote.checksum, status: .trashed);
|
||||
when(() => ctx.assetMediaApi.restore(any())).thenAnswer(_mapStatus(.notFound));
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
|
||||
test('backlog handled when MANAGE_MEDIA was off and handled when on', () async {
|
||||
await markAsset(localId: 'asset1', checksum: 'checksum1');
|
||||
await markAsset(localId: 'asset2', checksum: 'checksum2');
|
||||
|
||||
when(() => ctx.permissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||
await sut.reconcile();
|
||||
expect(await trashStatusOf('asset1'), TrashSyncStatus.pending);
|
||||
verifyNever(() => ctx.assetMediaApi.trash(any()));
|
||||
|
||||
when(() => ctx.permissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
await sut.reconcile();
|
||||
expect(await trashStatusOf('asset1'), TrashSyncStatus.trashed);
|
||||
expect(await trashStatusOf('asset2'), TrashSyncStatus.trashed);
|
||||
});
|
||||
|
||||
test('iOS: reconcile maintains the list but never trashes', () async {
|
||||
debugDefaultTargetPlatformOverride = .iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = .android);
|
||||
await markAsset(localId: 'asset', checksum: 'checksum');
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.pending);
|
||||
verifyNever(() => ctx.assetMediaApi.trash(any()));
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<AssetMediaActionResult>> Function(Invocation) _mapStatus(AssetMediaActionStatus status) =>
|
||||
(inv) async => _ids(inv).map((id) => AssetMediaActionResult(id: id, status: status)).toList();
|
||||
|
||||
List<String> _ids(Invocation inv) => (inv.positionalArguments.first as List).cast<String>();
|
||||
@@ -25,8 +25,6 @@ class _FrozenBucketService implements TimelineService {
|
||||
}
|
||||
|
||||
class _EmptyBucketService implements TimelineService {
|
||||
const _EmptyBucketService();
|
||||
|
||||
@override
|
||||
Stream<List<Bucket>> Function() get watchBuckets =>
|
||||
() => Stream.value(const []);
|
||||
@@ -111,7 +109,7 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWithValue(const _EmptyBucketService()),
|
||||
timelineServiceProvider.overrideWithValue(_EmptyBucketService()),
|
||||
appConfigProvider.overrideWithValue(const AppConfig()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/auth.repository.dart';
|
||||
import 'package:immich_mobile/repositories/auth_api.repository.dart';
|
||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
@@ -10,7 +10,7 @@ class MockAssetApiRepository extends Mock implements AssetApiRepository {}
|
||||
|
||||
class MockAssetMediaRepository extends Mock implements AssetMediaRepository {}
|
||||
|
||||
class MockPermissionRepository extends Mock implements PermissionRepository {}
|
||||
class MockPermissionRepository extends Mock implements IPermissionRepository {}
|
||||
|
||||
class MockAuthApiRepository extends Mock implements AuthApiRepository {}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
@@ -23,6 +24,7 @@ void main() {
|
||||
late MockDriftLocalAssetRepository localAssetRepository;
|
||||
late MockDriftAlbumApiRepository albumApiRepository;
|
||||
late MockRemoteAlbumRepository remoteAlbumRepository;
|
||||
late MockTrashedLocalAssetRepository trashedLocalAssetRepository;
|
||||
late MockAssetMediaRepository assetMediaRepository;
|
||||
late MockDownloadRepository downloadRepository;
|
||||
late MockTagService tagService;
|
||||
@@ -49,6 +51,7 @@ void main() {
|
||||
localAssetRepository = MockDriftLocalAssetRepository();
|
||||
albumApiRepository = MockDriftAlbumApiRepository();
|
||||
remoteAlbumRepository = MockRemoteAlbumRepository();
|
||||
trashedLocalAssetRepository = MockTrashedLocalAssetRepository();
|
||||
assetMediaRepository = MockAssetMediaRepository();
|
||||
downloadRepository = MockDownloadRepository();
|
||||
tagService = MockTagService();
|
||||
@@ -59,6 +62,7 @@ void main() {
|
||||
localAssetRepository,
|
||||
albumApiRepository,
|
||||
remoteAlbumRepository,
|
||||
trashedLocalAssetRepository,
|
||||
assetMediaRepository,
|
||||
downloadRepository,
|
||||
tagService,
|
||||
@@ -139,9 +143,25 @@ void main() {
|
||||
});
|
||||
|
||||
group('ActionService.deleteLocal', () {
|
||||
test('removes deleted ids from the local database', () async {
|
||||
test('routes deleted ids to trashed repository when Android trash handling is enabled', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
const ids = ['a', 'b'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
|
||||
when(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).thenAnswer((_) async {});
|
||||
|
||||
final result = await sut.deleteLocal(ids);
|
||||
|
||||
expect(result, ids.length);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verify(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).called(1);
|
||||
verifyNever(() => localAssetRepository.delete(any()));
|
||||
});
|
||||
|
||||
test('deletes locally when Android trash handling is disabled', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
const ids = ['c'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
|
||||
when(() => localAssetRepository.delete(ids)).thenAnswer((_) async {});
|
||||
|
||||
@@ -150,9 +170,11 @@ void main() {
|
||||
expect(result, ids.length);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verify(() => localAssetRepository.delete(ids)).called(1);
|
||||
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
|
||||
});
|
||||
|
||||
test('short-circuits when nothing was deleted', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
const ids = ['x'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => <String>[]);
|
||||
@@ -161,6 +183,7 @@ void main() {
|
||||
|
||||
expect(result, 0);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
|
||||
verifyNever(() => localAssetRepository.delete(any()));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,8 @@ import 'factories/user_factory.dart';
|
||||
class RepositoryMocks {
|
||||
final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository());
|
||||
final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository());
|
||||
final trashedAsset = MockTrashedLocalAssetRepository();
|
||||
|
||||
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
|
||||
|
||||
RepositoryMocks() {
|
||||
@@ -27,6 +29,7 @@ class RepositoryMocks {
|
||||
_registerFallbacks();
|
||||
localAlbum.reset();
|
||||
localAsset.reset();
|
||||
reset(trashedAsset);
|
||||
nativeApi.reset();
|
||||
_stubLocalAlbumRepository();
|
||||
_stubLocalAssetRepository();
|
||||
|
||||
@@ -17,6 +17,7 @@ void main() {
|
||||
localAlbumRepository: mocks.localAlbum.repo,
|
||||
localAssetRepository: mocks.localAsset.repo,
|
||||
nativeSyncApi: mocks.nativeApi.api,
|
||||
trashedLocalAssetRepository: mocks.trashedAsset,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -105,6 +106,7 @@ void main() {
|
||||
localAssetRepository: mocks.localAsset.repo,
|
||||
nativeSyncApi: mocks.nativeApi.api,
|
||||
batchSize: batchSize,
|
||||
trashedLocalAssetRepository: mocks.trashedAsset,
|
||||
);
|
||||
|
||||
final album = LocalAlbumFactory.create();
|
||||
|
||||
@@ -308,6 +308,76 @@
|
||||
},
|
||||
"uiHints": ["Filter"]
|
||||
},
|
||||
{
|
||||
"name": "assetExifFilter",
|
||||
"title": "Filter by EXIF metadata",
|
||||
"description": "Filter assets by their EXIF properties",
|
||||
"types": ["AssetV1"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"property": {
|
||||
"title": "Property",
|
||||
"description": "EXIF property to match",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"make",
|
||||
"model",
|
||||
"exifImageWidth",
|
||||
"exifImageHeight",
|
||||
"fileSizeInByte",
|
||||
"orientation",
|
||||
"lensModel",
|
||||
"fNumber",
|
||||
"focalLength",
|
||||
"iso",
|
||||
"description",
|
||||
"fps",
|
||||
"exposureTime",
|
||||
"livePhotoCID",
|
||||
"timeZone",
|
||||
"projectionType",
|
||||
"profileDescription",
|
||||
"colorspace",
|
||||
"bitsPerSample",
|
||||
"rating"
|
||||
],
|
||||
"uiHint": {
|
||||
"order": 1
|
||||
}
|
||||
},
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"title": "Pattern",
|
||||
"description": "Text or regex pattern to match against property value",
|
||||
"uiHint": {
|
||||
"order": 2
|
||||
}
|
||||
},
|
||||
"matchType": {
|
||||
"type": "string",
|
||||
"title": "Match type",
|
||||
"enum": ["contains", "startsWith", "exact", "regex"],
|
||||
"default": "contains",
|
||||
"description": "Type of pattern matching to perform",
|
||||
"uiHint": {
|
||||
"order": 3
|
||||
}
|
||||
},
|
||||
"caseSensitive": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"title": "Case sensitive",
|
||||
"description": "Whether matching should be case-sensitive",
|
||||
"uiHint": {
|
||||
"order": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["property", "pattern"]
|
||||
},
|
||||
"uiHints": ["Filter"]
|
||||
},
|
||||
{
|
||||
"name": "assetArchive",
|
||||
"title": "Archive asset",
|
||||
|
||||
@@ -2,6 +2,42 @@ import { wrapper } from '@immich/plugin-sdk';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import type { Manifest } from '../dist/index.d.ts';
|
||||
|
||||
type MatchValueConfig = {
|
||||
pattern: string;
|
||||
matchType?: 'contains' | 'exact' | 'regex' | 'startsWith';
|
||||
caseSensitive?: boolean;
|
||||
};
|
||||
|
||||
const matchValueResult = (value: string, config: MatchValueConfig) => {
|
||||
const { pattern, matchType = 'contains', caseSensitive = false } = config;
|
||||
const searchName = caseSensitive ? value : value.toLowerCase();
|
||||
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
|
||||
|
||||
switch (matchType) {
|
||||
case 'contains': {
|
||||
return { workflow: { continue: searchName.includes(searchPattern) } };
|
||||
}
|
||||
|
||||
case 'exact': {
|
||||
return { workflow: { continue: searchName === searchPattern } };
|
||||
}
|
||||
|
||||
case 'startsWith': {
|
||||
return { workflow: { continue: searchName.startsWith(searchPattern) } };
|
||||
}
|
||||
|
||||
case 'regex': {
|
||||
const flags = caseSensitive ? '' : 'i';
|
||||
const regex = new RegExp(searchPattern, flags);
|
||||
return { workflow: { continue: regex.test(value) } };
|
||||
}
|
||||
|
||||
default: {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const methods = wrapper<Manifest>({
|
||||
assetAddToAlbums: ({ config, data, functions }) => {
|
||||
const assetId = data.asset.id;
|
||||
@@ -53,39 +89,7 @@ const methods = wrapper<Manifest>({
|
||||
}
|
||||
},
|
||||
|
||||
assetFileFilter: ({ data, config }) => {
|
||||
const { pattern, matchType = 'contains', caseSensitive = false, usePath = false } = config;
|
||||
|
||||
const { asset } = data;
|
||||
|
||||
const fileName = usePath ? asset.originalPath : asset.originalFileName;
|
||||
const searchName = caseSensitive ? fileName : fileName.toLowerCase();
|
||||
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
|
||||
|
||||
switch (matchType) {
|
||||
case 'contains': {
|
||||
return { workflow: { continue: searchName.includes(searchPattern) } };
|
||||
}
|
||||
|
||||
case 'exact': {
|
||||
return { workflow: { continue: searchName === searchPattern } };
|
||||
}
|
||||
|
||||
case 'startsWith': {
|
||||
return { workflow: { continue: searchName.startsWith(searchPattern) } };
|
||||
}
|
||||
|
||||
case 'regex': {
|
||||
const flags = caseSensitive ? '' : 'i';
|
||||
const regex = new RegExp(searchPattern, flags);
|
||||
return { workflow: { continue: regex.test(fileName) } };
|
||||
}
|
||||
|
||||
default: {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
},
|
||||
assetFileFilter: ({ data, config }) => matchValueResult(data.asset.originalFileName || '', config),
|
||||
|
||||
assetLocationFilter: ({ config, data }) => {
|
||||
if (
|
||||
@@ -124,6 +128,14 @@ const methods = wrapper<Manifest>({
|
||||
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
|
||||
},
|
||||
|
||||
assetExifFilter: ({ config, data }) => {
|
||||
if (!data.asset.exifInfo || data.asset.exifInfo[config.property] === null) {
|
||||
return { workflow: { continue: false } };
|
||||
}
|
||||
|
||||
return matchValueResult(String(data.asset.exifInfo[config.property]), config);
|
||||
},
|
||||
|
||||
assetDateFilter: ({ config, data }) => {
|
||||
const assetDate = new Date(data.asset.localDateTime);
|
||||
let startDate = new Date(config.startDate.year, config.startDate.month - 1, config.startDate.day);
|
||||
@@ -200,6 +212,7 @@ const {
|
||||
assetFavorite,
|
||||
assetFileFilter,
|
||||
assetLocationFilter,
|
||||
assetExifFilter,
|
||||
assetDateFilter,
|
||||
assetLock,
|
||||
assetMissingTimeZoneFilter,
|
||||
@@ -217,6 +230,7 @@ export {
|
||||
assetFavorite,
|
||||
assetFileFilter,
|
||||
assetLocationFilter,
|
||||
assetExifFilter,
|
||||
assetDateFilter,
|
||||
assetLock,
|
||||
assetMissingTimeZoneFilter,
|
||||
|
||||
@@ -433,9 +433,10 @@ describe('core plugin', () => {
|
||||
describe('assetDateFilter', () => {
|
||||
it('should favorite assets created during the first 7 days of a specific year and month', async () => {
|
||||
const { user } = await ctx.newUser();
|
||||
const [{ asset: asset1 }, { asset: asset2 }] = await Promise.all([
|
||||
const [{ asset: asset1 }, { asset: asset2 }, { asset: asset3 }] = await Promise.all([
|
||||
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-01') }),
|
||||
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-07T23:59:59Z') }),
|
||||
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-08T00:00:00Z') }),
|
||||
]);
|
||||
|
||||
const workflow = await createWorkflow({
|
||||
@@ -461,6 +462,46 @@ describe('core plugin', () => {
|
||||
|
||||
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id });
|
||||
await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: true });
|
||||
|
||||
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset3.id });
|
||||
await expect(ctx.get(AssetRepository).getById(asset3.id)).resolves.toMatchObject({ isFavorite: false });
|
||||
});
|
||||
|
||||
it('should match recurring dates regardless of the year', async () => {
|
||||
const { user } = await ctx.newUser();
|
||||
const [{ asset: asset1 }, { asset: asset2 }, { asset: asset3 }] = await Promise.all([
|
||||
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2026-03-01') }),
|
||||
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('1998-12-21') }),
|
||||
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-08T00:00:00Z') }),
|
||||
]);
|
||||
await ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2010-06-15') });
|
||||
|
||||
const workflow = await createWorkflow({
|
||||
ownerId: user.id,
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
steps: [
|
||||
{
|
||||
method: 'immich-plugin-core#assetDateFilter',
|
||||
config: {
|
||||
startDate: { day: 12, month: 12, year: 2000 },
|
||||
endDate: { day: 30, month: 3, year: 2001 },
|
||||
recurring: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'immich-plugin-core#assetFavorite',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset1.id });
|
||||
await expect(ctx.get(AssetRepository).getById(asset1.id)).resolves.toMatchObject({ isFavorite: true });
|
||||
|
||||
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id });
|
||||
await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: true });
|
||||
|
||||
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset3.id });
|
||||
await expect(ctx.get(AssetRepository).getById(asset3.id)).resolves.toMatchObject({ isFavorite: false });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -55,34 +55,36 @@
|
||||
{/each}
|
||||
</div> -->
|
||||
|
||||
<div class="grid grid-flow-col grid-rows-7 gap-0.5">
|
||||
<div class="row-span-7 grid grid-rows-subgrid">
|
||||
{#if Info.getStartOfWeek({ locale: $locale }) === 7}
|
||||
<div></div>
|
||||
{/if}
|
||||
<div class="row-span-2 -mt-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[0]}</Text></div>
|
||||
<div class="row-span-2 -mt-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[1]}</Text></div>
|
||||
<div class="row-span-2 -mt-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[2]}</Text></div>
|
||||
{#if Info.getStartOfWeek({ locale: $locale }) === 1}
|
||||
<div class="-my-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[3]}</Text></div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="overflow-x-auto pb-1">
|
||||
<div class="grid grid-flow-col grid-rows-7 gap-0.5">
|
||||
<div class="row-span-7 grid grid-rows-subgrid">
|
||||
{#if Info.getStartOfWeek({ locale: $locale }) === 7}
|
||||
<div></div>
|
||||
{/if}
|
||||
<div class="row-span-2 -mt-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[0]}</Text></div>
|
||||
<div class="row-span-2 -mt-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[1]}</Text></div>
|
||||
<div class="row-span-2 -mt-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[2]}</Text></div>
|
||||
{#if Info.getStartOfWeek({ locale: $locale }) === 1}
|
||||
<div class="-my-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[3]}</Text></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#each data.series as day, idx (day.date)}
|
||||
{@const date = DateTime.fromISO(day.date, { zone: 'utc' }).toLocaleString(
|
||||
{ month: 'short', day: 'numeric' },
|
||||
{ locale: $locale },
|
||||
)}
|
||||
<div
|
||||
class="aspect-square size-full rounded-sm {itemColors(day.count)} row-start-(--heatmap-row-start)"
|
||||
style:--heatmap-row-start={idx === 0 ? padding + 1 : undefined}
|
||||
title={itemLabel({ date, count: day.count })}
|
||||
aria-label={itemLabel({ date, count: day.count })}
|
||||
></div>
|
||||
{/each}
|
||||
{#each data.series as day, idx (day.date)}
|
||||
{@const date = DateTime.fromISO(day.date, { zone: 'utc' }).toLocaleString(
|
||||
{ month: 'short', day: 'numeric' },
|
||||
{ locale: $locale },
|
||||
)}
|
||||
<div
|
||||
class="aspect-square size-full rounded-sm {itemColors(day.count)} row-start-(--heatmap-row-start)"
|
||||
style:--heatmap-row-start={idx === 0 ? padding + 1 : undefined}
|
||||
title={itemLabel({ date, count: day.count })}
|
||||
aria-label={itemLabel({ date, count: day.count })}
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{$t('less')}</span>
|
||||
<span class="size-3 rounded-sm bg-gray-200 dark:bg-gray-700"></span>
|
||||
<span class="size-3 rounded-sm bg-immich-primary/30"></span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AssetOrder, type AssetResponseDto } from '@immich/sdk';
|
||||
import { AssetOrder, AssetOrderBy, type AssetResponseDto } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
||||
import { getOrderingDate, plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
||||
import { TimelineManager } from '../timeline-manager.svelte';
|
||||
import type { TimelineMonth } from '../timeline-month.svelte';
|
||||
import type { AssetDescriptor, Direction, TimelineAsset } from '../types';
|
||||
@@ -125,7 +125,14 @@ export async function retrieveRange(timelineManager: TimelineManager, start: Ass
|
||||
return [];
|
||||
}
|
||||
const assetOrder: AssetOrder = timelineManager.getAssetOrder();
|
||||
if (plainDateTimeCompare(assetOrder === AssetOrder.Desc, startAsset.localDateTime, endAsset.localDateTime) < 0) {
|
||||
const orderBy: AssetOrderBy = timelineManager.getOrderBy();
|
||||
if (
|
||||
plainDateTimeCompare(
|
||||
assetOrder === AssetOrder.Desc,
|
||||
getOrderingDate(startAsset, orderBy),
|
||||
getOrderingDate(endAsset, orderBy),
|
||||
) < 0
|
||||
) {
|
||||
[startAsset, endAsset] = [endAsset, startAsset];
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
[startTimelineMonth, endTimelineMonth] = [endTimelineMonth, startTimelineMonth];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
import { AssetOrderBy, AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
import { tick } from 'svelte';
|
||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
@@ -808,4 +808,44 @@ describe('TimelineManager', () => {
|
||||
expect(b.showAssetOwners).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retrieveRange', () => {
|
||||
it('uses createdAt ordering in the Recently Added view (orderBy=CreatedAt)', async () => {
|
||||
// Simulate the "Recently Added" bug: two assets whose localDateTime order is
|
||||
// the reverse of their createdAt (upload) order. Before the fix, retrieveRange
|
||||
// compared localDateTime and selected the wrong range direction.
|
||||
const timelineManager = new TimelineManager();
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||
await timelineManager.updateOptions({ orderBy: AssetOrderBy.CreatedAt });
|
||||
|
||||
// assetA was taken recently (2024) but uploaded first (2025-01)
|
||||
const assetA = timelineAssetFactory.build({
|
||||
localDateTime: fromISODateTimeUTCToObject('2024-06-01T00:00:00.000Z'),
|
||||
createdAt: fromISODateTimeUTCToObject('2025-01-20T00:00:00.000Z'),
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2025-01-20T00:00:00.000Z'),
|
||||
});
|
||||
// assetB was taken long ago (2018) but uploaded second (2025-02)
|
||||
const assetB = timelineAssetFactory.build({
|
||||
localDateTime: fromISODateTimeUTCToObject('2018-03-15T00:00:00.000Z'),
|
||||
createdAt: fromISODateTimeUTCToObject('2025-02-10T00:00:00.000Z'),
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2025-02-10T00:00:00.000Z'),
|
||||
});
|
||||
// assetC is an unrelated asset that should not appear in the selection
|
||||
const assetC = timelineAssetFactory.build({
|
||||
localDateTime: fromISODateTimeUTCToObject('2022-01-01T00:00:00.000Z'),
|
||||
createdAt: fromISODateTimeUTCToObject('2025-03-01T00:00:00.000Z'),
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2025-03-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
timelineManager.upsertAssets([assetA, assetB, assetC]);
|
||||
|
||||
// Shift-click from assetB (uploaded most recently) to assetA — the range
|
||||
// between them in the "Recently Added" timeline should contain only those two.
|
||||
const range = await timelineManager.retrieveRange({ id: assetB.id }, { id: assetA.id });
|
||||
const ids = range.map((a) => a.id);
|
||||
expect(ids).toContain(assetA.id);
|
||||
expect(ids).toContain(assetB.id);
|
||||
expect(ids).not.toContain(assetC.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -625,6 +625,10 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
return this.#options.order ?? AssetOrder.Desc;
|
||||
}
|
||||
|
||||
getOrderBy() {
|
||||
return this.#options.orderBy ?? AssetOrderBy.TakenAt;
|
||||
}
|
||||
|
||||
protected postCreateSegments(): void {
|
||||
this.months.sort((a, b) => {
|
||||
return a.yearMonth.year === b.yearMonth.year
|
||||
|
||||
@@ -213,7 +213,7 @@
|
||||
</Stack>
|
||||
</AdminCard>
|
||||
|
||||
<div class="col-span-2 px-4 py-2">
|
||||
<div class="col-span-full px-4 py-2">
|
||||
<div class="flex gap-2 text-primary">
|
||||
<Icon icon={mdiCloudUploadOutline} size="1.5rem" />
|
||||
<CardTitle>{$t('uploads')}</CardTitle>
|
||||
|
||||
Reference in New Issue
Block a user