Compare commits

..

28 Commits

Author SHA1 Message Date
bo0tzz
ed7739b3ef fix: decrease datetime max ms since epoch limit 2026-07-23 17:09:43 +02:00
Adam Gastineau
a0a0aa3f3c fix(mobile): allow URL validation to pass when scheme is not provided (#30142)
* fix(mobile): allow URL validation to pass when scheme is not provided

* Do normalize and validate in the same step
2026-07-23 19:46:48 +05:30
upmcplanetracker
ceef4037f1 fix: hypertext link to example docker-compose.rootless.yml (#30155) 2026-07-23 15:16:44 +02:00
NOBOIKE
cbb565b7b7 fix(web): mirror asset viewer navigation icons in RTL (#30151) 2026-07-23 12:22:45 +02:00
okxint
3bd580e37d fix(web): restore correct back route when opening person asset via direct URL (#30129) 2026-07-23 09:06:59 +00:00
Timon
f24a128319 feat(server): new search schemas and query builders (#28686) 2026-07-22 22:33:23 +00:00
Santo Shakil
36dfc98527 fix(mobile): birthday picker date order follows locale (#29419)
* fix(mobile): birthday picker date order follows locale

* test(mobile): cover date order locales for the birthday picker
2026-07-23 02:32:17 +05:30
shenlong
9403e71d23 refactor: add asset update method (#29384)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-22 19:51:06 +00:00
shenlong
1f81eac8ab refactor: toast repository (#29386)
refactor: feedback repository

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-22 19:48:28 +00:00
Pavel Miniutka
564cda5088 chore(mobile): Adds Belarusian language option in settings on mobile (#29939)
chore(mobile): add missing Belarusian (be) language option in settings
2026-07-22 17:53:21 +00:00
Alex
a7f1d495c6 fix: wrong corner radius of recently added link (#30140) 2026-07-22 16:44:14 +00:00
Jason Rasmussen
b9f6c4aaf2 feat: password invalidate sessions (#30125) 2026-07-22 12:09:05 -04:00
shenlong
23778551f7 fix: locked view and asset view provider (#30136)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-22 09:51:33 -05:00
bo0tzz
457da17c5d fix: always set extension from provided file (#29839)
* fix: always set extension from provided file

The livephoto case can also happen with other files; for example, we
receive a DNG file as JPEG but then still call it .dng

Fixes #29805, partially addresses #28602

* chore: test cases for file extension naming
2026-07-22 18:29:54 +05:30
Jan Bader
12f741d911 chore(cli): remove -h shorthand for --skip-hash (#30119)
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-07-22 13:25:29 +02:00
Aditya Raj Singh
a0c7b81142 fix(server): dissolve stack when its non-primary assets are deleted (#29354) 2026-07-22 13:00:52 +02:00
Daniel Dietzler
20997823d8 chore: mise monorepo support is now stable (#30131) 2026-07-22 12:55:47 +02:00
okxint
8a875f5978 fix(web): use correct date field for shift-click range in Recently Added (#30071) 2026-07-22 11:53:45 +02:00
renovate[bot]
8435d219e4 chore(deps): update dependency opentofu to v1.12.4 (#28491) 2026-07-22 09:30:08 +00:00
Ben Beckford
8bbc252703 feat: exif metadata workflow filter (#29295) 2026-07-22 11:20:57 +02:00
renovate[bot]
2d3cf8ffaa chore(deps): update dependency terragrunt to v1.1.1 (#29399) 2026-07-22 11:16:09 +02:00
renovate[bot]
33cba2f9af chore(deps): update grafana/grafana docker tag to v12.4.5-ubuntu (#29670) 2026-07-22 10:59:29 +02:00
renovate[bot]
75053ebfba chore(deps): update terraform cloudflare to v4.52.8 (#29671) 2026-07-22 10:59:04 +02:00
Yaros
ba9ec7c80e fix: backup delay translation key parsing (#30112) 2026-07-22 10:58:38 +02:00
Yaros
99370b2ac4 fix: admin user details responsive layout (#30114) 2026-07-22 10:54:26 +02:00
Ben Beckford
0f5bf6071f chore(server): test assetDateFilter workflow method (#30110) 2026-07-22 10:48:31 +02:00
Ijas
998fd5f6a8 fix(mobile): send date-only value for memories query param (#30049)
* fix(ios-widget): send date-only value for memories query param

Server v3.0.3 (immich-app#29907) validates the /memories `for` query
param with a date-only schema (YYYY-MM-DD). The iOS widget still sent a
full ISO 8601 datetime, so every request 400s and the widget shows
"Unable to connect to your Immich instance" on any server >= v3.0.3.

Build the value from the local calendar day, matching what the web
client (memory-manager.svelte.ts) and Android already send, instead of
a UTC datetime.

Also fix a related bug in the timeline provider: fetchMemory was
called with a bare try inside a Task, so a failure silently dropped
the error and never called completion, leaving the widget in a stale
state instead of showing an error card. Wrap it in a guard/try?, same
pattern already used in getSnapshot.

Fixes immich-app#30044

Signed-off-by: ajuijas <189517297+ajuijas@users.noreply.github.com>

* address review: drop unrelated widget change and comment

Signed-off-by: ajuijas <189517297+ajuijas@users.noreply.github.com>

---------

Signed-off-by: ajuijas <189517297+ajuijas@users.noreply.github.com>
Co-authored-by: ajuijas <189517297+ajuijas@users.noreply.github.com>
Co-authored-by: Santo Shakil <santoshakil@users.noreply.github.com>
2026-07-22 14:19:19 +06:00
renovate[bot]
f413c446e3 chore(deps): update github-actions (#30089) 2026-07-22 09:15:38 +02:00
143 changed files with 6464 additions and 4591 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
]
}

View File

@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.52.7"
version = "4.52.8"
}
}
}

View File

@@ -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",
]
}

View File

@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.52.7"
version = "4.52.8"
}
}
}

View File

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

View File

@@ -409,7 +409,7 @@ To decrease Redis logs, you can add the following line to the `redis:` section o
You can change the user in the container by setting the `user` argument in `docker-compose.yml` for each service.
[Example docker-compose.yml file](https://github.com/immich-app/immich/blob/main/docker/docker-compose.rootless.yml)
[Example docker-compose.rootless.yml file](https://github.com/immich-app/immich/blob/main/docker/docker-compose.rootless.yml)
You may need to add mount points or docker volumes for the following internal container paths:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -35,6 +35,7 @@ Found Admin:
- Email=admin@example.com
- Name=Immich Admin
? Please choose a new password (optional) immich-is-cool
? Invalidate existing sessions? Yes
The admin password has been updated.
```

View File

@@ -650,7 +650,7 @@ describe(`immich upload`, () => {
]);
expect(stdout).toBe('');
expect(stderr).toEqual(`error: option '-n, --dry-run' cannot be used with option '-h, --skip-hash'`);
expect(stderr).toEqual(`error: option '-n, --dry-run' cannot be used with option '--skip-hash'`);
expect(exitCode).not.toBe(0);
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });

View File

@@ -65,6 +65,10 @@ describe(`immich-admin`, () => {
child.stdout.on('data', (chunk) => {
data += chunk;
if (data.includes('Please choose a new password (optional)')) {
child.stdin.write('\n');
}
if (data.includes('Invalidate existing sessions?')) {
child.stdin.end('\n');
}
});

132
mise.lock
View File

@@ -94,6 +94,23 @@ url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz"
@@ -112,17 +129,12 @@ version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"]
checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
@@ -133,18 +145,6 @@ checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_win64-clang-gpl.zip"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409036094"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"]
checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889"
[[tools."github:webassembly/binaryen"]]
version = "version_124"
backend = "github:webassembly/binaryen"
@@ -248,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"

View File

@@ -1,4 +1,4 @@
experimental_monorepo_root = true
monorepo_root = true
[monorepo]
config_roots = [
@@ -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"
@@ -35,7 +35,6 @@ macos-x64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz" }
macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" }
[settings]
experimental = true
pin = true
lockfile = true

View File

@@ -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()
}
}
}

View File

@@ -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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()
}
}

View File

@@ -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() }
}
}

View File

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

View File

@@ -1069,20 +1069,6 @@
1
],
"type": "index",
"data": {
"on": 1,
"name": "idx_remote_asset_soft_deleted_checksum",
"sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_soft_deleted_checksum\nON remote_asset_entity (checksum)\nWHERE deleted_at IS NOT NULL\n",
"unique": false,
"columns": []
}
},
{
"id": 16,
"references": [
1
],
"type": "index",
"data": {
"on": 1,
"name": "idx_remote_asset_stack_id",
@@ -1092,7 +1078,7 @@
}
},
{
"id": 17,
"id": 16,
"references": [
1
],
@@ -1106,7 +1092,7 @@
}
},
{
"id": 18,
"id": 17,
"references": [
1
],
@@ -1120,7 +1106,7 @@
}
},
{
"id": 19,
"id": 18,
"references": [],
"type": "table",
"data": {
@@ -1250,7 +1236,7 @@
}
},
{
"id": 20,
"id": 19,
"references": [
0
],
@@ -1325,7 +1311,7 @@
}
},
{
"id": 21,
"id": 20,
"references": [
0
],
@@ -1412,7 +1398,7 @@
}
},
{
"id": 22,
"id": 21,
"references": [
1
],
@@ -1668,7 +1654,7 @@
}
},
{
"id": 23,
"id": 22,
"references": [
1,
4
@@ -1742,7 +1728,7 @@
}
},
{
"id": 24,
"id": 23,
"references": [
4,
0
@@ -1830,7 +1816,7 @@
}
},
{
"id": 25,
"id": 24,
"references": [
1
],
@@ -1926,7 +1912,7 @@
}
},
{
"id": 26,
"id": 25,
"references": [
0
],
@@ -2090,10 +2076,10 @@
}
},
{
"id": 27,
"id": 26,
"references": [
1,
26
25
],
"type": "table",
"data": {
@@ -2164,7 +2150,7 @@
}
},
{
"id": 28,
"id": 27,
"references": [
0
],
@@ -2308,10 +2294,10 @@
}
},
{
"id": 29,
"id": 28,
"references": [
1,
28
27
],
"type": "table",
"data": {
@@ -2485,7 +2471,7 @@
}
},
{
"id": 30,
"id": 29,
"references": [],
"type": "table",
"data": {
@@ -2533,16 +2519,100 @@
}
},
{
"id": 31,
"id": 30,
"references": [],
"type": "table",
"data": {
"name": "trash_sync",
"name": "trashed_local_asset_entity",
"was_declared_in_moor": false,
"columns": [
{
"name": "asset_id",
"getter_name": "assetId",
"name": "name",
"getter_name": "name",
"moor_type": "string",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "type",
"getter_name": "type",
"moor_type": "int",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "const EnumIndexConverter<AssetType>(AssetType.values)",
"dart_type_name": "AssetType"
}
},
{
"name": "created_at",
"getter_name": "createdAt",
"moor_type": "dateTime",
"nullable": false,
"customConstraints": null,
"default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
"default_client_dart": null,
"dsl_features": []
},
{
"name": "updated_at",
"getter_name": "updatedAt",
"moor_type": "dateTime",
"nullable": false,
"customConstraints": null,
"default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
"default_client_dart": null,
"dsl_features": []
},
{
"name": "width",
"getter_name": "width",
"moor_type": "int",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "height",
"getter_name": "height",
"moor_type": "int",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "duration_ms",
"getter_name": "durationMs",
"moor_type": "int",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "id",
"getter_name": "id",
"moor_type": "string",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "album_id",
"getter_name": "albumId",
"moor_type": "string",
"nullable": false,
"customConstraints": null,
@@ -2554,15 +2624,53 @@
"name": "checksum",
"getter_name": "checksum",
"moor_type": "string",
"nullable": false,
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "status",
"getter_name": "status",
"name": "is_favorite",
"getter_name": "isFavorite",
"moor_type": "bool",
"nullable": false,
"customConstraints": null,
"defaultConstraints": "CHECK (\"is_favorite\" IN (0, 1))",
"dialectAwareDefaultConstraints": {
"sqlite": "CHECK (\"is_favorite\" IN (0, 1))"
},
"default_dart": "const CustomExpression('0')",
"default_client_dart": null,
"dsl_features": []
},
{
"name": "orientation",
"getter_name": "orientation",
"moor_type": "int",
"nullable": false,
"customConstraints": null,
"default_dart": "const CustomExpression('0')",
"default_client_dart": null,
"dsl_features": []
},
{
"name": "source",
"getter_name": "source",
"moor_type": "int",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "const EnumIndexConverter<TrashOrigin>(TrashOrigin.values)",
"dart_type_name": "TrashOrigin"
}
},
{
"name": "playback_style",
"getter_name": "playbackStyle",
"moor_type": "int",
"nullable": false,
"customConstraints": null,
@@ -2570,19 +2678,9 @@
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "const EnumIndexConverter<TrashSyncStatus>(TrashSyncStatus.values)",
"dart_type_name": "TrashSyncStatus"
"dart_expr": "const EnumIndexConverter<AssetPlaybackStyle>(AssetPlaybackStyle.values)",
"dart_type_name": "AssetPlaybackStyle"
}
},
{
"name": "asset_updated_at",
"getter_name": "assetUpdatedAt",
"moor_type": "dateTime",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
}
],
"is_virtual": false,
@@ -2590,40 +2688,13 @@
"constraints": [],
"strict": true,
"explicit_pk": [
"asset_id"
"id",
"album_id"
]
}
},
{
"id": 32,
"references": [],
"type": "table",
"data": {
"name": "server_deleted_checksum",
"was_declared_in_moor": false,
"columns": [
{
"name": "checksum",
"getter_name": "checksum",
"moor_type": "string",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
}
],
"is_virtual": false,
"without_rowid": true,
"constraints": [],
"strict": true,
"explicit_pk": [
"checksum"
]
}
},
{
"id": 33,
"id": 31,
"references": [
1
],
@@ -2717,7 +2788,7 @@
}
},
{
"id": 34,
"id": 32,
"references": [],
"type": "table",
"data": {
@@ -2765,7 +2836,7 @@
}
},
{
"id": 35,
"id": 33,
"references": [
1
],
@@ -2944,6 +3015,34 @@
]
}
},
{
"id": 34,
"references": [
20
],
"type": "index",
"data": {
"on": 20,
"name": "idx_partner_shared_with_id",
"sql": "CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)",
"unique": false,
"columns": []
}
},
{
"id": 35,
"references": [
21
],
"type": "index",
"data": {
"on": 21,
"name": "idx_lat_lng",
"sql": "CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)",
"unique": false,
"columns": []
}
},
{
"id": 36,
"references": [
@@ -2952,8 +3051,8 @@
"type": "index",
"data": {
"on": 21,
"name": "idx_partner_shared_with_id",
"sql": "CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)",
"name": "idx_remote_exif_city",
"sql": "CREATE INDEX IF NOT EXISTS idx_remote_exif_city\nON remote_exif_entity (city) WHERE city IS NOT NULL\n",
"unique": false,
"columns": []
}
@@ -2966,34 +3065,6 @@
"type": "index",
"data": {
"on": 22,
"name": "idx_lat_lng",
"sql": "CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)",
"unique": false,
"columns": []
}
},
{
"id": 38,
"references": [
22
],
"type": "index",
"data": {
"on": 22,
"name": "idx_remote_exif_city",
"sql": "CREATE INDEX IF NOT EXISTS idx_remote_exif_city\nON remote_exif_entity (city) WHERE city IS NOT NULL\n",
"unique": false,
"columns": []
}
},
{
"id": 39,
"references": [
23
],
"type": "index",
"data": {
"on": 23,
"name": "idx_remote_album_asset_album_asset",
"sql": "CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)",
"unique": false,
@@ -3001,19 +3072,47 @@
}
},
{
"id": 40,
"id": 38,
"references": [
25
24
],
"type": "index",
"data": {
"on": 25,
"on": 24,
"name": "idx_remote_asset_cloud_id",
"sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)",
"unique": false,
"columns": []
}
},
{
"id": 39,
"references": [
27
],
"type": "index",
"data": {
"on": 27,
"name": "idx_person_owner_id",
"sql": "CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)",
"unique": false,
"columns": []
}
},
{
"id": 40,
"references": [
28
],
"type": "index",
"data": {
"on": 28,
"name": "idx_asset_face_person_id",
"sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)",
"unique": false,
"columns": []
}
},
{
"id": 41,
"references": [
@@ -3022,34 +3121,6 @@
"type": "index",
"data": {
"on": 28,
"name": "idx_person_owner_id",
"sql": "CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)",
"unique": false,
"columns": []
}
},
{
"id": 42,
"references": [
29
],
"type": "index",
"data": {
"on": 29,
"name": "idx_asset_face_person_id",
"sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)",
"unique": false,
"columns": []
}
},
{
"id": 43,
"references": [
29
],
"type": "index",
"data": {
"on": 29,
"name": "idx_asset_face_asset_id",
"sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)",
"unique": false,
@@ -3057,19 +3128,47 @@
}
},
{
"id": 44,
"id": 42,
"references": [
29
28
],
"type": "index",
"data": {
"on": 29,
"on": 28,
"name": "idx_asset_face_visible_person",
"sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person\nON asset_face_entity (person_id, asset_id)\nWHERE is_visible = 1 AND deleted_at IS NULL\n",
"unique": false,
"columns": []
}
},
{
"id": 43,
"references": [
30
],
"type": "index",
"data": {
"on": 30,
"name": "idx_trashed_local_asset_checksum",
"sql": "CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)",
"unique": false,
"columns": []
}
},
{
"id": 44,
"references": [
30
],
"type": "index",
"data": {
"on": 30,
"name": "idx_trashed_local_asset_album",
"sql": "CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)",
"unique": false,
"columns": []
}
},
{
"id": 45,
"references": [
@@ -3078,8 +3177,8 @@
"type": "index",
"data": {
"on": 31,
"name": "idx_trash_sync_checksum",
"sql": "CREATE INDEX IF NOT EXISTS idx_trash_sync_checksum ON trash_sync (checksum)",
"name": "idx_asset_edit_asset_id",
"sql": "CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)",
"unique": false,
"columns": []
}
@@ -3092,20 +3191,6 @@
"type": "index",
"data": {
"on": 33,
"name": "idx_asset_edit_asset_id",
"sql": "CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)",
"unique": false,
"columns": []
}
},
{
"id": 47,
"references": [
35
],
"type": "index",
"data": {
"on": 35,
"name": "idx_asset_ocr_asset_id",
"sql": "CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)",
"unique": false,
@@ -3249,15 +3334,6 @@
}
]
},
{
"name": "idx_remote_asset_soft_deleted_checksum",
"sql": [
{
"dialect": "sqlite",
"sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_soft_deleted_checksum ON remote_asset_entity (checksum) WHERE deleted_at IS NOT NULL"
}
]
},
{
"name": "idx_remote_asset_stack_id",
"sql": [
@@ -3394,20 +3470,11 @@
]
},
{
"name": "trash_sync",
"name": "trashed_local_asset_entity",
"sql": [
{
"dialect": "sqlite",
"sql": "CREATE TABLE IF NOT EXISTS \"trash_sync\" (\"asset_id\" TEXT NOT NULL, \"checksum\" TEXT NOT NULL, \"status\" INTEGER NOT NULL DEFAULT 0, \"asset_updated_at\" TEXT NULL, PRIMARY KEY (\"asset_id\")) WITHOUT ROWID, STRICT;"
}
]
},
{
"name": "server_deleted_checksum",
"sql": [
{
"dialect": "sqlite",
"sql": "CREATE TABLE IF NOT EXISTS \"server_deleted_checksum\" (\"checksum\" TEXT NOT NULL, PRIMARY KEY (\"checksum\")) WITHOUT ROWID, STRICT;"
"sql": "CREATE TABLE IF NOT EXISTS \"trashed_local_asset_entity\" (\"name\" TEXT NOT NULL, \"type\" INTEGER NOT NULL, \"created_at\" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), \"updated_at\" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), \"width\" INTEGER NULL, \"height\" INTEGER NULL, \"duration_ms\" INTEGER NULL, \"id\" TEXT NOT NULL, \"album_id\" TEXT NOT NULL, \"checksum\" TEXT NULL, \"is_favorite\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_favorite\" IN (0, 1)), \"orientation\" INTEGER NOT NULL DEFAULT 0, \"source\" INTEGER NOT NULL, \"playback_style\" INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (\"id\", \"album_id\")) WITHOUT ROWID, STRICT;"
}
]
},
@@ -3520,11 +3587,20 @@
]
},
{
"name": "idx_trash_sync_checksum",
"name": "idx_trashed_local_asset_checksum",
"sql": [
{
"dialect": "sqlite",
"sql": "CREATE INDEX IF NOT EXISTS idx_trash_sync_checksum ON trash_sync (checksum)"
"sql": "CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)"
}
]
},
{
"name": "idx_trashed_local_asset_album",
"sql": [
{
"dialect": "sqlite",
"sql": "CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)"
}
]
},

View File

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

View File

@@ -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()
}
}

View File

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

View File

@@ -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([]))
}
}

View File

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

View File

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

View File

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

View File

@@ -9,8 +9,6 @@ enum SortOrder {
enum TextSearchType { context, filename, description, ocr }
enum AssetVisibilityEnum { timeline, hidden, archive, locked }
enum ActionSource { timeline, viewer }
enum ShareAssetType { original, preview }
@@ -26,17 +24,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,
}

View File

@@ -6,6 +6,7 @@ const Map<String, Locale> locales = {
// Additional locales
'Arabic (ar)': Locale('ar'),
'Basque (eu)': Locale('eu'),
'Belarusian (be)': Locale('be'),
'Bosnian (bl)': Locale('bn'),
'Brazilian Portuguese (pt_BR)': Locale('pt', 'BR'),
'Bulgarian (bg)': Locale('bg'),

View File

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

View File

@@ -36,9 +36,6 @@ enum SettingsKey<T> {
albumIsReverse<bool>(),
albumIsGrid<bool>(),
// Sync
trashSyncEnabled<bool>(),
// Backup
backupEnabled<bool>(),
backupUseCellularForVideos<bool>(),

View File

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

View File

@@ -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);
}
}
}

View File

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

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

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

View File

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

View File

@@ -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)',

View File

@@ -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";
}

View File

@@ -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();
}
}

View File

@@ -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";
}

View File

@@ -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();
}
}

View File

@@ -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,
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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)]);

View File

@@ -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,
@@ -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');
@@ -316,11 +319,24 @@ class Drift extends $Drift {
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');
const minDateTime = '0001-01-01T00:00:00.000Z';
const maxDateTime = '9999-12-31T00:00:00.000Z';
const timestampColumns = {
'local_asset_entity': ['created_at', 'updated_at'],
'local_album_entity': ['updated_at'],
'trashed_local_asset_entity': ['created_at', 'updated_at'],
};
for (final entry in timestampColumns.entries) {
final table = entry.key;
for (final column in entry.value) {
await customStatement("UPDATE $table SET $column = '$maxDateTime' WHERE $column LIKE '+%'");
await customStatement("UPDATE $table SET $column = '$minDateTime' WHERE $column LIKE '-%'");
}
}
await customStatement(
"UPDATE local_asset_entity SET adjustment_time = NULL WHERE adjustment_time LIKE '+%' OR adjustment_time LIKE '-%'",
);
},
),
),
@@ -353,8 +369,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

View File

@@ -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);
}

View File

@@ -16525,7 +16525,6 @@ final class Schema32 extends i0.VersionedSchema {
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetSoftDeletedChecksum,
idxRemoteAssetStackId,
idxRemoteAssetOwnerVisibilityDeletedCreated,
idxRemoteAssetUploaded,
@@ -16541,8 +16540,7 @@ final class Schema32 extends i0.VersionedSchema {
personEntity,
assetFaceEntity,
storeEntity,
trashSync,
serverDeletedChecksum,
trashedLocalAssetEntity,
assetEditEntity,
settings,
assetOcrEntity,
@@ -16555,7 +16553,8 @@ final class Schema32 extends i0.VersionedSchema {
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxAssetFaceVisiblePerson,
idxTrashSyncChecksum,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
idxAssetOcrAssetId,
];
@@ -16736,10 +16735,6 @@ final class Schema32 extends i0.VersionedSchema {
'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)',
@@ -16962,24 +16957,28 @@ final class Schema32 extends i0.VersionedSchema {
),
alias: null,
);
late final Shape52 trashSync = Shape52(
late final Shape47 trashedLocalAssetEntity = Shape47(
source: i0.VersionedTable(
entityName: 'trash_sync',
entityName: 'trashed_local_asset_entity',
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],
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_205,
_column_131,
_column_120,
_column_132,
_column_206,
_column_137,
],
attachedDatabase: database,
),
alias: null,
@@ -17074,9 +17073,13 @@ final class Schema32 extends i0.VersionedSchema {
'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 idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
@@ -17088,50 +17091,6 @@ final class Schema32 extends i0.VersionedSchema {
);
}
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,

View File

@@ -10,6 +10,7 @@ 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/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class RemoteAssetRepository extends DriftDatabaseRepository {
@@ -292,4 +293,20 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
..orderBy([(row) => OrderingTerm.asc(row.sequence)]);
return query.map((row) => row.toDto()!).get();
}
Future<void> update(
List<String> remoteIds, {
Option<bool> isFavorite = const .none(),
Option<AssetVisibility> visibility = const .none(),
}) {
final companion = RemoteAssetEntityCompanion(
visibility: visibility.toDriftValue(),
isFavorite: isFavorite.toDriftValue(),
);
return _db.batch((batch) {
for (final remoteId in remoteIds) {
batch.update(_db.remoteAssetEntity, companion, where: (e) => e.id.equals(remoteId));
}
});
}
}

View File

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

View File

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

View File

@@ -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();
}
}

View File

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

View File

@@ -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();
}

View File

@@ -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>();
}
}

View File

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

View File

@@ -886,7 +886,6 @@ class _QuickLinkList extends StatelessWidget {
_QuickLink(
title: context.t.recently_added,
icon: Icons.upload_outlined,
isTop: true,
onTap: () => context.pushRoute(const DriftRecentlyAddedRoute()),
),
_QuickLink(

View File

@@ -65,6 +65,7 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
child: ScrollDatePicker(
viewType: datePickerColumnOrder(DateFormat.yMd(context.locale.toLanguageTag()).pattern),
options: DatePickerOptions(
backgroundColor: context.colorScheme.surfaceContainerHigh,
itemExtent: 50,
@@ -118,3 +119,18 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
);
}
}
List<DatePickerViewType>? datePickerColumnOrder(String? pattern) {
if (pattern == null) {
return null;
}
final positions = {
DatePickerViewType.year: pattern.indexOf('y'),
DatePickerViewType.month: pattern.indexOf('M'),
DatePickerViewType.day: pattern.indexOf('d'),
};
if (positions.values.any((position) => position < 0)) {
return null;
}
return positions.keys.toList()..sort((a, b) => positions[a]!.compareTo(positions[b]!));
}

View File

@@ -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");
}

View File

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

View File

@@ -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());

View File

@@ -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),
),
);

View File

@@ -0,0 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/repositories/toast.repository.dart';
final toastRepositoryProvider = Provider<ToastRepository>((ref) => const .new());

View File

@@ -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]));
});

View File

@@ -1,8 +1,19 @@
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/routing/router.dart';
@visibleForTesting
bool isRouteInStack(Ref ref, String routeName) {
final router = ref.watch(appRouterProvider);
void onChange() => ref.invalidateSelf();
router.addListener(onChange);
ref.onDispose(() => router.removeListener(onChange));
return router.stackData.any((route) => route.name == routeName);
}
final inLockedViewProvider = Provider<bool>((ref) => isRouteInStack(ref, DriftLockedFolderRoute.name));
final isAssetViewerOpenProvider = Provider<bool>((ref) => isRouteInStack(ref, AssetViewerRoute.name));
final inLockedViewProvider = StateProvider<bool>((ref) => false);
final isAssetViewerOpenProvider = StateProvider<bool>((ref) => false);
final currentRouteNameProvider = StateProvider<String?>((ref) => null);
final previousRouteNameProvider = StateProvider<String?>((ref) => null);
final previousRouteDataProvider = StateProvider<RouteSettings?>((ref) => null);

View File

@@ -1,12 +1,14 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.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' hide AssetEditAction;
import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:openapi/api.dart';
import 'package:openapi/api.dart' as api show AssetVisibility;
import 'package:openapi/api.dart' hide AssetVisibility;
final assetApiRepositoryProvider = Provider(
(ref) => AssetApiRepository(
@@ -41,7 +43,7 @@ class AssetApiRepository extends ApiRepository {
return response?.count ?? 0;
}
Future<void> updateVisibility(List<String> ids, AssetVisibilityEnum visibility) async {
Future<void> updateVisibility(List<String> ids, AssetVisibility visibility) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility))));
}
@@ -77,11 +79,11 @@ class AssetApiRepository extends ApiRepository {
return _api.downloadAssetWithHttpInfo(id, edited: edited);
}
_mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) {
AssetVisibilityEnum.timeline => AssetVisibility.timeline,
AssetVisibilityEnum.hidden => AssetVisibility.hidden,
AssetVisibilityEnum.locked => AssetVisibility.locked,
AssetVisibilityEnum.archive => AssetVisibility.archive,
api.AssetVisibility _mapVisibility(AssetVisibility visibility) => switch (visibility) {
AssetVisibility.timeline => api.AssetVisibility.timeline,
AssetVisibility.hidden => api.AssetVisibility.hidden,
AssetVisibility.locked => api.AssetVisibility.locked,
AssetVisibility.archive => api.AssetVisibility.archive,
};
Future<String?> getAssetMIMEType(String assetId) async {
@@ -106,6 +108,20 @@ class AssetApiRepository extends ApiRepository {
Future<void> removeEdits(String assetId) async {
return _api.removeAssetEdits(assetId);
}
Future<void> update(
List<String> remoteIds, {
Option<bool> isFavorite = const .none(),
Option<AssetVisibility> visibility = const .none(),
}) {
return _api.updateAssets(
AssetBulkUpdateDto(
ids: remoteIds,
isFavorite: isFavorite.toOptional(),
visibility: visibility.map(_mapVisibility).toOptional(),
),
);
}
}
extension on StackResponseDto {

View File

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

View File

@@ -0,0 +1,26 @@
import 'dart:async';
import 'package:immich_ui/immich_ui.dart';
class ToastOption {
final Duration? timeout;
final FutureOr<void> Function()? onUndo;
const ToastOption({this.timeout, this.onUndo});
}
class ToastRepository {
const ToastRepository();
FutureOr<void> success(String message, {ToastOption? toast}) {
snackbar.success(message, duration: toast?.timeout);
}
FutureOr<void> info(String message, {ToastOption? toast}) {
snackbar.info(message, duration: toast?.timeout);
}
FutureOr<void> error(String message, {ToastOption? toast}) {
snackbar.error(message, duration: toast?.timeout);
}
}

View File

@@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class AppNavigationObserver extends AutoRouterObserver {
/// Riverpod Instance
@@ -12,46 +11,12 @@ class AppNavigationObserver extends AutoRouterObserver {
AppNavigationObserver({required this.ref});
@override
Future<void> didChangeTabRoute(TabPageRoute route, TabPageRoute previousRoute) async {
unawaited(Future(() => ref.read(inLockedViewProvider.notifier).state = false));
}
@override
void didPush(Route route, Route? previousRoute) {
_handleDriftLockedFolderState(route, previousRoute);
Future(() {
ref.read(currentRouteNameProvider.notifier).state = route.settings.name;
ref.read(previousRouteNameProvider.notifier).state = previousRoute?.settings.name;
ref.read(previousRouteDataProvider.notifier).state = previousRoute?.settings;
if (route.settings.name == AssetViewerRoute.name) {
ref.read(isAssetViewerOpenProvider.notifier).state = true;
}
});
}
@override
void didPop(Route route, Route? previousRoute) {
_handleDriftLockedFolderState(previousRoute ?? route, null);
if (route.settings.name == AssetViewerRoute.name) {
Future(() => ref.read(isAssetViewerOpenProvider.notifier).state = false);
}
}
_handleDriftLockedFolderState(Route route, Route? previousRoute) {
final isInLockedView = ref.read(inLockedViewProvider);
final isFromLockedViewToDetailView =
route.settings.name == AssetViewerRoute.name && previousRoute?.settings.name == DriftLockedFolderRoute.name;
final isFromDetailViewToInfoPanelView =
route.settings.name == null && previousRoute?.settings.name == AssetViewerRoute.name && isInLockedView;
if (route.settings.name == DriftLockedFolderRoute.name ||
isFromLockedViewToDetailView ||
isFromDetailViewToInfoPanelView) {
Future(() => ref.read(inLockedViewProvider.notifier).state = true);
} else {
Future(() => ref.read(inLockedViewProvider.notifier).state = false);
}
}
}

View File

@@ -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,
@@ -72,17 +79,17 @@ class ActionService {
}
Future<void> archive(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.archive);
await _assetApiRepository.updateVisibility(remoteIds, .archive);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.archive);
}
Future<void> unArchive(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
await _assetApiRepository.updateVisibility(remoteIds, .timeline);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
}
Future<void> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked);
await _assetApiRepository.updateVisibility(remoteIds, .locked);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked);
// Ask user if they want to delete local copies
@@ -92,7 +99,7 @@ class ActionService {
}
Future<void> removeFromLockFolder(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
await _assetApiRepository.updateVisibility(remoteIds, .timeline);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
}
@@ -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;
}
}

View File

@@ -96,12 +96,12 @@ class ApiService {
/// port - optional (default: based on schema)
/// path - optional
Future<String> resolveEndpoint(String serverUrl) async {
String url = sanitizeUrl(serverUrl);
String url = normalizeServerUrl(serverUrl);
// Check for /.well-known/immich
final wellKnownEndpoint = await _getWellKnownEndpoint(url);
if (wellKnownEndpoint.isNotEmpty) {
url = sanitizeUrl(wellKnownEndpoint);
url = normalizeServerUrl(wellKnownEndpoint);
}
if (!await _isEndpointAvailable(url)) {

View File

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

View File

@@ -290,13 +290,10 @@ class BackgroundUploadService {
return null;
}
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
final hasExtension = p.extension(fileName).isNotEmpty;
if (!hasExtension) {
fileName = p.setExtension(fileName, p.extension(asset.name));
}
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
// Some apps (e.g. DJI/Fusion) return names without an extension; fall back to the asset name for those.
final extension = p.extension(file.path).isNotEmpty ? p.extension(file.path) : p.extension(asset.name);
final originalFileName = p.setExtension(fileName, extension);
String metadata = UploadTaskMetadata(
localAssetId: asset.id,

View File

@@ -309,17 +309,10 @@ class ForegroundUploadService {
return;
}
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
/// Handle special file name from DJI or Fusion app
/// If the file name has no extension, likely due to special renaming template by specific apps
/// we append the original extension from the asset name
final hasExtension = p.extension(fileName).isNotEmpty;
if (!hasExtension) {
fileName = p.setExtension(fileName, p.extension(asset.name));
}
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
// Some apps (e.g. DJI/Fusion) return names without an extension; fall back to the asset name for those.
final extension = p.extension(file.path).isNotEmpty ? p.extension(file.path) : p.extension(asset.name);
final originalFileName = p.setExtension(fileName, extension);
final deviceId = Store.get(StoreKey.deviceId);
final fields = {

View File

@@ -1,4 +1,4 @@
const int _maxMillisecondsSinceEpoch = 8640000000000000; // 275760-09-13
const int _maxMillisecondsSinceEpoch = 253402214400000; // 9999-12-31
const int _minMillisecondsSinceEpoch = -62135596800000; // 0001-01-01
DateTime? tryFromSecondsSinceEpoch(int? secondsSinceEpoch, {bool isUtc = false}) {

View File

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

View File

@@ -1,3 +1,4 @@
import 'package:drift/drift.dart';
import 'package:openapi/api.dart' show Optional;
sealed class Option<T> {
@@ -21,6 +22,11 @@ sealed class Option<T> {
None() => null,
};
Option<U> map<U>(U Function(T value) f) => switch (this) {
Some(:final value) => Some(f(value)),
None() => None<U>(),
};
U fold<U>(U Function(T value) onSome, U Function() onNone) => switch (this) {
Some(:final value) => onSome(value),
None() => onNone(),
@@ -65,3 +71,10 @@ extension OptionToOptional<T> on Option<T> {
Some(:final value) => Optional.present(value),
};
}
extension OptionToDriftValue<T> on Option<T> {
Value<T> toDriftValue() => switch (this) {
Some(:final value) => Value(value),
None() => const Value.absent(),
};
}

View File

@@ -2,12 +2,31 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:punycode/punycode.dart';
String sanitizeUrl(String url) {
/// Normalizes a server URL, guaranteeing that it has a schema and no trailing slashes
String normalizeServerUrl(String url) {
final trimmedUrl = url.trim();
// Add schema if none is set
final urlWithSchema = url.trimLeft().startsWith(RegExp(r"https?://")) ? url : "https://$url";
final urlWithSchema = trimmedUrl.contains('://') ? trimmedUrl : "https://$trimmedUrl";
// Remove trailing slash(es)
return urlWithSchema.trimRight().replaceFirst(RegExp(r"/+$"), "");
return urlWithSchema.replaceFirst(RegExp(r"/+$"), "");
}
/// Validates a user-entered server URL
bool _validateServerUrl(String url) {
final parsedUrl = Uri.tryParse(url);
return parsedUrl != null && parsedUrl.scheme.startsWith(RegExp(r'^https?$')) && parsedUrl.host.isNotEmpty;
}
/// Normalizes and validates that a server URL is supported
bool normalizeAndValidateServerUrl(String? url) {
if (url == null || url.isEmpty) {
return true;
}
final normalizedUrl = normalizeServerUrl(url);
return _validateServerUrl(normalizedUrl);
}
String? getServerUrl() {

View File

@@ -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';
@@ -44,18 +43,7 @@ class LoginForm extends HookConsumerWidget {
final log = Logger('LoginForm');
String? _validateUrl(String? url) {
if (url == null || url.isEmpty) {
return null;
}
final parsedUrl = Uri.tryParse(url);
if (parsedUrl == null || !parsedUrl.isAbsolute || !parsedUrl.scheme.startsWith("http") || parsedUrl.host.isEmpty) {
return 'login_form_err_invalid_url'.tr();
}
return null;
}
String? _validateUrl(String? url) => normalizeAndValidateServerUrl(url) ? null : 'login_form_err_invalid_url'.tr();
String? _validateEmail(String? email) {
if (email == null || email == '') {
@@ -102,7 +90,7 @@ class LoginForm extends HookConsumerWidget {
/// Fetch the server login credential and enables oAuth login if necessary
/// Returns true if successful, false otherwise
Future<void> getServerAuthSettings() async {
final sanitizeServerUrl = sanitizeUrl(serverEndpointController.text);
final sanitizeServerUrl = normalizeServerUrl(serverEndpointController.text);
final serverUrl = punycodeEncodeUrl(sanitizeServerUrl);
// Guard empty URL
@@ -180,71 +168,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 +239,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());
@@ -310,7 +293,7 @@ class LoginForm extends HookConsumerWidget {
try {
oAuthServerUrl = await oAuthService.getOAuthServerUrl(
sanitizeUrl(serverEndpointController.text),
normalizeServerUrl(serverEndpointController.text),
state,
codeChallenge,
);
@@ -345,7 +328,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()]));
@@ -440,7 +425,7 @@ class LoginForm extends HookConsumerWidget {
Padding(
padding: const EdgeInsets.only(bottom: ImmichSpacing.md),
child: Text(
sanitizeUrl(serverEndpointController.text),
normalizeServerUrl(serverEndpointController.text),
style: context.textTheme.displaySmall,
textAlign: TextAlign.center,
),

View File

@@ -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;
},
),
],

View File

@@ -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),
),
],
);

View File

@@ -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'),
);
},
),
],
],
);
},

View File

@@ -6,18 +6,23 @@ final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
class SnackbarManager {
const SnackbarManager();
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(String message, SnackbarType type) {
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(
String message,
SnackbarType type, {
Duration? duration,
}) {
final messenger = scaffoldMessengerKey.currentState;
final context = scaffoldMessengerKey.currentContext;
if (messenger == null || context == null) {
return null;
}
duration ??= const .new(seconds: 4);
messenger.hideCurrentSnackBar();
return messenger.showSnackBar(_build(context, message, type));
return messenger.showSnackBar(_build(context, message, type, duration));
}
SnackBar _build(BuildContext context, String message, SnackbarType type) {
SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration) {
final theme = Theme.of(context);
final colors = theme.extension<ImmichColors>() ?? ImmichColors.harmonized(theme.colorScheme);
final (IconData icon, Color background, Color foreground) = switch (type) {
@@ -29,7 +34,7 @@ class SnackbarManager {
return SnackBar(
behavior: .floating,
backgroundColor: background,
duration: const .new(seconds: 4),
duration: duration,
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))),
content: Row(
children: [
@@ -48,11 +53,14 @@ class SnackbarManager {
);
}
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message) => show(message, .info);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message, {Duration? duration}) =>
show(message, .info, duration: duration);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message) => show(message, .success);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message, {Duration? duration}) =>
show(message, .success, duration: duration);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message) => show(message, .error);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message, {Duration? duration}) =>
show(message, .error, duration: duration);
}
const snackbar = SnackbarManager();

View File

@@ -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);
}

View File

@@ -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);
}

View File

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

View File

@@ -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()));
});
});

View File

@@ -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, "[]");

View File

@@ -7891,13 +7891,80 @@ class StoreEntityCompanion extends UpdateCompanion<StoreEntityData> {
}
}
class TrashSync extends Table with TableInfo<TrashSync, TrashSyncData> {
class TrashedLocalAssetEntity extends Table
with TableInfo<TrashedLocalAssetEntity, TrashedLocalAssetEntityData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
TrashSync(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> assetId = GeneratedColumn<String>(
'asset_id',
TrashedLocalAssetEntity(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> name = GeneratedColumn<String>(
'name',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<int> type = GeneratedColumn<int>(
'type',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<String> createdAt = GeneratedColumn<String>(
'created_at',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: false,
$customConstraints: 'NOT NULL DEFAULT CURRENT_TIMESTAMP',
defaultValue: const CustomExpression('CURRENT_TIMESTAMP'),
);
late final GeneratedColumn<String> updatedAt = GeneratedColumn<String>(
'updated_at',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: false,
$customConstraints: 'NOT NULL DEFAULT CURRENT_TIMESTAMP',
defaultValue: const CustomExpression('CURRENT_TIMESTAMP'),
);
late final GeneratedColumn<int> width = GeneratedColumn<int>(
'width',
aliasedName,
true,
type: DriftSqlType.int,
requiredDuringInsert: false,
$customConstraints: 'NULL',
);
late final GeneratedColumn<int> height = GeneratedColumn<int>(
'height',
aliasedName,
true,
type: DriftSqlType.int,
requiredDuringInsert: false,
$customConstraints: 'NULL',
);
late final GeneratedColumn<int> durationMs = GeneratedColumn<int>(
'duration_ms',
aliasedName,
true,
type: DriftSqlType.int,
requiredDuringInsert: false,
$customConstraints: 'NULL',
);
late final GeneratedColumn<String> id = GeneratedColumn<String>(
'id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<String> albumId = GeneratedColumn<String>(
'album_id',
aliasedName,
false,
type: DriftSqlType.string,
@@ -7907,13 +7974,22 @@ class TrashSync extends Table with TableInfo<TrashSync, TrashSyncData> {
late final GeneratedColumn<String> checksum = GeneratedColumn<String>(
'checksum',
aliasedName,
false,
true,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
requiredDuringInsert: false,
$customConstraints: 'NULL',
);
late final GeneratedColumn<int> status = GeneratedColumn<int>(
'status',
late final GeneratedColumn<int> isFavorite = GeneratedColumn<int>(
'is_favorite',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: false,
$customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_favorite IN (0, 1))',
defaultValue: const CustomExpression('0'),
);
late final GeneratedColumn<int> orientation = GeneratedColumn<int>(
'orientation',
aliasedName,
false,
type: DriftSqlType.int,
@@ -7921,54 +7997,116 @@ class TrashSync extends Table with TableInfo<TrashSync, TrashSyncData> {
$customConstraints: 'NOT NULL DEFAULT 0',
defaultValue: const CustomExpression('0'),
);
late final GeneratedColumn<String> assetUpdatedAt = GeneratedColumn<String>(
'asset_updated_at',
late final GeneratedColumn<int> source = GeneratedColumn<int>(
'source',
aliasedName,
true,
type: DriftSqlType.string,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<int> playbackStyle = GeneratedColumn<int>(
'playback_style',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: false,
$customConstraints: 'NULL',
$customConstraints: 'NOT NULL DEFAULT 0',
defaultValue: const CustomExpression('0'),
);
@override
List<GeneratedColumn> get $columns => [
assetId,
name,
type,
createdAt,
updatedAt,
width,
height,
durationMs,
id,
albumId,
checksum,
status,
assetUpdatedAt,
isFavorite,
orientation,
source,
playbackStyle,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'trash_sync';
static const String $name = 'trashed_local_asset_entity';
@override
Set<GeneratedColumn> get $primaryKey => {assetId};
Set<GeneratedColumn> get $primaryKey => {id, albumId};
@override
TrashSyncData map(Map<String, dynamic> data, {String? tablePrefix}) {
TrashedLocalAssetEntityData map(
Map<String, dynamic> data, {
String? tablePrefix,
}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return TrashSyncData(
assetId: attachedDatabase.typeMapping.read(
return TrashedLocalAssetEntityData(
name: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}asset_id'],
data['${effectivePrefix}name'],
)!,
type: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}type'],
)!,
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}created_at'],
)!,
updatedAt: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}updated_at'],
)!,
width: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}width'],
),
height: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}height'],
),
durationMs: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}duration_ms'],
),
id: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}id'],
)!,
albumId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}album_id'],
)!,
checksum: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}checksum'],
)!,
status: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}status'],
)!,
assetUpdatedAt: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}asset_updated_at'],
),
isFavorite: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}is_favorite'],
)!,
orientation: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}orientation'],
)!,
source: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}source'],
)!,
playbackStyle: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}playback_style'],
)!,
);
}
@override
TrashSync createAlias(String alias) {
return TrashSync(attachedDatabase, alias);
TrashedLocalAssetEntity createAlias(String alias) {
return TrashedLocalAssetEntity(attachedDatabase, alias);
}
@override
@@ -7976,313 +8114,417 @@ class TrashSync extends Table with TableInfo<TrashSync, TrashSyncData> {
@override
bool get isStrict => true;
@override
List<String> get customConstraints => const ['PRIMARY KEY(asset_id)'];
List<String> get customConstraints => const ['PRIMARY KEY(id, album_id)'];
@override
bool get dontWriteConstraints => true;
}
class TrashSyncData extends DataClass implements Insertable<TrashSyncData> {
final String assetId;
final String checksum;
final int status;
final String? assetUpdatedAt;
const TrashSyncData({
required this.assetId,
required this.checksum,
required this.status,
this.assetUpdatedAt,
class TrashedLocalAssetEntityData extends DataClass
implements Insertable<TrashedLocalAssetEntityData> {
final String name;
final int type;
final String createdAt;
final String updatedAt;
final int? width;
final int? height;
final int? durationMs;
final String id;
final String albumId;
final String? checksum;
final int isFavorite;
final int orientation;
final int source;
final int playbackStyle;
const TrashedLocalAssetEntityData({
required this.name,
required this.type,
required this.createdAt,
required this.updatedAt,
this.width,
this.height,
this.durationMs,
required this.id,
required this.albumId,
this.checksum,
required this.isFavorite,
required this.orientation,
required this.source,
required this.playbackStyle,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['asset_id'] = Variable<String>(assetId);
map['checksum'] = Variable<String>(checksum);
map['status'] = Variable<int>(status);
if (!nullToAbsent || assetUpdatedAt != null) {
map['asset_updated_at'] = Variable<String>(assetUpdatedAt);
map['name'] = Variable<String>(name);
map['type'] = Variable<int>(type);
map['created_at'] = Variable<String>(createdAt);
map['updated_at'] = Variable<String>(updatedAt);
if (!nullToAbsent || width != null) {
map['width'] = Variable<int>(width);
}
if (!nullToAbsent || height != null) {
map['height'] = Variable<int>(height);
}
if (!nullToAbsent || durationMs != null) {
map['duration_ms'] = Variable<int>(durationMs);
}
map['id'] = Variable<String>(id);
map['album_id'] = Variable<String>(albumId);
if (!nullToAbsent || checksum != null) {
map['checksum'] = Variable<String>(checksum);
}
map['is_favorite'] = Variable<int>(isFavorite);
map['orientation'] = Variable<int>(orientation);
map['source'] = Variable<int>(source);
map['playback_style'] = Variable<int>(playbackStyle);
return map;
}
factory TrashSyncData.fromJson(
factory TrashedLocalAssetEntityData.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return TrashSyncData(
assetId: serializer.fromJson<String>(json['assetId']),
checksum: serializer.fromJson<String>(json['checksum']),
status: serializer.fromJson<int>(json['status']),
assetUpdatedAt: serializer.fromJson<String?>(json['assetUpdatedAt']),
return TrashedLocalAssetEntityData(
name: serializer.fromJson<String>(json['name']),
type: serializer.fromJson<int>(json['type']),
createdAt: serializer.fromJson<String>(json['createdAt']),
updatedAt: serializer.fromJson<String>(json['updatedAt']),
width: serializer.fromJson<int?>(json['width']),
height: serializer.fromJson<int?>(json['height']),
durationMs: serializer.fromJson<int?>(json['durationMs']),
id: serializer.fromJson<String>(json['id']),
albumId: serializer.fromJson<String>(json['albumId']),
checksum: serializer.fromJson<String?>(json['checksum']),
isFavorite: serializer.fromJson<int>(json['isFavorite']),
orientation: serializer.fromJson<int>(json['orientation']),
source: serializer.fromJson<int>(json['source']),
playbackStyle: serializer.fromJson<int>(json['playbackStyle']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'assetId': serializer.toJson<String>(assetId),
'checksum': serializer.toJson<String>(checksum),
'status': serializer.toJson<int>(status),
'assetUpdatedAt': serializer.toJson<String?>(assetUpdatedAt),
'name': serializer.toJson<String>(name),
'type': serializer.toJson<int>(type),
'createdAt': serializer.toJson<String>(createdAt),
'updatedAt': serializer.toJson<String>(updatedAt),
'width': serializer.toJson<int?>(width),
'height': serializer.toJson<int?>(height),
'durationMs': serializer.toJson<int?>(durationMs),
'id': serializer.toJson<String>(id),
'albumId': serializer.toJson<String>(albumId),
'checksum': serializer.toJson<String?>(checksum),
'isFavorite': serializer.toJson<int>(isFavorite),
'orientation': serializer.toJson<int>(orientation),
'source': serializer.toJson<int>(source),
'playbackStyle': serializer.toJson<int>(playbackStyle),
};
}
TrashSyncData copyWith({
String? assetId,
String? checksum,
int? status,
Value<String?> assetUpdatedAt = const Value.absent(),
}) => TrashSyncData(
assetId: assetId ?? this.assetId,
checksum: checksum ?? this.checksum,
status: status ?? this.status,
assetUpdatedAt: assetUpdatedAt.present
? assetUpdatedAt.value
: this.assetUpdatedAt,
TrashedLocalAssetEntityData copyWith({
String? name,
int? type,
String? createdAt,
String? updatedAt,
Value<int?> width = const Value.absent(),
Value<int?> height = const Value.absent(),
Value<int?> durationMs = const Value.absent(),
String? id,
String? albumId,
Value<String?> checksum = const Value.absent(),
int? isFavorite,
int? orientation,
int? source,
int? playbackStyle,
}) => TrashedLocalAssetEntityData(
name: name ?? this.name,
type: type ?? this.type,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
width: width.present ? width.value : this.width,
height: height.present ? height.value : this.height,
durationMs: durationMs.present ? durationMs.value : this.durationMs,
id: id ?? this.id,
albumId: albumId ?? this.albumId,
checksum: checksum.present ? checksum.value : this.checksum,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
source: source ?? this.source,
playbackStyle: playbackStyle ?? this.playbackStyle,
);
TrashSyncData copyWithCompanion(TrashSyncCompanion data) {
return TrashSyncData(
assetId: data.assetId.present ? data.assetId.value : this.assetId,
TrashedLocalAssetEntityData copyWithCompanion(
TrashedLocalAssetEntityCompanion data,
) {
return TrashedLocalAssetEntityData(
name: data.name.present ? data.name.value : this.name,
type: data.type.present ? data.type.value : this.type,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
width: data.width.present ? data.width.value : this.width,
height: data.height.present ? data.height.value : this.height,
durationMs: data.durationMs.present
? data.durationMs.value
: this.durationMs,
id: data.id.present ? data.id.value : this.id,
albumId: data.albumId.present ? data.albumId.value : this.albumId,
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,
isFavorite: data.isFavorite.present
? data.isFavorite.value
: this.isFavorite,
orientation: data.orientation.present
? data.orientation.value
: this.orientation,
source: data.source.present ? data.source.value : this.source,
playbackStyle: data.playbackStyle.present
? data.playbackStyle.value
: this.playbackStyle,
);
}
@override
String toString() {
return (StringBuffer('TrashSyncData(')
..write('assetId: $assetId, ')
return (StringBuffer('TrashedLocalAssetEntityData(')
..write('name: $name, ')
..write('type: $type, ')
..write('createdAt: $createdAt, ')
..write('updatedAt: $updatedAt, ')
..write('width: $width, ')
..write('height: $height, ')
..write('durationMs: $durationMs, ')
..write('id: $id, ')
..write('albumId: $albumId, ')
..write('checksum: $checksum, ')
..write('status: $status, ')
..write('assetUpdatedAt: $assetUpdatedAt')
..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation, ')
..write('source: $source, ')
..write('playbackStyle: $playbackStyle')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(assetId, checksum, status, assetUpdatedAt);
int get hashCode => Object.hash(
name,
type,
createdAt,
updatedAt,
width,
height,
durationMs,
id,
albumId,
checksum,
isFavorite,
orientation,
source,
playbackStyle,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is TrashSyncData &&
other.assetId == this.assetId &&
(other is TrashedLocalAssetEntityData &&
other.name == this.name &&
other.type == this.type &&
other.createdAt == this.createdAt &&
other.updatedAt == this.updatedAt &&
other.width == this.width &&
other.height == this.height &&
other.durationMs == this.durationMs &&
other.id == this.id &&
other.albumId == this.albumId &&
other.checksum == this.checksum &&
other.status == this.status &&
other.assetUpdatedAt == this.assetUpdatedAt);
other.isFavorite == this.isFavorite &&
other.orientation == this.orientation &&
other.source == this.source &&
other.playbackStyle == this.playbackStyle);
}
class TrashSyncCompanion extends UpdateCompanion<TrashSyncData> {
final Value<String> assetId;
final Value<String> checksum;
final Value<int> status;
final Value<String?> assetUpdatedAt;
const TrashSyncCompanion({
this.assetId = const Value.absent(),
class TrashedLocalAssetEntityCompanion
extends UpdateCompanion<TrashedLocalAssetEntityData> {
final Value<String> name;
final Value<int> type;
final Value<String> createdAt;
final Value<String> updatedAt;
final Value<int?> width;
final Value<int?> height;
final Value<int?> durationMs;
final Value<String> id;
final Value<String> albumId;
final Value<String?> checksum;
final Value<int> isFavorite;
final Value<int> orientation;
final Value<int> source;
final Value<int> playbackStyle;
const TrashedLocalAssetEntityCompanion({
this.name = const Value.absent(),
this.type = const Value.absent(),
this.createdAt = const Value.absent(),
this.updatedAt = const Value.absent(),
this.width = const Value.absent(),
this.height = const Value.absent(),
this.durationMs = const Value.absent(),
this.id = const Value.absent(),
this.albumId = const Value.absent(),
this.checksum = const Value.absent(),
this.status = const Value.absent(),
this.assetUpdatedAt = const Value.absent(),
this.isFavorite = const Value.absent(),
this.orientation = const Value.absent(),
this.source = const Value.absent(),
this.playbackStyle = const Value.absent(),
});
TrashSyncCompanion.insert({
required String assetId,
required String checksum,
this.status = const Value.absent(),
this.assetUpdatedAt = const Value.absent(),
}) : assetId = Value(assetId),
checksum = Value(checksum);
static Insertable<TrashSyncData> custom({
Expression<String>? assetId,
TrashedLocalAssetEntityCompanion.insert({
required String name,
required int type,
this.createdAt = const Value.absent(),
this.updatedAt = const Value.absent(),
this.width = const Value.absent(),
this.height = const Value.absent(),
this.durationMs = const Value.absent(),
required String id,
required String albumId,
this.checksum = const Value.absent(),
this.isFavorite = const Value.absent(),
this.orientation = const Value.absent(),
required int source,
this.playbackStyle = const Value.absent(),
}) : name = Value(name),
type = Value(type),
id = Value(id),
albumId = Value(albumId),
source = Value(source);
static Insertable<TrashedLocalAssetEntityData> custom({
Expression<String>? name,
Expression<int>? type,
Expression<String>? createdAt,
Expression<String>? updatedAt,
Expression<int>? width,
Expression<int>? height,
Expression<int>? durationMs,
Expression<String>? id,
Expression<String>? albumId,
Expression<String>? checksum,
Expression<int>? status,
Expression<String>? assetUpdatedAt,
Expression<int>? isFavorite,
Expression<int>? orientation,
Expression<int>? source,
Expression<int>? playbackStyle,
}) {
return RawValuesInsertable({
if (assetId != null) 'asset_id': assetId,
if (name != null) 'name': name,
if (type != null) 'type': type,
if (createdAt != null) 'created_at': createdAt,
if (updatedAt != null) 'updated_at': updatedAt,
if (width != null) 'width': width,
if (height != null) 'height': height,
if (durationMs != null) 'duration_ms': durationMs,
if (id != null) 'id': id,
if (albumId != null) 'album_id': albumId,
if (checksum != null) 'checksum': checksum,
if (status != null) 'status': status,
if (assetUpdatedAt != null) 'asset_updated_at': assetUpdatedAt,
if (isFavorite != null) 'is_favorite': isFavorite,
if (orientation != null) 'orientation': orientation,
if (source != null) 'source': source,
if (playbackStyle != null) 'playback_style': playbackStyle,
});
}
TrashSyncCompanion copyWith({
Value<String>? assetId,
Value<String>? checksum,
Value<int>? status,
Value<String?>? assetUpdatedAt,
TrashedLocalAssetEntityCompanion copyWith({
Value<String>? name,
Value<int>? type,
Value<String>? createdAt,
Value<String>? updatedAt,
Value<int?>? width,
Value<int?>? height,
Value<int?>? durationMs,
Value<String>? id,
Value<String>? albumId,
Value<String?>? checksum,
Value<int>? isFavorite,
Value<int>? orientation,
Value<int>? source,
Value<int>? playbackStyle,
}) {
return TrashSyncCompanion(
assetId: assetId ?? this.assetId,
return TrashedLocalAssetEntityCompanion(
name: name ?? this.name,
type: type ?? this.type,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
width: width ?? this.width,
height: height ?? this.height,
durationMs: durationMs ?? this.durationMs,
id: id ?? this.id,
albumId: albumId ?? this.albumId,
checksum: checksum ?? this.checksum,
status: status ?? this.status,
assetUpdatedAt: assetUpdatedAt ?? this.assetUpdatedAt,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
source: source ?? this.source,
playbackStyle: playbackStyle ?? this.playbackStyle,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (assetId.present) {
map['asset_id'] = Variable<String>(assetId.value);
if (name.present) {
map['name'] = Variable<String>(name.value);
}
if (type.present) {
map['type'] = Variable<int>(type.value);
}
if (createdAt.present) {
map['created_at'] = Variable<String>(createdAt.value);
}
if (updatedAt.present) {
map['updated_at'] = Variable<String>(updatedAt.value);
}
if (width.present) {
map['width'] = Variable<int>(width.value);
}
if (height.present) {
map['height'] = Variable<int>(height.value);
}
if (durationMs.present) {
map['duration_ms'] = Variable<int>(durationMs.value);
}
if (id.present) {
map['id'] = Variable<String>(id.value);
}
if (albumId.present) {
map['album_id'] = Variable<String>(albumId.value);
}
if (checksum.present) {
map['checksum'] = Variable<String>(checksum.value);
}
if (status.present) {
map['status'] = Variable<int>(status.value);
if (isFavorite.present) {
map['is_favorite'] = Variable<int>(isFavorite.value);
}
if (assetUpdatedAt.present) {
map['asset_updated_at'] = Variable<String>(assetUpdatedAt.value);
if (orientation.present) {
map['orientation'] = Variable<int>(orientation.value);
}
if (source.present) {
map['source'] = Variable<int>(source.value);
}
if (playbackStyle.present) {
map['playback_style'] = Variable<int>(playbackStyle.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('TrashSyncCompanion(')
..write('assetId: $assetId, ')
return (StringBuffer('TrashedLocalAssetEntityCompanion(')
..write('name: $name, ')
..write('type: $type, ')
..write('createdAt: $createdAt, ')
..write('updatedAt: $updatedAt, ')
..write('width: $width, ')
..write('height: $height, ')
..write('durationMs: $durationMs, ')
..write('id: $id, ')
..write('albumId: $albumId, ')
..write('checksum: $checksum, ')
..write('status: $status, ')
..write('assetUpdatedAt: $assetUpdatedAt')
..write(')'))
.toString();
}
}
class ServerDeletedChecksum extends Table
with TableInfo<ServerDeletedChecksum, ServerDeletedChecksumData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
ServerDeletedChecksum(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> checksum = GeneratedColumn<String>(
'checksum',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
@override
List<GeneratedColumn> get $columns => [checksum];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'server_deleted_checksum';
@override
Set<GeneratedColumn> get $primaryKey => {checksum};
@override
ServerDeletedChecksumData map(
Map<String, dynamic> data, {
String? tablePrefix,
}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return ServerDeletedChecksumData(
checksum: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}checksum'],
)!,
);
}
@override
ServerDeletedChecksum createAlias(String alias) {
return ServerDeletedChecksum(attachedDatabase, alias);
}
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
@override
List<String> get customConstraints => const ['PRIMARY KEY(checksum)'];
@override
bool get dontWriteConstraints => true;
}
class ServerDeletedChecksumData extends DataClass
implements Insertable<ServerDeletedChecksumData> {
final String checksum;
const ServerDeletedChecksumData({required this.checksum});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['checksum'] = Variable<String>(checksum);
return map;
}
factory ServerDeletedChecksumData.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return ServerDeletedChecksumData(
checksum: serializer.fromJson<String>(json['checksum']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{'checksum': serializer.toJson<String>(checksum)};
}
ServerDeletedChecksumData copyWith({String? checksum}) =>
ServerDeletedChecksumData(checksum: checksum ?? this.checksum);
ServerDeletedChecksumData copyWithCompanion(
ServerDeletedChecksumCompanion data,
) {
return ServerDeletedChecksumData(
checksum: data.checksum.present ? data.checksum.value : this.checksum,
);
}
@override
String toString() {
return (StringBuffer('ServerDeletedChecksumData(')
..write('checksum: $checksum')
..write(')'))
.toString();
}
@override
int get hashCode => checksum.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is ServerDeletedChecksumData && other.checksum == this.checksum);
}
class ServerDeletedChecksumCompanion
extends UpdateCompanion<ServerDeletedChecksumData> {
final Value<String> checksum;
const ServerDeletedChecksumCompanion({this.checksum = const Value.absent()});
ServerDeletedChecksumCompanion.insert({required String checksum})
: checksum = Value(checksum);
static Insertable<ServerDeletedChecksumData> custom({
Expression<String>? checksum,
}) {
return RawValuesInsertable({if (checksum != null) 'checksum': checksum});
}
ServerDeletedChecksumCompanion copyWith({Value<String>? checksum}) {
return ServerDeletedChecksumCompanion(checksum: checksum ?? this.checksum);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (checksum.present) {
map['checksum'] = Variable<String>(checksum.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('ServerDeletedChecksumCompanion(')
..write('checksum: $checksum')
..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation, ')
..write('source: $source, ')
..write('playbackStyle: $playbackStyle')
..write(')'))
.toString();
}
@@ -9467,10 +9709,6 @@ class DatabaseAtV32 extends GeneratedDatabase {
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
late final Index idxRemoteAssetSoftDeletedChecksum = 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',
);
late final Index idxRemoteAssetStackId = Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
@@ -9498,9 +9736,8 @@ class DatabaseAtV32 extends GeneratedDatabase {
late final PersonEntity personEntity = PersonEntity(this);
late final AssetFaceEntity assetFaceEntity = AssetFaceEntity(this);
late final StoreEntity storeEntity = StoreEntity(this);
late final TrashSync trashSync = TrashSync(this);
late final ServerDeletedChecksum serverDeletedChecksum =
ServerDeletedChecksum(this);
late final TrashedLocalAssetEntity trashedLocalAssetEntity =
TrashedLocalAssetEntity(this);
late final AssetEditEntity assetEditEntity = AssetEditEntity(this);
late final Settings settings = Settings(this);
late final AssetOcrEntity assetOcrEntity = AssetOcrEntity(this);
@@ -9540,9 +9777,13 @@ class DatabaseAtV32 extends GeneratedDatabase {
'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',
);
late final Index idxTrashSyncChecksum = Index(
'idx_trash_sync_checksum',
'CREATE INDEX IF NOT EXISTS idx_trash_sync_checksum ON trash_sync (checksum)',
late final Index idxTrashedLocalAssetChecksum = Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
late final Index idxTrashedLocalAssetAlbum = Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
late final Index idxAssetEditAssetId = Index(
'idx_asset_edit_asset_id',
@@ -9572,7 +9813,6 @@ class DatabaseAtV32 extends GeneratedDatabase {
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetSoftDeletedChecksum,
idxRemoteAssetStackId,
idxRemoteAssetOwnerVisibilityDeletedCreated,
idxRemoteAssetUploaded,
@@ -9588,8 +9828,7 @@ class DatabaseAtV32 extends GeneratedDatabase {
personEntity,
assetFaceEntity,
storeEntity,
trashSync,
serverDeletedChecksum,
trashedLocalAssetEntity,
assetEditEntity,
settings,
assetOcrEntity,
@@ -9602,7 +9841,8 @@ class DatabaseAtV32 extends GeneratedDatabase {
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxAssetFaceVisiblePerson,
idxTrashSyncChecksum,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
idxAssetOcrAssetId,
];

View File

@@ -8,6 +8,7 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'generated/schema.dart';
import 'generated/schema_v1.dart' as v1;
import 'generated/schema_v2.dart' as v2;
import 'generated/schema_v31.dart' as v31;
void main() {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
@@ -35,4 +36,55 @@ void main() {
});
}
});
group('migration from v31 to v32', () {
test('clamps timestamps outside the SQLite-supported range', () async {
final schema = await verifier.schemaAt(31);
final oldDb = v31.DatabaseAtV31(schema.newConnection());
await oldDb.customStatement(
"INSERT INTO local_asset_entity (id, name, type, created_at, updated_at, adjustment_time) "
"VALUES ('asset-1', 'a.jpg', 0, '+275760-09-13T00:00:00.000Z', '-000001-01-01T00:00:00.000Z', '+275760-09-13T00:00:00.000Z'), "
"('asset-2', 'b.jpg', 0, '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')",
);
await oldDb.customStatement(
"INSERT INTO local_album_entity (id, name, updated_at, backup_selection) "
"VALUES ('album-1', 'album', '+275760-09-13T00:00:00.000Z', 0)",
);
await oldDb.close();
final db = Drift(schema.newConnection());
await verifier.migrateAndValidate(db, 32);
final clamped = await db
.customSelect(
"SELECT created_at, updated_at, adjustment_time FROM local_asset_entity WHERE id = 'asset-1'",
)
.getSingle();
expect(clamped.read<String>('created_at'), '9999-12-31T00:00:00.000Z');
expect(clamped.read<String>('updated_at'), '0001-01-01T00:00:00.000Z');
expect(clamped.readNullable<String>('adjustment_time'), null);
final untouched = await db
.customSelect(
"SELECT created_at, updated_at, adjustment_time FROM local_asset_entity WHERE id = 'asset-2'",
)
.getSingle();
expect(untouched.read<String>('created_at'), '2025-01-01T00:00:00.000Z');
expect(untouched.read<String>('updated_at'), '2025-01-01T00:00:00.000Z');
expect(
untouched.readNullable<String>('adjustment_time'),
'2025-01-01T00:00:00.000Z',
);
final album = await db
.customSelect(
"SELECT updated_at FROM local_album_entity WHERE id = 'album-1'",
)
.getSingle();
expect(album.read<String>('updated_at'), '9999-12-31T00:00:00.000Z');
await db.close();
});
});
}

View File

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

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