Compare commits

...

23 Commits

Author SHA1 Message Date
shenlong-tanwen
ff7d92b380 refactor: auto trash / restore sync 2026-07-15 06:20:48 +05:30
Santo Shakil
2cdbd0d00f fix(mobile): apply exif orientation to remote raw photos on android (#29906) 2026-07-14 19:10:41 -04:00
github-actions
cd308ad930 chore: version v3.0.3 2026-07-14 21:10:19 +00:00
Alex
12061f3bf8 fix: Ken Burns effect on sliver app bar jittery on Android (#29890) 2026-07-14 19:21:33 +00:00
Daniel Dietzler
2460d431af feat: system integrity docs (#29911) 2026-07-14 14:00:27 -05:00
Matthew Momjian
24556cd3fc fix(docs): update fdroid link (#29797)
Co-authored-by: Brandon Wees <brandonwees@gmail.com>
2026-07-14 20:04:59 +02:00
Adam Gastineau
c84ab54889 fix(mobile): clear zoom state when swapping assets (#29909) 2026-07-14 12:18:18 -05:00
renovate[bot]
19e59ebd98 chore(deps): update github-actions (#29894) 2026-07-14 18:44:32 +02:00
Mert
89b0d906e8 fix(server): set hvc1 tag when using hwa (#29908)
fix hvc1 check
2026-07-14 11:03:30 -04:00
Daniel Dietzler
3c43e8d6c1 fix: revert "fix(mobile): send a full datetime from the android memories widget" (#29913) 2026-07-14 14:41:43 +00:00
Daniel Dietzler
2ed8b2bddd fix: memory search date validation (#29907) 2026-07-14 16:34:38 +02:00
Santo Shakil
9c8d718ddc fix(mobile): send a full datetime from the android memories widget (#29689) 2026-07-14 09:25:06 -05:00
renovate[bot]
20e123c839 chore(deps): update base-image to v202607132058 (#29895) 2026-07-14 11:16:42 +02:00
Daniel Dietzler
35fcca6254 fix: locked visibility in search random endpoint (#29887) 2026-07-13 20:03:20 +00:00
shenlong
ffafc144c6 refactor: use single bucket for none grouping (#29880)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-07-13 15:01:59 -05:00
bo0tzz
89d31aaba1 feat: publish app releases to FUTO F-Droid repo (#29804)
* feat: publish app releases to FUTO F-Droid repo

* chore: separate parse step
2026-07-13 14:57:40 -05:00
Alex
f2ddace584 fix: system appbar icon color has incorrect color in asset viewer (#29885) 2026-07-13 14:50:38 -05:00
shenlong
99883096d6 fix: still part of live photo hidden on bg upload (#29884)
* fix: still part of live photo hidden on bg upload

* add foreground tests

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-13 14:50:06 -05:00
Daniel Dietzler
84dff19ca9 fix: unauthorized album owner update (#29883) 2026-07-13 11:46:18 -05:00
Priyanshu
da8774801b fix(mobile): correct person age singular pluralization (#29868)
The ICU plural string for person_age_years in i18n/en.json only defined
the other plural category, so the intl MessageFormat runtime always
fell back to it — displaying '1 years old' instead of '1 year old'.

Added the missing one plural category:

Before: {years, plural, other {# years}} old
After:  {years, plural, one {# year} other {# years}} old

No Dart source changes required. formatAge already uses the
localization system correctly.

Fixes #29865

Co-authored-by: priyanshuANDcoad <priyanshu23154050@akgec.ac.in>
2026-07-13 14:49:06 +00:00
Ben Beckford
9057ae9759 fix(web): closing plugin method picker selects "unknown" method (#29784) 2026-07-13 09:41:28 -05:00
Alex
557189d7a8 chore: add Adam to mobile's codeowner (#29816) 2026-07-10 15:10:52 -05:00
Mees Frensel
19313e75fd chore(web): fix testing deprecations (#29799) 2026-07-10 14:30:12 +02:00
143 changed files with 18428 additions and 2927 deletions

View File

@@ -103,7 +103,7 @@ jobs:
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
- uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with:
distribution: 'zulu'
java-version: '17'
@@ -237,7 +237,7 @@ jobs:
run: flutter build ios --config-only --no-codesign
- name: Setup Ruby
uses: ruby/setup-ruby@0dafeac902942906541bc140009cdbf32665b601 # v1.315.0
uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.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@ae5ef6402ebe218ac6e872c59d0fa4557694e75c # v0.1.4
uses: oasdiff/oasdiff-action/breaking@291b6594ce617f22f49af5898ee038ed0b46e8fe # v0.1.5
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json

View File

@@ -81,7 +81,7 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to GitHub Container Registry
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io

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:2a64c7f045cb7b580fbdf3614d7d1805f5775fec453e3d1023764180efa8c70b
image: ghcr.io/immich-app/mdq:main@sha256:e3fe456f346b02c2f479462100763aee3ba8245768dfeac3c4262acb068e5c62
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:

View File

@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
category: '/language:${{matrix.language}}'

View File

@@ -60,7 +60,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -90,7 +90,7 @@ jobs:
suffix: ['']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}

56
.github/workflows/fdroid.yml vendored Normal file
View File

@@ -0,0 +1,56 @@
name: Update F-Droid Repo
on:
release:
types: [published]
permissions: {}
jobs:
update-index:
runs-on: ubuntu-latest
if: ${{ !github.event.release.prerelease }}
permissions:
contents: read
steps:
- name: Checkout pubspec for versionCode
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.release.tag_name }}
persist-credentials: false
sparse-checkout: mobile/pubspec.yaml
sparse-checkout-cone-mode: false
- name: Update F-Droid repo
env:
GITLAB_TOKEN: ${{ secrets.FDROID_REPO_TOKEN }}
RELEASE: ${{ toJSON(github.event.release) }}
TAG: ${{ github.event.release.tag_name }}
run: |
VERSION_CODE=$(yq '.version' mobile/pubspec.yaml | cut -d+ -f2)
export VERSION_CODE
NEW_ENTRY=$(yq -P '
[.assets[] | select(.name == "app-release.apk")] as $matches |
with(select($matches | length == 0); error("app-release.apk not found in release assets")) |
{
"url": $matches[0].browser_download_url,
"sha256sum": ($matches[0].digest | sub("^sha256:", "")),
"date": (.published_at | split("T") | .[0]),
"version-code": env(VERSION_CODE)
} | .date tag= "!!timestamp"
' <<< "$RELEASE")
export NEW_ENTRY
git clone --depth 1 "https://oauth2:${GITLAB_TOKEN}@gitlab.futo.org/fdroid/repo-v2.git" fdroid-repo
cd fdroid-repo
yq -i '
with(select([.apks[] | select(."version-code" == env(VERSION_CODE))] | length > 0); error("version-code already in index")) |
.apks = [env(NEW_ENTRY)] + .apks
' apps/Immich/index.yml
git config user.name "Immich Release Bot"
git config user.email "bot@immich.app"
git commit -am "Immich: add version-code ${VERSION_CODE} (${TAG})"
git push

View File

@@ -20,7 +20,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Require PR to have a changelog label
uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5.5.2
uses: mheap/github-action-required-labels@23e10fde7e062233401931a0eece796cd9bf3177 # v5.6.0
with:
token: ${{ steps.token.outputs.token }}
mode: exactly

View File

@@ -17,6 +17,6 @@ jobs:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
- uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
with:
repo-token: ${{ steps.token.outputs.token }}

View File

@@ -73,7 +73,7 @@ jobs:
# TODO move to mise
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Bump version
env:

View File

@@ -4,4 +4,4 @@
/web/ @danieldietzler
/machine-learning/ @mertalev
/e2e/ @danieldietzler
/mobile/ @shenlong-tanwen @santoshakil
/mobile/ @shenlong-tanwen @santoshakil @agg23

View File

@@ -1,5 +1,30 @@
# System Integrity
## Integrity report
At a [customizable interval](https://my.immich.app/admin/system-settings?isOpen=integrity-checks), Immich runs integrity checks to ensure that your library is still intact and there are no corrupt files.
There are three kind of issues Immich checks for:
- Untracked files: the path was found in Immich's directories but it is not referenced in Immich's database
- Missing files: the path is found in the Immich internal database, but does not actually exist on disk
- Checksum mismatches: the checksum of the file stored in Immich's database does not match the actual file's checksum anymore
All three run nightly at 3am by default. For the "Checksum files" check, there are additional time and progress limits, as those are the most taxing checks. With these additional limits, Immich can slowly check checksums of your files over the course of a couple of days.
You can see the results of these checks on the [maintenance](https://my.immich.app/admin/maintenance) page. Here, you can also trigger a full scan (a _check_) for specific jobs, or all of them. In addition, you can also _refresh_ checks. This will only look at items that have currently been reported on, and check if those have been fixed.
### Common causes
Most common are untracked files. In many cases those are corrupted thumbnails or encoded videos that have been partially generated at some point and never got cleaned up properly. These are usually fine to delete, as both can always be regenerated at a later point. Other files will need to be investigated on a case-by-case basis by checking they already exist in Immich and thinking about how they might have gotten untracked.
:::info
You might want to run the _missing_ jobs for thumbnails and encoded videos (https://my.immich.app/admin/queues) to make sure all your assets have proper thumbnails and encoded videos. Watch the server logs while running the jobs, in case there are any issues with some assets.
:::
Missing files are files where Immich references them internally, but they don't actually exist on disk in that location. It could be that you deleted a file on disk from the internal library (don't do that, Immich doesn't support it). It could also be that there are issues with your file storage. Please carefully investigate missing files, and never hesitate to reach out on [our Discord](https://discord.immich.app) if you have any questions!
Checksum mismatches are often indicative of file system corruption. It could also be that you previously edited a file from the internal library on the disk, which is also not supported and will cause a checksum mismatch. Again, the recommended action is to look at any reported item individually, check it out, try to remember if you changed it or some metadata on it at some point. If you edited the file, the supported resolution is to delete the mismatched asset from Immich and reupload it as a new asset.
## Folder checks
:::info
@@ -40,7 +65,7 @@ The above error messages show that the server has previously (successfully) writ
### Ignoring the checks
:::warning
:::danger
The checks are designed to catch common problems that we have seen users have in the past, and often indicate there's something wrong that you should solve. If you know what you're doing and you want to disable them you can set the following environment variable:
:::

View File

@@ -1,7 +1,7 @@
The mobile app can be downloaded from the following places:
- Obtainium: You can get your Obtainium config link from the [Utilities page of your Immich server](https://my.immich.app/utilities).
- [Google Play Store](https://play.google.com/store/apps/details?id=app.alextran.immich)
- [Apple App Store](https://apps.apple.com/us/app/immich/id1613945652)
- [F-Droid](https://f-droid.org/packages/app.alextran.immich)
- [GitHub Releases (apk)](https://github.com/immich-app/immich/releases)
- [Google Play Store](https://play.google.com/store/apps/details?id=app.alextran.immich)
- [GitHub Releases (APK)](https://github.com/immich-app/immich/releases)
- Obtainium: You can get your Obtainium config link from the [Utilities page of your Immich server](https://my.immich.app/utilities).
- [F-Droid](https://app.futo.org/fdroid/repo/)

View File

@@ -1,7 +1,7 @@
[
{
"label": "v3.0.2",
"url": "https://docs.v3.0.2.archive.immich.app"
"label": "v3.0.3",
"url": "https://docs.v3.0.3.archive.immich.app"
},
{
"label": "v2.7.5",

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "3.0.2",
"version": "3.0.3",
"description": "",
"main": "index.js",
"type": "module",

View File

@@ -796,5 +796,22 @@ describe('/albums', () => {
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not found or no album.share access'));
});
it('should not allow an editor to change the role of an owner', async () => {
const album = await utils.createAlbum(user1.accessToken, {
albumName: 'testAlbum',
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }],
});
expect(album.albumUsers[1].role).toEqual(AlbumUserRole.Editor);
const { status, body } = await request(app)
.put(`/albums/${album.id}/user/${user1.userId}`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ role: AlbumUserRole.Editor });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('User is owner'));
});
});
});

View File

@@ -1621,7 +1621,7 @@
"person": "Person",
"person_age_months": "{months, plural, one {# month} other {# months}} old",
"person_age_year_months": "1 year, {months, plural, one {# month} other {# months}} old",
"person_age_years": "{years, plural, other {# years}} old",
"person_age_years": "{years, plural, one {# year} other {# years}} old",
"person_birthdate": "Born on {date}",
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
"person_recognized": "Person recognized",

View File

@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
version = "3.0.2"
version = "3.0.3"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0"

View File

@@ -974,7 +974,7 @@ wheels = [
[[package]]
name = "immich-ml"
version = "3.0.2"
version = "3.0.3"
source = { editable = "." }
dependencies = [
{ name = "aiocache" },

View File

@@ -18,6 +18,7 @@ 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
@@ -66,6 +67,7 @@ class MainActivity : FlutterFragmentActivity() {
flutterEngine.plugins.add(backgroundEngineLockImpl)
flutterEngine.plugins.add(nativeSyncApiImpl)
flutterEngine.plugins.add(permissionApiImpl)
flutterEngine.plugins.add(AssetMediaApiImpl(ctx))
}
fun cancelPlugins(flutterEngine: FlutterEngine) {
@@ -75,6 +77,8 @@ 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,17 +2,30 @@ 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 detached: Boolean = false
private var supervisor = SupervisorJob()
protected var scope = CoroutineScope(supervisor + Dispatchers.Default)
private set
@CallSuper
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
detached = false;
detached = false
if (!scope.isActive) {
supervisor = SupervisorJob()
scope = CoroutineScope(supervisor + Dispatchers.Default)
}
}
fun detachFromEngine() {
detached = true
supervisor.cancel()
}
@CallSuper
@@ -22,8 +35,9 @@ abstract class ImmichPlugin : FlutterPlugin {
fun <T> completeWhenActive(callback: (T) -> Unit, value: T) {
if (detached) {
return;
return
}
callback(value);
callback(value)
}
}

View File

@@ -64,13 +64,7 @@ fun Bitmap.toNativeBuffer(): Map<String, Long> {
// native convert declined (OOM/lock) -> fall through to the Skia copy path below.
}
// Other non-8888 configs (e.g. HDR F16) still need Skia's convert; 8-bit is copied as-is.
val bitmap = if (config != Bitmap.Config.ARGB_8888) {
val converted = copy(Bitmap.Config.ARGB_8888, false)
recycle()
converted ?: throw IOException("could not convert bitmap to ARGB_8888")
} else {
this
}
val bitmap = toArgb8888()
val size = bitmap.width * bitmap.height * 4
val pointer = NativeBuffer.allocate(size)
try {
@@ -217,7 +211,7 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
if (orientation == ExifInterface.ORIENTATION_NORMAL || orientation == ExifInterface.ORIENTATION_UNDEFINED) {
bitmap.toNativeBuffer()
} else {
rotateToNativeBuffer(bitmap, orientation, signal)
rotateToNativeBuffer(bitmap, orientation)
}
}
// Don't re-check cancellation here: res owns a malloc'd buffer, and bailing to CANCELLED would
@@ -257,39 +251,7 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
}
private fun rawOrientation(uri: Uri): Int {
return resolver.openInputStream(uri)?.use {
ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
} ?: ExifInterface.ORIENTATION_NORMAL
}
// ImageDecoder / loadThumbnail skip EXIF orientation for raw (e.g. DNG) on Q+, so the decoded
// bitmap comes back unrotated. Rotate it into the output buffer in native code (one pass, no
// intermediate rotated bitmap).
private fun rotateToNativeBuffer(bitmap: Bitmap, orientation: Int, signal: CancellationSignal): Map<String, Long> {
signal.throwIfCanceled()
// Force ARGB_8888: the native rotate needs a lockable 8888 buffer, and toNativeBuffer() below
// allocates width*height*4 (an F16/HDR decode would otherwise under-allocate). No-op for the
// common already-8888 case.
val src = if (bitmap.config != Bitmap.Config.ARGB_8888) {
val converted = bitmap.copy(Bitmap.Config.ARGB_8888, false)
bitmap.recycle()
converted ?: throw IOException("could not convert bitmap to ARGB_8888")
} else {
bitmap
}
try {
val info = IntArray(3)
val pointer = NativeImage.rotate(src, orientation, info)
if (pointer == 0L) throw IOException("native rotate failed for orientation $orientation")
return mapOf(
"pointer" to pointer,
"width" to info[0].toLong(),
"height" to info[1].toLong(),
"rowBytes" to info[2].toLong()
)
} finally {
if (!src.isRecycled) src.recycle()
}
return resolver.openInputStream(uri)?.use { readOrientation(it) } ?: ExifInterface.ORIENTATION_NORMAL
}
private fun decodeVideoThumbnail(id: Long, target: Size, signal: CancellationSignal): Bitmap {

View File

@@ -0,0 +1,68 @@
package app.alextran.immich.images
import android.graphics.Bitmap
import androidx.exifinterface.media.ExifInterface
import app.alextran.immich.NativeImage
import java.io.IOException
import java.io.InputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
fun readOrientation(input: InputStream): Int =
ExifInterface(input).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
// Reads the EXIF orientation (TIFF tag 0x0112) from a raw file's IFD0.
fun readRawOrientation(buffer: ByteBuffer, len: Int): Int {
try {
if (len < 8) return ExifInterface.ORIENTATION_NORMAL
buffer.order(
when (buffer.get(0).toInt() and 0xFF) {
0x49 -> ByteOrder.LITTLE_ENDIAN
0x4D -> ByteOrder.BIG_ENDIAN
else -> return ExifInterface.ORIENTATION_NORMAL
}
)
val ifd0 = buffer.getInt(4).toLong() and 0xFFFFFFFFL
if (ifd0 < 8 || ifd0 + 2 > len) return ExifInterface.ORIENTATION_NORMAL
val start = ifd0.toInt()
val entries = buffer.getShort(start).toInt() and 0xFFFF
if (entries <= 0 || entries > 512) return ExifInterface.ORIENTATION_NORMAL
// Each IFD entry is 12 bytes: tag(2) type(2) count(4) value(4); orientation is a SHORT in value.
for (i in 0 until entries) {
val p = start + 2 + i * 12
if (p + 12 > len) return ExifInterface.ORIENTATION_NORMAL
if ((buffer.getShort(p).toInt() and 0xFFFF) == 0x0112) {
val value = buffer.getShort(p + 8).toInt() and 0xFFFF
return if (value in 1..8) value else ExifInterface.ORIENTATION_NORMAL
}
}
return ExifInterface.ORIENTATION_NORMAL
} catch (_: Exception) {
return ExifInterface.ORIENTATION_NORMAL
}
}
fun Bitmap.toArgb8888(): Bitmap {
if (config == Bitmap.Config.ARGB_8888) return this
val converted = copy(Bitmap.Config.ARGB_8888, false)
recycle()
return converted ?: throw IOException("could not convert bitmap to ARGB_8888")
}
// Force ARGB_8888 first: the native rotate needs a lockable 8888 buffer and allocates w*h*4.
fun rotateToNativeBuffer(bitmap: Bitmap, orientation: Int): Map<String, Long> {
val src = bitmap.toArgb8888()
try {
val info = IntArray(3)
val pointer = NativeImage.rotate(src, orientation, info)
if (pointer == 0L) throw IOException("native rotate failed for orientation $orientation")
return mapOf(
"pointer" to pointer,
"width" to info[0].toLong(),
"height" to info[1].toLong(),
"rowBytes" to info[2].toLong()
)
} finally {
if (!src.isRecycled) src.recycle()
}
}

View File

@@ -5,6 +5,7 @@ import android.graphics.ImageDecoder
import android.os.Build
import android.os.CancellationSignal
import android.os.OperationCanceledException
import androidx.exifinterface.media.ExifInterface
import app.alextran.immich.INITIAL_BUFFER_SIZE
import app.alextran.immich.NativeBuffer
import app.alextran.immich.NativeByteBuffer
@@ -30,6 +31,35 @@ private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
private class RemoteRequest(val cancellationSignal: CancellationSignal)
// The full mimeTypes.raw set the server can serve (short + vendor forms).
private val RAW_MIME_TYPES = setOf(
"image/3fr", "image/ari", "image/arw",
"image/cap", "image/cin", "image/cr2",
"image/cr3", "image/crw", "image/dcr",
"image/dng", "image/erf", "image/fff",
"image/iiq", "image/k25", "image/kdc",
"image/mrw", "image/nef", "image/nrw",
"image/orf", "image/ori", "image/pef",
"image/psd", "image/raf", "image/raw",
"image/rw2", "image/rwl", "image/sr2",
"image/srf", "image/srw", "image/vnd.adobe.photoshop",
"image/x-adobe-dng", "image/x-arriflex-ari", "image/x-canon-cr2",
"image/x-canon-cr3", "image/x-canon-crw", "image/x-epson-erf",
"image/x-fuji-raf", "image/x-hasselblad-3fr", "image/x-hasselblad-fff",
"image/x-kodak-dcr", "image/x-kodak-k25", "image/x-kodak-kdc",
"image/x-leica-rwl", "image/x-minolta-mrw", "image/x-nikon-nef",
"image/x-nikon-nrw", "image/x-olympus-orf", "image/x-olympus-ori",
"image/x-panasonic-raw", "image/x-panasonic-rw2", "image/x-pentax-pef",
"image/x-phantom-cin", "image/x-phaseone-cap", "image/x-phaseone-iiq",
"image/x-samsung-srw", "image/x-sigma-x3f", "image/x-sony-arw",
"image/x-sony-sr2", "image/x-sony-srf", "image/x3f",
)
private fun isRawMime(contentType: String?): Boolean {
val mime = contentType?.substringBefore(';')?.trim()?.lowercase() ?: return false
return mime in RAW_MIME_TYPES
}
class RemoteImagesImpl(context: Context) : RemoteImageApi {
private val requestMap = ConcurrentHashMap<Long, RemoteRequest>()
@@ -57,7 +87,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
ImageFetcherManager.fetch(
url,
signal,
onSuccess = { buffer ->
onSuccess = { buffer, contentType ->
if (signal.isCanceled) {
requestMap.remove(requestId)
buffer.free()
@@ -72,8 +102,18 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
if (!preferEncoded && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
decodeExecutor.execute {
val res = if (signal.isCanceled) null else try {
val source = ImageDecoder.createSource(NativeBuffer.wrap(buffer.pointer, buffer.offset))
source.decodeBitmap().toNativeBuffer()
val bitmap = ImageDecoder.createSource(NativeBuffer.wrap(buffer.pointer, buffer.offset)).decodeBitmap()
// The embedded preview a raw decodes to has no orientation, so read the container's.
val orientation = if (isRawMime(contentType)) {
readRawOrientation(NativeBuffer.wrap(buffer.pointer, buffer.offset), buffer.offset)
} else {
ExifInterface.ORIENTATION_NORMAL
}
if (orientation == ExifInterface.ORIENTATION_NORMAL || orientation == ExifInterface.ORIENTATION_UNDEFINED) {
bitmap.toNativeBuffer()
} else {
rotateToNativeBuffer(bitmap, orientation)
}
} catch (_: Throwable) {
null
}
@@ -154,7 +194,7 @@ private object ImageFetcherManager {
fun fetch(
url: String,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onSuccess: (NativeByteBuffer, String?) -> Unit,
onFailure: (Exception) -> Unit,
) {
fetcher.fetch(url, signal, onSuccess, onFailure)
@@ -185,7 +225,7 @@ private sealed interface ImageFetcher {
fun fetch(
url: String,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onSuccess: (NativeByteBuffer, String?) -> Unit,
onFailure: (Exception) -> Unit,
)
@@ -203,7 +243,7 @@ private class CronetImageFetcher : ImageFetcher {
override fun fetch(
url: String,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onSuccess: (NativeByteBuffer, String?) -> Unit,
onFailure: (Exception) -> Unit,
) {
synchronized(stateLock) {
@@ -271,7 +311,7 @@ private class CronetImageFetcher : ImageFetcher {
}
private class FetchCallback(
private val onSuccess: (NativeByteBuffer) -> Unit,
private val onSuccess: (NativeByteBuffer, String?) -> Unit,
private val onFailure: (Exception) -> Unit,
private val onComplete: () -> Unit,
) : UrlRequest.Callback() {
@@ -328,7 +368,9 @@ private class CronetImageFetcher : ImageFetcher {
}
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
onSuccess(buffer!!)
val contentType = info.allHeaders.entries
.firstOrNull { it.key.equals("content-type", ignoreCase = true) }?.value?.firstOrNull()
onSuccess(buffer!!, contentType)
onComplete()
}
@@ -379,7 +421,7 @@ private class OkHttpImageFetcher private constructor(
override fun fetch(
url: String,
signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit,
onSuccess: (NativeByteBuffer, String?) -> Unit,
onFailure: (Exception) -> Unit,
) {
synchronized(stateLock) {
@@ -433,7 +475,7 @@ private class OkHttpImageFetcher private constructor(
buffer.ensureHeadroom()
}
}
onSuccess(buffer)
onSuccess(buffer, response.header("Content-Type"))
} catch (e: Exception) {
buffer.free()
onFailure(e)

View File

@@ -0,0 +1,354 @@
// 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

@@ -0,0 +1,239 @@
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

@@ -1,133 +0,0 @@
package app.alextran.immich.sync
import android.app.Activity
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import 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,8 +553,6 @@ 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 {
@@ -765,42 +763,6 @@ 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,9 +29,4 @@ 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,29 +99,4 @@ 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,7 +4,6 @@ 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
@@ -17,8 +16,6 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.ImageHeaderParser
import com.bumptech.glide.load.ImageHeaderParserUtils
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -41,12 +38,11 @@ sealed class AssetResult {
private const val TAG = "NativeSyncApiImplBase"
@SuppressLint("InlinedApi")
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAware {
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
private val ctx: Context = context.applicationContext
private var hashTask: Job? = null
private var syncJob: Job? = null
private val mediaTrashDelegate = MediaTrashDelegate(ctx)
companion object {
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
@@ -377,7 +373,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
)?.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,33 +477,16 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
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

@@ -22,8 +22,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3055,
"android.injected.version.name" => "3.0.2",
"android.injected.version.code" => 3056,
"android.injected.version.name" => "3.0.3",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab', track: 'beta')
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3055,
"android.injected.version.name" => "3.0.2",
"android.injected.version.code" => 3056,
"android.injected.version.name" => "3.0.3",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

File diff suppressed because it is too large Load Diff

View File

@@ -161,6 +161,11 @@
path = Sync;
sourceTree = "<group>";
};
B2D5F7B13005FCA00080CE3E /* AssetMedia */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = AssetMedia;
sourceTree = "<group>";
};
F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
@@ -279,6 +284,7 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
B2D5F7B13005FCA00080CE3E /* AssetMedia */,
FE1BB4562F8319560087DBF9 /* Utility */,
FEE084F22EC172080045228E /* Schemas */,
B231F52D2E93A44A00BC45D1 /* Core */,
@@ -381,6 +387,7 @@
fileSystemSynchronizedGroups = (
B231F52D2E93A44A00BC45D1 /* Core */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
B2D5F7B13005FCA00080CE3E /* AssetMedia */,
FEE084F22EC172080045228E /* Schemas */,
);
name = Runner;

View File

@@ -26,6 +26,7 @@ import native_video_player
public static func registerPlugins(with registry: FlutterPluginRegistry, messenger: FlutterBinaryMessenger) {
NativeSyncApiImpl.register(with: registry.registrar(forPlugin: NativeSyncApiImpl.name)!)
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())
@@ -36,5 +37,6 @@ 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

@@ -0,0 +1,313 @@
// 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

@@ -0,0 +1,26 @@
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

@@ -78,7 +78,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>3.0.2</string>
<string>3.0.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>

View File

@@ -537,8 +537,6 @@ 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]
}
@@ -723,39 +721,6 @@ 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,14 +436,6 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
})
}
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
}
func 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

@@ -26,7 +26,6 @@ const String kDownloadGroupLivePhoto = 'group_livephoto';
const String kShareDownloadGroup = 'group_share';
// Timeline constants
const int kTimelineNoneSegmentSize = 120;
const int kTimelineAssetLoadBatchSize = 1024;
const int kTimelineAssetLoadOppositeSize = 64;

View File

@@ -26,3 +26,17 @@ 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

@@ -23,6 +23,7 @@ const defaultConfig = AppConfig();
class AppConfig {
final LogLevel logLevel;
final bool trashSyncEnabled;
final ThemeConfig theme;
final CleanupConfig cleanup;
final MapConfig map;
@@ -38,6 +39,7 @@ class AppConfig {
const AppConfig({
this.logLevel = .info,
this.trashSyncEnabled = false,
this.theme = const .new(),
this.cleanup = const .new(),
this.map = const .new(),
@@ -54,6 +56,7 @@ class AppConfig {
AppConfig copyWith({
LogLevel? logLevel,
bool? trashSyncEnabled,
ThemeConfig? theme,
CleanupConfig? cleanup,
MapConfig? map,
@@ -68,6 +71,7 @@ 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,
@@ -87,6 +91,7 @@ class AppConfig {
identical(this, other) ||
(other is AppConfig &&
other.logLevel == logLevel &&
other.trashSyncEnabled == trashSyncEnabled &&
other.theme == theme &&
other.cleanup == cleanup &&
other.map == map &&
@@ -103,6 +108,7 @@ class AppConfig {
@override
int get hashCode => Object.hash(
logLevel,
trashSyncEnabled,
theme,
cleanup,
map,
@@ -119,11 +125,12 @@ class AppConfig {
@override
String toString() =>
'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)';
'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)';
T read<T>(SettingsKey<T> key) =>
(switch (key) {
.logLevel => logLevel,
.trashSyncEnabled => trashSyncEnabled,
.themePrimaryColor => theme.primaryColor,
.themeMode => theme.mode,
.themeDynamic => theme.dynamicTheme,
@@ -176,6 +183,7 @@ 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

@@ -37,6 +37,9 @@ 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,10 +4,8 @@ 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';
@@ -17,7 +15,6 @@ 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');
@@ -25,7 +22,6 @@ class HashService {
HashService({
required this._localAlbumRepository,
required this._localAssetRepository,
required this._trashedLocalAssetRepository,
required this._nativeSyncApi,
this._cancellation,
int? batchSize,
@@ -58,14 +54,6 @@ 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");
@@ -82,7 +70,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, {bool isTrashed = false}) async {
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash) async {
final toHash = <String, LocalAsset>{};
for (final asset in assetsToHash) {
@@ -93,16 +81,16 @@ class HashService {
toHash[asset.id] = asset;
if (toHash.length == _batchSize) {
await _processBatch(album, toHash, isTrashed);
await _processBatch(album, toHash);
toHash.clear();
}
}
await _processBatch(album, toHash, isTrashed);
await _processBatch(album, toHash);
}
/// Processes a batch of assets.
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash, bool isTrashed) async {
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash) async {
if (toHash.isEmpty) {
return;
}
@@ -137,10 +125,6 @@ class HashService {
}
_log.fine("Hashed ${hashed.length}/${toHash.length} assets");
if (isTrashed) {
await _trashedLocalAssetRepository.updateHashes(hashed);
} else {
await _localAssetRepository.updateHashes(hashed);
}
await _localAssetRepository.updateHashes(hashed);
}
}

View File

@@ -5,15 +5,11 @@ 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/trashed_local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.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';
@@ -24,20 +20,16 @@ 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));
@@ -48,15 +40,6 @@ 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);
@@ -119,6 +102,7 @@ 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");
@@ -150,6 +134,7 @@ class LocalSyncService {
);
await _nativeSyncApi.checkpointSync();
await _trashSyncRepository.restoreChecksums();
stopwatch.stop();
_log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
} on PlatformException catch (e, s) {
@@ -383,48 +368,6 @@ 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> {
@@ -445,10 +388,6 @@ 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,18 +3,13 @@
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/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/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:logging/logging.dart';
@@ -33,10 +28,7 @@ class SyncStreamService {
final SyncApiRepository _syncApiRepository;
final SyncStreamRepository _syncStreamRepository;
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final IPermissionRepository _permissionRepository;
final DriftTrashSyncRepository _trashSyncRepository;
final SyncMigrationRepository _syncMigrationRepository;
final ApiService _api;
final Completer<void>? _cancellation;
@@ -44,10 +36,7 @@ class SyncStreamService {
SyncStreamService({
required this._syncApiRepository,
required this._syncStreamRepository,
required this._localAssetRepository,
required this._trashedLocalAssetRepository,
required this._assetMediaRepository,
required this._permissionRepository,
required this._trashSyncRepository,
required this._syncMigrationRepository,
required this._api,
this._cancellation,
@@ -202,24 +191,13 @@ class SyncStreamService {
case SyncEntityType.partnerDeleteV1:
return _syncStreamRepository.deletePartnerV1(data.cast());
case SyncEntityType.assetV1:
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;
return _syncStreamRepository.updateAssetsV1(data.cast<SyncAssetV1>());
case SyncEntityType.assetV2:
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;
return _syncStreamRepository.updateAssetsV2(data.cast<SyncAssetV2>());
case SyncEntityType.assetDeleteV1:
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _syncAssetDeletion(remoteSyncAssets.map((e) => e.assetId).toList());
}
// 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));
return _syncStreamRepository.deleteAssetsV1(remoteSyncAssets);
case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast());
@@ -499,59 +477,4 @@ 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

@@ -0,0 +1,124 @@
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,6 +3,7 @@ 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';
@@ -33,6 +34,7 @@ class BackgroundSyncManager {
Cancelable<void>? _deviceAlbumSyncTask;
Cancelable<void>? _linkedAlbumSyncTask;
Cancelable<void>? _hashTask;
Cancelable<void>? _trashSyncTask;
BackgroundSyncManager({
this.onRemoteSyncStart,
@@ -57,6 +59,7 @@ class BackgroundSyncManager {
_linkedAlbumSyncTask,
_deviceAlbumSyncTask,
_hashTask,
_trashSyncTask,
];
final futures = [
for (final task in tasks)
@@ -71,6 +74,7 @@ class BackgroundSyncManager {
_linkedAlbumSyncTask = null;
_deviceAlbumSyncTask = null;
_hashTask = null;
_trashSyncTask = null;
try {
await Future.wait(futures);
@@ -160,6 +164,21 @@ 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,6 +16,11 @@ 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

@@ -1771,6 +1771,10 @@ 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

@@ -0,0 +1,14 @@
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

@@ -0,0 +1,318 @@
// 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

@@ -0,0 +1,22 @@
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

@@ -0,0 +1,524 @@
// 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

@@ -1,53 +0,0 @@
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,6 +58,12 @@ 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
);
''';
@@ -69,7 +75,14 @@ class DriftBackupRepository extends DriftDatabaseRepository {
Variable.withInt(BackupSelection.selected.index),
Variable.withInt(BackupSelection.excluded.index),
],
readsFrom: {_db.localAlbumAssetEntity, _db.localAlbumEntity, _db.localAssetEntity, _db.remoteAssetEntity},
readsFrom: {
_db.localAlbumAssetEntity,
_db.localAlbumEntity,
_db.localAssetEntity,
_db.remoteAssetEntity,
_db.trashSyncEntity,
_db.serverDeletedChecksumEntity,
},
)
.getSingle();
@@ -104,6 +117,16 @@ 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/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.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,7 +63,8 @@ import 'package:sqlite_async/sqlite_async.dart';
PersonEntity,
AssetFaceEntity,
StoreEntity,
TrashedLocalAssetEntity,
TrashSyncEntity,
ServerDeletedChecksumEntity,
AssetEditEntity,
SettingsEntity,
AssetOcrEntity,
@@ -120,7 +121,7 @@ class Drift extends $Drift {
}
@override
int get schemaVersion => 31;
int get schemaVersion => 32;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -221,7 +222,7 @@ class Drift extends $Drift {
await m.alterTable(
TableMigration(
v15.trashedLocalAssetEntity,
columnTransformer: {v15.trashedLocalAssetEntity.source: Constant(TrashOrigin.localSync.index)},
columnTransformer: {v15.trashedLocalAssetEntity.source: const Constant(0)}, // TrashOrigin.localSync
newColumns: [v15.trashedLocalAssetEntity.source],
),
);
@@ -280,11 +281,7 @@ class Drift extends $Drift {
durationMs: v23.remoteAssetEntity.durationMs * const Constant(1000),
),
);
await trashedLocalAssetEntity.update().write(
TrashedLocalAssetEntityCompanion.custom(
durationMs: v23.trashedLocalAssetEntity.durationMs * const Constant(1000),
),
);
await customStatement('UPDATE trashed_local_asset_entity SET duration_ms = duration_ms * 1000');
},
from23To24: (m, v24) async {
await customStatement('DROP INDEX IF EXISTS idx_remote_album_owner_id');
@@ -318,6 +315,13 @@ class Drift extends $Drift {
from30To31: (m, v31) async {
await m.createIndex(v31.idxRemoteAssetUploaded);
},
from31To32: (m, v32) async {
await m.createTable(v32.trashSync);
await m.create(v32.idxTrashSyncChecksum);
await m.createTable(v32.serverDeletedChecksum);
await m.create(v32.idxRemoteAssetSoftDeletedChecksum);
await m.deleteTable('trashed_local_asset_entity');
},
),
),
);
@@ -349,6 +353,8 @@ 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,17 +39,19 @@ 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/trashed_local_asset.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart'
as i20;
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/server_deleted_checksum.entity.drift.dart'
as i21;
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
as i22;
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart'
as i23;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart'
as i24;
import 'package:drift/internal/modular.dart' as i25;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i25;
import 'package:drift/internal/modular.dart' as i26;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@@ -89,19 +91,21 @@ 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.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i20
.$TrashedLocalAssetEntityTable(this);
late final i21.$AssetEditEntityTable assetEditEntity = i21
late final i20.$TrashSyncEntityTable trashSyncEntity = i20
.$TrashSyncEntityTable(this);
late final i21.$ServerDeletedChecksumEntityTable serverDeletedChecksumEntity =
i21.$ServerDeletedChecksumEntityTable(this);
late final i22.$AssetEditEntityTable assetEditEntity = i22
.$AssetEditEntityTable(this);
late final i22.$SettingsEntityTable settingsEntity = i22.$SettingsEntityTable(
late final i23.$SettingsEntityTable settingsEntity = i23.$SettingsEntityTable(
this,
);
late final i23.$AssetOcrEntityTable assetOcrEntity = i23.$AssetOcrEntityTable(
late final i24.$AssetOcrEntityTable assetOcrEntity = i24.$AssetOcrEntityTable(
this,
);
i24.MergedAssetDrift get mergedAssetDrift => i25.ReadDatabaseContainer(
i25.MergedAssetDrift get mergedAssetDrift => i26.ReadDatabaseContainer(
this,
).accessor<i24.MergedAssetDrift>(i24.MergedAssetDrift.new);
).accessor<i25.MergedAssetDrift>(i25.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -122,6 +126,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
i2.uQRemoteAssetsOwnerChecksum,
i2.uQRemoteAssetsOwnerLibraryChecksum,
i2.idxRemoteAssetChecksum,
i2.idxRemoteAssetSoftDeletedChecksum,
i2.idxRemoteAssetStackId,
i2.idxRemoteAssetOwnerVisibilityDeletedCreated,
i2.idxRemoteAssetUploaded,
@@ -137,7 +142,8 @@ abstract class $Drift extends i0.GeneratedDatabase {
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
trashSyncEntity,
serverDeletedChecksumEntity,
assetEditEntity,
settingsEntity,
assetOcrEntity,
@@ -150,10 +156,9 @@ abstract class $Drift extends i0.GeneratedDatabase {
i18.idxAssetFacePersonId,
i18.idxAssetFaceAssetId,
i18.idxAssetFaceVisiblePerson,
i20.idxTrashedLocalAssetChecksum,
i20.idxTrashedLocalAssetAlbum,
i21.idxAssetEditAssetId,
i23.idxAssetOcrAssetId,
i20.idxTrashSyncChecksum,
i22.idxAssetEditAssetId,
i24.idxAssetOcrAssetId,
];
@override
i0.StreamQueryUpdateRules
@@ -404,15 +409,18 @@ class $DriftManager {
i18.$$AssetFaceEntityTableTableManager(_db, _db.assetFaceEntity);
i19.$$StoreEntityTableTableManager get storeEntity =>
i19.$$StoreEntityTableTableManager(_db, _db.storeEntity);
i20.$$TrashedLocalAssetEntityTableTableManager get trashedLocalAssetEntity =>
i20.$$TrashedLocalAssetEntityTableTableManager(
i20.$$TrashSyncEntityTableTableManager get trashSyncEntity =>
i20.$$TrashSyncEntityTableTableManager(_db, _db.trashSyncEntity);
i21.$$ServerDeletedChecksumEntityTableTableManager
get serverDeletedChecksumEntity =>
i21.$$ServerDeletedChecksumEntityTableTableManager(
_db,
_db.trashedLocalAssetEntity,
_db.serverDeletedChecksumEntity,
);
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);
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);
}

View File

@@ -16506,6 +16506,632 @@ final class Schema31 extends i0.VersionedSchema {
);
}
final class Schema32 extends i0.VersionedSchema {
Schema32({required super.database}) : super(version: 32);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxLocalAssetCreatedAt,
idxStackPrimaryAssetId,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetSoftDeletedChecksum,
idxRemoteAssetStackId,
idxRemoteAssetOwnerVisibilityDeletedCreated,
idxRemoteAssetUploaded,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashSync,
serverDeletedChecksum,
assetEditEntity,
settings,
assetOcrEntity,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteExifCity,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxAssetFaceVisiblePerson,
idxTrashSyncChecksum,
idxAssetEditAssetId,
idxAssetOcrAssetId,
];
late final Shape33 userEntity = Shape33(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape50 remoteAssetEntity = Shape50(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_212,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 stackEntity = Shape35(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_130,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape36 localAssetEntity = Shape36(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 remoteAlbumEntity = Shape48(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_138,
_column_114,
_column_115,
_column_139,
_column_140,
_column_141,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 localAlbumEntity = Shape38(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_115,
_column_142,
_column_143,
_column_144,
_column_145,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape39 localAlbumAssetEntity = Shape39(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_146, _column_147, _column_145],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxLocalAssetCreatedAt = i1.Index(
'idx_local_asset_created_at',
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetSoftDeletedChecksum = i1.Index(
'idx_remote_asset_soft_deleted_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_soft_deleted_checksum ON remote_asset_entity (checksum) WHERE deleted_at IS NOT NULL',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
'idx_remote_asset_owner_visibility_deleted_created',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
);
final i1.Index idxRemoteAssetUploaded = i1.Index(
'idx_remote_asset_uploaded',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_uploaded ON remote_asset_entity (uploaded_at)',
);
late final Shape40 authUserEntity = Shape40(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_148,
_column_110,
_column_111,
_column_149,
_column_150,
_column_151,
_column_152,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_153, _column_154, _column_155],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 partnerEntity = Shape41(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_156, _column_157, _column_158],
attachedDatabase: database,
),
alias: null,
);
late final Shape42 remoteExifEntity = Shape42(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_160,
_column_161,
_column_162,
_column_163,
_column_164,
_column_117,
_column_116,
_column_165,
_column_166,
_column_167,
_column_168,
_column_135,
_column_136,
_column_169,
_column_170,
_column_171,
_column_172,
_column_173,
_column_174,
_column_175,
_column_176,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_159, _column_177],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_177, _column_153, _column_178],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 remoteAssetCloudIdEntity = Shape43(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_179,
_column_180,
_column_134,
_column_135,
_column_136,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 memoryEntity = Shape44(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_124,
_column_121,
_column_113,
_column_181,
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_159, _column_187],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 personEntity = Shape45(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_108,
_column_188,
_column_189,
_column_190,
_column_191,
_column_192,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 assetFaceEntity = Shape46(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_193,
_column_194,
_column_195,
_column_196,
_column_197,
_column_198,
_column_199,
_column_200,
_column_201,
_column_124,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_202, _column_203, _column_204],
attachedDatabase: database,
),
alias: null,
);
late final Shape52 trashSync = Shape52(
source: i0.VersionedTable(
entityName: 'trash_sync',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [_column_225, _column_119, _column_226, _column_227],
attachedDatabase: database,
),
alias: null,
);
late final Shape53 serverDeletedChecksum = Shape53(
source: i0.VersionedTable(
entityName: 'server_deleted_checksum',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(checksum)'],
columns: [_column_119],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 assetEditEntity = Shape32(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_207,
_column_208,
_column_209,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape49 settings = Shape49(
source: i0.VersionedTable(
entityName: 'settings',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY("key")'],
columns: [_column_210, _column_224, _column_115],
attachedDatabase: database,
),
alias: null,
);
late final Shape51 assetOcrEntity = Shape51(
source: i0.VersionedTable(
entityName: 'asset_ocr_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_213,
_column_214,
_column_215,
_column_216,
_column_217,
_column_218,
_column_219,
_column_220,
_column_221,
_column_222,
_column_223,
_column_201,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteExifCity = i1.Index(
'idx_remote_exif_city',
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
'idx_asset_face_visible_person',
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
);
final i1.Index idxTrashSyncChecksum = i1.Index(
'idx_trash_sync_checksum',
'CREATE INDEX IF NOT EXISTS idx_trash_sync_checksum ON trash_sync (checksum)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
final i1.Index idxAssetOcrAssetId = i1.Index(
'idx_asset_ocr_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)',
);
}
class Shape52 extends i0.VersionedTable {
Shape52({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get assetId =>
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get status =>
columnsByName['status']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get assetUpdatedAt =>
columnsByName['asset_updated_at']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<String> _column_225(String aliasedName) =>
i1.GeneratedColumn<String>(
'asset_id',
aliasedName,
false,
type: i1.DriftSqlType.string,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<int> _column_226(String aliasedName) =>
i1.GeneratedColumn<int>(
'status',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL DEFAULT 0',
defaultValue: const i1.CustomExpression('0'),
);
i1.GeneratedColumn<String> _column_227(String aliasedName) =>
i1.GeneratedColumn<String>(
'asset_updated_at',
aliasedName,
true,
type: i1.DriftSqlType.string,
$customConstraints: 'NULL',
);
class Shape53 extends i0.VersionedTable {
Shape53({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
}
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -16537,6 +17163,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
required Future<void> Function(i1.Migrator m, Schema32 schema) from31To32,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -16690,6 +17317,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from30To31(migrator, schema);
return 31;
case 31:
final schema = Schema32(database: database);
final migrator = i1.Migrator(database, schema);
await from31To32(migrator, schema);
return 32;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -16727,6 +17359,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
required Future<void> Function(i1.Migrator m, Schema32 schema) from31To32,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -16759,5 +17392,6 @@ i1.OnUpgrade stepByStep({
from28To29: from28To29,
from29To30: from29To30,
from30To31: from30To31,
from31To32: from31To32,
),
);

View File

@@ -3,6 +3,7 @@ import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:flutter/foundation.dart';
class SettingsRepository extends DriftDatabaseRepository {
final Drift _db;
@@ -31,6 +32,9 @@ class SettingsRepository extends DriftDatabaseRepository {
return _instance!;
}
@visibleForTesting
static void reset() => _instance = null;
Future<void> refresh() async => _applyOverrides(await _db.select(_db.settingsEntity).get());
Future<void> clear(Iterable<SettingsKey> keys) async {

View File

@@ -71,6 +71,8 @@ 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

@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:drift/drift.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
@@ -685,16 +684,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}
}
List<Bucket> _generateBuckets(int count) {
final buckets = List.filled(
(count / kTimelineNoneSegmentSize).ceil(),
const Bucket(assetCount: kTimelineNoneSegmentSize),
);
if (count % kTimelineNoneSegmentSize != 0) {
buckets[buckets.length - 1] = Bucket(assetCount: count % kTimelineNoneSegmentSize);
}
return buckets;
}
List<Bucket> _generateBuckets(int count) => count == 0 ? const [] : [Bucket(assetCount: count)];
extension on Expression<DateTime> {
Expression<String> dateFmt(GroupAssetsBy groupBy, {bool toLocal = false}) {

View File

@@ -0,0 +1,280 @@
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

@@ -1,311 +0,0 @@
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,6 +333,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
// TODO: Bring back when the soft freeze issue is addressed
// backgroundManager.syncCloudIds(),
]);
await backgroundManager.syncTrash();
} else {
await backgroundManager.hashAssets();
}

228
mobile/lib/platform/asset_media_api.g.dart generated Normal file
View File

@@ -0,0 +1,228 @@
// 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,44 +649,6 @@ 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

@@ -99,6 +99,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
case TimelineReloadEvent():
final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
if (asset != _asset) {
_isZoomed = false;
_viewer.setZoomed(false);
setState(() => _asset = asset);
}
default:

View File

@@ -81,6 +81,12 @@ class AssetViewer extends ConsumerStatefulWidget {
}
class _AssetViewerState extends ConsumerState<AssetViewer> {
static const _viewerOverlayStyle = SystemUiOverlayStyle(
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
systemNavigationBarIconBrightness: Brightness.light,
);
late final _heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
late final _pageController = PageController(initialPage: widget.initialIndex);
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
@@ -280,49 +286,52 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_setSystemUIMode(controls, details);
});
return Scaffold(
backgroundColor: backgroundColor,
resizeToAvoidBottomInset: false,
appBar: const ViewerTopAppBar(),
extendBody: true,
extendBodyBehindAppBar: true,
floatingActionButton: IgnorePointer(
ignoring: !showingControls,
child: AnimatedOpacity(
opacity: showingControls ? 1.0 : 0.0,
duration: Durations.short2,
child: const DownloadStatusFloatingButton(),
),
),
bottomNavigationBar: const ViewerBottomAppBar(),
body: Stack(
children: [
NotificationListener<ScrollEndNotification>(
onNotification: _onScrollEnd,
child: PhotoViewGestureDetectorScope(
axis: Axis.horizontal,
child: PageView.builder(
controller: _pageController,
physics: isZoomed
? const NeverScrollableScrollPhysics()
: CurrentPlatform.isIOS
? const FastScrollPhysics()
: const FastClampingScrollPhysics(),
itemCount: _totalAssets,
itemBuilder: (context, index) =>
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
),
),
return AnnotatedRegion(
value: _viewerOverlayStyle,
child: Scaffold(
backgroundColor: backgroundColor,
resizeToAvoidBottomInset: false,
appBar: const ViewerTopAppBar(),
extendBody: true,
extendBodyBehindAppBar: true,
floatingActionButton: IgnorePointer(
ignoring: !showingControls,
child: AnimatedOpacity(
opacity: showingControls ? 1.0 : 0.0,
duration: Durations.short2,
child: const DownloadStatusFloatingButton(),
),
if (!CurrentPlatform.isIOS)
IgnorePointer(
child: AnimatedContainer(
duration: Durations.short2,
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
height: context.padding.top,
),
bottomNavigationBar: const ViewerBottomAppBar(),
body: Stack(
children: [
NotificationListener<ScrollEndNotification>(
onNotification: _onScrollEnd,
child: PhotoViewGestureDetectorScope(
axis: Axis.horizontal,
child: PageView.builder(
controller: _pageController,
physics: isZoomed
? const NeverScrollableScrollPhysics()
: CurrentPlatform.isIOS
? const FastScrollPhysics()
: const FastClampingScrollPhysics(),
itemCount: _totalAssets,
itemBuilder: (context, index) =>
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
),
),
),
],
if (!CurrentPlatform.isIOS)
IgnorePointer(
child: AnimatedContainer(
duration: Durations.short2,
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
height: context.padding.top,
),
),
],
),
),
);
}

View File

@@ -126,6 +126,7 @@ 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,7 +2,6 @@ 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';
@@ -15,10 +14,6 @@ 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,5 +1,6 @@
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';
@@ -17,6 +18,8 @@ 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,8 +11,7 @@ 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/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
@@ -20,10 +19,7 @@ final syncStreamServiceProvider = Provider(
(ref) => SyncStreamService(
syncApiRepository: ref.watch(syncApiRepositoryProvider),
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
localAssetRepository: ref.watch(localAssetRepository),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
permissionRepository: ref.watch(permissionRepositoryProvider),
trashSyncRepository: ref.watch(trashSyncRepositoryProvider),
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
api: ref.watch(apiServiceProvider),
cancellation: ref.watch(cancellationProvider),
@@ -38,9 +34,7 @@ final localSyncServiceProvider = Provider(
(ref) => LocalSyncService(
localAlbumRepository: ref.watch(localAlbumRepository),
localAssetRepository: ref.watch(localAssetRepository),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
permissionRepository: ref.watch(permissionRepositoryProvider),
trashSyncRepository: ref.watch(trashSyncRepositoryProvider),
nativeSyncApi: ref.watch(nativeSyncApiProvider),
cancellation: ref.watch(cancellationProvider),
),
@@ -51,7 +45,6 @@ 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

@@ -1,12 +1,20 @@
import 'package:async/async.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.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';
typedef TrashedAssetsCount = ({int total, int hashed});
final trashSyncRepositoryProvider = Provider<DriftTrashSyncRepository>(
(ref) => DriftTrashSyncRepository(ref.watch(driftProvider)),
);
final trashedAssetsCountProvider = StreamProvider<TrashedAssetsCount>((ref) {
final repo = ref.watch(trashedLocalAssetRepository);
final total$ = repo.watchCount();
final hashed$ = repo.watchHashedCount();
return StreamZip<int>([total$, hashed$]).map((values) => (total: values[0], hashed: values[1]));
});
final trashSyncServiceProvider = Provider<TrashSyncService>(
(ref) => TrashSyncService(
repo: ref.watch(trashSyncRepositoryProvider),
assetMediaApi: ref.watch(assetMediaApiProvider),
permission: ref.watch(permissionRepositoryProvider),
settings: ref.watch(settingsProvider),
),
);

View File

@@ -11,8 +11,6 @@ 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';
@@ -25,15 +23,14 @@ import 'package:share_plus/share_plus.dart';
typedef _ShareFile = ({File file, bool cleanup, String displayName});
final assetMediaRepositoryProvider = Provider(
(ref) => AssetMediaRepository(ref.watch(nativeSyncApiProvider), ref.watch(storageRepositoryProvider)),
(ref) => AssetMediaRepository(ref.watch(storageRepositoryProvider)),
);
class AssetMediaRepository {
final NativeSyncApi _nativeSyncApi;
final StorageRepository _storageRepository;
static final Logger _log = Logger("AssetMediaRepository");
const AssetMediaRepository(this._nativeSyncApi, this._storageRepository);
const AssetMediaRepository(this._storageRepository);
Future<bool> _androidSupportsTrash() async {
if (Platform.isAndroid) {
@@ -58,27 +55,6 @@ 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

@@ -6,14 +6,10 @@ 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';
@@ -34,7 +30,6 @@ 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),
@@ -47,7 +42,6 @@ class ActionService {
final DriftLocalAssetRepository _localAssetRepository;
final DriftAlbumApiRepository _albumApiRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final DownloadRepository _downloadRepository;
final TagService _tagService;
@@ -58,7 +52,6 @@ class ActionService {
this._localAssetRepository,
this._albumApiRepository,
this._remoteAlbumRepository,
this._trashedLocalAssetRepository,
this._assetMediaRepository,
this._downloadRepository,
this._tagService,
@@ -318,11 +311,7 @@ class ActionService {
if (deletedIds.isEmpty) {
return 0;
}
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds);
} else {
await _localAssetRepository.delete(deletedIds);
}
await _localAssetRepository.delete(deletedIds);
return deletedIds.length;
}
}

View File

@@ -3,7 +3,6 @@ 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

@@ -317,6 +317,8 @@ class BackgroundUploadService {
priority: priority,
isFavorite: asset.isFavorite,
requiresWiFi: requiresWiFi,
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset
fields: entity.isLivePhoto ? {'visibility': api.AssetVisibility.hidden.value} : null,
cloudId: entity.isLivePhoto ? null : asset.cloudId,
adjustmentTime: entity.isLivePhoto ? null : asset.adjustmentTime?.toIso8601String(),
latitude: entity.isLivePhoto ? null : asset.latitude?.toString(),
@@ -336,8 +338,7 @@ class BackgroundUploadService {
return null;
}
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset
final fields = {'livePhotoVideoId': livePhotoVideoId, 'visibility': api.AssetVisibility.hidden.value};
final fields = {'livePhotoVideoId': livePhotoVideoId};
final requiresWiFi = _shouldRequireWiFi(asset);
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' hide AssetVisibility;
@@ -100,7 +101,7 @@ class ForegroundUploadService {
final requireWifi = _shouldRequireWiFi(asset);
return requireWifi && !hasWifi;
},
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
processItem: (asset) => uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
);
}
}
@@ -126,7 +127,7 @@ class ForegroundUploadService {
continue;
}
await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
await uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
}
}
@@ -143,7 +144,7 @@ class ForegroundUploadService {
await _executeWithWorkerPool<LocalAsset>(
items: localAssets,
cancelToken: cancelToken,
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
processItem: (asset) => uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
);
}
@@ -233,7 +234,8 @@ class ForegroundUploadService {
await Future.wait(workerFutures);
}
Future<void> _uploadSingleAsset(
@visibleForTesting
Future<void> uploadSingleAsset(
LocalAsset asset,
Completer<void>? cancelToken, {
required UploadCallbacks callbacks,

View File

@@ -18,7 +18,7 @@ import 'package:immich_mobile/infrastructure/repositories/network.repository.dar
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 = 26;
const int targetVersion = 27;
Future<void> migrateDatabaseIfNeeded(Drift drift) async {
final int version = Store.get(StoreKey.version, targetVersion);
@@ -31,6 +31,10 @@ Future<void> migrateDatabaseIfNeeded(Drift drift) async {
await _migrateTo26(drift);
}
if (version < 27) {
await _migrateTo27(drift);
}
await Store.put(StoreKey.version, targetVersion);
return;
}
@@ -138,6 +142,12 @@ 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

@@ -393,10 +393,8 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
builder: (context, child) {
return Transform.scale(
scale: _zoomAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Transform.translate(
offset: _panAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Stack(
fit: StackFit.expand,
children: [

View File

@@ -496,10 +496,8 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
builder: (context, child) {
return Transform.scale(
scale: _zoomAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Transform.translate(
offset: _panAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Stack(
fit: StackFit.expand,
children: [

View File

@@ -472,10 +472,8 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
builder: (context, child) {
return Transform.scale(
scale: _zoomAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Transform.translate(
offset: _panAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Stack(
fit: StackFit.expand,
children: [

View File

@@ -11,15 +11,16 @@ 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';
@@ -179,64 +180,71 @@ class LoginForm extends HookConsumerWidget {
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
await backgroundManager.syncLocal(full: true);
await backgroundManager.syncRemote();
final syncSuccess = await backgroundManager.syncRemote();
await viewIntentHandler.flushDeferredViewIntent();
await backgroundManager.hashAssets();
if (syncSuccess) {
await backgroundManager.syncTrash();
}
if (SettingsRepository.instance.appConfig.backup.syncAlbums) {
await backgroundManager.syncLinkedAlbum();
}
}
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(),
],
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),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'cancel'.tr(),
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
),
TextButton(
onPressed: () {
unawaited(permission.requestManageMediaPermission());
Navigator.of(context).pop();
},
child: Text(
context.t.manage_media_access_settings,
style: .new(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();
@@ -250,9 +258,7 @@ class LoginForm extends HookConsumerWidget {
unawaited(context.pushRoute(const ChangePasswordRoute()));
} else {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (isSyncRemoteDeletionsMode()) {
await getManageMediaPermission();
}
await promptManageMediaIfNeeded();
unawaited(handleSyncFlow());
ref.read(websocketProvider.notifier).connect();
unawaited(ref.read(featureMessageServiceProvider).markSeen());
@@ -339,9 +345,7 @@ class LoginForm extends HookConsumerWidget {
if (isSuccess) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (isSyncRemoteDeletionsMode()) {
await getManageMediaPermission();
}
await promptManageMediaIfNeeded();
unawaited(handleSyncFlow());
unawaited(ref.read(featureMessageServiceProvider).markSeen());
unawaited(context.router.replaceAll([const TabShellRoute()]));

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 manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final trashSyncEnabled = useState(ref.read(appConfigProvider).trashSyncEnabled);
final isManageMediaSupported = useState(false);
final manageMediaAndroidPermission = useState(false);
final levelId = useState<int>(ref.read(appConfigProvider).logLevel.index);
@@ -75,14 +75,16 @@ class AdvancedSettings extends HookConsumerWidget {
children: [
SettingsSwitchListTile(
enabled: true,
valueNotifier: manageLocalMediaAndroid,
valueNotifier: trashSyncEnabled,
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) {
final result = await ref.read(permissionRepositoryProvider).requestManageMediaPermission();
manageLocalMediaAndroid.value = result;
manageMediaAndroidPermission.value = result;
manageMediaAndroidPermission.value = await ref
.read(permissionRepositoryProvider)
.requestManageMediaPermission();
}
},
),
@@ -90,12 +92,14 @@ 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: manageLocalMediaAndroid.value && !manageMediaAndroidPermission.value
statusColor: trashSyncEnabled.value && !manageMediaAndroidPermission.value
? const Color.fromARGB(255, 243, 188, 106)
: null,
onActionTap: () async {
final result = await ref.read(permissionRepositoryProvider).manageMediaPermission();
manageMediaAndroidPermission.value = result;
await ref.read(permissionRepositoryProvider).manageMediaPermission();
manageMediaAndroidPermission.value = await ref
.read(permissionRepositoryProvider)
.hasManageMediaPermission();
},
),
],

View File

@@ -7,17 +7,14 @@ 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';
@@ -219,7 +216,6 @@ 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();
@@ -353,47 +349,6 @@ 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

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 3.0.2
- API version: 3.0.3
- Generator version: 7.22.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@@ -0,0 +1,33 @@
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,12 +135,6 @@ 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

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 3.0.2+3055
version: 3.0.3+3056
environment:
sdk: '>=3.12.0 <4.0.0'

View File

@@ -1,3 +1,4 @@
import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
@@ -7,3 +8,5 @@ class MockSyncApi extends Mock implements SyncApi {}
class MockServerApi extends Mock implements ServerApi {}
class MockPartnerApiRepository extends Mock implements PartnerApiRepository {}
class MockConnectivityApi extends Mock implements ConnectivityApi {}

View File

@@ -1,8 +1,10 @@
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';
@@ -13,6 +15,10 @@ 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,7 +3,6 @@ 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';
@@ -11,23 +10,17 @@ 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 DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
late AssetMediaRepository mockAssetMediaRepository;
late MockPermissionRepository mockPermissionRepository;
late MockTrashSyncRepository mockTrashSyncRepository;
late MockNativeSyncApi mockNativeSyncApi;
late Drift db;
@@ -48,175 +41,29 @@ void main() {
setUp(() async {
mockLocalAlbumRepository = MockLocalAlbumRepository();
mockLocalAssetRepository = MockLocalAssetRepository();
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
mockAssetMediaRepository = MockAssetMediaRepository();
mockPermissionRepository = MockPermissionRepository();
mockTrashSyncRepository = MockTrashSyncRepository();
mockNativeSyncApi = MockNativeSyncApi();
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
when(() => mockNativeSyncApi.getMediaChanges()).thenAnswer(
(_) async => SyncDelta(hasChanges: false, updates: const [], deletes: const [], assetAlbums: const {}),
);
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;
});
when(() => mockTrashSyncRepository.restoreChecksums()).thenAnswer((_) async {});
sut = LocalSyncService(
localAlbumRepository: mockLocalAlbumRepository,
localAssetRepository: mockLocalAssetRepository,
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
assetMediaRepository: mockAssetMediaRepository,
permissionRepository: mockPermissionRepository,
trashSyncRepository: mockTrashSyncRepository,
nativeSyncApi: mockNativeSyncApi,
);
await Store.put(StoreKey.manageLocalMediaAndroid, false);
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
});
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);
group('LocalSyncService - no changes', () {
test('does nothing when there are no media changes', () async {
await sut.sync();
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()));
verify(() => mockNativeSyncApi.getMediaChanges()).called(1);
verifyNever(() => mockLocalAlbumRepository.updateAll(any()));
});
});

View File

@@ -4,19 +4,15 @@ 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';
@@ -25,7 +21,6 @@ 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 {
@@ -36,15 +31,11 @@ class _AbortCallbackWrapper {
class _MockAbortCallbackWrapper extends Mock implements _AbortCallbackWrapper {}
void main() {
late SyncStreamService sut;
late SyncStreamRepository mockSyncStreamRepo;
late SyncApiRepository mockSyncApiRepo;
late DriftLocalAssetRepository mockLocalAssetRepo;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
late AssetMediaRepository mockAssetMediaRepo;
late MockPermissionRepository mockPermissionRepo;
late MockTrashSyncRepository mockTrashSyncRepo;
late MockApiService mockApi;
late MockServerApi mockServerApi;
late MockSyncMigrationRepository mockSyncMigrationRepo;
@@ -52,7 +43,6 @@ void main() {
late _MockAbortCallbackWrapper mockAbortCallbackWrapper;
late _MockAbortCallbackWrapper mockResetCallbackWrapper;
late Drift db;
late bool hasManageMediaPermission;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -75,10 +65,7 @@ void main() {
setUp(() async {
mockSyncStreamRepo = MockSyncStreamRepository();
mockSyncApiRepo = MockSyncApiRepository();
mockLocalAssetRepo = MockLocalAssetRepository();
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
mockAssetMediaRepo = MockAssetMediaRepository();
mockPermissionRepo = MockPermissionRepository();
mockTrashSyncRepo = MockTrashSyncRepository();
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
mockApi = MockApiService();
@@ -153,26 +140,12 @@ void main() {
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,
syncStreamRepository: mockSyncStreamRepo,
localAssetRepository: mockLocalAssetRepo,
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
assetMediaRepository: mockAssetMediaRepo,
permissionRepository: mockPermissionRepo,
trashSyncRepository: mockTrashSyncRepo,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
);
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);
when(() => mockTrashSyncRepo.recordHardDeletedChecksums(any())).thenAnswer((_) => Future.value());
});
Future<void> simulateEvents(List<SyncEvent> events) async {
@@ -205,6 +178,13 @@ 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];
@@ -236,10 +216,7 @@ void main() {
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,
syncStreamRepository: mockSyncStreamRepo,
localAssetRepository: mockLocalAssetRepo,
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
assetMediaRepository: mockAssetMediaRepo,
permissionRepository: mockPermissionRepo,
trashSyncRepository: mockTrashSyncRepo,
cancellation: cancellation,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
@@ -276,10 +253,7 @@ void main() {
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,
syncStreamRepository: mockSyncStreamRepo,
localAssetRepository: mockLocalAssetRepo,
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
assetMediaRepository: mockAssetMediaRepo,
permissionRepository: mockPermissionRepo,
trashSyncRepository: mockTrashSyncRepo,
cancellation: cancellation,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
@@ -392,164 +366,6 @@ 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

@@ -35,6 +35,7 @@ import 'schema_v28.dart' as v28;
import 'schema_v29.dart' as v29;
import 'schema_v30.dart' as v30;
import 'schema_v31.dart' as v31;
import 'schema_v32.dart' as v32;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -102,6 +103,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v30.DatabaseAtV30(db);
case 31:
return v31.DatabaseAtV31(db);
case 32:
return v32.DatabaseAtV32(db);
default:
throw MissingSchemaException(version, versions);
}
@@ -139,5 +142,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
29,
30,
31,
32,
];
}

File diff suppressed because it is too large Load Diff

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/trashed_local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/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 MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
class MockTrashSyncRepository extends Mock implements DriftTrashSyncRepository {}
class MockStorageRepository extends Mock implements StorageRepository {}

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