diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml
index 2f1446c6e5..1a4243ecae 100644
--- a/.github/workflows/build-mobile.yml
+++ b/.github/workflows/build-mobile.yml
@@ -94,6 +94,7 @@ jobs:
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
+ working_directory: ./mobile
- name: Create the Keystore
if: ${{ !github.event.pull_request.head.repo.fork }}
@@ -219,6 +220,7 @@ jobs:
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
+ working_directory: ./mobile
- name: Install Flutter dependencies
working-directory: ./mobile
diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml
index f2b3e3c248..1346d05112 100644
--- a/.github/workflows/check-openapi.yml
+++ b/.github/workflows/check-openapi.yml
@@ -4,6 +4,7 @@ on:
pull_request:
paths:
- 'open-api/**'
+ - 'mobile/lib/utils/openapi_patching.dart'
- '.github/workflows/check-openapi.yml'
concurrency:
@@ -29,3 +30,36 @@ jobs:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
fail-on: ERR
+
+ check-mobile-patches:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Setup Mise
+ uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
+ with:
+ github_token: ${{ github.token }}
+ working_directory: ./mobile
+
+ - name: Get packages
+ working-directory: ./mobile
+ run: flutter pub get
+
+ - name: Fetch base spec from main
+ run: |
+ curl -fsSL \
+ "https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json" \
+ -o /tmp/base-spec.json
+
+ - name: Check newly-required fields have a backward-compat patch
+ working-directory: ./mobile
+ env:
+ OPENAPI_BASE_SPEC: /tmp/base-spec.json
+ OPENAPI_REVISION_SPEC: ../open-api/immich-openapi-specs.json
+ run: flutter test test/openapi_patches_coverage.dart
diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml
index 872d0c08c7..f3ab31d630 100644
--- a/.github/workflows/static_analysis.yml
+++ b/.github/workflows/static_analysis.yml
@@ -64,6 +64,7 @@ jobs:
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
+ working_directory: ./mobile
- name: Install dependencies
run: flutter pub get
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index fdaee15a59..b0ec88d5f8 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -560,6 +560,7 @@ jobs:
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
+ working_directory: ./mobile
- name: Install dependencies
run: flutter pub get
diff --git a/docs/docs/administration/reverse-proxy.md b/docs/docs/administration/reverse-proxy.md
index b53356139f..3c90c26cf9 100644
--- a/docs/docs/administration/reverse-proxy.md
+++ b/docs/docs/administration/reverse-proxy.md
@@ -112,7 +112,7 @@ services:
traefik.enable: true
# increase readingTimeouts for the entrypoint used here
traefik.http.routers.immich.entrypoints: websecure
- traefik.http.routers.immich.rule: Host(`immich.your-domain.com`)
+ traefik.http.routers.immich.rule: Host(`immich.example.com`)
traefik.http.services.immich.loadbalancer.server.port: 2283
```
diff --git a/docs/docs/administration/server-commands.md b/docs/docs/administration/server-commands.md
index 6938cfadd6..104c16c0a2 100644
--- a/docs/docs/administration/server-commands.md
+++ b/docs/docs/administration/server-commands.md
@@ -90,7 +90,7 @@ immich-admin list-users
[
{
id: 'e65e6f88-2a30-4dbe-8dd9-1885f4889b53',
- email: 'immich@example.com.com',
+ email: 'immich@example.com',
name: 'Immich Admin',
storageLabel: 'admin',
externalPath: null,
diff --git a/docs/docs/guides/database-gui.md b/docs/docs/guides/database-gui.md
index 67b658f838..f9e90c166c 100644
--- a/docs/docs/guides/database-gui.md
+++ b/docs/docs/guides/database-gui.md
@@ -17,7 +17,7 @@ services:
ports:
- "8888:80"
environment:
- PGADMIN_DEFAULT_EMAIL: user-name@domain-name.com
+ PGADMIN_DEFAULT_EMAIL: admin@example.com
PGADMIN_DEFAULT_PASSWORD: strong-password
volumes:
- pgadmin-data:/var/lib/pgadmin
diff --git a/e2e/src/specs/server/api/asset.e2e-spec.ts b/e2e/src/specs/server/api/asset.e2e-spec.ts
index 010b096c4d..7f89cb515d 100644
--- a/e2e/src/specs/server/api/asset.e2e-spec.ts
+++ b/e2e/src/specs/server/api/asset.e2e-spec.ts
@@ -492,20 +492,6 @@ describe('/asset', () => {
expect(status).toEqual(200);
});
- it('should set the negative rating', async () => {
- const { status, body } = await request(app)
- .put(`/assets/${user1Assets[0].id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ rating: -1 });
- expect(body).toMatchObject({
- id: user1Assets[0].id,
- exifInfo: expect.objectContaining({
- rating: -1,
- }),
- });
- expect(status).toEqual(200);
- });
-
it('should return tagged people', async () => {
const { status, body } = await request(app)
.put(`/assets/${user1Assets[0].id}`)
diff --git a/e2e/src/specs/server/api/search.e2e-spec.ts b/e2e/src/specs/server/api/search.e2e-spec.ts
index 09d33b735b..0b86053f78 100644
--- a/e2e/src/specs/server/api/search.e2e-spec.ts
+++ b/e2e/src/specs/server/api/search.e2e-spec.ts
@@ -259,17 +259,6 @@ describe('/search', () => {
assets: [assetHeic],
}),
},
- {
- should: "should search city ('')",
- deferred: () => ({
- dto: {
- city: '',
- visibility: AssetVisibility.Timeline,
- includeNull: true,
- },
- assets: [assetLast],
- }),
- },
{
should: 'should search city (null)',
deferred: () => ({
@@ -291,18 +280,6 @@ describe('/search', () => {
assets: [assetDensity],
}),
},
- {
- should: "should search state ('')",
- deferred: () => ({
- dto: {
- state: '',
- visibility: AssetVisibility.Timeline,
- withExif: true,
- includeNull: true,
- },
- assets: [assetLast, assetNotocactus],
- }),
- },
{
should: 'should search state (null)',
deferred: () => ({
@@ -324,17 +301,6 @@ describe('/search', () => {
assets: [assetFalcon],
}),
},
- {
- should: "should search country ('')",
- deferred: () => ({
- dto: {
- country: '',
- visibility: AssetVisibility.Timeline,
- includeNull: true,
- },
- assets: [assetLast],
- }),
- },
{
should: 'should search country (null)',
deferred: () => ({
diff --git a/i18n/en.json b/i18n/en.json
index f4ad3001c2..adf73aac01 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -699,6 +699,7 @@
"backup_settings_subtitle": "Manage upload settings",
"backup_upload_details_page_more_details": "Tap for more details",
"backward": "Backward",
+ "battery_optimization_backup_reliability": "Disabling battery optimizations can improve the reliability of background backup",
"biometric_auth_enabled": "Biometric authentication enabled",
"biometric_locked_out": "You are locked out of biometric authentication",
"biometric_no_options": "No biometric options available",
@@ -1689,6 +1690,7 @@
"not_selected": "Not selected",
"notes": "Notes",
"nothing_here_yet": "Nothing here yet",
+ "notification_backup_reliability": "Enable notifications to improve background backup reliability",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
"notification_permission_list_tile_content": "Grant permission to enable notifications.",
"notification_permission_list_tile_enable_button": "Enable Notifications",
diff --git a/machine-learning/test_main.py b/machine-learning/test_main.py
index 5145be0045..be574c6397 100644
--- a/machine-learning/test_main.py
+++ b/machine-learning/test_main.py
@@ -816,6 +816,10 @@ class TestFaceRecognition:
def test_recognition(self, cv_image: cv2.Mat, mocker: MockerFixture) -> None:
mocker.patch.object(FaceRecognizer, "load")
+ mocker.patch(
+ "immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
+ return_value=["CPUExecutionProvider"],
+ )
face_recognizer = FaceRecognizer("buffalo_s", min_score=0.0, cache_dir="test_cache")
num_faces = 2
@@ -860,6 +864,10 @@ class TestFaceRecognition:
)
mocker.patch("immich_ml.models.base.InferenceModel.download")
mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX")
+ mocker.patch(
+ "immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
+ return_value=["CPUExecutionProvider"],
+ )
ort_session.return_value.get_inputs.return_value = [SimpleNamespace(name="input.1", shape=(1, 3, 224, 224))]
ort_session.return_value.get_outputs.return_value = [SimpleNamespace(name="output.1", shape=(1, 800))]
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
@@ -894,6 +902,10 @@ class TestFaceRecognition:
)
mocker.patch("immich_ml.models.base.InferenceModel.download")
mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX")
+ mocker.patch(
+ "immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
+ return_value=["CPUExecutionProvider"],
+ )
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
inputs = [SimpleNamespace(name="input.1", shape=("batch", 3, 224, 224))]
@@ -996,6 +1008,10 @@ class TestFaceRecognition:
def test_ignore_other_custom_max_batch_size(self, mocker: MockerFixture) -> None:
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(ocr=2))
+ mocker.patch(
+ "immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
+ return_value=["CPUExecutionProvider"],
+ )
recognizer = FaceRecognizer("buffalo_l", cache_dir="test_cache")
diff --git a/mise.lock b/mise.lock
index fa63fda5e0..df7819caa1 100644
--- a/mise.lock
+++ b/mise.lock
@@ -1,74 +1,5 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
-[[tools."aqua:flutter/flutter"]]
-version = "3.44.1"
-backend = "aqua:flutter/flutter"
-
-[tools."aqua:flutter/flutter"."platforms.linux-arm64"]
-url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
-
-[tools."aqua:flutter/flutter"."platforms.linux-arm64-musl"]
-url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
-
-[tools."aqua:flutter/flutter"."platforms.linux-x64"]
-url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
-
-[tools."aqua:flutter/flutter"."platforms.linux-x64-musl"]
-url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
-
-[tools."aqua:flutter/flutter"."platforms.macos-arm64"]
-checksum = "blake3:15069c982a30ca0189a83edb5627b69d91485ad94fb74d2de8585b43364e9e8e"
-url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.1-stable.zip"
-
-[tools."aqua:flutter/flutter"."platforms.macos-x64"]
-url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.1-stable.zip"
-
-[tools."aqua:flutter/flutter"."platforms.windows-x64"]
-url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.1-stable.zip"
-
-[[tools.flutter]]
-version = "3.41.9-stable"
-backend = "asdf:flutter"
-
-[[tools."github:CQLabs/homebrew-dcm"]]
-version = "1.37.0"
-backend = "github:CQLabs/homebrew-dcm"
-
-[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64"]
-checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
-url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
-url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
-
-[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64-musl"]
-checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
-url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
-url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
-
-[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64"]
-checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
-url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
-url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
-
-[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64-musl"]
-checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
-url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
-url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
-
-[tools."github:CQLabs/homebrew-dcm"."platforms.macos-arm64"]
-checksum = "sha256:30bede64367d09067093cc57af6ec9496d7717898138ded5cb98a16ac8dd9d93"
-url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-arm-release.zip"
-url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543757"
-
-[tools."github:CQLabs/homebrew-dcm"."platforms.macos-x64"]
-checksum = "sha256:e56cb99872be7445a4de1d37e5438ca70e3bcd83be7a2b9b385e3538881f8068"
-url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-x64-release.zip"
-url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543727"
-
-[tools."github:CQLabs/homebrew-dcm"."platforms.windows-x64"]
-checksum = "sha256:f133470daa3fb0427f039b424392af7e917d7e7db6b556aa2a968ab0e31587da"
-url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-windows-release.zip"
-url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543660"
-
[[tools."github:extism/cli"]]
version = "1.6.3"
backend = "github:extism/cli"
@@ -225,30 +156,6 @@ checksum = "sha256:b5e1d2a1ad3c03229ddc89823848f4a1c11f9c6402a51fa26f0aaa5f1d7a2
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-windows.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288925833"
-[[tools.java]]
-version = "21.0.2"
-backend = "core:java"
-
-[tools.java."platforms.linux-arm64"]
-checksum = "sha256:08db1392a48d4eb5ea5315cf8f18b89dbaf36cda663ba882cf03c704c9257ec2"
-url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-aarch64_bin.tar.gz"
-
-[tools.java."platforms.linux-x64"]
-checksum = "sha256:a2def047a73941e01a73739f92755f86b895811afb1f91243db214cff5bdac3f"
-url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz"
-
-[tools.java."platforms.macos-arm64"]
-checksum = "sha256:b3d588e16ec1e0ef9805d8a696591bd518a5cea62567da8f53b5ce32d11d22e4"
-url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-aarch64_bin.tar.gz"
-
-[tools.java."platforms.macos-x64"]
-checksum = "sha256:8fd09e15dc406387a0aba70bf5d99692874e999bf9cd9208b452b5d76ac922d3"
-url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-x64_bin.tar.gz"
-
-[tools.java."platforms.windows-x64"]
-checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
-url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
-
[[tools.node]]
version = "24.15.0"
backend = "core:node"
@@ -321,6 +228,34 @@ url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.
version = "10.33.4"
backend = "aqua:pnpm/pnpm"
+[tools.pnpm."platforms.linux-arm64"]
+checksum = "sha256:d29649c7380b5cd522f574208fbd35335846686498f45004604d3f5b8658b5cb"
+url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-arm64"
+
+[tools.pnpm."platforms.linux-arm64-musl"]
+checksum = "sha256:d29649c7380b5cd522f574208fbd35335846686498f45004604d3f5b8658b5cb"
+url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-arm64"
+
+[tools.pnpm."platforms.linux-x64"]
+checksum = "sha256:ff1795595535a10d0dfe327303f3dd02377be141190b1f5756de68edde2cf813"
+url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-x64"
+
+[tools.pnpm."platforms.linux-x64-musl"]
+checksum = "sha256:ff1795595535a10d0dfe327303f3dd02377be141190b1f5756de68edde2cf813"
+url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-x64"
+
+[tools.pnpm."platforms.macos-arm64"]
+checksum = "sha256:7aae186a04e1ffaa0047d43cd07d68a98dec303304f28be52234ba955d26c671"
+url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-macos-arm64"
+
+[tools.pnpm."platforms.macos-x64"]
+checksum = "sha256:3b0c97b9f794cdda293949a8ee0e0151ca08f512f4a832408386221c7c73eec6"
+url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-macos-x64"
+
+[tools.pnpm."platforms.windows-x64"]
+checksum = "sha256:3268b2f29defe0dce8a3a26c0ef01488f0d4aa4872923173186ef618ab7d68ef"
+url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-win-x64.exe"
+
[[tools.terragrunt]]
version = "1.0.3"
backend = "aqua:gruntwork-io/terragrunt"
diff --git a/mise.toml b/mise.toml
index b501f15f0c..65ed2377be 100644
--- a/mise.toml
+++ b/mise.toml
@@ -16,28 +16,14 @@ config_roots = [
[tools]
node = "24.15.0"
-"aqua:flutter/flutter" = "3.44.1"
pnpm = "10.33.4"
terragrunt = "1.0.3"
opentofu = "1.11.6"
-java = "21.0.2"
"npm:oazapfts" = "7.5.0"
"github:extism/cli" = "1.6.3"
"github:webassembly/binaryen" = "version_124"
"github:extism/js-pdk" = "1.6.0"
-[tools."github:CQLabs/homebrew-dcm"]
-version = "1.37.0"
-bin = "dcm"
-postinstall = "chmod +x \"$MISE_TOOL_INSTALL_PATH/dcm\" || true"
-
-[tools."github:CQLabs/homebrew-dcm".platforms]
-linux-x64 = { asset_pattern = "dcm-linux-x64-release.zip" }
-linux-arm64 = { asset_pattern = "dcm-linux-arm-release.zip" }
-macos-x64 = { asset_pattern = "dcm-macos-x64-release.zip" }
-macos-arm64 = { asset_pattern = "dcm-macos-arm-release.zip" }
-windows-x64 = { asset_pattern = "dcm-windows-release.zip" }
-
[tools."github:jellyfin/jellyfin-ffmpeg"]
version = "7.1.3-6"
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index 436d8c492d..1b8d2a97fb 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -89,6 +89,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
index b4cd705b05..fc9ab28fa2 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
@@ -1,6 +1,7 @@
package app.alextran.immich
import android.content.Context
+import android.content.Intent
import android.os.Build
import android.os.ext.SdkExtensions
import app.alextran.immich.background.BackgroundEngineLock
@@ -22,6 +23,7 @@ import app.alextran.immich.permission.PermissionApiImpl
import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30
+import app.alextran.immich.viewintent.ViewIntentPlugin
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
@@ -31,6 +33,11 @@ class MainActivity : FlutterFragmentActivity() {
registerPlugins(this, flutterEngine)
}
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ }
+
companion object {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
HttpClientManager.initialize(ctx)
@@ -55,6 +62,7 @@ class MainActivity : FlutterFragmentActivity() {
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
+ flutterEngine.plugins.add(ViewIntentPlugin())
flutterEngine.plugins.add(backgroundEngineLockImpl)
flutterEngine.plugins.add(nativeSyncApiImpl)
flutterEngine.plugins.add(permissionApiImpl)
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt
index 48a1a72037..5f7bf806b4 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt
@@ -47,18 +47,44 @@ class FlutterError (
override val message: String? = null,
val details: Any? = null
) : RuntimeException()
+
+enum class PermissionStatus(val raw: Int) {
+ GRANTED(0),
+ DENIED(1),
+ PERMANENTLY_DENIED(2);
+
+ companion object {
+ fun ofRaw(raw: Int): PermissionStatus? {
+ return values().firstOrNull { it.raw == raw }
+ }
+ }
+}
private open class PermissionApiPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
- return super.readValueOfType(type, buffer)
+ return when (type) {
+ 129.toByte() -> {
+ return (readValue(buffer) as Long?)?.let {
+ PermissionStatus.ofRaw(it.toInt())
+ }
+ }
+ else -> super.readValueOfType(type, buffer)
+ }
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
- super.writeValue(stream, value)
+ when (value) {
+ is PermissionStatus -> {
+ stream.write(129)
+ writeValue(stream, value.raw.toLong())
+ }
+ else -> super.writeValue(stream, value)
+ }
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface PermissionApi {
+ fun isIgnoringBatteryOptimizations(): PermissionStatus
fun hasManageMediaPermission(): Boolean
fun requestManageMediaPermission(callback: (Result) -> Unit)
fun manageMediaPermission(callback: (Result) -> Unit)
@@ -72,6 +98,21 @@ interface PermissionApi {
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List = try {
+ listOf(api.isIgnoringBatteryOptimizations())
+ } catch (exception: Throwable) {
+ PermissionApiPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
run {
val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$separatedMessageChannelSuffix", codec)
if (api != null) {
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApiImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApiImpl.kt
index c3443bb06d..4e4ce7b424 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApiImpl.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApiImpl.kt
@@ -1,13 +1,26 @@
package app.alextran.immich.permission
import android.content.Context
+import android.os.PowerManager
import app.alextran.immich.core.ImmichPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
class PermissionApiImpl(context: Context) : ImmichPlugin(), PermissionApi, ActivityAware {
+ private val ctx: Context = context.applicationContext
private val manageMediaPermissionDelegate = ManageMediaPermissionDelegate(context)
+ private val powerManager =
+ ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
+
+
+ override fun isIgnoringBatteryOptimizations(): PermissionStatus {
+ if (powerManager.isIgnoringBatteryOptimizations(ctx.packageName)) {
+ return PermissionStatus.GRANTED
+ }
+ return PermissionStatus.DENIED
+ }
+
override fun hasManageMediaPermission(): Boolean =
manageMediaPermissionDelegate.hasManageMediaPermission()
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt
index 345302026d..02f1cb237d 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt
@@ -542,16 +542,17 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface NativeSyncApi {
- fun shouldFullSync(): Boolean
- fun getMediaChanges(): SyncDelta
+ fun shouldFullSync(callback: (Result) -> Unit)
+ fun getMediaChanges(callback: (Result) -> Unit)
fun checkpointSync()
fun clearSyncCheckpoint()
- fun getAssetIdsForAlbum(albumId: String): List
- fun getAlbums(): List
+ fun getAssetIdsForAlbum(albumId: String, callback: (Result>) -> Unit)
+ fun getAlbums(callback: (Result>) -> Unit)
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
- fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List
+ fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result>) -> Unit)
fun hashAssets(assetIds: List, allowNetworkAccess: Boolean, callback: (Result>) -> Unit)
fun cancelHashing()
+ fun cancelSync()
fun getTrashedAssets(): Map>
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result) -> Unit)
fun getCloudIdForAssetIds(assetIds: List): List
@@ -570,27 +571,33 @@ interface NativeSyncApi {
val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
- val wrapped: List = try {
- listOf(api.shouldFullSync())
- } catch (exception: Throwable) {
- MessagesPigeonUtils.wrapError(exception)
+ api.shouldFullSync{ result: Result ->
+ val error = result.exceptionOrNull()
+ if (error != null) {
+ reply.reply(MessagesPigeonUtils.wrapError(error))
+ } else {
+ val data = result.getOrNull()
+ reply.reply(MessagesPigeonUtils.wrapResult(data))
+ }
}
- reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
- val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$separatedMessageChannelSuffix", codec, taskQueue)
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
- val wrapped: List = try {
- listOf(api.getMediaChanges())
- } catch (exception: Throwable) {
- MessagesPigeonUtils.wrapError(exception)
+ api.getMediaChanges{ result: Result ->
+ val error = result.exceptionOrNull()
+ if (error != null) {
+ reply.reply(MessagesPigeonUtils.wrapError(error))
+ } else {
+ val data = result.getOrNull()
+ reply.reply(MessagesPigeonUtils.wrapResult(data))
+ }
}
- reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -629,32 +636,38 @@ interface NativeSyncApi {
}
}
run {
- val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$separatedMessageChannelSuffix", codec, taskQueue)
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List
val albumIdArg = args[0] as String
- val wrapped: List = try {
- listOf(api.getAssetIdsForAlbum(albumIdArg))
- } catch (exception: Throwable) {
- MessagesPigeonUtils.wrapError(exception)
+ api.getAssetIdsForAlbum(albumIdArg) { result: Result> ->
+ val error = result.exceptionOrNull()
+ if (error != null) {
+ reply.reply(MessagesPigeonUtils.wrapError(error))
+ } else {
+ val data = result.getOrNull()
+ reply.reply(MessagesPigeonUtils.wrapResult(data))
+ }
}
- reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
- val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$separatedMessageChannelSuffix", codec, taskQueue)
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
- val wrapped: List = try {
- listOf(api.getAlbums())
- } catch (exception: Throwable) {
- MessagesPigeonUtils.wrapError(exception)
+ api.getAlbums{ result: Result> ->
+ val error = result.exceptionOrNull()
+ if (error != null) {
+ reply.reply(MessagesPigeonUtils.wrapError(error))
+ } else {
+ val data = result.getOrNull()
+ reply.reply(MessagesPigeonUtils.wrapResult(data))
+ }
}
- reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -679,18 +692,21 @@ interface NativeSyncApi {
}
}
run {
- val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$separatedMessageChannelSuffix", codec, taskQueue)
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List
val albumIdArg = args[0] as String
val updatedTimeCondArg = args[1] as Long?
- val wrapped: List = try {
- listOf(api.getAssetsForAlbum(albumIdArg, updatedTimeCondArg))
- } catch (exception: Throwable) {
- MessagesPigeonUtils.wrapError(exception)
+ api.getAssetsForAlbum(albumIdArg, updatedTimeCondArg) { result: Result> ->
+ val error = result.exceptionOrNull()
+ if (error != null) {
+ reply.reply(MessagesPigeonUtils.wrapError(error))
+ } else {
+ val data = result.getOrNull()
+ reply.reply(MessagesPigeonUtils.wrapResult(data))
+ }
}
- reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -733,6 +749,22 @@ interface NativeSyncApi {
channel.setMessageHandler(null)
}
}
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelSync$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List = try {
+ api.cancelSync()
+ listOf(null)
+ } catch (exception: Throwable) {
+ MessagesPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
run {
val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt
index 6d2c35d78f..180e23286c 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt
@@ -4,7 +4,11 @@ import android.content.Context
class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), NativeSyncApi {
- override fun shouldFullSync(): Boolean {
+ override fun shouldFullSync(callback: (Result) -> Unit) {
+ runSync(callback) { shouldFullSync() }
+ }
+
+ private fun shouldFullSync(): Boolean {
return true
}
@@ -18,7 +22,11 @@ class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), Na
// No-op for Android 10 and below
}
- override fun getMediaChanges(): SyncDelta {
+ override fun getMediaChanges(callback: (Result) -> Unit) {
+ runSync(callback) { getMediaChanges() }
+ }
+
+ private fun getMediaChanges(): SyncDelta {
throw IllegalStateException("Method not supported on this Android version.")
}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt
index ca54c9f823..4785b751c0 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt
@@ -7,6 +7,8 @@ import android.os.Bundle
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.annotation.RequiresExtension
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
import kotlinx.serialization.json.Json
@RequiresApi(Build.VERSION_CODES.Q)
@@ -35,7 +37,11 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
}
}
- override fun shouldFullSync(): Boolean =
+ override fun shouldFullSync(callback: (Result) -> Unit) {
+ runSync(callback) { shouldFullSync() }
+ }
+
+ private fun shouldFullSync(): Boolean =
MediaStore.getVersion(ctx) != prefs.getString(SHARED_PREF_MEDIA_STORE_VERSION_KEY, null)
override fun checkpointSync() {
@@ -49,7 +55,11 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
}
}
- override fun getMediaChanges(): SyncDelta {
+ override fun getMediaChanges(callback: (Result) -> Unit) {
+ runSync(callback) { getMediaChanges() }
+ }
+
+ private suspend fun getMediaChanges(): SyncDelta {
val genMap = getSavedGenerationMap()
val currentVolumes = MediaStore.getExternalVolumeNames(ctx)
val changed = mutableListOf()
@@ -58,6 +68,7 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
var hasChanges = genMap.keys != currentVolumes
for (volume in currentVolumes) {
+ currentCoroutineContext().ensureActive()
val currentGen = MediaStore.getGeneration(ctx, volume)
val storedGen = genMap[volume] ?: 0
if (currentGen <= storedGen) {
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt
index 1f5ff2529e..18b771a613 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt
@@ -45,12 +45,14 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
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
private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS)
private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED"
+ private const val SYNC_CANCELLED_CODE = "SYNC_CANCELLED"
// MediaStore.Files.FileColumns.SPECIAL_FORMAT — S Extensions 21+
// https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT
@@ -295,7 +297,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
return PlatformAssetPlaybackStyle.IMAGE
}
- fun getAlbums(): List {
+ fun getAlbums(callback: (Result>) -> Unit) {
+ runSync(callback) { getAlbums() }
+ }
+
+ private suspend fun getAlbums(): List {
val albums = mutableListOf()
val albumsCount = mutableMapOf()
@@ -322,6 +328,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_MODIFIED)
while (cursor.moveToNext()) {
+ currentCoroutineContext().ensureActive()
val id = cursor.getString(bucketIdColumn)
val count = albumsCount.getOrDefault(id, 0)
@@ -342,7 +349,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
.sortedBy { it.id }
}
- fun getAssetIdsForAlbum(albumId: String): List {
+ fun getAssetIdsForAlbum(albumId: String, callback: (Result>) -> Unit) {
+ runSync(callback) { getAssetIdsForAlbum(albumId) }
+ }
+
+ private fun getAssetIdsForAlbum(albumId: String): List {
val projection = arrayOf(MediaStore.MediaColumns._ID)
return getCursor(
@@ -366,7 +377,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
)?.use { cursor -> cursor.count.toLong() } ?: 0L
- fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List {
+ fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result>) -> Unit) {
+ runSync(callback) { getAssetsForAlbum(albumId, updatedTimeCond) }
+ }
+
+ private fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List {
var selection = "$BUCKET_SELECTION AND $MEDIA_SELECTION"
val selectionArgs = mutableListOf(albumId, *MEDIA_SELECTION_ARGS)
@@ -451,6 +466,24 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
hashTask = null
}
+ fun cancelSync() {
+ syncJob?.cancel()
+ syncJob = null
+ }
+
+ protected fun runSync(callback: (Result) -> Unit, work: suspend () -> T) {
+ syncJob?.cancel()
+ syncJob = CoroutineScope(Dispatchers.IO).launch {
+ try {
+ completeWhenActive(callback, Result.success(work()))
+ } catch (e: CancellationException) {
+ 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) -> Unit) {
mediaTrashDelegate.restoreFromTrashById(mediaId, type) { completeWhenActive(callback, it) }
}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntent.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntent.g.kt
new file mode 100644
index 0000000000..1d5af15cb4
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntent.g.kt
@@ -0,0 +1,292 @@
+// 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.viewintent
+
+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 ViewIntentPigeonUtils {
+
+ fun wrapResult(result: Any?): List {
+ return listOf(result)
+ }
+
+ fun wrapError(exception: Throwable): List {
+ return if (exception is FlutterError) {
+ listOf(
+ exception.code,
+ exception.message,
+ exception.details
+ )
+ } else {
+ listOf(
+ exception.javaClass.simpleName,
+ exception.toString(),
+ "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
+ )
+ }
+ }
+ 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()
+
+/** Generated class from Pigeon that represents data sent in messages. */
+data class ViewIntentPayload (
+ val path: String? = null,
+ val mimeType: String,
+ val localAssetId: String? = null
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): ViewIntentPayload {
+ val path = pigeonVar_list[0] as String?
+ val mimeType = pigeonVar_list[1] as String
+ val localAssetId = pigeonVar_list[2] as String?
+ return ViewIntentPayload(path, mimeType, localAssetId)
+ }
+ }
+ fun toList(): List {
+ return listOf(
+ path,
+ mimeType,
+ localAssetId,
+ )
+ }
+ override fun equals(other: Any?): Boolean {
+ if (other == null || other.javaClass != javaClass) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ val other = other as ViewIntentPayload
+ return ViewIntentPigeonUtils.deepEquals(this.path, other.path) && ViewIntentPigeonUtils.deepEquals(this.mimeType, other.mimeType) && ViewIntentPigeonUtils.deepEquals(this.localAssetId, other.localAssetId)
+ }
+
+ override fun hashCode(): Int {
+ var result = javaClass.hashCode()
+ result = 31 * result + ViewIntentPigeonUtils.deepHash(this.path)
+ result = 31 * result + ViewIntentPigeonUtils.deepHash(this.mimeType)
+ result = 31 * result + ViewIntentPigeonUtils.deepHash(this.localAssetId)
+ return result
+ }
+}
+private open class ViewIntentPigeonCodec : StandardMessageCodec() {
+ override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
+ return when (type) {
+ 129.toByte() -> {
+ return (readValue(buffer) as? List)?.let {
+ ViewIntentPayload.fromList(it)
+ }
+ }
+ else -> super.readValueOfType(type, buffer)
+ }
+ }
+ override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
+ when (value) {
+ is ViewIntentPayload -> {
+ stream.write(129)
+ writeValue(stream, value.toList())
+ }
+ else -> super.writeValue(stream, value)
+ }
+ }
+}
+
+
+/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
+interface ViewIntentHostApi {
+ fun consumeViewIntent(callback: (Result) -> Unit)
+
+ companion object {
+ /** The codec used by ViewIntentHostApi. */
+ val codec: MessageCodec by lazy {
+ ViewIntentPigeonCodec()
+ }
+ /** Sets up an instance of `ViewIntentHostApi` to handle messages through the `binaryMessenger`. */
+ @JvmOverloads
+ fun setUp(binaryMessenger: BinaryMessenger, api: ViewIntentHostApi?, messageChannelSuffix: String = "") {
+ val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ api.consumeViewIntent{ result: Result ->
+ val error = result.exceptionOrNull()
+ if (error != null) {
+ reply.reply(ViewIntentPigeonUtils.wrapError(error))
+ } else {
+ val data = result.getOrNull()
+ reply.reply(ViewIntentPigeonUtils.wrapResult(data))
+ }
+ }
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ }
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt
new file mode 100644
index 0000000000..a1e1fea3dd
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt
@@ -0,0 +1,201 @@
+package app.alextran.immich.viewintent
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.provider.DocumentsContract
+import android.provider.MediaStore
+import android.provider.OpenableColumns
+import android.util.Log
+import android.webkit.MimeTypeMap
+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.PluginRegistry
+import java.io.File
+import java.io.FileOutputStream
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+
+private const val TAG = "ViewIntentPlugin"
+
+class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener, ViewIntentHostApi {
+ private var context: Context? = null
+ private var activity: Activity? = null
+ private var unconsumedIntent: Intent? = null
+ private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
+ context = binding.applicationContext
+ ViewIntentHostApi.setUp(binding.binaryMessenger, this)
+ }
+
+ override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
+ ViewIntentHostApi.setUp(binding.binaryMessenger, null)
+ ioScope.cancel()
+ context = null
+ }
+
+ override fun onAttachedToActivity(binding: ActivityPluginBinding) {
+ activity = binding.activity
+ unconsumedIntent = binding.activity.intent
+ binding.addOnNewIntentListener(this)
+ }
+
+ override fun onDetachedFromActivityForConfigChanges() {
+ activity = null
+ }
+
+ override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
+ onAttachedToActivity(binding)
+ }
+
+ override fun onDetachedFromActivity() {
+ activity = null
+ }
+
+ override fun onNewIntent(intent: Intent): Boolean {
+ unconsumedIntent = intent
+ return false
+ }
+
+ override fun consumeViewIntent(callback: (Result) -> Unit) {
+ val context = context ?: run {
+ callback(Result.success(null))
+ return
+ }
+ val intent = unconsumedIntent ?: activity?.intent
+
+ if (intent?.action != Intent.ACTION_VIEW) {
+ callback(Result.success(null))
+ return
+ }
+
+ val uri = intent.data
+ if (uri == null) {
+ callback(Result.success(null))
+ return
+ }
+
+ ioScope.launch {
+ try {
+ val mimeType = context.contentResolver.getType(uri) ?: intent.type
+ if (mimeType == null || (!mimeType.startsWith("image/") && !mimeType.startsWith("video/"))) {
+ callback(Result.success(null))
+ return@launch
+ }
+
+ val localAssetId = extractLocalAssetId(context, uri, mimeType)
+ val tempFilePath = if (localAssetId == null) {
+ copyUriToTempFile(context, uri, mimeType)?.absolutePath ?: run {
+ callback(Result.success(null))
+ return@launch
+ }
+ } else {
+ null
+ }
+ val payload = ViewIntentPayload(
+ path = tempFilePath,
+ mimeType = mimeType,
+ localAssetId = localAssetId,
+ )
+ consumeViewIntent(intent)
+ callback(Result.success(payload))
+ } catch (e: Exception) {
+ callback(Result.failure(e))
+ }
+ }
+ }
+
+ private fun consumeViewIntent(currentIntent: Intent) {
+ unconsumedIntent = Intent(currentIntent).apply {
+ action = null
+ data = null
+ type = null
+ }
+ activity?.intent = unconsumedIntent
+ }
+
+ private fun extractLocalAssetId(context: Context, uri: Uri, mimeType: String): String? {
+ return tryExtractDocumentLocalAssetId(context, uri)
+ ?: tryParseContentUriId(uri)
+ ?: resolveLocalIdByNameAndSize(context, uri, mimeType)
+ }
+
+ private fun tryExtractDocumentLocalAssetId(context: Context, uri: Uri): String? {
+ return try {
+ if (!DocumentsContract.isDocumentUri(context, uri)) return null
+ val docId = DocumentsContract.getDocumentId(uri)
+ if (docId.isBlank() || docId.startsWith("raw:")) return null
+ docId.substringAfter(':', docId).toLongOrNull()?.toString()
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to resolve local asset id from document URI: $uri", e)
+ null
+ }
+ }
+
+ private fun tryParseContentUriId(uri: Uri): String? {
+ val id = uri.lastPathSegment?.toLongOrNull() ?: return null
+ return if (id >= 0) id.toString() else null
+ }
+
+ private fun copyUriToTempFile(context: Context, uri: Uri, mimeType: String): File? {
+ return try {
+ val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let { ".$it" }
+ val tempFile = File.createTempFile("view_intent_", extension, context.cacheDir)
+ context.contentResolver.openInputStream(uri)?.use { inputStream ->
+ FileOutputStream(tempFile).use { outputStream ->
+ inputStream.copyTo(outputStream)
+ }
+ } ?: return null
+ tempFile
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ private fun resolveLocalIdByNameAndSize(context: Context, uri: Uri, mimeType: String): String? {
+ val metaProjection = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
+ val (displayName, size) =
+ try {
+ context.contentResolver.query(uri, metaProjection, null, null, null)?.use { cursor ->
+ if (!cursor.moveToFirst()) return null
+ val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+ val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
+ val name = if (nameIdx >= 0) cursor.getString(nameIdx) else null
+ val bytes = if (sizeIdx >= 0) cursor.getLong(sizeIdx) else -1L
+ if (name.isNullOrBlank() || bytes < 0) return null
+ name to bytes
+ } ?: return null
+ } catch (_: Exception) {
+ return null
+ }
+
+ val tableUri = when {
+ mimeType.startsWith("image/") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
+ mimeType.startsWith("video/") -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
+ else -> return null
+ }
+ return try {
+ context.contentResolver
+ .query(
+ tableUri,
+ arrayOf(MediaStore.MediaColumns._ID),
+ "${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.SIZE}=?",
+ arrayOf(displayName, size.toString()),
+ "${MediaStore.MediaColumns.DATE_MODIFIED} DESC",
+ )?.use { cursor ->
+ if (!cursor.moveToFirst()) return null
+ val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID)
+ if (idIndex < 0) return null
+ cursor.getLong(idIndex).toString()
+ }
+ } catch (_: Exception) {
+ null
+ }
+ }
+}
diff --git a/mobile/drift_schemas/main/drift_schema_v28.json b/mobile/drift_schemas/main/drift_schema_v28.json
new file mode 100644
index 0000000000..c635d0a6a2
--- /dev/null
+++ b/mobile/drift_schemas/main/drift_schema_v28.json
@@ -0,0 +1,3391 @@
+{
+ "_meta": {
+ "description": "This file contains a serialized version of schema entities for drift.",
+ "version": "1.3.0"
+ },
+ "options": {
+ "store_date_time_values_as_text": true
+ },
+ "entities": [
+ {
+ "id": 0,
+ "references": [],
+ "type": "table",
+ "data": {
+ "name": "user_entity",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "id",
+ "getter_name": "id",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "name",
+ "getter_name": "name",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "email",
+ "getter_name": "email",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "has_profile_image",
+ "getter_name": "hasProfileImage",
+ "moor_type": "bool",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "CHECK (\"has_profile_image\" IN (0, 1))",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "CHECK (\"has_profile_image\" IN (0, 1))"
+ },
+ "default_dart": "const CustomExpression('0')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "profile_changed_at",
+ "getter_name": "profileChangedAt",
+ "moor_type": "dateTime",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "avatar_color",
+ "getter_name": "avatarColor",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('0')",
+ "default_client_dart": null,
+ "dsl_features": [],
+ "type_converter": {
+ "dart_expr": "const EnumIndexConverter(AvatarColor.values)",
+ "dart_type_name": "AvatarColor"
+ }
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "id"
+ ]
+ }
+ },
+ {
+ "id": 1,
+ "references": [
+ 0
+ ],
+ "type": "table",
+ "data": {
+ "name": "remote_asset_entity",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "name",
+ "getter_name": "name",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "type",
+ "getter_name": "type",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [],
+ "type_converter": {
+ "dart_expr": "const EnumIndexConverter(AssetType.values)",
+ "dart_type_name": "AssetType"
+ }
+ },
+ {
+ "name": "created_at",
+ "getter_name": "createdAt",
+ "moor_type": "dateTime",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "updated_at",
+ "getter_name": "updatedAt",
+ "moor_type": "dateTime",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "width",
+ "getter_name": "width",
+ "moor_type": "int",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "height",
+ "getter_name": "height",
+ "moor_type": "int",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "duration_ms",
+ "getter_name": "durationMs",
+ "moor_type": "int",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "id",
+ "getter_name": "id",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "checksum",
+ "getter_name": "checksum",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "is_favorite",
+ "getter_name": "isFavorite",
+ "moor_type": "bool",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "CHECK (\"is_favorite\" IN (0, 1))",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "CHECK (\"is_favorite\" IN (0, 1))"
+ },
+ "default_dart": "const CustomExpression('0')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "owner_id",
+ "getter_name": "ownerId",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES user_entity (id) ON DELETE CASCADE",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES user_entity (id) ON DELETE CASCADE"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "user_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "cascade"
+ }
+ }
+ ]
+ },
+ {
+ "name": "local_date_time",
+ "getter_name": "localDateTime",
+ "moor_type": "dateTime",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "thumb_hash",
+ "getter_name": "thumbHash",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "deleted_at",
+ "getter_name": "deletedAt",
+ "moor_type": "dateTime",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "uploaded_at",
+ "getter_name": "uploadedAt",
+ "moor_type": "dateTime",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "live_photo_video_id",
+ "getter_name": "livePhotoVideoId",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "visibility",
+ "getter_name": "visibility",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [],
+ "type_converter": {
+ "dart_expr": "const EnumIndexConverter(AssetVisibility.values)",
+ "dart_type_name": "AssetVisibility"
+ }
+ },
+ {
+ "name": "stack_id",
+ "getter_name": "stackId",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "library_id",
+ "getter_name": "libraryId",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "is_edited",
+ "getter_name": "isEdited",
+ "moor_type": "bool",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "CHECK (\"is_edited\" IN (0, 1))",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "CHECK (\"is_edited\" IN (0, 1))"
+ },
+ "default_dart": "const CustomExpression('0')",
+ "default_client_dart": null,
+ "dsl_features": []
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "id"
+ ]
+ }
+ },
+ {
+ "id": 2,
+ "references": [
+ 0
+ ],
+ "type": "table",
+ "data": {
+ "name": "stack_entity",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "id",
+ "getter_name": "id",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "created_at",
+ "getter_name": "createdAt",
+ "moor_type": "dateTime",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "updated_at",
+ "getter_name": "updatedAt",
+ "moor_type": "dateTime",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "owner_id",
+ "getter_name": "ownerId",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES user_entity (id) ON DELETE CASCADE",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES user_entity (id) ON DELETE CASCADE"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "user_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "cascade"
+ }
+ }
+ ]
+ },
+ {
+ "name": "primary_asset_id",
+ "getter_name": "primaryAssetId",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "id"
+ ]
+ }
+ },
+ {
+ "id": 3,
+ "references": [],
+ "type": "table",
+ "data": {
+ "name": "local_asset_entity",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "name",
+ "getter_name": "name",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "type",
+ "getter_name": "type",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [],
+ "type_converter": {
+ "dart_expr": "const EnumIndexConverter(AssetType.values)",
+ "dart_type_name": "AssetType"
+ }
+ },
+ {
+ "name": "created_at",
+ "getter_name": "createdAt",
+ "moor_type": "dateTime",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "updated_at",
+ "getter_name": "updatedAt",
+ "moor_type": "dateTime",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "width",
+ "getter_name": "width",
+ "moor_type": "int",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "height",
+ "getter_name": "height",
+ "moor_type": "int",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "duration_ms",
+ "getter_name": "durationMs",
+ "moor_type": "int",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "id",
+ "getter_name": "id",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "checksum",
+ "getter_name": "checksum",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "is_favorite",
+ "getter_name": "isFavorite",
+ "moor_type": "bool",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "CHECK (\"is_favorite\" IN (0, 1))",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "CHECK (\"is_favorite\" IN (0, 1))"
+ },
+ "default_dart": "const CustomExpression('0')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "orientation",
+ "getter_name": "orientation",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('0')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "i_cloud_id",
+ "getter_name": "iCloudId",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "adjustment_time",
+ "getter_name": "adjustmentTime",
+ "moor_type": "dateTime",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "latitude",
+ "getter_name": "latitude",
+ "moor_type": "double",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "longitude",
+ "getter_name": "longitude",
+ "moor_type": "double",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "playback_style",
+ "getter_name": "playbackStyle",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('0')",
+ "default_client_dart": null,
+ "dsl_features": [],
+ "type_converter": {
+ "dart_expr": "const EnumIndexConverter(AssetPlaybackStyle.values)",
+ "dart_type_name": "AssetPlaybackStyle"
+ }
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "id"
+ ]
+ }
+ },
+ {
+ "id": 4,
+ "references": [
+ 1
+ ],
+ "type": "table",
+ "data": {
+ "name": "remote_album_entity",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "id",
+ "getter_name": "id",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "name",
+ "getter_name": "name",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "description",
+ "getter_name": "description",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('\\'\\'')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "created_at",
+ "getter_name": "createdAt",
+ "moor_type": "dateTime",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "updated_at",
+ "getter_name": "updatedAt",
+ "moor_type": "dateTime",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "thumbnail_asset_id",
+ "getter_name": "thumbnailAssetId",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES remote_asset_entity (id) ON DELETE SET NULL",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES remote_asset_entity (id) ON DELETE SET NULL"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "remote_asset_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "setNull"
+ }
+ }
+ ]
+ },
+ {
+ "name": "is_activity_enabled",
+ "getter_name": "isActivityEnabled",
+ "moor_type": "bool",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "CHECK (\"is_activity_enabled\" IN (0, 1))",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "CHECK (\"is_activity_enabled\" IN (0, 1))"
+ },
+ "default_dart": "const CustomExpression('1')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "order",
+ "getter_name": "order",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [],
+ "type_converter": {
+ "dart_expr": "const EnumIndexConverter(AlbumAssetOrder.values)",
+ "dart_type_name": "AlbumAssetOrder"
+ }
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "id"
+ ]
+ }
+ },
+ {
+ "id": 5,
+ "references": [
+ 4
+ ],
+ "type": "table",
+ "data": {
+ "name": "local_album_entity",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "id",
+ "getter_name": "id",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "name",
+ "getter_name": "name",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "updated_at",
+ "getter_name": "updatedAt",
+ "moor_type": "dateTime",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "backup_selection",
+ "getter_name": "backupSelection",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [],
+ "type_converter": {
+ "dart_expr": "const EnumIndexConverter(BackupSelection.values)",
+ "dart_type_name": "BackupSelection"
+ }
+ },
+ {
+ "name": "is_ios_shared_album",
+ "getter_name": "isIosSharedAlbum",
+ "moor_type": "bool",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "CHECK (\"is_ios_shared_album\" IN (0, 1))",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "CHECK (\"is_ios_shared_album\" IN (0, 1))"
+ },
+ "default_dart": "const CustomExpression('0')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "linked_remote_album_id",
+ "getter_name": "linkedRemoteAlbumId",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES remote_album_entity (id) ON DELETE SET NULL",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES remote_album_entity (id) ON DELETE SET NULL"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "remote_album_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "setNull"
+ }
+ }
+ ]
+ },
+ {
+ "name": "marker",
+ "getter_name": "marker_",
+ "moor_type": "bool",
+ "nullable": true,
+ "customConstraints": null,
+ "defaultConstraints": "CHECK (\"marker\" IN (0, 1))",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "CHECK (\"marker\" IN (0, 1))"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "id"
+ ]
+ }
+ },
+ {
+ "id": 6,
+ "references": [
+ 3,
+ 5
+ ],
+ "type": "table",
+ "data": {
+ "name": "local_album_asset_entity",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "asset_id",
+ "getter_name": "assetId",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES local_asset_entity (id) ON DELETE CASCADE",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES local_asset_entity (id) ON DELETE CASCADE"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "local_asset_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "cascade"
+ }
+ }
+ ]
+ },
+ {
+ "name": "album_id",
+ "getter_name": "albumId",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES local_album_entity (id) ON DELETE CASCADE",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES local_album_entity (id) ON DELETE CASCADE"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "local_album_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "cascade"
+ }
+ }
+ ]
+ },
+ {
+ "name": "marker",
+ "getter_name": "marker_",
+ "moor_type": "bool",
+ "nullable": true,
+ "customConstraints": null,
+ "defaultConstraints": "CHECK (\"marker\" IN (0, 1))",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "CHECK (\"marker\" IN (0, 1))"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "asset_id",
+ "album_id"
+ ]
+ }
+ },
+ {
+ "id": 7,
+ "references": [
+ 6
+ ],
+ "type": "index",
+ "data": {
+ "on": 6,
+ "name": "idx_local_album_asset_album_asset",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)",
+ "unique": false,
+ "columns": []
+ }
+ },
+ {
+ "id": 8,
+ "references": [
+ 3
+ ],
+ "type": "index",
+ "data": {
+ "on": 3,
+ "name": "idx_local_asset_checksum",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)",
+ "unique": false,
+ "columns": []
+ }
+ },
+ {
+ "id": 9,
+ "references": [
+ 3
+ ],
+ "type": "index",
+ "data": {
+ "on": 3,
+ "name": "idx_local_asset_cloud_id",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)",
+ "unique": false,
+ "columns": []
+ }
+ },
+ {
+ "id": 10,
+ "references": [
+ 3
+ ],
+ "type": "index",
+ "data": {
+ "on": 3,
+ "name": "idx_local_asset_created_at",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)",
+ "unique": false,
+ "columns": []
+ }
+ },
+ {
+ "id": 11,
+ "references": [
+ 2
+ ],
+ "type": "index",
+ "data": {
+ "on": 2,
+ "name": "idx_stack_primary_asset_id",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)",
+ "unique": false,
+ "columns": []
+ }
+ },
+ {
+ "id": 12,
+ "references": [
+ 1
+ ],
+ "type": "index",
+ "data": {
+ "on": 1,
+ "name": "UQ_remote_assets_owner_checksum",
+ "sql": "CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum\nON remote_asset_entity (owner_id, checksum)\nWHERE (library_id IS NULL);\n",
+ "unique": true,
+ "columns": []
+ }
+ },
+ {
+ "id": 13,
+ "references": [
+ 1
+ ],
+ "type": "index",
+ "data": {
+ "on": 1,
+ "name": "UQ_remote_assets_owner_library_checksum",
+ "sql": "CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum\nON remote_asset_entity (owner_id, library_id, checksum)\nWHERE (library_id IS NOT NULL);\n",
+ "unique": true,
+ "columns": []
+ }
+ },
+ {
+ "id": 14,
+ "references": [
+ 1
+ ],
+ "type": "index",
+ "data": {
+ "on": 1,
+ "name": "idx_remote_asset_checksum",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)",
+ "unique": false,
+ "columns": []
+ }
+ },
+ {
+ "id": 15,
+ "references": [
+ 1
+ ],
+ "type": "index",
+ "data": {
+ "on": 1,
+ "name": "idx_remote_asset_stack_id",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)",
+ "unique": false,
+ "columns": []
+ }
+ },
+ {
+ "id": 16,
+ "references": [
+ 1
+ ],
+ "type": "index",
+ "data": {
+ "on": 1,
+ "name": "idx_remote_asset_owner_visibility_deleted_created",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created\nON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)\n",
+ "unique": false,
+ "columns": []
+ }
+ },
+ {
+ "id": 17,
+ "references": [],
+ "type": "table",
+ "data": {
+ "name": "auth_user_entity",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "id",
+ "getter_name": "id",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "name",
+ "getter_name": "name",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "email",
+ "getter_name": "email",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "is_admin",
+ "getter_name": "isAdmin",
+ "moor_type": "bool",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "CHECK (\"is_admin\" IN (0, 1))",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "CHECK (\"is_admin\" IN (0, 1))"
+ },
+ "default_dart": "const CustomExpression('0')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "has_profile_image",
+ "getter_name": "hasProfileImage",
+ "moor_type": "bool",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "CHECK (\"has_profile_image\" IN (0, 1))",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "CHECK (\"has_profile_image\" IN (0, 1))"
+ },
+ "default_dart": "const CustomExpression('0')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "profile_changed_at",
+ "getter_name": "profileChangedAt",
+ "moor_type": "dateTime",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "avatar_color",
+ "getter_name": "avatarColor",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [],
+ "type_converter": {
+ "dart_expr": "const EnumIndexConverter(AvatarColor.values)",
+ "dart_type_name": "AvatarColor"
+ }
+ },
+ {
+ "name": "quota_size_in_bytes",
+ "getter_name": "quotaSizeInBytes",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('0')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "quota_usage_in_bytes",
+ "getter_name": "quotaUsageInBytes",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('0')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "pin_code",
+ "getter_name": "pinCode",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "id"
+ ]
+ }
+ },
+ {
+ "id": 18,
+ "references": [
+ 0
+ ],
+ "type": "table",
+ "data": {
+ "name": "user_metadata_entity",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "user_id",
+ "getter_name": "userId",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES user_entity (id) ON DELETE CASCADE",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES user_entity (id) ON DELETE CASCADE"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "user_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "cascade"
+ }
+ }
+ ]
+ },
+ {
+ "name": "key",
+ "getter_name": "key",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [],
+ "type_converter": {
+ "dart_expr": "const EnumIndexConverter(UserMetadataKey.values)",
+ "dart_type_name": "UserMetadataKey"
+ }
+ },
+ {
+ "name": "value",
+ "getter_name": "value",
+ "moor_type": "blob",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [],
+ "type_converter": {
+ "dart_expr": "userMetadataConverter",
+ "dart_type_name": "Map"
+ }
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "user_id",
+ "key"
+ ]
+ }
+ },
+ {
+ "id": 19,
+ "references": [
+ 0
+ ],
+ "type": "table",
+ "data": {
+ "name": "partner_entity",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "shared_by_id",
+ "getter_name": "sharedById",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES user_entity (id) ON DELETE CASCADE",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES user_entity (id) ON DELETE CASCADE"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "user_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "cascade"
+ }
+ }
+ ]
+ },
+ {
+ "name": "shared_with_id",
+ "getter_name": "sharedWithId",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES user_entity (id) ON DELETE CASCADE",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES user_entity (id) ON DELETE CASCADE"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "user_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "cascade"
+ }
+ }
+ ]
+ },
+ {
+ "name": "in_timeline",
+ "getter_name": "inTimeline",
+ "moor_type": "bool",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "CHECK (\"in_timeline\" IN (0, 1))",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "CHECK (\"in_timeline\" IN (0, 1))"
+ },
+ "default_dart": "const CustomExpression('0')",
+ "default_client_dart": null,
+ "dsl_features": []
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "shared_by_id",
+ "shared_with_id"
+ ]
+ }
+ },
+ {
+ "id": 20,
+ "references": [
+ 1
+ ],
+ "type": "table",
+ "data": {
+ "name": "remote_exif_entity",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "asset_id",
+ "getter_name": "assetId",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES remote_asset_entity (id) ON DELETE CASCADE",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES remote_asset_entity (id) ON DELETE CASCADE"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "remote_asset_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "cascade"
+ }
+ }
+ ]
+ },
+ {
+ "name": "city",
+ "getter_name": "city",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "state",
+ "getter_name": "state",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "country",
+ "getter_name": "country",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "date_time_original",
+ "getter_name": "dateTimeOriginal",
+ "moor_type": "dateTime",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "description",
+ "getter_name": "description",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "height",
+ "getter_name": "height",
+ "moor_type": "int",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "width",
+ "getter_name": "width",
+ "moor_type": "int",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "exposure_time",
+ "getter_name": "exposureTime",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "f_number",
+ "getter_name": "fNumber",
+ "moor_type": "double",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "file_size",
+ "getter_name": "fileSize",
+ "moor_type": "int",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "focal_length",
+ "getter_name": "focalLength",
+ "moor_type": "double",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "latitude",
+ "getter_name": "latitude",
+ "moor_type": "double",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "longitude",
+ "getter_name": "longitude",
+ "moor_type": "double",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "iso",
+ "getter_name": "iso",
+ "moor_type": "int",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "make",
+ "getter_name": "make",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "model",
+ "getter_name": "model",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "lens",
+ "getter_name": "lens",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "orientation",
+ "getter_name": "orientation",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "time_zone",
+ "getter_name": "timeZone",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "rating",
+ "getter_name": "rating",
+ "moor_type": "int",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "projection_type",
+ "getter_name": "projectionType",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "asset_id"
+ ]
+ }
+ },
+ {
+ "id": 21,
+ "references": [
+ 1,
+ 4
+ ],
+ "type": "table",
+ "data": {
+ "name": "remote_album_asset_entity",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "asset_id",
+ "getter_name": "assetId",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES remote_asset_entity (id) ON DELETE CASCADE",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES remote_asset_entity (id) ON DELETE CASCADE"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "remote_asset_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "cascade"
+ }
+ }
+ ]
+ },
+ {
+ "name": "album_id",
+ "getter_name": "albumId",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES remote_album_entity (id) ON DELETE CASCADE",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES remote_album_entity (id) ON DELETE CASCADE"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "remote_album_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "cascade"
+ }
+ }
+ ]
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "asset_id",
+ "album_id"
+ ]
+ }
+ },
+ {
+ "id": 22,
+ "references": [
+ 4,
+ 0
+ ],
+ "type": "table",
+ "data": {
+ "name": "remote_album_user_entity",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "album_id",
+ "getter_name": "albumId",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES remote_album_entity (id) ON DELETE CASCADE",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES remote_album_entity (id) ON DELETE CASCADE"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "remote_album_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "cascade"
+ }
+ }
+ ]
+ },
+ {
+ "name": "user_id",
+ "getter_name": "userId",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES user_entity (id) ON DELETE CASCADE",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES user_entity (id) ON DELETE CASCADE"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "user_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "cascade"
+ }
+ }
+ ]
+ },
+ {
+ "name": "role",
+ "getter_name": "role",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [],
+ "type_converter": {
+ "dart_expr": "const EnumIndexConverter(AlbumUserRole.values)",
+ "dart_type_name": "AlbumUserRole"
+ }
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "album_id",
+ "user_id"
+ ]
+ }
+ },
+ {
+ "id": 23,
+ "references": [
+ 1
+ ],
+ "type": "table",
+ "data": {
+ "name": "remote_asset_cloud_id_entity",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "asset_id",
+ "getter_name": "assetId",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES remote_asset_entity (id) ON DELETE CASCADE",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES remote_asset_entity (id) ON DELETE CASCADE"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "remote_asset_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "cascade"
+ }
+ }
+ ]
+ },
+ {
+ "name": "cloud_id",
+ "getter_name": "cloudId",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "created_at",
+ "getter_name": "createdAt",
+ "moor_type": "dateTime",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "adjustment_time",
+ "getter_name": "adjustmentTime",
+ "moor_type": "dateTime",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "latitude",
+ "getter_name": "latitude",
+ "moor_type": "double",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "longitude",
+ "getter_name": "longitude",
+ "moor_type": "double",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "asset_id"
+ ]
+ }
+ },
+ {
+ "id": 24,
+ "references": [
+ 0
+ ],
+ "type": "table",
+ "data": {
+ "name": "memory_entity",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "id",
+ "getter_name": "id",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "created_at",
+ "getter_name": "createdAt",
+ "moor_type": "dateTime",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "updated_at",
+ "getter_name": "updatedAt",
+ "moor_type": "dateTime",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "deleted_at",
+ "getter_name": "deletedAt",
+ "moor_type": "dateTime",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "owner_id",
+ "getter_name": "ownerId",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES user_entity (id) ON DELETE CASCADE",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES user_entity (id) ON DELETE CASCADE"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "user_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "cascade"
+ }
+ }
+ ]
+ },
+ {
+ "name": "type",
+ "getter_name": "type",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [],
+ "type_converter": {
+ "dart_expr": "const EnumIndexConverter(MemoryTypeEnum.values)",
+ "dart_type_name": "MemoryTypeEnum"
+ }
+ },
+ {
+ "name": "data",
+ "getter_name": "data",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "is_saved",
+ "getter_name": "isSaved",
+ "moor_type": "bool",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "CHECK (\"is_saved\" IN (0, 1))",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "CHECK (\"is_saved\" IN (0, 1))"
+ },
+ "default_dart": "const CustomExpression('0')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "memory_at",
+ "getter_name": "memoryAt",
+ "moor_type": "dateTime",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "seen_at",
+ "getter_name": "seenAt",
+ "moor_type": "dateTime",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "show_at",
+ "getter_name": "showAt",
+ "moor_type": "dateTime",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "hide_at",
+ "getter_name": "hideAt",
+ "moor_type": "dateTime",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "id"
+ ]
+ }
+ },
+ {
+ "id": 25,
+ "references": [
+ 1,
+ 24
+ ],
+ "type": "table",
+ "data": {
+ "name": "memory_asset_entity",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "asset_id",
+ "getter_name": "assetId",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES remote_asset_entity (id) ON DELETE CASCADE",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES remote_asset_entity (id) ON DELETE CASCADE"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "remote_asset_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "cascade"
+ }
+ }
+ ]
+ },
+ {
+ "name": "memory_id",
+ "getter_name": "memoryId",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES memory_entity (id) ON DELETE CASCADE",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES memory_entity (id) ON DELETE CASCADE"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "memory_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "cascade"
+ }
+ }
+ ]
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "asset_id",
+ "memory_id"
+ ]
+ }
+ },
+ {
+ "id": 26,
+ "references": [
+ 0
+ ],
+ "type": "table",
+ "data": {
+ "name": "person_entity",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "id",
+ "getter_name": "id",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "created_at",
+ "getter_name": "createdAt",
+ "moor_type": "dateTime",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "updated_at",
+ "getter_name": "updatedAt",
+ "moor_type": "dateTime",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "owner_id",
+ "getter_name": "ownerId",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES user_entity (id) ON DELETE CASCADE",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES user_entity (id) ON DELETE CASCADE"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "user_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "cascade"
+ }
+ }
+ ]
+ },
+ {
+ "name": "name",
+ "getter_name": "name",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "face_asset_id",
+ "getter_name": "faceAssetId",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "is_favorite",
+ "getter_name": "isFavorite",
+ "moor_type": "bool",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "CHECK (\"is_favorite\" IN (0, 1))",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "CHECK (\"is_favorite\" IN (0, 1))"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "is_hidden",
+ "getter_name": "isHidden",
+ "moor_type": "bool",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "CHECK (\"is_hidden\" IN (0, 1))",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "CHECK (\"is_hidden\" IN (0, 1))"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "color",
+ "getter_name": "color",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "birth_date",
+ "getter_name": "birthDate",
+ "moor_type": "dateTime",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "id"
+ ]
+ }
+ },
+ {
+ "id": 27,
+ "references": [
+ 1,
+ 26
+ ],
+ "type": "table",
+ "data": {
+ "name": "asset_face_entity",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "id",
+ "getter_name": "id",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "asset_id",
+ "getter_name": "assetId",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES remote_asset_entity (id) ON DELETE CASCADE",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES remote_asset_entity (id) ON DELETE CASCADE"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "remote_asset_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "cascade"
+ }
+ }
+ ]
+ },
+ {
+ "name": "person_id",
+ "getter_name": "personId",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES person_entity (id) ON DELETE SET NULL",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES person_entity (id) ON DELETE SET NULL"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "person_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "setNull"
+ }
+ }
+ ]
+ },
+ {
+ "name": "image_width",
+ "getter_name": "imageWidth",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "image_height",
+ "getter_name": "imageHeight",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "bounding_box_x1",
+ "getter_name": "boundingBoxX1",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "bounding_box_y1",
+ "getter_name": "boundingBoxY1",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "bounding_box_x2",
+ "getter_name": "boundingBoxX2",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "bounding_box_y2",
+ "getter_name": "boundingBoxY2",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "source_type",
+ "getter_name": "sourceType",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "is_visible",
+ "getter_name": "isVisible",
+ "moor_type": "bool",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "CHECK (\"is_visible\" IN (0, 1))",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "CHECK (\"is_visible\" IN (0, 1))"
+ },
+ "default_dart": "const CustomExpression('1')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "deleted_at",
+ "getter_name": "deletedAt",
+ "moor_type": "dateTime",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "id"
+ ]
+ }
+ },
+ {
+ "id": 28,
+ "references": [],
+ "type": "table",
+ "data": {
+ "name": "store_entity",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "id",
+ "getter_name": "id",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "string_value",
+ "getter_name": "stringValue",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "int_value",
+ "getter_name": "intValue",
+ "moor_type": "int",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "id"
+ ]
+ }
+ },
+ {
+ "id": 29,
+ "references": [],
+ "type": "table",
+ "data": {
+ "name": "trashed_local_asset_entity",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "name",
+ "getter_name": "name",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "type",
+ "getter_name": "type",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [],
+ "type_converter": {
+ "dart_expr": "const EnumIndexConverter(AssetType.values)",
+ "dart_type_name": "AssetType"
+ }
+ },
+ {
+ "name": "created_at",
+ "getter_name": "createdAt",
+ "moor_type": "dateTime",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "updated_at",
+ "getter_name": "updatedAt",
+ "moor_type": "dateTime",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "width",
+ "getter_name": "width",
+ "moor_type": "int",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "height",
+ "getter_name": "height",
+ "moor_type": "int",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "duration_ms",
+ "getter_name": "durationMs",
+ "moor_type": "int",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "id",
+ "getter_name": "id",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "album_id",
+ "getter_name": "albumId",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "checksum",
+ "getter_name": "checksum",
+ "moor_type": "string",
+ "nullable": true,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "is_favorite",
+ "getter_name": "isFavorite",
+ "moor_type": "bool",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "CHECK (\"is_favorite\" IN (0, 1))",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "CHECK (\"is_favorite\" IN (0, 1))"
+ },
+ "default_dart": "const CustomExpression('0')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "orientation",
+ "getter_name": "orientation",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('0')",
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "source",
+ "getter_name": "source",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [],
+ "type_converter": {
+ "dart_expr": "const EnumIndexConverter(TrashOrigin.values)",
+ "dart_type_name": "TrashOrigin"
+ }
+ },
+ {
+ "name": "playback_style",
+ "getter_name": "playbackStyle",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('0')",
+ "default_client_dart": null,
+ "dsl_features": [],
+ "type_converter": {
+ "dart_expr": "const EnumIndexConverter(AssetPlaybackStyle.values)",
+ "dart_type_name": "AssetPlaybackStyle"
+ }
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "id",
+ "album_id"
+ ]
+ }
+ },
+ {
+ "id": 30,
+ "references": [
+ 1
+ ],
+ "type": "table",
+ "data": {
+ "name": "asset_edit_entity",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "id",
+ "getter_name": "id",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "asset_id",
+ "getter_name": "assetId",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "defaultConstraints": "REFERENCES remote_asset_entity (id) ON DELETE CASCADE",
+ "dialectAwareDefaultConstraints": {
+ "sqlite": "REFERENCES remote_asset_entity (id) ON DELETE CASCADE"
+ },
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [
+ {
+ "foreign_key": {
+ "to": {
+ "table": "remote_asset_entity",
+ "column": "id"
+ },
+ "initially_deferred": false,
+ "on_update": null,
+ "on_delete": "cascade"
+ }
+ }
+ ]
+ },
+ {
+ "name": "action",
+ "getter_name": "action",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [],
+ "type_converter": {
+ "dart_expr": "const EnumIndexConverter(AssetEditAction.values)",
+ "dart_type_name": "AssetEditAction"
+ }
+ },
+ {
+ "name": "parameters",
+ "getter_name": "parameters",
+ "moor_type": "blob",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": [],
+ "type_converter": {
+ "dart_expr": "editParameterConverter",
+ "dart_type_name": "Map"
+ }
+ },
+ {
+ "name": "sequence",
+ "getter_name": "sequence",
+ "moor_type": "int",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "id"
+ ]
+ }
+ },
+ {
+ "id": 31,
+ "references": [],
+ "type": "table",
+ "data": {
+ "name": "settings",
+ "was_declared_in_moor": false,
+ "columns": [
+ {
+ "name": "key",
+ "getter_name": "key",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "value",
+ "getter_name": "value",
+ "moor_type": "string",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": null,
+ "default_client_dart": null,
+ "dsl_features": []
+ },
+ {
+ "name": "updated_at",
+ "getter_name": "updatedAt",
+ "moor_type": "dateTime",
+ "nullable": false,
+ "customConstraints": null,
+ "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
+ "default_client_dart": null,
+ "dsl_features": []
+ }
+ ],
+ "is_virtual": false,
+ "without_rowid": true,
+ "constraints": [],
+ "strict": true,
+ "explicit_pk": [
+ "key"
+ ]
+ }
+ },
+ {
+ "id": 32,
+ "references": [
+ 19
+ ],
+ "type": "index",
+ "data": {
+ "on": 19,
+ "name": "idx_partner_shared_with_id",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)",
+ "unique": false,
+ "columns": []
+ }
+ },
+ {
+ "id": 33,
+ "references": [
+ 20
+ ],
+ "type": "index",
+ "data": {
+ "on": 20,
+ "name": "idx_lat_lng",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)",
+ "unique": false,
+ "columns": []
+ }
+ },
+ {
+ "id": 34,
+ "references": [
+ 20
+ ],
+ "type": "index",
+ "data": {
+ "on": 20,
+ "name": "idx_remote_exif_city",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_remote_exif_city\nON remote_exif_entity (city) WHERE city IS NOT NULL\n",
+ "unique": false,
+ "columns": []
+ }
+ },
+ {
+ "id": 35,
+ "references": [
+ 21
+ ],
+ "type": "index",
+ "data": {
+ "on": 21,
+ "name": "idx_remote_album_asset_album_asset",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)",
+ "unique": false,
+ "columns": []
+ }
+ },
+ {
+ "id": 36,
+ "references": [
+ 23
+ ],
+ "type": "index",
+ "data": {
+ "on": 23,
+ "name": "idx_remote_asset_cloud_id",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)",
+ "unique": false,
+ "columns": []
+ }
+ },
+ {
+ "id": 37,
+ "references": [
+ 26
+ ],
+ "type": "index",
+ "data": {
+ "on": 26,
+ "name": "idx_person_owner_id",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)",
+ "unique": false,
+ "columns": []
+ }
+ },
+ {
+ "id": 38,
+ "references": [
+ 27
+ ],
+ "type": "index",
+ "data": {
+ "on": 27,
+ "name": "idx_asset_face_person_id",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)",
+ "unique": false,
+ "columns": []
+ }
+ },
+ {
+ "id": 39,
+ "references": [
+ 27
+ ],
+ "type": "index",
+ "data": {
+ "on": 27,
+ "name": "idx_asset_face_asset_id",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)",
+ "unique": false,
+ "columns": []
+ }
+ },
+ {
+ "id": 40,
+ "references": [
+ 27
+ ],
+ "type": "index",
+ "data": {
+ "on": 27,
+ "name": "idx_asset_face_visible_person",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person\nON asset_face_entity (person_id, asset_id)\nWHERE is_visible = 1 AND deleted_at IS NULL\n",
+ "unique": false,
+ "columns": []
+ }
+ },
+ {
+ "id": 41,
+ "references": [
+ 29
+ ],
+ "type": "index",
+ "data": {
+ "on": 29,
+ "name": "idx_trashed_local_asset_checksum",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)",
+ "unique": false,
+ "columns": []
+ }
+ },
+ {
+ "id": 42,
+ "references": [
+ 29
+ ],
+ "type": "index",
+ "data": {
+ "on": 29,
+ "name": "idx_trashed_local_asset_album",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)",
+ "unique": false,
+ "columns": []
+ }
+ },
+ {
+ "id": 43,
+ "references": [
+ 30
+ ],
+ "type": "index",
+ "data": {
+ "on": 30,
+ "name": "idx_asset_edit_asset_id",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)",
+ "unique": false,
+ "columns": []
+ }
+ }
+ ],
+ "fixed_sql": [
+ {
+ "name": "user_entity",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"user_entity\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"email\" TEXT NOT NULL, \"has_profile_image\" INTEGER NOT NULL DEFAULT 0 CHECK (\"has_profile_image\" IN (0, 1)), \"profile_changed_at\" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), \"avatar_color\" INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (\"id\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "remote_asset_entity",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"remote_asset_entity\" (\"name\" TEXT NOT NULL, \"type\" INTEGER NOT NULL, \"created_at\" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), \"updated_at\" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), \"width\" INTEGER NULL, \"height\" INTEGER NULL, \"duration_ms\" INTEGER NULL, \"id\" TEXT NOT NULL, \"checksum\" TEXT NOT NULL, \"is_favorite\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_favorite\" IN (0, 1)), \"owner_id\" TEXT NOT NULL REFERENCES user_entity (id) ON DELETE CASCADE, \"local_date_time\" TEXT NULL, \"thumb_hash\" TEXT NULL, \"deleted_at\" TEXT NULL, \"uploaded_at\" TEXT NULL, \"live_photo_video_id\" TEXT NULL, \"visibility\" INTEGER NOT NULL, \"stack_id\" TEXT NULL, \"library_id\" TEXT NULL, \"is_edited\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_edited\" IN (0, 1)), PRIMARY KEY (\"id\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "stack_entity",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"stack_entity\" (\"id\" TEXT NOT NULL, \"created_at\" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), \"updated_at\" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), \"owner_id\" TEXT NOT NULL REFERENCES user_entity (id) ON DELETE CASCADE, \"primary_asset_id\" TEXT NOT NULL, PRIMARY KEY (\"id\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "local_asset_entity",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"local_asset_entity\" (\"name\" TEXT NOT NULL, \"type\" INTEGER NOT NULL, \"created_at\" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), \"updated_at\" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), \"width\" INTEGER NULL, \"height\" INTEGER NULL, \"duration_ms\" INTEGER NULL, \"id\" TEXT NOT NULL, \"checksum\" TEXT NULL, \"is_favorite\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_favorite\" IN (0, 1)), \"orientation\" INTEGER NOT NULL DEFAULT 0, \"i_cloud_id\" TEXT NULL, \"adjustment_time\" TEXT NULL, \"latitude\" REAL NULL, \"longitude\" REAL NULL, \"playback_style\" INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (\"id\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "remote_album_entity",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"remote_album_entity\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"description\" TEXT NOT NULL DEFAULT '', \"created_at\" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), \"updated_at\" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), \"thumbnail_asset_id\" TEXT NULL REFERENCES remote_asset_entity (id) ON DELETE SET NULL, \"is_activity_enabled\" INTEGER NOT NULL DEFAULT 1 CHECK (\"is_activity_enabled\" IN (0, 1)), \"order\" INTEGER NOT NULL, PRIMARY KEY (\"id\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "local_album_entity",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"local_album_entity\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"updated_at\" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), \"backup_selection\" INTEGER NOT NULL, \"is_ios_shared_album\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_ios_shared_album\" IN (0, 1)), \"linked_remote_album_id\" TEXT NULL REFERENCES remote_album_entity (id) ON DELETE SET NULL, \"marker\" INTEGER NULL CHECK (\"marker\" IN (0, 1)), PRIMARY KEY (\"id\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "local_album_asset_entity",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"local_album_asset_entity\" (\"asset_id\" TEXT NOT NULL REFERENCES local_asset_entity (id) ON DELETE CASCADE, \"album_id\" TEXT NOT NULL REFERENCES local_album_entity (id) ON DELETE CASCADE, \"marker\" INTEGER NULL CHECK (\"marker\" IN (0, 1)), PRIMARY KEY (\"asset_id\", \"album_id\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "idx_local_album_asset_album_asset",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)"
+ }
+ ]
+ },
+ {
+ "name": "idx_local_asset_checksum",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)"
+ }
+ ]
+ },
+ {
+ "name": "idx_local_asset_cloud_id",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)"
+ }
+ ]
+ },
+ {
+ "name": "idx_local_asset_created_at",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)"
+ }
+ ]
+ },
+ {
+ "name": "idx_stack_primary_asset_id",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)"
+ }
+ ]
+ },
+ {
+ "name": "UQ_remote_assets_owner_checksum",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)"
+ }
+ ]
+ },
+ {
+ "name": "UQ_remote_assets_owner_library_checksum",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "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)"
+ }
+ ]
+ },
+ {
+ "name": "idx_remote_asset_checksum",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)"
+ }
+ ]
+ },
+ {
+ "name": "idx_remote_asset_stack_id",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)"
+ }
+ ]
+ },
+ {
+ "name": "idx_remote_asset_owner_visibility_deleted_created",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)"
+ }
+ ]
+ },
+ {
+ "name": "auth_user_entity",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"auth_user_entity\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"email\" TEXT NOT NULL, \"is_admin\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_admin\" IN (0, 1)), \"has_profile_image\" INTEGER NOT NULL DEFAULT 0 CHECK (\"has_profile_image\" IN (0, 1)), \"profile_changed_at\" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), \"avatar_color\" INTEGER NOT NULL, \"quota_size_in_bytes\" INTEGER NOT NULL DEFAULT 0, \"quota_usage_in_bytes\" INTEGER NOT NULL DEFAULT 0, \"pin_code\" TEXT NULL, PRIMARY KEY (\"id\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "user_metadata_entity",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"user_metadata_entity\" (\"user_id\" TEXT NOT NULL REFERENCES user_entity (id) ON DELETE CASCADE, \"key\" INTEGER NOT NULL, \"value\" BLOB NOT NULL, PRIMARY KEY (\"user_id\", \"key\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "partner_entity",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"partner_entity\" (\"shared_by_id\" TEXT NOT NULL REFERENCES user_entity (id) ON DELETE CASCADE, \"shared_with_id\" TEXT NOT NULL REFERENCES user_entity (id) ON DELETE CASCADE, \"in_timeline\" INTEGER NOT NULL DEFAULT 0 CHECK (\"in_timeline\" IN (0, 1)), PRIMARY KEY (\"shared_by_id\", \"shared_with_id\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "remote_exif_entity",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"remote_exif_entity\" (\"asset_id\" TEXT NOT NULL REFERENCES remote_asset_entity (id) ON DELETE CASCADE, \"city\" TEXT NULL, \"state\" TEXT NULL, \"country\" TEXT NULL, \"date_time_original\" TEXT NULL, \"description\" TEXT NULL, \"height\" INTEGER NULL, \"width\" INTEGER NULL, \"exposure_time\" TEXT NULL, \"f_number\" REAL NULL, \"file_size\" INTEGER NULL, \"focal_length\" REAL NULL, \"latitude\" REAL NULL, \"longitude\" REAL NULL, \"iso\" INTEGER NULL, \"make\" TEXT NULL, \"model\" TEXT NULL, \"lens\" TEXT NULL, \"orientation\" TEXT NULL, \"time_zone\" TEXT NULL, \"rating\" INTEGER NULL, \"projection_type\" TEXT NULL, PRIMARY KEY (\"asset_id\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "remote_album_asset_entity",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"remote_album_asset_entity\" (\"asset_id\" TEXT NOT NULL REFERENCES remote_asset_entity (id) ON DELETE CASCADE, \"album_id\" TEXT NOT NULL REFERENCES remote_album_entity (id) ON DELETE CASCADE, PRIMARY KEY (\"asset_id\", \"album_id\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "remote_album_user_entity",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"remote_album_user_entity\" (\"album_id\" TEXT NOT NULL REFERENCES remote_album_entity (id) ON DELETE CASCADE, \"user_id\" TEXT NOT NULL REFERENCES user_entity (id) ON DELETE CASCADE, \"role\" INTEGER NOT NULL, PRIMARY KEY (\"album_id\", \"user_id\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "remote_asset_cloud_id_entity",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"remote_asset_cloud_id_entity\" (\"asset_id\" TEXT NOT NULL REFERENCES remote_asset_entity (id) ON DELETE CASCADE, \"cloud_id\" TEXT NULL, \"created_at\" TEXT NULL, \"adjustment_time\" TEXT NULL, \"latitude\" REAL NULL, \"longitude\" REAL NULL, PRIMARY KEY (\"asset_id\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "memory_entity",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"memory_entity\" (\"id\" TEXT NOT NULL, \"created_at\" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), \"updated_at\" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), \"deleted_at\" TEXT NULL, \"owner_id\" TEXT NOT NULL REFERENCES user_entity (id) ON DELETE CASCADE, \"type\" INTEGER NOT NULL, \"data\" TEXT NOT NULL, \"is_saved\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_saved\" IN (0, 1)), \"memory_at\" TEXT NOT NULL, \"seen_at\" TEXT NULL, \"show_at\" TEXT NULL, \"hide_at\" TEXT NULL, PRIMARY KEY (\"id\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "memory_asset_entity",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"memory_asset_entity\" (\"asset_id\" TEXT NOT NULL REFERENCES remote_asset_entity (id) ON DELETE CASCADE, \"memory_id\" TEXT NOT NULL REFERENCES memory_entity (id) ON DELETE CASCADE, PRIMARY KEY (\"asset_id\", \"memory_id\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "person_entity",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"person_entity\" (\"id\" TEXT NOT NULL, \"created_at\" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), \"updated_at\" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), \"owner_id\" TEXT NOT NULL REFERENCES user_entity (id) ON DELETE CASCADE, \"name\" TEXT NOT NULL, \"face_asset_id\" TEXT NULL, \"is_favorite\" INTEGER NOT NULL CHECK (\"is_favorite\" IN (0, 1)), \"is_hidden\" INTEGER NOT NULL CHECK (\"is_hidden\" IN (0, 1)), \"color\" TEXT NULL, \"birth_date\" TEXT NULL, PRIMARY KEY (\"id\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "asset_face_entity",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"asset_face_entity\" (\"id\" TEXT NOT NULL, \"asset_id\" TEXT NOT NULL REFERENCES remote_asset_entity (id) ON DELETE CASCADE, \"person_id\" TEXT NULL REFERENCES person_entity (id) ON DELETE SET NULL, \"image_width\" INTEGER NOT NULL, \"image_height\" INTEGER NOT NULL, \"bounding_box_x1\" INTEGER NOT NULL, \"bounding_box_y1\" INTEGER NOT NULL, \"bounding_box_x2\" INTEGER NOT NULL, \"bounding_box_y2\" INTEGER NOT NULL, \"source_type\" TEXT NOT NULL, \"is_visible\" INTEGER NOT NULL DEFAULT 1 CHECK (\"is_visible\" IN (0, 1)), \"deleted_at\" TEXT NULL, PRIMARY KEY (\"id\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "store_entity",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"store_entity\" (\"id\" INTEGER NOT NULL, \"string_value\" TEXT NULL, \"int_value\" INTEGER NULL, PRIMARY KEY (\"id\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "trashed_local_asset_entity",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"trashed_local_asset_entity\" (\"name\" TEXT NOT NULL, \"type\" INTEGER NOT NULL, \"created_at\" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), \"updated_at\" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), \"width\" INTEGER NULL, \"height\" INTEGER NULL, \"duration_ms\" INTEGER NULL, \"id\" TEXT NOT NULL, \"album_id\" TEXT NOT NULL, \"checksum\" TEXT NULL, \"is_favorite\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_favorite\" IN (0, 1)), \"orientation\" INTEGER NOT NULL DEFAULT 0, \"source\" INTEGER NOT NULL, \"playback_style\" INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (\"id\", \"album_id\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "asset_edit_entity",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"asset_edit_entity\" (\"id\" TEXT NOT NULL, \"asset_id\" TEXT NOT NULL REFERENCES remote_asset_entity (id) ON DELETE CASCADE, \"action\" INTEGER NOT NULL, \"parameters\" BLOB NOT NULL, \"sequence\" INTEGER NOT NULL, PRIMARY KEY (\"id\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "settings",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE TABLE IF NOT EXISTS \"settings\" (\"key\" TEXT NOT NULL, \"value\" TEXT NOT NULL, \"updated_at\" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), PRIMARY KEY (\"key\")) WITHOUT ROWID, STRICT;"
+ }
+ ]
+ },
+ {
+ "name": "idx_partner_shared_with_id",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)"
+ }
+ ]
+ },
+ {
+ "name": "idx_lat_lng",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)"
+ }
+ ]
+ },
+ {
+ "name": "idx_remote_exif_city",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL"
+ }
+ ]
+ },
+ {
+ "name": "idx_remote_album_asset_album_asset",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)"
+ }
+ ]
+ },
+ {
+ "name": "idx_remote_asset_cloud_id",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)"
+ }
+ ]
+ },
+ {
+ "name": "idx_person_owner_id",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)"
+ }
+ ]
+ },
+ {
+ "name": "idx_asset_face_person_id",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)"
+ }
+ ]
+ },
+ {
+ "name": "idx_asset_face_asset_id",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)"
+ }
+ ]
+ },
+ {
+ "name": "idx_asset_face_visible_person",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "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"
+ }
+ ]
+ },
+ {
+ "name": "idx_trashed_local_asset_checksum",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)"
+ }
+ ]
+ },
+ {
+ "name": "idx_trashed_local_asset_album",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)"
+ }
+ ]
+ },
+ {
+ "name": "idx_asset_edit_asset_id",
+ "sql": [
+ {
+ "dialect": "sqlite",
+ "sql": "CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)"
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/mobile/integration_test/background_sync_teardown_test.dart b/mobile/integration_test/background_sync_teardown_test.dart
new file mode 100644
index 0000000000..0f125b7fcc
--- /dev/null
+++ b/mobile/integration_test/background_sync_teardown_test.dart
@@ -0,0 +1,154 @@
+import 'dart:async';
+
+import 'package:drift/drift.dart' show Value;
+import 'package:flutter_test/flutter_test.dart';
+import 'package:immich_mobile/domain/models/store.model.dart';
+import 'package:immich_mobile/domain/utils/background_sync.dart';
+import 'package:immich_mobile/entities/store.entity.dart';
+import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
+import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
+import 'package:immich_mobile/main.dart' as app;
+import 'package:immich_mobile/services/api.service.dart';
+import 'package:immich_mobile/utils/bootstrap.dart';
+import 'package:immich_mobile/wm_executor.dart';
+import 'package:integration_test/integration_test.dart';
+import 'package:openapi/api.dart';
+
+import 'test_utils/fake_immich_server.dart';
+
+void main() {
+ final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+ // These tests do real I/O without pumping a widget tree, so disable the fake async clock
+ binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
+
+ late Drift drift;
+ late FakeImmichServer server;
+
+ setUpAll(() async {
+ await app.initApp();
+ (drift, _) = await Bootstrap.initDomain();
+ });
+
+ setUp(() async {
+ await workerManagerPatch.init(dynamicSpawning: true);
+ server = await FakeImmichServer.start();
+ await ApiService().resolveAndSetEndpoint(server.endpoint);
+ await drift.delete(drift.userEntity).go();
+ await Store.delete(StoreKey.syncMigrationStatus);
+ });
+
+ tearDown(() async {
+ await workerManagerPatch.dispose();
+ await server.close();
+ await Store.delete(StoreKey.serverEndpoint);
+ await Store.delete(StoreKey.syncMigrationStatus);
+ });
+
+ void sendUser(SyncStream stream, String id, String name) {
+ stream.send(
+ type: SyncEntityType.userV1.value,
+ data: SyncUserV1(
+ id: id,
+ name: name,
+ email: '$id@test.com',
+ hasProfileImage: false,
+ deletedAt: null,
+ profileChangedAt: DateTime.utc(2025),
+ ).toJson(),
+ ack: id,
+ );
+ }
+
+ Future dbReadable() async {
+ try {
+ await drift.customSelect('SELECT 1').get().timeout(const Duration(seconds: 5));
+ return true;
+ } catch (_) {
+ return false;
+ }
+ }
+
+ Future userCount() async => (await drift.select(drift.userEntity).get()).length;
+
+ // Starts a remote sync and resolves once its /sync/stream request is open.
+ Future<(Future, SyncStream)> startSync() async {
+ final sync = BackgroundSyncManager().syncRemote();
+ final stream = await server.streamOpened.timeout(
+ const Duration(seconds: 30),
+ onTimeout: () => fail('sync isolate never opened /sync/stream'),
+ );
+ return (sync, stream);
+ }
+
+ testWidgets('a full sync ingests streamed events into the shared DB', (tester) async {
+ expect(await userCount(), 0);
+
+ final (sync, stream) = await startSync();
+
+ sendUser(stream, 'u1', 'Alice');
+ sendUser(stream, 'u2', 'Bob');
+ await stream.close();
+
+ final result = await sync.timeout(
+ const Duration(seconds: 30),
+ onTimeout: () => fail('sync did not complete after the stream ended'),
+ );
+ expect(result, isTrue);
+ expect(await userCount(), 2);
+ expect(server.ackRequests, greaterThan(0));
+ });
+
+ testWidgets('disposing the pool during an in-flight sync drains promptly', (tester) async {
+ final (sync, _) = await startSync();
+
+ final sw = Stopwatch()..start();
+ await workerManagerPatch.dispose().timeout(
+ const Duration(seconds: 15),
+ onTimeout: () => fail('dispose() hung — worker did not drain and exit'),
+ );
+ expect(sw.elapsed, lessThan(const Duration(seconds: 10)), reason: 'abort-driven, not socket-timeout bound');
+
+ expect(await sync.timeout(const Duration(seconds: 5), onTimeout: () => false), isFalse);
+ });
+
+ testWidgets('tearing down a worker blocked mid-write leaves the DB usable', (tester) async {
+ final (sync, stream) = await startSync();
+
+ // Hold an exclusive write transaction so the worker's write is blocked. The lock is taken only
+ // after the stream opens to avoid blocking the worker's own startup DB reads.
+ final releaseTxn = Completer();
+ final txnHeld = Completer();
+ final txn = drift.transaction(() async {
+ await drift.into(drift.userEntity).insert(
+ UserEntityCompanion.insert(
+ id: 'holder',
+ name: 'holder',
+ email: 'holder@test.com',
+ hasProfileImage: const Value(false),
+ profileChangedAt: Value(DateTime.utc(2025)),
+ ),
+ );
+ txnHeld.complete();
+ await releaseTxn.future;
+ });
+ await txnHeld.future;
+
+ sendUser(stream, 'u1', 'Alice');
+ await stream.close();
+
+ // dispose() can only finish once the worker unwinds, which is blocked on the
+ // lock — so start it, release the lock, then await completion.
+ final disposed = workerManagerPatch.dispose();
+ releaseTxn.complete();
+ await txn;
+ await disposed.timeout(
+ const Duration(seconds: 15),
+ onTimeout: () => fail('dispose() hung after releasing the write lock'),
+ );
+ await sync.timeout(const Duration(seconds: 5), onTimeout: () => false);
+
+ expect(await dbReadable(), isTrue);
+ final users = await drift.select(drift.userEntity).get();
+ expect(users.map((u) => u.id), contains('holder'));
+ });
+}
diff --git a/mobile/integration_test/test_utils/fake_immich_server.dart b/mobile/integration_test/test_utils/fake_immich_server.dart
new file mode 100644
index 0000000000..c434f83bc5
--- /dev/null
+++ b/mobile/integration_test/test_utils/fake_immich_server.dart
@@ -0,0 +1,115 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+/// A dummy localhost server that implements only the endpoints that remote-sync touches.
+class FakeImmichServer {
+ FakeImmichServer._(this._server, this.version);
+
+ final HttpServer _server;
+ final (int, int, int) version;
+
+ final Completer _streamOpened = Completer();
+
+ int ackRequests = 0;
+
+ String get endpoint => 'http://${_server.address.host}:${_server.port}/api';
+
+ /// Resolves when the sync isolate opens `POST /sync/stream`.
+ Future get streamOpened => _streamOpened.future;
+
+ static Future start({(int, int, int) version = (3, 0, 0)}) async {
+ final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
+ final fake = FakeImmichServer._(server, version);
+ fake._listen();
+ return fake;
+ }
+
+ void _listen() {
+ // A connection torn down mid-write during teardown is expected
+ _server.listen((request) => unawaited(_route(request).catchError((_) {})));
+ }
+
+ Future _route(HttpRequest request) async {
+ final method = request.method;
+ final path = request.uri.path;
+
+ if (method == 'GET' && path == '/api/server/ping') {
+ return _respondJson(request, {'res': 'pong'});
+ }
+ if (method == 'GET' && path == '/api/server/version') {
+ final (major, minor, patch) = version;
+ return _respondJson(request, {'major': major, 'minor': minor, 'patch': patch});
+ }
+ if (path == '/api/sync/ack') {
+ if (method != 'DELETE') {
+ ackRequests++;
+ }
+ return _respondEmpty(request);
+ }
+ if (method == 'POST' && path == '/api/sync/stream') {
+ return _openSyncStream(request);
+ }
+ return _respondEmpty(request, status: HttpStatus.notFound);
+ }
+
+ Future _openSyncStream(HttpRequest request) async {
+ await request.drain();
+ request.response
+ ..statusCode = HttpStatus.ok
+ ..headers.contentType = ContentType('application', 'jsonlines+json')
+ ..contentLength = -1 // chunked: stays open to stream incrementally
+ ..bufferOutput = false;
+ // Flush headers so the client's send() resolves and enters its read loop.
+ await request.response.flush();
+ if (!_streamOpened.isCompleted) {
+ _streamOpened.complete(SyncStream._(request.response));
+ }
+ }
+
+ Future _respondJson(HttpRequest request, Object body) async {
+ await request.drain();
+ request.response
+ ..statusCode = HttpStatus.ok
+ ..headers.contentType = ContentType.json
+ ..write(jsonEncode(body));
+ await request.response.close();
+ }
+
+ Future _respondEmpty(HttpRequest request, {int status = HttpStatus.ok}) async {
+ await request.drain();
+ request.response.statusCode = status;
+ await request.response.close();
+ }
+
+ Future close() async {
+ if (_streamOpened.isCompleted) {
+ await (await _streamOpened.future).close();
+ }
+ await _server.close(force: true);
+ }
+}
+
+/// Handle to the open `/sync/stream` response: push jsonlines events, then end.
+class SyncStream {
+ SyncStream._(this._response);
+
+ final HttpResponse _response;
+ bool _closed = false;
+
+ /// [data] should be a Sync*V1 DTO's `toJson()` so the parser's `fromJson` round-trips it.
+ void send({required String type, required Object data, required String ack}) {
+ if (_closed) {
+ return;
+ }
+ _response.write('${jsonEncode({'type': type, 'data': data, 'ack': ack})}\n');
+ }
+
+ Future close() async {
+ if (_closed) {
+ return;
+ }
+ _closed = true;
+ await _response.close();
+ }
+}
diff --git a/mobile/ios/Runner/Background/BackgroundWorker.swift b/mobile/ios/Runner/Background/BackgroundWorker.swift
index c5b5e1778a..ad583065f0 100644
--- a/mobile/ios/Runner/Background/BackgroundWorker.swift
+++ b/mobile/ios/Runner/Background/BackgroundWorker.swift
@@ -121,8 +121,8 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
/**
* Cancels the currently running background task, either due to timeout or external request.
- * Sends a cancel signal to the Flutter side and sets up a fallback timer to ensure
- * the completion handler is eventually called even if Flutter doesn't respond.
+ * Only tears down the engine after Dart confirms it's drained. If Dart overruns iOS's grace window,
+ * the expiration handler still calls setTaskCompleted and iOS suspends us.
*/
func close() {
if isComplete {
@@ -132,12 +132,6 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
flutterApi?.cancel { result in
self.complete(success: false)
}
-
- // Fallback safety mechanism: ensure completion is called within 2 seconds
- // This prevents the background task from hanging indefinitely if Flutter doesn't respond
- Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
- self.complete(success: false)
- }
}
diff --git a/mobile/ios/Runner/Permission/PermissionApi.g.swift b/mobile/ios/Runner/Permission/PermissionApi.g.swift
index 53ad9e5b11..988e9b56dd 100644
--- a/mobile/ios/Runner/Permission/PermissionApi.g.swift
+++ b/mobile/ios/Runner/Permission/PermissionApi.g.swift
@@ -11,6 +11,24 @@ import Foundation
#error("Unsupported platform.")
#endif
+/// Error class for passing custom error details to Dart side.
+final class PigeonError: Error {
+ let code: String
+ let message: String?
+ let details: Sendable?
+
+ init(code: String, message: String?, details: Sendable?) {
+ self.code = code
+ self.message = message
+ self.details = details
+ }
+
+ var localizedDescription: String {
+ return
+ "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")"
+ }
+}
+
private func wrapResult(_ result: Any?) -> [Any?] {
return [result]
}
@@ -46,8 +64,57 @@ private func nilOrValue(_ value: Any?) -> T? {
return value as! T?
}
+
+enum PermissionStatus: Int {
+ case granted = 0
+ case denied = 1
+ case permanentlyDenied = 2
+}
+
+private class PermissionApiPigeonCodecReader: 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 PermissionStatus(rawValue: enumResultAsInt)
+ }
+ return nil
+ default:
+ return super.readValue(ofType: type)
+ }
+ }
+}
+
+private class PermissionApiPigeonCodecWriter: FlutterStandardWriter {
+ override func writeValue(_ value: Any) {
+ if let value = value as? PermissionStatus {
+ super.writeByte(129)
+ super.writeValue(value.rawValue)
+ } else {
+ super.writeValue(value)
+ }
+ }
+}
+
+private class PermissionApiPigeonCodecReaderWriter: FlutterStandardReaderWriter {
+ override func reader(with data: Data) -> FlutterStandardReader {
+ return PermissionApiPigeonCodecReader(data: data)
+ }
+
+ override func writer(with data: NSMutableData) -> FlutterStandardWriter {
+ return PermissionApiPigeonCodecWriter(data: data)
+ }
+}
+
+class PermissionApiPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
+ static let shared = PermissionApiPigeonCodec(readerWriter: PermissionApiPigeonCodecReaderWriter())
+}
+
+
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol PermissionApi {
+ func isIgnoringBatteryOptimizations() throws -> PermissionStatus
func hasManageMediaPermission() throws -> Bool
func requestManageMediaPermission(completion: @escaping (Result) -> Void)
func manageMediaPermission(completion: @escaping (Result) -> Void)
@@ -55,10 +122,23 @@ protocol PermissionApi {
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class PermissionApiSetup {
- static var codec: FlutterStandardMessageCodec { FlutterStandardMessageCodec.sharedInstance() }
+ static var codec: FlutterStandardMessageCodec { PermissionApiPigeonCodec.shared }
/// Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
+ let isIgnoringBatteryOptimizationsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+ if let api = api {
+ isIgnoringBatteryOptimizationsChannel.setMessageHandler { _, reply in
+ do {
+ let result = try api.isIgnoringBatteryOptimizations()
+ reply(wrapResult(result))
+ } catch {
+ reply(wrapError(error))
+ }
+ }
+ } else {
+ isIgnoringBatteryOptimizationsChannel.setMessageHandler(nil)
+ }
let hasManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
hasManageMediaPermissionChannel.setMessageHandler { _, reply in
diff --git a/mobile/ios/Runner/Permission/PermissionApiImpl.swift b/mobile/ios/Runner/Permission/PermissionApiImpl.swift
index e725b742fd..3d8e89486d 100644
--- a/mobile/ios/Runner/Permission/PermissionApiImpl.swift
+++ b/mobile/ios/Runner/Permission/PermissionApiImpl.swift
@@ -1,6 +1,10 @@
import Foundation
class PermissionApiImpl: PermissionApi {
+ func isIgnoringBatteryOptimizations() throws -> PermissionStatus {
+ return PermissionStatus.granted;
+ }
+
func hasManageMediaPermission() throws -> Bool {
return false
}
diff --git a/mobile/ios/Runner/Sync/Messages.g.swift b/mobile/ios/Runner/Sync/Messages.g.swift
index d18a153bb7..a752785c5b 100644
--- a/mobile/ios/Runner/Sync/Messages.g.swift
+++ b/mobile/ios/Runner/Sync/Messages.g.swift
@@ -526,16 +526,17 @@ class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol NativeSyncApi {
- func shouldFullSync() throws -> Bool
- func getMediaChanges() throws -> SyncDelta
+ func shouldFullSync(completion: @escaping (Result) -> Void)
+ func getMediaChanges(completion: @escaping (Result) -> Void)
func checkpointSync() throws
func clearSyncCheckpoint() throws
- func getAssetIdsForAlbum(albumId: String) throws -> [String]
- func getAlbums() throws -> [PlatformAlbum]
+ func getAssetIdsForAlbum(albumId: String, completion: @escaping (Result<[String], Error>) -> Void)
+ func getAlbums(completion: @escaping (Result<[PlatformAlbum], Error>) -> Void)
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
- func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
+ func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?, completion: @escaping (Result<[PlatformAsset], Error>) -> Void)
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) -> Void)
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
@@ -555,26 +556,28 @@ class NativeSyncApiSetup {
let shouldFullSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
shouldFullSyncChannel.setMessageHandler { _, reply in
- do {
- let result = try api.shouldFullSync()
- reply(wrapResult(result))
- } catch {
- reply(wrapError(error))
+ api.shouldFullSync { result in
+ switch result {
+ case .success(let res):
+ reply(wrapResult(res))
+ case .failure(let error):
+ reply(wrapError(error))
+ }
}
}
} else {
shouldFullSyncChannel.setMessageHandler(nil)
}
- let getMediaChangesChannel = taskQueue == nil
- ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
- : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
+ let getMediaChangesChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getMediaChangesChannel.setMessageHandler { _, reply in
- do {
- let result = try api.getMediaChanges()
- reply(wrapResult(result))
- } catch {
- reply(wrapError(error))
+ api.getMediaChanges { result in
+ switch result {
+ case .success(let res):
+ reply(wrapResult(res))
+ case .failure(let error):
+ reply(wrapError(error))
+ }
}
}
} else {
@@ -606,33 +609,33 @@ class NativeSyncApiSetup {
} else {
clearSyncCheckpointChannel.setMessageHandler(nil)
}
- let getAssetIdsForAlbumChannel = taskQueue == nil
- ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
- : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
+ let getAssetIdsForAlbumChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getAssetIdsForAlbumChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let albumIdArg = args[0] as! String
- do {
- let result = try api.getAssetIdsForAlbum(albumId: albumIdArg)
- reply(wrapResult(result))
- } catch {
- reply(wrapError(error))
+ api.getAssetIdsForAlbum(albumId: albumIdArg) { result in
+ switch result {
+ case .success(let res):
+ reply(wrapResult(res))
+ case .failure(let error):
+ reply(wrapError(error))
+ }
}
}
} else {
getAssetIdsForAlbumChannel.setMessageHandler(nil)
}
- let getAlbumsChannel = taskQueue == nil
- ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
- : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
+ let getAlbumsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getAlbumsChannel.setMessageHandler { _, reply in
- do {
- let result = try api.getAlbums()
- reply(wrapResult(result))
- } catch {
- reply(wrapError(error))
+ api.getAlbums { result in
+ switch result {
+ case .success(let res):
+ reply(wrapResult(res))
+ case .failure(let error):
+ reply(wrapError(error))
+ }
}
}
} else {
@@ -656,19 +659,19 @@ class NativeSyncApiSetup {
} else {
getAssetsCountSinceChannel.setMessageHandler(nil)
}
- let getAssetsForAlbumChannel = taskQueue == nil
- ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
- : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
+ let getAssetsForAlbumChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getAssetsForAlbumChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let albumIdArg = args[0] as! String
let updatedTimeCondArg: Int64? = nilOrValue(args[1])
- do {
- let result = try api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg)
- reply(wrapResult(result))
- } catch {
- reply(wrapError(error))
+ api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg) { result in
+ switch result {
+ case .success(let res):
+ reply(wrapResult(res))
+ case .failure(let error):
+ reply(wrapError(error))
+ }
}
}
} else {
@@ -707,6 +710,19 @@ class NativeSyncApiSetup {
} else {
cancelHashingChannel.setMessageHandler(nil)
}
+ let cancelSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+ if let api = api {
+ cancelSyncChannel.setMessageHandler { _, reply in
+ do {
+ try api.cancelSync()
+ reply(wrapResult(nil))
+ } catch {
+ reply(wrapError(error))
+ }
+ }
+ } 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)
diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift
index e6903defeb..ddfd023690 100644
--- a/mobile/ios/Runner/Sync/MessagesImpl.swift
+++ b/mobile/ios/Runner/Sync/MessagesImpl.swift
@@ -39,6 +39,9 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
private static let hashCancelledCode = "HASH_CANCELLED"
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
+ private var syncTask: Task?
+ private static let syncCancelledCode = "SYNC_CANCELLED"
+ private static let syncCancelled = PigeonError(code: syncCancelledCode, message: "Sync cancelled", details: nil)
init(with defaults: UserDefaults = .standard) {
self.defaults = defaults
@@ -71,7 +74,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken)
}
- func shouldFullSync() -> Bool {
+ func shouldFullSync(completion: @escaping (Result) -> Void) {
+ runSync(completion) { $0.shouldFullSync() }
+ }
+
+ private func shouldFullSync() -> Bool {
guard #available(iOS 16, *),
PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
let storedToken = getChangeToken() else {
@@ -87,12 +94,17 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return false
}
- func getAlbums() throws -> [PlatformAlbum] {
+ func getAlbums(completion: @escaping (Result<[PlatformAlbum], Error>) -> Void) {
+ runSync(completion) { try $0.getAlbums() }
+ }
+
+ private func getAlbums() throws -> [PlatformAlbum] {
var albums: [PlatformAlbum] = []
- albumTypes.forEach { type in
+ for type in albumTypes {
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
for i in 0.. SyncDelta {
+ func getMediaChanges(completion: @escaping (Result) -> Void) {
+ runSync(completion) { try $0.getMediaChanges() }
+ }
+
+ private func getMediaChanges() throws -> SyncDelta {
guard #available(iOS 16, *) else {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
}
@@ -146,51 +162,49 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:])
}
- do {
- let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
+ let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
+
+ var updatedAssets: Set = []
+ var deletedAssets: Set = []
+
+ for change in changes {
+ try Task.checkCancellation()
+ guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
- var updatedAssets: Set = []
- var deletedAssets: Set = []
+ let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
+ deletedAssets.formUnion(details.deletedLocalIdentifiers)
- for change in changes {
- guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
+ if (updated.isEmpty) { continue }
+
+ let options = PHFetchOptions()
+ options.includeHiddenAssets = false
+ let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
+ for i in 0..) -> [String: [String]] {
guard !assets.isEmpty else {
return [:]
@@ -213,7 +227,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return albumAssets
}
- func getAssetIdsForAlbum(albumId: String) throws -> [String] {
+ func getAssetIdsForAlbum(albumId: String, completion: @escaping (Result<[String], Error>) -> Void) {
+ runSync(completion) { try $0.getAssetIdsForAlbum(albumId: albumId) }
+ }
+
+ private func getAssetIdsForAlbum(albumId: String) throws -> [String] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return []
@@ -223,9 +241,14 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
let options = PHFetchOptions()
options.includeHiddenAssets = false
let assets = getAssetsFromAlbum(in: album, options: options)
- assets.enumerateObjects { (asset, _, _) in
+ assets.enumerateObjects { (asset, _, stop) in
+ if Task.isCancelled {
+ stop.pointee = true
+ return
+ }
ids.append(asset.localIdentifier)
}
+ try Task.checkCancellation()
return ids
}
@@ -243,7 +266,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return Int64(assets.count)
}
- func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
+ func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?, completion: @escaping (Result<[PlatformAsset], Error>) -> Void) {
+ runSync(completion) { try $0.getAssetsForAlbum(albumId: albumId, updatedTimeCond: updatedTimeCond) }
+ }
+
+ private func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return []
@@ -262,9 +289,14 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
}
var assets: [PlatformAsset] = []
- result.enumerateObjects { (asset, _, _) in
+ result.enumerateObjects { (asset, _, stop) in
+ if Task.isCancelled {
+ stop.pointee = true
+ return
+ }
assets.append(asset.toPlatformAsset())
}
+ try Task.checkCancellation()
return assets
}
@@ -324,6 +356,31 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
hashTask = nil
}
+ func cancelSync() {
+ syncTask?.cancel()
+ syncTask = nil
+ }
+
+ private func runSync(
+ _ completion: @escaping (Result) -> Void,
+ _ work: @escaping (NativeSyncApiImpl) throws -> T
+ ) {
+ syncTask?.cancel()
+ syncTask = Task { [weak self] in
+ guard let self else { return nil }
+ let result: Result
+ do {
+ result = .success(try work(self))
+ } catch is CancellationError {
+ result = .failure(Self.syncCancelled)
+ } catch {
+ result = .failure(error)
+ }
+ self.completeWhenActive(for: completion, with: result)
+ return nil
+ }
+ }
+
private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? {
class RequestRef {
var id: PHAssetResourceDataRequestID?
diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart
index 473bd52b03..902b40b395 100644
--- a/mobile/lib/constants/enums.dart
+++ b/mobile/lib/constants/enums.dart
@@ -22,3 +22,5 @@ enum AssetDateAggregation { start, end }
enum SlideshowLook { contain, cover, blurredBackground }
enum SlideshowDirection { forward, backward, shuffle }
+
+enum PartnerDirection { sharedBy, sharedWith }
diff --git a/mobile/lib/domain/models/user.model.dart b/mobile/lib/domain/models/user.model.dart
index 9ed70d61d6..d1a7fc5546 100644
--- a/mobile/lib/domain/models/user.model.dart
+++ b/mobile/lib/domain/models/user.model.dart
@@ -237,3 +237,125 @@ class PartnerUserDto {
return id.hashCode ^ email.hashCode ^ name.hashCode ^ inTimeline.hashCode ^ profileImagePath.hashCode;
}
}
+
+class User {
+ final String id;
+ final String name;
+ final String email;
+ final DateTime profileChangedAt;
+ final bool hasProfileImage;
+ final AvatarColor? avatarColor;
+
+ const User({
+ required this.id,
+ required this.name,
+ required this.email,
+ required this.profileChangedAt,
+ required this.hasProfileImage,
+ this.avatarColor = AvatarColor.primary,
+ });
+
+ @override
+ String toString() {
+ return 'User(id: $id, name: $name, email: $email, profileChangedAt: $profileChangedAt, hasProfileImage: $hasProfileImage, avatarColor: $avatarColor)';
+ }
+
+ @override
+ bool operator ==(covariant User other) {
+ if (identical(this, other)) {
+ return true;
+ }
+
+ return other.id == id &&
+ other.name == name &&
+ other.email == email &&
+ other.profileChangedAt == profileChangedAt &&
+ other.hasProfileImage == hasProfileImage &&
+ other.avatarColor == avatarColor;
+ }
+
+ @override
+ int get hashCode => Object.hash(id, name, email, profileChangedAt, hasProfileImage, avatarColor);
+}
+
+class AuthUser extends User {
+ final bool isAdmin;
+ final String? pinCode;
+ final int? quotaSizeInBytes;
+ final int quotaUsageInBytes;
+
+ const AuthUser({
+ required super.id,
+ required super.name,
+ required super.email,
+ required super.profileChangedAt,
+ required super.hasProfileImage,
+ super.avatarColor,
+ this.isAdmin = false,
+ this.pinCode,
+ this.quotaSizeInBytes = 0,
+ this.quotaUsageInBytes = 0,
+ });
+
+ @override
+ String toString() {
+ return 'AuthUser(user: ${super.toString()}, isAdmin: $isAdmin, pinCode: $pinCode, quotaSizeInBytes: $quotaSizeInBytes, quotaUsageInBytes: $quotaUsageInBytes)';
+ }
+
+ @override
+ bool operator ==(covariant AuthUser other) {
+ if (identical(this, other)) {
+ return true;
+ }
+
+ return super == other &&
+ other.isAdmin == isAdmin &&
+ other.pinCode == pinCode &&
+ other.quotaSizeInBytes == quotaSizeInBytes &&
+ other.quotaUsageInBytes == quotaUsageInBytes;
+ }
+
+ @override
+ int get hashCode => Object.hash(super.hashCode, isAdmin, pinCode, quotaSizeInBytes, quotaUsageInBytes);
+}
+
+class Partner extends User {
+ final bool inTimeline;
+
+ const Partner({
+ required super.id,
+ required super.name,
+ required super.email,
+ required super.profileChangedAt,
+ required super.hasProfileImage,
+ super.avatarColor,
+ this.inTimeline = false,
+ });
+
+ Partner.fromUser(User user, {this.inTimeline = false})
+ : super(
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ profileChangedAt: user.profileChangedAt,
+ hasProfileImage: user.hasProfileImage,
+ avatarColor: user.avatarColor,
+ );
+
+ @override
+ String toString() {
+ return 'Partner(user: ${super.toString()}, inTimeline: $inTimeline)';
+ }
+
+ @override
+ bool operator ==(covariant Partner other) {
+ if (identical(this, other)) {
+ return true;
+ }
+
+ return super == other && other.inTimeline == inTimeline;
+ }
+
+ @override
+ int get hashCode => Object.hash(super.hashCode, inTimeline);
+}
diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart
index d28f7ff14b..529ec770c4 100644
--- a/mobile/lib/domain/services/background_worker.service.dart
+++ b/mobile/lib/domain/services/background_worker.service.dart
@@ -113,9 +113,35 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
@override
Future onIosUpload(bool isRefresh, int? maxSeconds) async {
- final hashTimeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6);
- final backupTimeout = maxSeconds != null ? Duration(seconds: maxSeconds - 1) : null;
- return _backgroundLoop(hashTimeout: hashTimeout, backupTimeout: backupTimeout, debugLabel: 'iOS background upload');
+ _logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s');
+ final sw = Stopwatch()..start();
+ try {
+ final budget = maxSeconds != null ? Duration(seconds: maxSeconds - 1) : null;
+
+ final sync = _ref?.read(backgroundSyncProvider);
+ if (sync == null) {
+ return;
+ }
+
+ // Run sync local, sync remote, hash and backup concurrently so the bg
+ // refresh task (20s budget) can make progress on all four instead of
+ // racing them sequentially. Phases are independent at the data layer:
+ // hash and handle_backup read drift state and tolerate stale reads
+ // (server-side dedup catches the rare race). The single budget caps the
+ // whole batch; no phase needs its own timeout.
+ final all = Future.wait([sync.syncLocal(), sync.syncRemote(), sync.hashAssets(), _handleBackup()]);
+ if (budget != null) {
+ await all.timeout(budget, onTimeout: () => []);
+ } else {
+ await all;
+ }
+ } catch (error, stack) {
+ _logger.severe("Failed to complete iOS background upload", error, stack);
+ } finally {
+ sw.stop();
+ _logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s");
+ await _cleanup();
+ }
}
Future _backgroundLoop({
@@ -188,20 +214,14 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
if (!_cancellationToken.isCompleted) {
_cancellationToken.complete();
}
- final cleanupFutures = [
- nativeSyncApi?.cancelHashing(),
- workerManagerPatch.dispose().catchError((_) async {
- // Discard any errors on the dispose call
- return;
- }),
- LogService.I.dispose(),
- Store.dispose(),
- backgroundSyncManager?.cancel(),
- _drift.optimize(allTables: true),
- ];
-
- await Future.wait(cleanupFutures.nonNulls);
+ // Workers share one sqlite connection, so DB teardown must wait until every worker has stopped using it.
+ await Future.wait([
+ if (backgroundSyncManager != null) backgroundSyncManager.cancel(),
+ if (nativeSyncApi != null) nativeSyncApi.cancelHashing(),
+ ]);
+ await workerManagerPatch.dispose().catchError((_) async {});
+ await Future.wait([LogService.I.dispose(), Store.dispose(), _drift.optimize(allTables: true)]);
await _drift.close();
await _driftLogger.close();
diff --git a/mobile/lib/domain/services/hash.service.dart b/mobile/lib/domain/services/hash.service.dart
index e2938a79ad..e4c332b283 100644
--- a/mobile/lib/domain/services/hash.service.dart
+++ b/mobile/lib/domain/services/hash.service.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
import 'package:flutter/services.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
@@ -17,7 +19,7 @@ class HashService {
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final NativeSyncApi _nativeSyncApi;
- final bool Function()? _cancelChecker;
+ final Completer? _cancellation;
final _log = Logger('HashService');
HashService({
@@ -25,11 +27,15 @@ class HashService {
required this._localAssetRepository,
required this._trashedLocalAssetRepository,
required this._nativeSyncApi,
- this._cancelChecker,
+ this._cancellation,
int? batchSize,
- }) : _batchSize = batchSize ?? kBatchHashFileLimit;
+ }) : _batchSize = batchSize ?? kBatchHashFileLimit {
+ // Stop the in-flight native hash call promptly on cancellation; the loops
+ // below also observe [isCancelled] to bail between batches.
+ _cancellation?.future.then((_) => _nativeSyncApi.cancelHashing().onError(_log.warning));
+ }
- bool get isCancelled => _cancelChecker?.call() ?? false;
+ bool get isCancelled => _cancellation?.isCompleted ?? false;
Future hashAssets() async {
_log.info("Starting hashing of assets");
diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart
index 77ded0ba4d..feb104f90d 100644
--- a/mobile/lib/domain/services/local_sync.service.dart
+++ b/mobile/lib/domain/services/local_sync.service.dart
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:collection/collection.dart';
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';
@@ -17,6 +18,8 @@ import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart';
+const String _kSyncCancelledCode = "SYNC_CANCELLED";
+
class LocalSyncService {
final DriftLocalAlbumRepository _localAlbumRepository;
// ignore: unused_field
@@ -25,6 +28,7 @@ class LocalSyncService {
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final IPermissionRepository _permissionRepository;
+ final Completer? _cancellation;
final Logger _log = Logger("DeviceSyncService");
LocalSyncService({
@@ -34,7 +38,12 @@ class LocalSyncService {
required this._trashedLocalAssetRepository,
required this._assetMediaRepository,
required this._permissionRepository,
- });
+ this._cancellation,
+ }) {
+ _cancellation?.future.then((_) => _nativeSyncApi.cancelSync().onError(_log.warning));
+ }
+
+ bool get _isCancelled => _cancellation?.isCompleted ?? false;
Future sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start();
@@ -81,6 +90,10 @@ class LocalSyncService {
// detect album deletions from the native side
if (CurrentPlatform.isAndroid) {
for (final album in dbAlbums) {
+ if (_isCancelled) {
+ _log.warning("Local sync cancelled. Stopped processing albums.");
+ return;
+ }
final deviceIds = await _nativeSyncApi.getAssetIdsForAlbum(album.id);
await _localAlbumRepository.syncDeletes(album.id, deviceIds);
}
@@ -91,6 +104,10 @@ class LocalSyncService {
// does not include changes for cloud albums.
final cloudAlbums = deviceAlbums.where((a) => a.isCloud).toLocalAlbums();
for (final album in cloudAlbums) {
+ if (_isCancelled) {
+ _log.warning("Local sync cancelled. Stopped processing cloud albums.");
+ return;
+ }
final dbAlbum = dbAlbums.firstWhereOrNull((a) => a.id == album.id);
if (dbAlbum == null) {
_log.warning("Cloud album ${album.name} not found in local database. Skipping sync.");
@@ -102,6 +119,12 @@ class LocalSyncService {
await _mapIosCloudIds(newAssets);
}
await _nativeSyncApi.checkpointSync();
+ } on PlatformException catch (e, s) {
+ if (e.code == _kSyncCancelledCode) {
+ _log.warning("Local sync cancelled");
+ } else {
+ _log.severe("Error performing device sync", e, s);
+ }
} catch (e, s) {
_log.severe("Error performing device sync", e, s);
} finally {
@@ -129,12 +152,21 @@ class LocalSyncService {
await _nativeSyncApi.checkpointSync();
stopwatch.stop();
_log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
+ } on PlatformException catch (e, s) {
+ if (e.code == _kSyncCancelledCode) {
+ _log.warning("Full device sync cancelled");
+ } else {
+ _log.severe("Error performing full device sync", e, s);
+ }
} catch (e, s) {
_log.severe("Error performing full device sync", e, s);
}
}
Future addAlbum(LocalAlbum album) async {
+ if (_isCancelled) {
+ return;
+ }
try {
_log.fine("Adding device album ${album.name}");
@@ -162,6 +194,9 @@ class LocalSyncService {
// The deviceAlbum is ignored since we are going to refresh it anyways
FutureOr updateAlbum(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
+ if (_isCancelled) {
+ return false;
+ }
try {
_log.fine("Syncing device album ${dbAlbum.name}");
diff --git a/mobile/lib/domain/services/log.service.dart b/mobile/lib/domain/services/log.service.dart
index 216f030b12..b612b3ce91 100644
--- a/mobile/lib/domain/services/log.service.dart
+++ b/mobile/lib/domain/services/log.service.dart
@@ -112,10 +112,16 @@ class LogService {
return _flushBuffer();
}
- Future dispose() {
+ Future dispose() async {
_flushTimer?.cancel();
- _logSubscription.cancel();
- return _flushBuffer();
+ _flushTimer = null;
+ await _logSubscription.cancel();
+ await _flushBuffer();
+ // Allow a subsequent init() (e.g. when a worker isolate is reused) to
+ // create a fresh instance instead of returning this disposed one.
+ if (identical(_instance, this)) {
+ _instance = null;
+ }
}
Future _flushBuffer() async {
diff --git a/mobile/lib/domain/services/partner.service.dart b/mobile/lib/domain/services/partner.service.dart
index ce1bd9557b..63985823aa 100644
--- a/mobile/lib/domain/services/partner.service.dart
+++ b/mobile/lib/domain/services/partner.service.dart
@@ -1,51 +1,42 @@
+import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
+import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
-import 'package:immich_mobile/utils/debug_print.dart';
+import 'package:stream_transform/stream_transform.dart';
-class DriftPartnerService {
- final DriftPartnerRepository _driftPartnerRepository;
+class PartnerService {
+ final UserRepository _userRepository;
+ final PartnerRepository _partnerRepository;
final PartnerApiRepository _partnerApiRepository;
- const DriftPartnerService(this._driftPartnerRepository, this._partnerApiRepository);
+ const PartnerService(this._userRepository, this._partnerRepository, this._partnerApiRepository);
- Future> getSharedWith(String userId) {
- return _driftPartnerRepository.getSharedWith(userId);
+ Stream> getCandidates(String userId) {
+ final userStream = _userRepository.getAll();
+ final partnerStream = _partnerRepository.search(userId, .sharedBy);
+
+ return userStream.combineLatest(partnerStream, (users, partners) {
+ final partnersSet = partners.map((partner) => partner.id).toSet();
+ return users.where((user) => user.id != userId && !partnersSet.contains(user.id));
+ });
}
- Future> getSharedBy(String userId) {
- return _driftPartnerRepository.getSharedBy(userId);
+ Stream> search(String userId, PartnerDirection direction) =>
+ _partnerRepository.search(userId, direction);
+
+ Future update({required String sharedById, required String sharedWithId, required bool inTimeline}) async {
+ await _partnerApiRepository.update(sharedById, inTimeline: inTimeline);
+ await _partnerRepository.update(sharedById: sharedById, sharedWithId: sharedWithId, inTimeline: inTimeline);
}
- Future> getAvailablePartners(String currentUserId) async {
- final otherUsers = await _driftPartnerRepository.getAvailablePartners(currentUserId);
- final currentPartners = await _driftPartnerRepository.getSharedBy(currentUserId);
- final available = otherUsers.where((user) {
- return !currentPartners.any((partner) => partner.id == user.id);
- }).toList();
-
- return available;
+ Future create({required String sharedById, required String sharedWithId, bool inTimeline = false}) async {
+ await _partnerApiRepository.create(sharedWithId);
+ await _partnerRepository.create(sharedById: sharedById, sharedWithId: sharedWithId, inTimeline: inTimeline);
}
- Future toggleShowInTimeline(String partnerId, String userId) async {
- final partner = await _driftPartnerRepository.getPartner(partnerId, userId);
- if (partner == null) {
- dPrint(() => "Partner not found: $partnerId for user: $userId");
- return;
- }
-
- await _partnerApiRepository.update(partnerId, inTimeline: !partner.inTimeline);
-
- await _driftPartnerRepository.toggleShowInTimeline(partner, userId);
- }
-
- Future addPartner(String partnerId, String userId) async {
- await _partnerApiRepository.create(partnerId);
- await _driftPartnerRepository.create(partnerId, userId);
- }
-
- Future removePartner(String partnerId, String userId) async {
- await _partnerApiRepository.delete(partnerId);
- await _driftPartnerRepository.delete(partnerId, userId);
+ Future delete({required String sharedById, required String sharedWithId}) async {
+ await _partnerApiRepository.delete(sharedWithId);
+ await _partnerRepository.delete(sharedById: sharedById, sharedWithId: sharedWithId);
}
}
diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart
index 35a8f899a8..3c8999decd 100644
--- a/mobile/lib/domain/services/remote_album.service.dart
+++ b/mobile/lib/domain/services/remote_album.service.dart
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
+import 'package:openapi/api.dart' show Optional;
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:logging/logging.dart';
@@ -137,7 +138,7 @@ class RemoteAlbumService {
Future updateAlbum(
String albumId, {
String? name,
- String? description,
+ Optional description = const Optional.absent(),
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
diff --git a/mobile/lib/domain/services/store.service.dart b/mobile/lib/domain/services/store.service.dart
index 16ed64e6d3..758622a43b 100644
--- a/mobile/lib/domain/services/store.service.dart
+++ b/mobile/lib/domain/services/store.service.dart
@@ -54,7 +54,13 @@ class StoreService {
/// Disposes the store and cancels the subscription. To reuse the store call init() again
Future dispose() async {
await _storeUpdateSubscription?.cancel();
+ _storeUpdateSubscription = null;
_cache.clear();
+ // Allow a subsequent init() (e.g. when a worker isolate is reused) to
+ // create a fresh instance instead of returning this disposed one.
+ if (identical(_instance, this)) {
+ _instance = null;
+ }
}
/// Returns the cached value for [key], or `null`
diff --git a/mobile/lib/domain/services/sync_linked_album.service.dart b/mobile/lib/domain/services/sync_linked_album.service.dart
index 3bc76083b8..ddcd6721d7 100644
--- a/mobile/lib/domain/services/sync_linked_album.service.dart
+++ b/mobile/lib/domain/services/sync_linked_album.service.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
@@ -5,6 +7,7 @@ import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
+import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
@@ -16,6 +19,7 @@ final syncLinkedAlbumServiceProvider = Provider(
ref.watch(remoteAlbumRepository),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(storeServiceProvider),
+ cancellation: ref.watch(cancellationProvider),
),
);
@@ -24,13 +28,15 @@ class SyncLinkedAlbumService {
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftAlbumApiRepository _albumApiRepository;
final StoreService _storeService;
+ final Completer? _cancellation;
SyncLinkedAlbumService(
this._localAlbumRepository,
this._remoteAlbumRepository,
this._albumApiRepository,
- this._storeService,
- );
+ this._storeService, {
+ this._cancellation,
+ });
final _log = Logger("SyncLinkedAlbumService");
@@ -55,7 +61,11 @@ class SyncLinkedAlbumService {
final assetIds = await _remoteAlbumRepository.getLinkedAssetIds(userId, localAlbum.id, linkedRemoteAlbumId);
_log.fine("Syncing ${assetIds.length} assets to remote album: ${remoteAlbum.name}");
if (assetIds.isNotEmpty) {
- final album = await _albumApiRepository.addAssets(remoteAlbum.id, assetIds);
+ final album = await _albumApiRepository.addAssets(
+ remoteAlbum.id,
+ assetIds,
+ abortTrigger: _cancellation?.future,
+ );
await _remoteAlbumRepository.addAssets(remoteAlbum.id, album.added);
}
}),
diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart
index 200dca2418..08109b25d3 100644
--- a/mobile/lib/domain/services/sync_stream.service.dart
+++ b/mobile/lib/domain/services/sync_stream.service.dart
@@ -38,7 +38,7 @@ class SyncStreamService {
final IPermissionRepository _permissionRepository;
final SyncMigrationRepository _syncMigrationRepository;
final ApiService _api;
- final bool Function()? _cancelChecker;
+ final Completer? _cancellation;
SyncStreamService({
required this._syncApiRepository,
@@ -49,10 +49,10 @@ class SyncStreamService {
required this._permissionRepository,
required this._syncMigrationRepository,
required this._api,
- this._cancelChecker,
+ this._cancellation,
});
- bool get isCancelled => _cancelChecker?.call() ?? false;
+ bool get isCancelled => _cancellation?.isCompleted ?? false;
Future sync() async {
_logger.info("Remote sync request for user");
@@ -80,10 +80,15 @@ class SyncStreamService {
_handleEvents,
serverVersion: serverSemVer,
onReset: () => shouldReset = true,
+ abortSignal: _cancellation?.future,
);
if (shouldReset) {
_logger.info("Resetting sync state as requested by server");
- await _syncApiRepository.streamChanges(_handleEvents, serverVersion: serverSemVer);
+ await _syncApiRepository.streamChanges(
+ _handleEvents,
+ serverVersion: serverSemVer,
+ abortSignal: _cancellation?.future,
+ );
}
previousLength = migrations.length;
@@ -318,7 +323,7 @@ class SyncStreamService {
}
Future handleWsAssetUploadReadyV1Batch(List batchData) async {
- if (batchData.isEmpty) {
+ if (batchData.isEmpty || isCancelled) {
return;
}
@@ -361,7 +366,7 @@ class SyncStreamService {
}
Future handleWsAssetUploadReadyV2Batch(List batchData) async {
- if (batchData.isEmpty) {
+ if (batchData.isEmpty || isCancelled) {
return;
}
@@ -404,6 +409,9 @@ class SyncStreamService {
}
Future handleWsAssetEditReadyV1(dynamic data) async {
+ if (isCancelled) {
+ return;
+ }
_logger.info('Processing AssetEditReadyV1 event');
try {
@@ -444,6 +452,9 @@ class SyncStreamService {
}
Future handleWsAssetEditReadyV2(dynamic data) async {
+ if (isCancelled) {
+ return;
+ }
_logger.info('Processing AssetEditReadyV2 event');
try {
diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart
index 030e77cd54..82f397d9b6 100644
--- a/mobile/lib/domain/utils/background_sync.dart
+++ b/mobile/lib/domain/utils/background_sync.dart
@@ -50,53 +50,27 @@ class BackgroundSyncManager {
});
Future cancel() async {
- final futures = [];
-
- if (_syncTask != null) {
- futures.add(_syncTask!.future);
+ final tasks = [
+ _syncTask,
+ _syncWebsocketTask,
+ _cloudIdSyncTask,
+ _linkedAlbumSyncTask,
+ _deviceAlbumSyncTask,
+ _hashTask,
+ ];
+ final futures = [
+ for (final task in tasks)
+ if (task != null) task.future,
+ ];
+ for (final task in tasks) {
+ task?.cancel();
}
- _syncTask?.cancel();
_syncTask = null;
-
- if (_syncWebsocketTask != null) {
- futures.add(_syncWebsocketTask!.future);
- }
- _syncWebsocketTask?.cancel();
_syncWebsocketTask = null;
-
- if (_cloudIdSyncTask != null) {
- futures.add(_cloudIdSyncTask!.future);
- }
- _cloudIdSyncTask?.cancel();
_cloudIdSyncTask = null;
-
- if (_linkedAlbumSyncTask != null) {
- futures.add(_linkedAlbumSyncTask!.future);
- }
- _linkedAlbumSyncTask?.cancel();
_linkedAlbumSyncTask = null;
-
- try {
- await Future.wait(futures);
- } on CanceledError {
- // Ignore cancellation errors
- }
- }
-
- Future cancelLocal() async {
- final futures = [];
-
- if (_hashTask != null) {
- futures.add(_hashTask!.future);
- }
- _hashTask?.cancel();
- _hashTask = null;
-
- if (_deviceAlbumSyncTask != null) {
- futures.add(_deviceAlbumSyncTask!.future);
- }
- _deviceAlbumSyncTask?.cancel();
_deviceAlbumSyncTask = null;
+ _hashTask = null;
try {
await Future.wait(futures);
diff --git a/mobile/lib/domain/utils/migrate_cloud_ids.dart b/mobile/lib/domain/utils/migrate_cloud_ids.dart
index 32188b4838..efef6e8327 100644
--- a/mobile/lib/domain/utils/migrate_cloud_ids.dart
+++ b/mobile/lib/domain/utils/migrate_cloud_ids.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
@@ -9,6 +11,7 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/api.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/sync.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -51,9 +54,10 @@ Future syncCloudIds(ProviderContainer ref) async {
}
final assetApi = ref.read(apiServiceProvider).assetsApi;
+ final cancellation = ref.read(cancellationProvider);
// Process cloud IDs in paginated batches
- await _processCloudIdMappingsInBatches(db, currentUser.id, assetApi, canBulkUpdateMetadata, logger);
+ await _processCloudIdMappingsInBatches(db, currentUser.id, assetApi, canBulkUpdateMetadata, logger, cancellation);
}
Future _processCloudIdMappingsInBatches(
@@ -62,12 +66,17 @@ Future _processCloudIdMappingsInBatches(
AssetsApi assetsApi,
bool canBulkUpdate,
Logger logger,
+ Completer cancellation,
) async {
const pageSize = 20000;
String? lastLocalId;
final seenRemoteAssetIds = {};
while (true) {
+ if (cancellation.isCompleted) {
+ logger.warning('Cloud ID migration cancelled. Stopping batch processing.');
+ break;
+ }
final mappings = await _fetchCloudIdMappings(drift, userId, pageSize, lastLocalId);
if (mappings.isEmpty) {
break;
@@ -98,9 +107,9 @@ Future _processCloudIdMappingsInBatches(
if (items.isNotEmpty) {
if (canBulkUpdate) {
- await _bulkUpdateCloudIds(assetsApi, items);
+ await _bulkUpdateCloudIds(assetsApi, items, cancellation.future);
} else {
- await _sequentialUpdateCloudIds(assetsApi, items);
+ await _sequentialUpdateCloudIds(assetsApi, items, cancellation);
}
}
@@ -111,20 +120,35 @@ Future _processCloudIdMappingsInBatches(
}
}
-Future _sequentialUpdateCloudIds(AssetsApi assetsApi, List items) async {
+Future _sequentialUpdateCloudIds(
+ AssetsApi assetsApi,
+ List items,
+ Completer cancellation,
+) async {
for (final item in items) {
+ if (cancellation.isCompleted) {
+ break;
+ }
final upsertItem = AssetMetadataUpsertItemDto(key: item.key, value: item.value);
try {
- await assetsApi.updateAssetMetadata(item.assetId, AssetMetadataUpsertDto(items: [upsertItem]));
+ await assetsApi.updateAssetMetadata(
+ item.assetId,
+ AssetMetadataUpsertDto(items: [upsertItem]),
+ abortTrigger: cancellation.future,
+ );
} catch (error, stack) {
Logger('migrateCloudIds').warning('Failed to update metadata for asset ${item.assetId}', error, stack);
}
}
}
-Future _bulkUpdateCloudIds(AssetsApi assetsApi, List items) async {
+Future _bulkUpdateCloudIds(
+ AssetsApi assetsApi,
+ List items,
+ Future abortTrigger,
+) async {
try {
- await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items));
+ await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items), abortTrigger: abortTrigger);
} catch (error, stack) {
Logger('migrateCloudIds').warning('Failed to bulk update metadata', error, stack);
}
diff --git a/mobile/lib/extensions/asset_extensions.dart b/mobile/lib/extensions/asset_extensions.dart
index 7e1bef1a1c..445af99e79 100644
--- a/mobile/lib/extensions/asset_extensions.dart
+++ b/mobile/lib/extensions/asset_extensions.dart
@@ -18,11 +18,11 @@ extension DTOToAsset on api.AssetResponseDto {
height: height?.toInt(),
width: width?.toInt(),
isFavorite: isFavorite,
- livePhotoVideoId: livePhotoVideoId,
+ livePhotoVideoId: livePhotoVideoId.orElse(null),
thumbHash: thumbhash,
localId: null,
type: type.toAssetType(),
- stackId: stack?.id,
+ stackId: stack.orElse(null)?.id,
isEdited: isEdited,
);
}
@@ -41,13 +41,13 @@ extension DTOToAsset on api.AssetResponseDto {
height: height?.toInt(),
width: width?.toInt(),
isFavorite: isFavorite,
- livePhotoVideoId: livePhotoVideoId,
+ livePhotoVideoId: livePhotoVideoId.orElse(null),
thumbHash: thumbhash,
localId: null,
type: type.toAssetType(),
- stackId: stack?.id,
+ stackId: stack.orElse(null)?.id,
isEdited: isEdited,
- exifInfo: exifInfo != null ? ExifDtoConverter.fromDto(exifInfo!) : const ExifInfo(),
+ exifInfo: exifInfo.orElse(null) != null ? ExifDtoConverter.fromDto(exifInfo.orElse(null)!) : const ExifInfo(),
);
}
}
diff --git a/mobile/lib/infrastructure/entities/local_asset.entity.dart b/mobile/lib/infrastructure/entities/local_asset.entity.dart
index 5a14a44fb7..a19c1aa540 100644
--- a/mobile/lib/infrastructure/entities/local_asset.entity.dart
+++ b/mobile/lib/infrastructure/entities/local_asset.entity.dart
@@ -6,6 +6,7 @@ import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)')
+@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)')
class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
const LocalAssetEntity();
diff --git a/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart
index e01e6ce745..fe03f9b208 100644
--- a/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart
+++ b/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart
@@ -1348,3 +1348,7 @@ i0.Index get idxLocalAssetCloudId => i0.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
+i0.Index get idxLocalAssetCreatedAt => i0.Index(
+ 'idx_local_asset_created_at',
+ 'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
+);
diff --git a/mobile/lib/infrastructure/mapper.dart b/mobile/lib/infrastructure/mapper.dart
new file mode 100644
index 0000000000..a53aa2419a
--- /dev/null
+++ b/mobile/lib/infrastructure/mapper.dart
@@ -0,0 +1,15 @@
+import 'package:immich_mobile/domain/models/user.model.dart';
+import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart';
+import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
+
+User mapToUser(UserEntityData data) => User(
+ id: data.id,
+ name: data.name,
+ email: data.email,
+ hasProfileImage: data.hasProfileImage,
+ profileChangedAt: data.profileChangedAt,
+ avatarColor: data.avatarColor,
+);
+
+Partner mapToPartner(UserEntityData user, PartnerEntityData partner) =>
+ Partner.fromUser(mapToUser(user), inTimeline: partner.inTimeline);
diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart
index 6bb2e946f1..2499a08a36 100644
--- a/mobile/lib/infrastructure/repositories/db.repository.dart
+++ b/mobile/lib/infrastructure/repositories/db.repository.dart
@@ -1,7 +1,8 @@
import 'dart:async';
+import 'dart:io';
import 'package:drift/drift.dart';
-import 'package:drift_flutter/drift_flutter.dart';
+import 'package:drift_sqlite_async/drift_sqlite_async.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
@@ -13,7 +14,6 @@ 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/memory.entity.dart';
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart';
-import 'package:immich_mobile/infrastructure/entities/settings.entity.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.dart';
import 'package:immich_mobile/infrastructure/entities/person.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
@@ -22,6 +22,7 @@ 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/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';
@@ -31,6 +32,11 @@ import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'
import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
import 'package:logging/logging.dart';
+import 'package:path/path.dart' as p;
+import 'package:path_provider/path_provider.dart';
+import 'package:sqlite3/sqlite3.dart';
+import 'package:sqlite_async/native.dart';
+import 'package:sqlite_async/sqlite_async.dart';
@DriftDatabase(
tables: [
@@ -60,8 +66,9 @@ import 'package:logging/logging.dart';
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
)
class Drift extends $Drift {
- Drift([QueryExecutor? executor])
- : super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true)));
+ Drift(super.executor);
+
+ Drift.sqlite(SqliteConnection db) : super(SqliteAsyncDriftConnection(db));
Future reset() async {
// https://github.com/simolus3/drift/commit/bd80a46264b6dd833ef4fd87fffc03f5a832ab41#diff-3f879e03b4a35779344ef16170b9353608dd9c42385f5402ec6035aac4dd8a04R76-R94
@@ -98,7 +105,7 @@ class Drift extends $Drift {
}
@override
- int get schemaVersion => 27;
+ int get schemaVersion => 28;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -279,6 +286,9 @@ class Drift extends $Drift {
from26To27: (m, v27) async {
await customStatement('ALTER TABLE metadata RENAME TO settings');
},
+ from27To28: (m, v28) async {
+ await m.createIndex(v28.idxLocalAssetCreatedAt);
+ },
),
);
@@ -308,3 +318,41 @@ class DriftDatabaseRepository {
Future transaction(Future Function() callback) => _db.transaction(callback);
}
+
+Future openSqliteConnection({required String name}) async {
+ final dbFolder = await getApplicationDocumentsDirectory();
+ final file = File(p.join(dbFolder.path, '$name.sqlite'));
+ return SqliteDatabase.withFactory(
+ _ImmichSqliteOpenFactory(
+ path: file.path,
+ sqliteOptions: const SqliteOptions(
+ journalMode: SqliteJournalMode.wal, // PRAGMA journal_mode (writer only)
+ synchronous: SqliteSynchronous.normal, // PRAGMA synchronous
+ lockTimeout: Duration(seconds: 30), // -> PRAGMA busy_timeout = 30000
+ ),
+ ),
+ );
+}
+
+final class _ImmichSqliteOpenFactory extends NativeSqliteOpenFactory {
+ _ImmichSqliteOpenFactory({required super.path, super.sqliteOptions});
+
+ @override
+ List pragmaStatements(SqliteOpenOptions options) {
+ return [
+ ...super.pragmaStatements(options),
+ 'PRAGMA cache_size = -32000', // 32MB
+ 'PRAGMA temp_store = MEMORY',
+ 'PRAGMA foreign_keys = ON',
+ ];
+ }
+}
+
+Future configureSqliteCache() async {
+ // Make sqlite3 pick a more suitable location for temporary files - the
+ // one from the system may be inaccessible due to sand-boxing.
+ final cacheBase = (await getTemporaryDirectory()).path;
+ // We can't access /tmp on Android, which sqlite3 would try by default.
+ // Explicitly tell it about the correct temporary directory.
+ sqlite3.tempDirectory = cacheBase;
+}
diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart
index 692523219b..3a913dda97 100644
--- a/mobile/lib/infrastructure/repositories/db.repository.drift.dart
+++ b/mobile/lib/infrastructure/repositories/db.repository.drift.dart
@@ -112,6 +112,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
i7.idxLocalAlbumAssetAlbumAsset,
i4.idxLocalAssetChecksum,
i4.idxLocalAssetCloudId,
+ i4.idxLocalAssetCreatedAt,
i3.idxStackPrimaryAssetId,
i2.uQRemoteAssetsOwnerChecksum,
i2.uQRemoteAssetsOwnerLibraryChecksum,
diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart
index a51174d980..9f3498df49 100644
--- a/mobile/lib/infrastructure/repositories/db.repository.steps.dart
+++ b/mobile/lib/infrastructure/repositories/db.repository.steps.dart
@@ -14083,6 +14083,554 @@ final class Schema27 extends i0.VersionedSchema {
);
}
+final class Schema28 extends i0.VersionedSchema {
+ Schema28({required super.database}) : super(version: 28);
+ @override
+ late final List entities = [
+ userEntity,
+ remoteAssetEntity,
+ stackEntity,
+ localAssetEntity,
+ remoteAlbumEntity,
+ localAlbumEntity,
+ localAlbumAssetEntity,
+ idxLocalAlbumAssetAlbumAsset,
+ idxLocalAssetChecksum,
+ idxLocalAssetCloudId,
+ idxLocalAssetCreatedAt,
+ idxStackPrimaryAssetId,
+ uQRemoteAssetsOwnerChecksum,
+ uQRemoteAssetsOwnerLibraryChecksum,
+ idxRemoteAssetChecksum,
+ idxRemoteAssetStackId,
+ idxRemoteAssetOwnerVisibilityDeletedCreated,
+ authUserEntity,
+ userMetadataEntity,
+ partnerEntity,
+ remoteExifEntity,
+ remoteAlbumAssetEntity,
+ remoteAlbumUserEntity,
+ remoteAssetCloudIdEntity,
+ memoryEntity,
+ memoryAssetEntity,
+ personEntity,
+ assetFaceEntity,
+ storeEntity,
+ trashedLocalAssetEntity,
+ assetEditEntity,
+ settings,
+ idxPartnerSharedWithId,
+ idxLatLng,
+ idxRemoteExifCity,
+ idxRemoteAlbumAssetAlbumAsset,
+ idxRemoteAssetCloudId,
+ idxPersonOwnerId,
+ idxAssetFacePersonId,
+ idxAssetFaceAssetId,
+ idxAssetFaceVisiblePerson,
+ idxTrashedLocalAssetChecksum,
+ idxTrashedLocalAssetAlbum,
+ idxAssetEditAssetId,
+ ];
+ 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 idxRemoteAssetStackId = i1.Index(
+ 'idx_remote_asset_stack_id',
+ 'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
+ );
+ final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
+ 'idx_remote_asset_owner_visibility_deleted_created',
+ 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
+ );
+ late final Shape40 authUserEntity = Shape40(
+ source: i0.VersionedTable(
+ entityName: 'auth_user_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_107,
+ _column_108,
+ _column_109,
+ _column_148,
+ _column_110,
+ _column_111,
+ _column_149,
+ _column_150,
+ _column_151,
+ _column_152,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape4 userMetadataEntity = Shape4(
+ source: i0.VersionedTable(
+ entityName: 'user_metadata_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(user_id, "key")'],
+ columns: [_column_153, _column_154, _column_155],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape41 partnerEntity = Shape41(
+ source: i0.VersionedTable(
+ entityName: 'partner_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
+ columns: [_column_156, _column_157, _column_158],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape42 remoteExifEntity = Shape42(
+ source: i0.VersionedTable(
+ entityName: 'remote_exif_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(asset_id)'],
+ columns: [
+ _column_159,
+ _column_160,
+ _column_161,
+ _column_162,
+ _column_163,
+ _column_164,
+ _column_117,
+ _column_116,
+ _column_165,
+ _column_166,
+ _column_167,
+ _column_168,
+ _column_135,
+ _column_136,
+ _column_169,
+ _column_170,
+ _column_171,
+ _column_172,
+ _column_173,
+ _column_174,
+ _column_175,
+ _column_176,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape7 remoteAlbumAssetEntity = Shape7(
+ source: i0.VersionedTable(
+ entityName: 'remote_album_asset_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
+ columns: [_column_159, _column_177],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape10 remoteAlbumUserEntity = Shape10(
+ source: i0.VersionedTable(
+ entityName: 'remote_album_user_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
+ columns: [_column_177, _column_153, _column_178],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape43 remoteAssetCloudIdEntity = Shape43(
+ source: i0.VersionedTable(
+ entityName: 'remote_asset_cloud_id_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(asset_id)'],
+ columns: [
+ _column_159,
+ _column_179,
+ _column_180,
+ _column_134,
+ _column_135,
+ _column_136,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape44 memoryEntity = Shape44(
+ source: i0.VersionedTable(
+ entityName: 'memory_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_107,
+ _column_114,
+ _column_115,
+ _column_124,
+ _column_121,
+ _column_113,
+ _column_181,
+ _column_182,
+ _column_183,
+ _column_184,
+ _column_185,
+ _column_186,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape12 memoryAssetEntity = Shape12(
+ source: i0.VersionedTable(
+ entityName: 'memory_asset_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
+ columns: [_column_159, _column_187],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape45 personEntity = Shape45(
+ source: i0.VersionedTable(
+ entityName: 'person_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_107,
+ _column_114,
+ _column_115,
+ _column_121,
+ _column_108,
+ _column_188,
+ _column_189,
+ _column_190,
+ _column_191,
+ _column_192,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape46 assetFaceEntity = Shape46(
+ source: i0.VersionedTable(
+ entityName: 'asset_face_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_107,
+ _column_159,
+ _column_193,
+ _column_194,
+ _column_195,
+ _column_196,
+ _column_197,
+ _column_198,
+ _column_199,
+ _column_200,
+ _column_201,
+ _column_124,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape18 storeEntity = Shape18(
+ source: i0.VersionedTable(
+ entityName: 'store_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [_column_202, _column_203, _column_204],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape47 trashedLocalAssetEntity = Shape47(
+ source: i0.VersionedTable(
+ entityName: 'trashed_local_asset_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id, album_id)'],
+ columns: [
+ _column_108,
+ _column_113,
+ _column_114,
+ _column_115,
+ _column_116,
+ _column_117,
+ _column_118,
+ _column_107,
+ _column_205,
+ _column_131,
+ _column_120,
+ _column_132,
+ _column_206,
+ _column_137,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ 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_211, _column_115],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ final i1.Index idxPartnerSharedWithId = i1.Index(
+ 'idx_partner_shared_with_id',
+ 'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
+ );
+ final i1.Index idxLatLng = i1.Index(
+ 'idx_lat_lng',
+ 'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
+ );
+ final i1.Index idxRemoteExifCity = i1.Index(
+ 'idx_remote_exif_city',
+ 'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
+ );
+ final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
+ 'idx_remote_album_asset_album_asset',
+ 'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
+ );
+ final i1.Index idxRemoteAssetCloudId = i1.Index(
+ 'idx_remote_asset_cloud_id',
+ 'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
+ );
+ final i1.Index idxPersonOwnerId = i1.Index(
+ 'idx_person_owner_id',
+ 'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
+ );
+ final i1.Index idxAssetFacePersonId = i1.Index(
+ 'idx_asset_face_person_id',
+ 'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
+ );
+ final i1.Index idxAssetFaceAssetId = i1.Index(
+ 'idx_asset_face_asset_id',
+ 'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
+ );
+ final i1.Index idxAssetFaceVisiblePerson = i1.Index(
+ 'idx_asset_face_visible_person',
+ 'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
+ );
+ final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
+ 'idx_trashed_local_asset_checksum',
+ 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
+ );
+ final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
+ 'idx_trashed_local_asset_album',
+ 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
+ );
+ final i1.Index idxAssetEditAssetId = i1.Index(
+ 'idx_asset_edit_asset_id',
+ 'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
+ );
+}
+
i0.MigrationStepWithVersion migrationSteps({
required Future Function(i1.Migrator m, Schema2 schema) from1To2,
required Future Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -14110,6 +14658,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future Function(i1.Migrator m, Schema25 schema) from24To25,
required Future Function(i1.Migrator m, Schema26 schema) from25To26,
required Future Function(i1.Migrator m, Schema27 schema) from26To27,
+ required Future Function(i1.Migrator m, Schema28 schema) from27To28,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -14243,6 +14792,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from26To27(migrator, schema);
return 27;
+ case 27:
+ final schema = Schema28(database: database);
+ final migrator = i1.Migrator(database, schema);
+ await from27To28(migrator, schema);
+ return 28;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -14276,6 +14830,7 @@ i1.OnUpgrade stepByStep({
required Future Function(i1.Migrator m, Schema25 schema) from24To25,
required Future Function(i1.Migrator m, Schema26 schema) from25To26,
required Future Function(i1.Migrator m, Schema27 schema) from26To27,
+ required Future Function(i1.Migrator m, Schema28 schema) from27To28,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -14304,5 +14859,6 @@ i1.OnUpgrade stepByStep({
from24To25: from24To25,
from25To26: from25To26,
from26To27: from26To27,
+ from27To28: from27To28,
),
);
diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart
index 9b355334d4..2c80385c34 100644
--- a/mobile/lib/infrastructure/repositories/local_album.repository.dart
+++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart
@@ -241,7 +241,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
])
..where(_db.localAlbumAssetEntity.albumId.equals(albumId) & _db.localAssetEntity.checksum.isNull())
- ..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)]);
+ ..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)]);
return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get();
}
diff --git a/mobile/lib/infrastructure/repositories/logger_db.repository.dart b/mobile/lib/infrastructure/repositories/logger_db.repository.dart
index d11174356d..32af4af748 100644
--- a/mobile/lib/infrastructure/repositories/logger_db.repository.dart
+++ b/mobile/lib/infrastructure/repositories/logger_db.repository.dart
@@ -1,14 +1,14 @@
import 'package:drift/drift.dart';
-import 'package:drift_flutter/drift_flutter.dart';
+import 'package:drift_sqlite_async/drift_sqlite_async.dart';
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.drift.dart';
+import 'package:sqlite_async/sqlite_async.dart';
@DriftDatabase(tables: [LogMessageEntity])
class DriftLogger extends $DriftLogger {
- DriftLogger([QueryExecutor? executor])
- : super(
- executor ?? driftDatabase(name: 'immich_logs', native: const DriftNativeOptions(shareAcrossIsolates: true)),
- );
+ DriftLogger.fromExecutor(super.executor);
+
+ DriftLogger.sqlite(SqliteConnection db) : super(SqliteAsyncDriftConnection(db));
@override
int get schemaVersion => 1;
@@ -19,7 +19,8 @@ class DriftLogger extends $DriftLogger {
await customStatement('PRAGMA foreign_keys = ON');
await customStatement('PRAGMA synchronous = NORMAL');
await customStatement('PRAGMA journal_mode = WAL');
- await customStatement('PRAGMA busy_timeout = 500');
+ await customStatement('PRAGMA busy_timeout = 30000'); // 30s
+ await customStatement('PRAGMA cache_size = -32000'); // 32MB
await customStatement('PRAGMA temp_store = MEMORY');
},
);
diff --git a/mobile/lib/infrastructure/repositories/partner.repository.dart b/mobile/lib/infrastructure/repositories/partner.repository.dart
index b12061ad24..ee18c84b4e 100644
--- a/mobile/lib/infrastructure/repositories/partner.repository.dart
+++ b/mobile/lib/infrastructure/repositories/partner.repository.dart
@@ -1,106 +1,62 @@
import 'package:drift/drift.dart';
+import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart';
+import 'package:immich_mobile/infrastructure/mapper.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
-class DriftPartnerRepository extends DriftDatabaseRepository {
+class PartnerRepository {
final Drift _db;
- const DriftPartnerRepository(this._db) : super(_db);
+ const PartnerRepository(this._db);
- Future> getPartners(String userId) {
- final query = _db.select(_db.partnerEntity).join([
- innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById)),
- ])..where(_db.partnerEntity.sharedWithId.equals(userId));
+ Future get({required String sharedById, required String sharedWithId}) =>
+ (_db.select(_db.partnerEntity).join([
+ innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById)),
+ ])..where(
+ _db.partnerEntity.sharedById.equals(sharedById) & _db.partnerEntity.sharedWithId.equals(sharedWithId),
+ ))
+ .map(_resultToPartner)
+ .getSingle();
- return query.map((row) {
- final user = row.readTable(_db.userEntity);
- final partner = row.readTable(_db.partnerEntity);
- return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: partner.inTimeline);
- }).get();
- }
+ Stream> search(String userId, PartnerDirection direction) =>
+ (_db.select(_db.partnerEntity).join([
+ innerJoin(
+ _db.userEntity,
+ _db.userEntity.id.equalsExp(switch (direction) {
+ .sharedBy => _db.partnerEntity.sharedWithId,
+ .sharedWith => _db.partnerEntity.sharedById,
+ }),
+ ),
+ ])..where(
+ switch (direction) {
+ .sharedBy => _db.partnerEntity.sharedById,
+ .sharedWith => _db.partnerEntity.sharedWithId,
+ }.equals(userId) &
+ _db.userEntity.id.equals(userId).not(),
+ ))
+ .map(_resultToPartner)
+ .watch();
- // Get users who we can share our library with
- Future> getAvailablePartners(String currentUserId) {
- final query = _db.select(_db.userEntity)..where((row) => row.id.equals(currentUserId).not());
+ Future create({required String sharedById, required String sharedWithId, bool inTimeline = false}) =>
+ _db.partnerEntity.insertOnConflictUpdate(
+ PartnerEntityCompanion(
+ sharedById: Value(sharedById),
+ sharedWithId: Value(sharedWithId),
+ inTimeline: Value(inTimeline),
+ ),
+ );
- return query.map((user) {
- return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: false);
- }).get();
- }
+ Future update({required String sharedById, required String sharedWithId, required bool inTimeline}) =>
+ (_db.partnerEntity.update()..where((t) => t.sharedById.equals(sharedById) & t.sharedWithId.equals(sharedWithId)))
+ .write(PartnerEntityCompanion(inTimeline: Value(inTimeline)));
- // Get users who are sharing their photos WITH the current user
- Future> getSharedWith(String partnerId) {
- final query = _db.select(_db.partnerEntity).join([
- innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById)),
- ])..where(_db.partnerEntity.sharedWithId.equals(partnerId));
+ Future delete({required String sharedById, required String sharedWithId}) =>
+ (_db.partnerEntity.delete()..where((t) => t.sharedById.equals(sharedById) & t.sharedWithId.equals(sharedWithId)))
+ .go();
- return query.map((row) {
- final user = row.readTable(_db.userEntity);
- final partner = row.readTable(_db.partnerEntity);
- return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: partner.inTimeline);
- }).get();
- }
-
- // Get users who the current user is sharing their photos TO
- Future> getSharedBy(String userId) {
- final query = _db.select(_db.partnerEntity).join([
- innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedWithId)),
- ])..where(_db.partnerEntity.sharedById.equals(userId));
-
- return query.map((row) {
- final user = row.readTable(_db.userEntity);
- final partner = row.readTable(_db.partnerEntity);
- return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: partner.inTimeline);
- }).get();
- }
-
- Future> getAllPartnerIds(String userId) async {
- // Get users who are sharing with me (sharedWithId = userId)
- final sharingWithMeQuery = _db.select(_db.partnerEntity)..where((tbl) => tbl.sharedWithId.equals(userId));
- final sharingWithMe = await sharingWithMeQuery.map((row) => row.sharedById).get();
-
- // Get users who I am sharing with (sharedById = userId)
- final sharingWithThemQuery = _db.select(_db.partnerEntity)..where((tbl) => tbl.sharedById.equals(userId));
- final sharingWithThem = await sharingWithThemQuery.map((row) => row.sharedWithId).get();
-
- // Combine both lists and remove duplicates
- final allPartnerIds = {...sharingWithMe, ...sharingWithThem}.toList();
- return allPartnerIds;
- }
-
- Future getPartner(String partnerId, String userId) {
- final query = _db.select(_db.partnerEntity).join([
- innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById)),
- ])..where(_db.partnerEntity.sharedById.equals(partnerId) & _db.partnerEntity.sharedWithId.equals(userId));
-
- return query.map((row) {
- final user = row.readTable(_db.userEntity);
- final partner = row.readTable(_db.partnerEntity);
- return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: partner.inTimeline);
- }).getSingleOrNull();
- }
-
- Future toggleShowInTimeline(PartnerUserDto partner, String userId) {
- return _db.partnerEntity.update().replace(
- PartnerEntityCompanion(
- sharedById: Value(partner.id),
- sharedWithId: Value(userId),
- inTimeline: Value(!partner.inTimeline),
- ),
- );
- }
-
- Future create(String partnerId, String userId) {
- final entity = PartnerEntityCompanion(
- sharedById: Value(userId),
- sharedWithId: Value(partnerId),
- inTimeline: const Value(false),
- );
-
- return _db.partnerEntity.insertOne(entity);
- }
-
- Future delete(String partnerId, String userId) {
- return _db.partnerEntity.deleteWhere((t) => t.sharedById.equals(userId) & t.sharedWithId.equals(partnerId));
+ Partner _resultToPartner(TypedResult result) {
+ final user = result.readTable(_db.userEntity);
+ final partner = result.readTable(_db.partnerEntity);
+ return mapToPartner(user, partner);
}
}
diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart
index 7d4e23c22b..2e4a239a0b 100644
--- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart
+++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart
@@ -267,7 +267,7 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
);
}
- Future updateRating(String assetId, int rating) async {
+ Future updateRating(String assetId, int? rating) async {
await (_db.remoteExifEntity.update()..where((row) => row.assetId.equals(assetId))).write(
RemoteExifEntityCompanion(rating: Value(rating)),
);
diff --git a/mobile/lib/infrastructure/repositories/search_api.repository.dart b/mobile/lib/infrastructure/repositories/search_api.repository.dart
index bcfddfce6e..395d4045cf 100644
--- a/mobile/lib/infrastructure/repositories/search_api.repository.dart
+++ b/mobile/lib/infrastructure/repositories/search_api.repository.dart
@@ -1,6 +1,7 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' hide AssetVisibility;
import 'package:immich_mobile/infrastructure/repositories/api.repository.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
+import 'package:immich_mobile/utils/option.dart';
import 'package:openapi/api.dart';
class SearchApiRepository extends ApiRepository {
@@ -20,50 +21,64 @@ class SearchApiRepository extends ApiRepository {
(filter.assetId != null && filter.assetId!.isNotEmpty)) {
return _api.searchSmart(
SmartSearchDto(
- query: filter.context,
- queryAssetId: filter.assetId,
- language: filter.language,
- country: filter.location.country,
- state: filter.location.state,
- city: filter.location.city,
- make: filter.camera.make,
- model: filter.camera.model,
- takenAfter: filter.date.takenAfter,
- takenBefore: filter.date.takenBefore,
- visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
- rating: filter.rating.rating,
- isFavorite: filter.display.isFavorite ? true : null,
- isNotInAlbum: filter.display.isNotInAlbum ? true : null,
- personIds: filter.people.map((e) => e.id).toList(),
- tagIds: filter.tagIds,
- type: type,
- page: page,
- size: 100,
+ query: filter.context == null ? const Optional.absent() : Optional.present(filter.context!),
+ queryAssetId: filter.assetId == null ? const Optional.absent() : Optional.present(filter.assetId!),
+ language: filter.language == null ? const Optional.absent() : Optional.present(filter.language!),
+ country: filter.location.country == null
+ ? const Optional.absent()
+ : Optional.present(filter.location.country!),
+ state: filter.location.state == null ? const Optional.absent() : Optional.present(filter.location.state!),
+ city: filter.location.city == null ? const Optional.absent() : Optional.present(filter.location.city!),
+ make: filter.camera.make == null ? const Optional.absent() : Optional.present(filter.camera.make!),
+ model: filter.camera.model == null ? const Optional.absent() : Optional.present(filter.camera.model!),
+ takenAfter: filter.date.takenAfter == null
+ ? const Optional.absent()
+ : Optional.present(filter.date.takenAfter!),
+ takenBefore: filter.date.takenBefore == null
+ ? const Optional.absent()
+ : Optional.present(filter.date.takenBefore!),
+ visibility: Optional.present(filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline),
+ rating: filter.rating.rating.toOptional(),
+ isFavorite: filter.display.isFavorite ? const Optional.present(true) : const Optional.absent(),
+ isNotInAlbum: filter.display.isNotInAlbum ? const Optional.present(true) : const Optional.absent(),
+ personIds: Optional.present(filter.people.map((e) => e.id).toList()),
+ tagIds: filter.tagIds == null ? const Optional.absent() : Optional.present(filter.tagIds!),
+ type: type == null ? const Optional.absent() : Optional.present(type),
+ page: Optional.present(page),
+ size: const Optional.present(100),
),
);
}
return _api.searchAssets(
MetadataSearchDto(
- originalFileName: filter.filename != null && filter.filename!.isNotEmpty ? filter.filename : null,
- country: filter.location.country,
- description: filter.description != null && filter.description!.isNotEmpty ? filter.description : null,
- ocr: filter.ocr != null && filter.ocr!.isNotEmpty ? filter.ocr : null,
- state: filter.location.state,
- city: filter.location.city,
- make: filter.camera.make,
- model: filter.camera.model,
- takenAfter: filter.date.takenAfter,
- takenBefore: filter.date.takenBefore,
- visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
- rating: filter.rating.rating,
- isFavorite: filter.display.isFavorite ? true : null,
- isNotInAlbum: filter.display.isNotInAlbum ? true : null,
- personIds: filter.people.map((e) => e.id).toList(),
- tagIds: filter.tagIds,
- type: type,
- page: page,
- size: 1000,
+ originalFileName: filter.filename != null && filter.filename!.isNotEmpty
+ ? Optional.present(filter.filename!)
+ : const Optional.absent(),
+ country: filter.location.country == null ? const Optional.absent() : Optional.present(filter.location.country!),
+ description: filter.description != null && filter.description!.isNotEmpty
+ ? Optional.present(filter.description!)
+ : const Optional.absent(),
+ ocr: filter.ocr != null && filter.ocr!.isNotEmpty ? Optional.present(filter.ocr!) : const Optional.absent(),
+ state: filter.location.state == null ? const Optional.absent() : Optional.present(filter.location.state!),
+ city: filter.location.city == null ? const Optional.absent() : Optional.present(filter.location.city!),
+ make: filter.camera.make == null ? const Optional.absent() : Optional.present(filter.camera.make!),
+ model: filter.camera.model == null ? const Optional.absent() : Optional.present(filter.camera.model!),
+ takenAfter: filter.date.takenAfter == null
+ ? const Optional.absent()
+ : Optional.present(filter.date.takenAfter!),
+ takenBefore: filter.date.takenBefore == null
+ ? const Optional.absent()
+ : Optional.present(filter.date.takenBefore!),
+ visibility: Optional.present(filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline),
+ rating: filter.rating.rating.toOptional(),
+ isFavorite: filter.display.isFavorite ? const Optional.present(true) : const Optional.absent(),
+ isNotInAlbum: filter.display.isNotInAlbum ? const Optional.present(true) : const Optional.absent(),
+ personIds: Optional.present(filter.people.map((e) => e.id).toList()),
+ tagIds: filter.tagIds == null ? const Optional.absent() : Optional.present(filter.tagIds!),
+ type: type == null ? const Optional.absent() : Optional.present(type),
+ page: Optional.present(page),
+ size: const Optional.present(1000),
),
);
}
diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart
index d9d262e64f..65214f3846 100644
--- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart
+++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart
@@ -20,7 +20,7 @@ class SyncApiRepository {
}
Future deleteSyncAck(List types) {
- return _api.syncApi.deleteSyncAck(SyncAckDeleteDto(types: types));
+ return _api.syncApi.deleteSyncAck(SyncAckDeleteDto(types: Optional.present(types)));
}
Future streamChanges(
@@ -29,6 +29,7 @@ class SyncApiRepository {
Function()? onReset,
int batchSize = kSyncEventBatchSize,
http.Client? httpClient,
+ Future? abortSignal,
}) async {
final stopwatch = Stopwatch()..start();
final client = httpClient ?? NetworkRepository.client;
@@ -36,7 +37,7 @@ class SyncApiRepository {
final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'};
- final request = http.Request('POST', Uri.parse(endpoint));
+ final request = http.AbortableRequest('POST', Uri.parse(endpoint), abortTrigger: abortSignal);
request.headers.addAll(headers);
request.body = jsonEncode(
SyncStreamDto(
diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart
index b7593c3202..bd672171bc 100644
--- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart
+++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart
@@ -91,7 +91,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
email: Value(user.email),
hasProfileImage: Value(user.hasProfileImage),
profileChangedAt: Value(user.profileChangedAt),
- avatarColor: Value(user.avatarColor?.toAvatarColor() ?? AvatarColor.primary),
+ avatarColor: Value(user.avatarColor.orElse(null)?.toAvatarColor() ?? AvatarColor.primary),
isAdmin: Value(user.isAdmin),
pinCode: Value(user.pinCode),
quotaSizeInBytes: Value(user.quotaSizeInBytes ?? 0),
@@ -133,7 +133,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
email: Value(user.email),
hasProfileImage: Value(user.hasProfileImage),
profileChangedAt: Value(user.profileChangedAt),
- avatarColor: Value(user.avatarColor?.toAvatarColor() ?? AvatarColor.primary),
+ avatarColor: Value(user.avatarColor.orElse(null)?.toAvatarColor() ?? AvatarColor.primary),
);
batch.insert(_db.userEntity, companion.copyWith(id: Value(user.id)), onConflict: DoUpdate((_) => companion));
diff --git a/mobile/lib/infrastructure/repositories/user.repository.dart b/mobile/lib/infrastructure/repositories/user.repository.dart
index afcf2271dd..6df7344991 100644
--- a/mobile/lib/infrastructure/repositories/user.repository.dart
+++ b/mobile/lib/infrastructure/repositories/user.repository.dart
@@ -2,9 +2,17 @@ import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
+import 'package:immich_mobile/infrastructure/mapper.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart';
+class UserRepository {
+ final Drift _db;
+ const UserRepository(this._db);
+
+ Stream> getAll() => _db.select(_db.userEntity).map(mapToUser).watch();
+}
+
class DriftAuthUserRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftAuthUserRepository(super.db) : _db = db;
diff --git a/mobile/lib/infrastructure/utils/exif.converter.dart b/mobile/lib/infrastructure/utils/exif.converter.dart
index 50639e8e42..9f9b6f9324 100644
--- a/mobile/lib/infrastructure/utils/exif.converter.dart
+++ b/mobile/lib/infrastructure/utils/exif.converter.dart
@@ -5,24 +5,24 @@ import 'package:openapi/api.dart';
abstract final class ExifDtoConverter {
static ExifInfo fromDto(ExifResponseDto dto) {
return ExifInfo(
- fileSize: dto.fileSizeInByte,
- description: dto.description,
- orientation: dto.orientation,
- timeZone: dto.timeZone,
- dateTimeOriginal: dto.dateTimeOriginal,
- isFlipped: isOrientationFlipped(dto.orientation),
- latitude: dto.latitude?.toDouble(),
- longitude: dto.longitude?.toDouble(),
- city: dto.city,
- state: dto.state,
- country: dto.country,
- make: dto.make,
- model: dto.model,
- lens: dto.lensModel,
- f: dto.fNumber?.toDouble(),
- mm: dto.focalLength?.toDouble(),
- iso: dto.iso?.toInt(),
- exposureSeconds: exposureTimeToSeconds(dto.exposureTime),
+ fileSize: dto.fileSizeInByte.orElse(null),
+ description: dto.description.orElse(null),
+ orientation: dto.orientation.orElse(null),
+ timeZone: dto.timeZone.orElse(null),
+ dateTimeOriginal: dto.dateTimeOriginal.orElse(null),
+ isFlipped: isOrientationFlipped(dto.orientation.orElse(null)),
+ latitude: dto.latitude.orElse(null)?.toDouble(),
+ longitude: dto.longitude.orElse(null)?.toDouble(),
+ city: dto.city.orElse(null),
+ state: dto.state.orElse(null),
+ country: dto.country.orElse(null),
+ make: dto.make.orElse(null),
+ model: dto.model.orElse(null),
+ lens: dto.lensModel.orElse(null),
+ f: dto.fNumber.orElse(null)?.toDouble(),
+ mm: dto.focalLength.orElse(null)?.toDouble(),
+ iso: dto.iso.orElse(null)?.toInt(),
+ exposureSeconds: exposureTimeToSeconds(dto.exposureTime.orElse(null)),
);
}
diff --git a/mobile/lib/infrastructure/utils/user.converter.dart b/mobile/lib/infrastructure/utils/user.converter.dart
index 826649b247..7e8a2c6fcc 100644
--- a/mobile/lib/infrastructure/utils/user.converter.dart
+++ b/mobile/lib/infrastructure/utils/user.converter.dart
@@ -40,7 +40,7 @@ abstract final class UserConverter {
updatedAt: DateTime.now(),
avatarColor: dto.avatarColor.toAvatarColor(),
memoryEnabled: false,
- inTimeline: dto.inTimeline ?? false,
+ inTimeline: dto.inTimeline.orElse(null) ?? false,
isPartnerSharedBy: false,
isPartnerSharedWith: false,
profileChangedAt: dto.profileChangedAt,
diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart
index cc5f131572..75f1c2221a 100644
--- a/mobile/lib/main.dart
+++ b/mobile/lib/main.dart
@@ -24,6 +24,7 @@ import 'package:immich_mobile/pages/common/splash_screen.page.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
+import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
@@ -128,6 +129,7 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve
case AppLifecycleState.resumed:
dPrint(() => "[APP STATE] resumed");
ref.read(appStateProvider.notifier).handleAppResume();
+ unawaited(ref.read(viewIntentHandlerProvider).onAppResumed());
break;
case AppLifecycleState.inactive:
dPrint(() => "[APP STATE] inactive");
@@ -233,6 +235,7 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve
}
});
+ ref.read(viewIntentHandlerProvider).init();
ref.read(shareIntentUploadProvider.notifier).init();
}
diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart
index cf1a1dcdaf..825acb32c6 100644
--- a/mobile/lib/models/search/search_filter.model.dart
+++ b/mobile/lib/models/search/search_filter.model.dart
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
+import 'package:immich_mobile/utils/option.dart';
class SearchLocationFilter {
String? country;
@@ -133,19 +134,26 @@ class SearchDateFilter {
}
class SearchRatingFilter {
- int? rating;
- SearchRatingFilter({this.rating});
+ /// none = no filter; some(null) = filter for unrated; some(1-5) = filter for that rating
+ Option rating;
+ SearchRatingFilter({this.rating = const Option.none()});
- SearchRatingFilter copyWith({int? rating}) {
+ SearchRatingFilter copyWith({Option? rating}) {
return SearchRatingFilter(rating: rating ?? this.rating);
}
Map toMap() {
- return {'rating': rating};
+ if (rating.isNone) {
+ return {'active': false};
+ }
+ return {'active': true, 'value': rating.unwrapOrNull};
}
factory SearchRatingFilter.fromMap(Map map) {
- return SearchRatingFilter(rating: map['rating'] != null ? map['rating'] as int : null);
+ if (!(map['active'] as bool? ?? false)) {
+ return SearchRatingFilter();
+ }
+ return SearchRatingFilter(rating: Option.some(map['value'] as int?));
}
String toJson() => json.encode(toMap());
@@ -270,7 +278,7 @@ class SearchFilter {
display.isNotInAlbum == false &&
display.isArchive == false &&
display.isFavorite == false &&
- rating.rating == null &&
+ rating.rating.isNone &&
mediaType == AssetType.other;
}
diff --git a/mobile/lib/models/shared_link/shared_link.model.dart b/mobile/lib/models/shared_link/shared_link.model.dart
index 4315cf616a..e7b65a96ef 100644
--- a/mobile/lib/models/shared_link/shared_link.model.dart
+++ b/mobile/lib/models/shared_link/shared_link.model.dart
@@ -73,10 +73,10 @@ class SharedLink {
slug = dto.slug,
type = dto.type == SharedLinkType.ALBUM ? SharedLinkSource.album : SharedLinkSource.individual,
title = dto.type == SharedLinkType.ALBUM
- ? dto.album?.albumName.toUpperCase() ?? "UNKNOWN SHARE"
+ ? dto.album.orElse(null)?.albumName.toUpperCase() ?? "UNKNOWN SHARE"
: "INDIVIDUAL SHARE",
thumbAssetId = dto.type == SharedLinkType.ALBUM
- ? dto.album?.albumThumbnailAssetId
+ ? dto.album.orElse(null)?.albumThumbnailAssetId
: dto.assets.isNotEmpty
? dto.assets[0].id
: null;
diff --git a/mobile/lib/models/view_intent/view_intent_payload.extension.dart b/mobile/lib/models/view_intent/view_intent_payload.extension.dart
new file mode 100644
index 0000000000..ca66e6a163
--- /dev/null
+++ b/mobile/lib/models/view_intent/view_intent_payload.extension.dart
@@ -0,0 +1,35 @@
+import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
+import 'package:immich_mobile/platform/view_intent_api.g.dart';
+import 'package:path/path.dart';
+
+extension ViewIntentPayloadX on ViewIntentPayload {
+ String get fileName {
+ final resolvedPath = path;
+ if (resolvedPath != null && resolvedPath.isNotEmpty) {
+ return basename(resolvedPath);
+ }
+ return localAssetId ?? 'view_intent_asset';
+ }
+
+ bool get isImage => mimeType.toLowerCase().startsWith('image/');
+
+ bool get isVideo => mimeType.toLowerCase().startsWith('video/');
+
+ AssetPlaybackStyle get playbackStyle {
+ if (isVideo) {
+ return AssetPlaybackStyle.video;
+ }
+
+ final normalizedMimeType = mimeType.toLowerCase();
+ if (normalizedMimeType == 'image/gif' || normalizedMimeType == 'image/webp') {
+ return AssetPlaybackStyle.imageAnimated;
+ }
+
+ final normalizedPath = path?.toLowerCase();
+ if (normalizedPath != null && (normalizedPath.endsWith('.gif') || normalizedPath.endsWith('.webp'))) {
+ return AssetPlaybackStyle.imageAnimated;
+ }
+
+ return AssetPlaybackStyle.image;
+ }
+}
diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart
index 2e18c3edc6..9e78fb4795 100644
--- a/mobile/lib/pages/backup/drift_backup.page.dart
+++ b/mobile/lib/pages/backup/drift_backup.page.dart
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/album/local_album.model.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/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
@@ -15,11 +16,16 @@ import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.w
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
+import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
+import 'package:immich_mobile/providers/permission.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
+import 'package:immich_ui/immich_ui.dart';
import 'package:logging/logging.dart';
+import 'package:permission_handler/permission_handler.dart';
+import 'package:url_launcher/url_launcher.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage()
@@ -162,11 +168,7 @@ class _DriftBackupPageState extends ConsumerState {
),
),
},
- TextButton.icon(
- icon: const Icon(Icons.info_outline_rounded),
- onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
- label: Text("view_details".t(context: context)),
- ),
+ const _BackupFooter(),
],
],
),
@@ -177,6 +179,137 @@ class _DriftBackupPageState extends ConsumerState {
}
}
+class _BackupFooter extends ConsumerStatefulWidget {
+ const _BackupFooter();
+
+ @override
+ ConsumerState<_BackupFooter> createState() => _BackupFooterState();
+}
+
+class _BackupFooterState extends ConsumerState<_BackupFooter> with WidgetsBindingObserver {
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addObserver(this);
+ }
+
+ @override
+ void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
+ super.dispose();
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ if (CurrentPlatform.isAndroid && state == AppLifecycleState.resumed && mounted) {
+ unawaited(ref.read(notificationPermissionProvider.notifier).getNotificationPermission());
+ unawaited(ref.read(batteryOptimizationProvider.notifier).getBatteryOptimizationPermission());
+ }
+ }
+
+ void showPermissionsDialog() {
+ showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ content: Text(context.t.notification_permission_dialog_content),
+ actions: [
+ ImmichTextButton(
+ labelText: context.t.cancel,
+ variant: .ghost,
+ expanded: false,
+ onPressed: () => ContextHelper(ctx).pop(),
+ ),
+ ImmichTextButton(
+ labelText: context.t.settings,
+ variant: .ghost,
+ expanded: false,
+ onPressed: () {
+ ContextHelper(context).pop();
+ openAppSettings();
+ },
+ ),
+ ],
+ ),
+ );
+ }
+
+ void showBatteryOptimizationInfo() {
+ showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (BuildContext ctx) {
+ return AlertDialog(
+ title: Text(context.t.backup_controller_page_background_battery_info_title),
+ content: SingleChildScrollView(child: Text(context.t.backup_controller_page_background_battery_info_message)),
+ actions: [
+ ImmichTextButton(
+ labelText: context.t.backup_controller_page_background_battery_info_link,
+ variant: .ghost,
+ expanded: false,
+ onPressed: () => launchUrl(Uri.parse('https://dontkillmyapp.com'), mode: LaunchMode.externalApplication),
+ ),
+ ImmichTextButton(
+ labelText: context.t.backup_controller_page_background_battery_info_ok,
+ variant: .ghost,
+ expanded: false,
+ onPressed: () => ContextHelper(ctx).pop(),
+ ),
+ ],
+ );
+ },
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final isBackupEnabled = ref.watch(appConfigProvider.select((config) => config.backup.enabled));
+ final notificationStatus = ref.watch(notificationPermissionProvider);
+ final batteryOptimizationStatus = ref.watch(batteryOptimizationProvider).valueOrNull;
+
+ return Column(
+ children: [
+ if (CurrentPlatform.isAndroid && isBackupEnabled) ...[
+ if (notificationStatus != PermissionStatus.granted)
+ TextButton.icon(
+ iconAlignment: .end,
+ icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
+ label: Text(
+ context.t.notification_backup_reliability,
+ textAlign: TextAlign.left,
+ style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
+ ),
+ onPressed: () {
+ ref.read(notificationPermissionProvider.notifier).requestNotificationPermission().then((p) {
+ if (p == PermissionStatus.permanentlyDenied) {
+ showPermissionsDialog();
+ }
+ });
+ },
+ ),
+ if (notificationStatus != PermissionStatus.granted && batteryOptimizationStatus != PermissionStatus.granted)
+ const Divider(indent: 32, endIndent: 32),
+ if (batteryOptimizationStatus != PermissionStatus.granted)
+ TextButton.icon(
+ iconAlignment: .end,
+ icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
+ label: Text(
+ context.t.battery_optimization_backup_reliability,
+ textAlign: TextAlign.left,
+ style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
+ ),
+ onPressed: showBatteryOptimizationInfo,
+ ),
+ ],
+ TextButton.icon(
+ icon: const Icon(Icons.info_outline_rounded),
+ onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
+ label: Text(context.t.view_details),
+ ),
+ ],
+ );
+ }
+}
+
class _BackupAlbumSelectionCard extends ConsumerWidget {
const _BackupAlbumSelectionCard();
diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart
index aaa9fffc05..de6fda5773 100644
--- a/mobile/lib/pages/common/splash_screen.page.dart
+++ b/mobile/lib/pages/common/splash_screen.page.dart
@@ -17,6 +17,7 @@ import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
+import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/theme/color_scheme.dart';
@@ -314,6 +315,7 @@ class SplashScreenPageState extends ConsumerState {
final wsProvider = ref.read(websocketProvider.notifier);
final backgroundManager = ref.read(backgroundSyncProvider);
final backupProvider = ref.read(driftBackupProvider.notifier);
+ final viewIntentHandler = ref.read(viewIntentHandlerProvider);
unawaited(
ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then(
@@ -328,6 +330,8 @@ class SplashScreenPageState extends ConsumerState {
backgroundManager.syncRemote().then((success) => syncSuccess = success),
]);
+ await viewIntentHandler.flushDeferredViewIntent();
+
if (syncSuccess) {
await Future.wait([
backgroundManager.hashAssets().then((_) {
diff --git a/mobile/lib/pages/library/partner/drift_partner.page.dart b/mobile/lib/pages/library/partner/drift_partner.page.dart
deleted file mode 100644
index a24323c02a..0000000000
--- a/mobile/lib/pages/library/partner/drift_partner.page.dart
+++ /dev/null
@@ -1,139 +0,0 @@
-import 'package:auto_route/auto_route.dart';
-import 'package:easy_localization/easy_localization.dart';
-import 'package:flutter/material.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/domain/models/user.model.dart';
-import 'package:immich_mobile/extensions/translate_extensions.dart';
-import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
-import 'package:immich_mobile/providers/infrastructure/partner.provider.dart';
-import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
-import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
-import 'package:immich_mobile/widgets/common/immich_toast.dart';
-
-@RoutePage()
-class DriftPartnerPage extends HookConsumerWidget {
- const DriftPartnerPage({super.key});
-
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final potentialPartnersAsync = ref.watch(driftAvailablePartnerProvider);
-
- addNewUsersHandler() async {
- final potentialPartners = potentialPartnersAsync.value;
- if (potentialPartners == null || potentialPartners.isEmpty) {
- ImmichToast.show(context: context, msg: "partner_page_no_more_users".tr());
- return;
- }
-
- final selectedUser = await showDialog(
- context: context,
- builder: (context) {
- return SimpleDialog(
- title: const Text("partner_page_select_partner").tr(),
- children: [
- for (PartnerUserDto partner in potentialPartners)
- SimpleDialogOption(
- onPressed: () => context.pop(partner),
- child: Row(
- children: [
- Padding(
- padding: const EdgeInsets.only(right: 8),
- child: PartnerUserAvatar(partner: partner),
- ),
- Text(partner.name),
- ],
- ),
- ),
- ],
- );
- },
- );
- if (selectedUser != null) {
- await ref.read(partnerUsersProvider.notifier).addPartner(selectedUser);
- }
- }
-
- onDeleteUser(PartnerUserDto partner) {
- return showDialog(
- context: context,
- builder: (BuildContext context) {
- return ConfirmDialog(
- title: "stop_photo_sharing",
- content: "partner_page_stop_sharing_content".tr(namedArgs: {'partner': partner.name}),
- onOk: () => ref.read(partnerUsersProvider.notifier).removePartner(partner),
- );
- },
- );
- }
-
- return Scaffold(
- appBar: AppBar(
- title: const Text("partners").t(context: context),
- elevation: 0,
- centerTitle: false,
- actions: [
- IconButton(
- onPressed: potentialPartnersAsync.whenOrNull(data: (data) => addNewUsersHandler),
- icon: const Icon(Icons.person_add),
- tooltip: "add_partner".tr(),
- ),
- ],
- ),
- body: _SharedToPartnerList(onAddPartner: addNewUsersHandler, onDeletePartner: onDeleteUser),
- );
- }
-}
-
-class _SharedToPartnerList extends ConsumerWidget {
- final VoidCallback onAddPartner;
- final Function(PartnerUserDto partner) onDeletePartner;
-
- const _SharedToPartnerList({required this.onAddPartner, required this.onDeletePartner});
-
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final partnerAsync = ref.watch(driftSharedByPartnerProvider);
-
- return partnerAsync.when(
- data: (partners) {
- if (partners.isEmpty) {
- return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16.0),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Padding(
- padding: const EdgeInsets.symmetric(vertical: 8),
- child: const Text("partner_page_empty_message", style: TextStyle(fontSize: 14)).tr(),
- ),
- Align(
- alignment: Alignment.center,
- child: ElevatedButton.icon(
- onPressed: onAddPartner,
- icon: const Icon(Icons.person_add),
- label: const Text("add_partner").tr(),
- ),
- ),
- ],
- ),
- );
- }
-
- return ListView.builder(
- itemCount: partners.length,
- itemBuilder: (context, index) {
- final partner = partners[index];
- return ListTile(
- leading: PartnerUserAvatar(partner: partner),
- title: Text(partner.name),
- subtitle: Text(partner.email),
- trailing: IconButton(icon: const Icon(Icons.person_remove), onPressed: () => onDeletePartner(partner)),
- );
- },
- );
- },
- loading: () => const Center(child: CircularProgressIndicator()),
- error: (error, stack) => Center(child: Text('error_loading_partners'.tr(args: [error.toString()]))),
- );
- }
-}
diff --git a/mobile/lib/pages/library/partner/partner.page.dart b/mobile/lib/pages/library/partner/partner.page.dart
new file mode 100644
index 0000000000..0d9e8f95bd
--- /dev/null
+++ b/mobile/lib/pages/library/partner/partner.page.dart
@@ -0,0 +1,200 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/domain/models/user.model.dart';
+import 'package:immich_mobile/generated/translations.g.dart';
+import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
+import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
+import 'package:immich_mobile/providers/user.provider.dart';
+import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
+
+@visibleForTesting
+final candidatesStateProvider = StreamProvider.autoDispose>((ref) {
+ final currentUser = ref.watch(currentUserProvider);
+ // TODO: Refactor with a route guard to avoid this check in every provider
+ if (currentUser == null) {
+ return const Stream.empty();
+ }
+ return ref.watch(partnerServiceProvider).getCandidates(currentUser.id);
+});
+
+@visibleForTesting
+final partnersStateProvider = StreamProvider.autoDispose>((ref) {
+ final currentUser = ref.watch(currentUserProvider);
+ // TODO: Refactor with a route guard to avoid this check in every provider
+ if (currentUser == null) {
+ return const Stream.empty();
+ }
+
+ return ref.watch(partnerServiceProvider).search(currentUser.id, .sharedBy);
+});
+
+Future _addPartner(BuildContext context, WidgetRef ref) async {
+ final selected = await showDialog(context: context, builder: (_) => const PartnerSelectionDialog());
+ final currentUser = ref.read(currentUserProvider);
+ if (selected != null && currentUser != null) {
+ await ref.read(partnerServiceProvider).create(sharedById: currentUser.id, sharedWithId: selected.id);
+ }
+}
+
+Future _removePartner(BuildContext context, WidgetRef ref, Partner partner) => showDialog(
+ context: context,
+ builder: (_) => ConfirmDialog(
+ title: "stop_photo_sharing",
+ content: context.t.partner_page_stop_sharing_content(partner: partner.name),
+ onOk: () {
+ final currentUser = ref.read(currentUserProvider);
+ if (currentUser != null) {
+ ref.read(partnerServiceProvider).delete(sharedById: currentUser.id, sharedWithId: partner.id);
+ }
+ },
+ ),
+);
+
+@RoutePage()
+class PartnerPage extends ConsumerWidget {
+ const PartnerPage({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final sharedByAsync = ref.watch(partnersStateProvider);
+
+ return Scaffold(
+ appBar: AppBar(
+ title: Text(context.t.partners),
+ elevation: 0,
+ centerTitle: false,
+ actions: [
+ IconButton(
+ onPressed: () => _addPartner(context, ref),
+ icon: const Icon(Icons.person_add),
+ tooltip: context.t.add_partner,
+ ),
+ ],
+ ),
+ body: sharedByAsync.when(
+ data: (partners) => PartnerSharedByList(
+ partners: partners.toList(growable: false),
+ onAdd: () => _addPartner(context, ref),
+ onRemove: (partner) => _removePartner(context, ref, partner),
+ ),
+ loading: () => const Center(child: CircularProgressIndicator()),
+ error: (error, _) => Center(child: Text(context.t.error_loading_partners(error: error))),
+ ),
+ );
+ }
+}
+
+class _EmptyPartners extends StatelessWidget {
+ const _EmptyPartners({required this.onAdd});
+
+ final VoidCallback onAdd;
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const .symmetric(horizontal: 16.0),
+ child: Column(
+ crossAxisAlignment: .start,
+ children: [
+ Padding(
+ padding: const .symmetric(vertical: 8),
+ child: Text(context.t.partner_page_empty_message, style: const TextStyle(fontSize: 14)),
+ ),
+ Align(
+ alignment: .center,
+ child: ElevatedButton.icon(
+ onPressed: onAdd,
+ icon: const Icon(Icons.person_add),
+ label: Text(context.t.add_partner),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+@visibleForTesting
+class PartnerSharedByList extends StatelessWidget {
+ const PartnerSharedByList({super.key, required this.partners, required this.onAdd, required this.onRemove});
+
+ final List partners;
+ final VoidCallback onAdd;
+ final ValueChanged onRemove;
+
+ @override
+ Widget build(BuildContext context) {
+ if (partners.isEmpty) {
+ return _EmptyPartners(onAdd: onAdd);
+ }
+
+ return ListView.builder(
+ itemCount: partners.length,
+ itemBuilder: (_, index) {
+ final partner = partners[index];
+ return ListTile(
+ leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
+ title: Text(partner.name),
+ subtitle: Text(partner.email),
+ trailing: IconButton(icon: const Icon(Icons.person_remove), onPressed: () => onRemove(partner)),
+ );
+ },
+ );
+ }
+}
+
+@visibleForTesting
+class PartnerSelectionDialog extends ConsumerWidget {
+ const PartnerSelectionDialog({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final candidatesAsync = ref.watch(candidatesStateProvider);
+
+ return SimpleDialog(
+ title: const Text("partner_page_select_partner").tr(),
+ children: candidatesAsync.when(
+ data: (candidates) {
+ final users = candidates.toList();
+ if (users.isEmpty) {
+ return [
+ Padding(
+ padding: const .symmetric(horizontal: 24, vertical: 8),
+ child: const Text("partner_page_no_more_users").tr(),
+ ),
+ ];
+ }
+ return [
+ for (final candidate in users)
+ SimpleDialogOption(
+ onPressed: () => Navigator.of(context).pop(candidate),
+ child: Row(
+ children: [
+ Padding(
+ padding: const .only(right: 8),
+ child: PartnerUserAvatar(userId: candidate.id, name: candidate.name),
+ ),
+ Text(candidate.name),
+ ],
+ ),
+ ),
+ ];
+ },
+ loading: () => const [
+ Padding(
+ padding: .all(24),
+ child: Center(child: CircularProgressIndicator()),
+ ),
+ ],
+ error: (error, _) => [
+ Padding(
+ padding: const .symmetric(horizontal: 24, vertical: 8),
+ child: Text(context.t.error_loading_partners(error: error)),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart
index 41486d7c98..65973918e4 100644
--- a/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart
+++ b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart
@@ -11,6 +11,7 @@ import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/shared_link.provider.dart';
import 'package:immich_mobile/services/shared_link.service.dart';
+import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -365,11 +366,10 @@ class SharedLinkEditPage extends HookConsumerWidget {
bool? download;
bool? upload;
bool? meta;
- String? desc;
- String? password;
+ var password = const Optional.absent();
+ var description = const Optional.absent();
String? slug;
- DateTime? expiry;
- bool? changeExpiry;
+ var expiry = const Optional.absent();
if (allowDownload.value != existingLink!.allowDownload) {
download = allowDownload.value;
@@ -383,12 +383,16 @@ class SharedLinkEditPage extends HookConsumerWidget {
meta = showMetadata.value;
}
- if (descriptionController.text != existingLink!.description) {
- desc = descriptionController.text;
+ if (descriptionController.text != (existingLink!.description ?? '')) {
+ description = descriptionController.text.isEmpty
+ ? const Optional.present(null)
+ : Optional.present(descriptionController.text);
}
- if (passwordController.text != existingLink!.password) {
- password = passwordController.text;
+ if (passwordController.text != (existingLink!.password ?? '')) {
+ password = passwordController.text.isEmpty
+ ? const Optional.present(null)
+ : Optional.present(passwordController.text);
}
if (slugController.text != (existingLink!.slug ?? "")) {
@@ -399,8 +403,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
final newExpiry = expiryAfter.value;
if (newExpiry?.toUtc() != existingLink!.expiresAt?.toUtc()) {
- expiry = newExpiry;
- changeExpiry = true;
+ expiry = newExpiry == null ? const Optional.present(null) : Optional.present(newExpiry.toUtc());
}
await ref
@@ -410,11 +413,10 @@ class SharedLinkEditPage extends HookConsumerWidget {
showMeta: meta,
allowDownload: download,
allowUpload: upload,
- description: desc,
+ description: description,
password: password,
slug: slug,
- expiresAt: expiry?.toUtc(),
- changeExpiry: changeExpiry,
+ expiresAt: expiry,
);
if (!context.mounted) {
return;
diff --git a/mobile/lib/platform/native_sync_api.g.dart b/mobile/lib/platform/native_sync_api.g.dart
index ff6ca7bf9d..bd979af87b 100644
--- a/mobile/lib/platform/native_sync_api.g.dart
+++ b/mobile/lib/platform/native_sync_api.g.dart
@@ -635,6 +635,20 @@ class NativeSyncApi {
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
}
+ Future cancelSync() async {
+ final pigeonVar_channelName =
+ 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelSync$pigeonVar_messageChannelSuffix';
+ final pigeonVar_channel = BasicMessageChannel